fix(cron): dual-case keys when loading jobs.json

jobs.json hand-edits and asdict-style snake_case for schedule intervals and
runHistory crashed or silently disabled cron. Deserialize via Cron* from_store_dict
and shared get_camel_snake (also used by local triggers).
This commit is contained in:
santhreal
2026-07-18 17:39:06 +08:00
committed by Xubin Ren
parent 8c68c6fe1e
commit afed32b013
5 changed files with 135 additions and 62 deletions
+1 -56
View File
@@ -222,62 +222,7 @@ class CronService:
jobs = []
version = data.get("version", 1)
for j in data.get("jobs", []):
job = CronJob(
id=j["id"],
name=j["name"],
enabled=j.get("enabled", True),
schedule=CronSchedule(
kind=j["schedule"]["kind"],
at_ms=j["schedule"].get("atMs"),
every_ms=j["schedule"].get("everyMs"),
expr=j["schedule"].get("expr"),
tz=j["schedule"].get("tz"),
),
payload=CronPayload(
kind=j["payload"].get("kind", "agent_turn"),
message=j["payload"].get("message", ""),
deliver=j["payload"].get("deliver", False),
channel=j["payload"].get("channel"),
to=j["payload"].get("to"),
channel_meta=(
j["payload"].get("channelMeta")
or j["payload"].get("channel_meta")
or {}
),
session_key=j["payload"].get("sessionKey") or j["payload"].get("session_key"),
origin_channel=(
j["payload"].get("originChannel")
or j["payload"].get("origin_channel")
),
origin_chat_id=(
j["payload"].get("originChatId")
or j["payload"].get("origin_chat_id")
),
origin_metadata=(
j["payload"].get("originMetadata")
or j["payload"].get("origin_metadata")
or {}
),
),
state=CronJobState(
next_run_at_ms=j.get("state", {}).get("nextRunAtMs"),
last_run_at_ms=j.get("state", {}).get("lastRunAtMs"),
last_status=j.get("state", {}).get("lastStatus"),
last_error=j.get("state", {}).get("lastError"),
run_history=[
CronRunRecord(
run_at_ms=r["runAtMs"],
status=r["status"],
duration_ms=r.get("durationMs", 0),
error=r.get("error"),
)
for r in j.get("state", {}).get("runHistory", [])
],
),
created_at_ms=j.get("createdAtMs", 0),
updated_at_ms=j.get("updatedAtMs", 0),
delete_after_run=j.get("deleteAfterRun", False),
)
job = CronJob.from_store_dict(j)
_normalize_agent_turn_job(job)
jobs.append(job)
except Exception:
+75
View File
@@ -1,8 +1,12 @@
"""Cron types."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
from nanobot.utils.dict_keys import get_camel_snake
@dataclass
class CronSchedule:
@@ -17,6 +21,16 @@ class CronSchedule:
# Timezone for cron expressions
tz: str | None = None
@classmethod
def from_store_dict(cls, data: dict[str, Any]) -> CronSchedule:
return cls(
kind=data["kind"],
at_ms=get_camel_snake(data, "atMs", "at_ms"),
every_ms=get_camel_snake(data, "everyMs", "every_ms"),
expr=data.get("expr"),
tz=data.get("tz"),
)
@dataclass
class CronPayload:
@@ -33,6 +47,25 @@ class CronPayload:
origin_chat_id: str | None = None
origin_metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_store_dict(cls, data: dict[str, Any]) -> CronPayload:
return cls(
kind=data.get("kind", "agent_turn"),
message=data.get("message", ""),
deliver=data.get("deliver", False),
channel=data.get("channel"),
to=data.get("to"),
channel_meta=dict(
get_camel_snake(data, "channelMeta", "channel_meta", {}) or {}
),
session_key=get_camel_snake(data, "sessionKey", "session_key"),
origin_channel=get_camel_snake(data, "originChannel", "origin_channel"),
origin_chat_id=get_camel_snake(data, "originChatId", "origin_chat_id"),
origin_metadata=dict(
get_camel_snake(data, "originMetadata", "origin_metadata", {}) or {}
),
)
@dataclass
class CronRunRecord:
@@ -42,6 +75,15 @@ class CronRunRecord:
duration_ms: int = 0
error: str | None = None
@classmethod
def from_store_dict(cls, data: dict[str, Any]) -> CronRunRecord:
return cls(
run_at_ms=int(get_camel_snake(data, "runAtMs", "run_at_ms", 0)),
status=data["status"],
duration_ms=int(get_camel_snake(data, "durationMs", "duration_ms", 0)),
error=data.get("error"),
)
@dataclass
class CronJobState:
@@ -52,6 +94,22 @@ class CronJobState:
last_error: str | None = None
run_history: list[CronRunRecord] = field(default_factory=list)
@classmethod
def from_store_dict(cls, data: dict[str, Any]) -> CronJobState:
history = get_camel_snake(data, "runHistory", "run_history", []) or []
return cls(
next_run_at_ms=get_camel_snake(data, "nextRunAtMs", "next_run_at_ms"),
last_run_at_ms=get_camel_snake(data, "lastRunAtMs", "last_run_at_ms"),
last_status=get_camel_snake(data, "lastStatus", "last_status"),
last_error=get_camel_snake(data, "lastError", "last_error"),
run_history=[
record
if isinstance(record, CronRunRecord)
else CronRunRecord.from_store_dict(record)
for record in history
],
)
@dataclass
class CronJob:
@@ -78,6 +136,23 @@ class CronJob:
kwargs["state"] = CronJobState(**state_kwargs)
return cls(**kwargs)
@classmethod
def from_store_dict(cls, data: dict[str, Any]) -> CronJob:
"""Load a job from jobs.json (camelCase with snake_case fallbacks)."""
return cls(
id=data["id"],
name=data["name"],
enabled=data.get("enabled", True),
schedule=CronSchedule.from_store_dict(data["schedule"]),
payload=CronPayload.from_store_dict(data.get("payload") or {}),
state=CronJobState.from_store_dict(data.get("state") or {}),
created_at_ms=int(get_camel_snake(data, "createdAtMs", "created_at_ms", 0)),
updated_at_ms=int(get_camel_snake(data, "updatedAtMs", "updated_at_ms", 0)),
delete_after_run=bool(
get_camel_snake(data, "deleteAfterRun", "delete_after_run", False)
),
)
@dataclass
class CronStore:
+2 -6
View File
@@ -6,15 +6,11 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from nanobot.utils.dict_keys import get_camel_snake as _get
TriggerStatus = Literal["ok", "error"]
def _get(data: dict[str, Any], camel: str, snake: str, default: Any = None) -> Any:
if camel in data:
return data[camel]
return data.get(snake, default)
@dataclass
class TriggerRunRecord:
"""A single local trigger delivery record."""
+17
View File
@@ -0,0 +1,17 @@
"""Dict key helpers for persisted JSON that mixes camelCase and snake_case."""
from __future__ import annotations
from typing import Any
def get_camel_snake(
data: dict[str, Any],
camel: str,
snake: str,
default: Any = None,
) -> Any:
"""Prefer camelCase store keys, fall back to snake_case (asdict / hand-edits)."""
if camel in data:
return data[camel]
return data.get(snake, default)
+40
View File
@@ -25,6 +25,46 @@ def _bound_chat(chat_id: str = "chat-1") -> dict[str, str]:
}
def test_load_jobs_accepts_snake_case_schedule_and_run_history(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
store_path.parent.mkdir(parents=True)
store_path.write_text(
json.dumps(
{
"version": 1,
"jobs": [
{
"id": "j1",
"name": "t",
"enabled": True,
"schedule": {"kind": "every", "every_ms": 60_000},
"payload": {
"kind": "agent_turn",
"message": "hi",
"session_key": "websocket:chat-1",
},
"state": {
"run_history": [
{"run_at_ms": 1000, "status": "ok", "duration_ms": 12},
],
},
"created_at_ms": 0,
"updated_at_ms": 0,
}
],
}
),
encoding="utf-8",
)
jobs, _version = CronService(store_path)._load_jobs()
assert jobs is not None
assert jobs[0].schedule.every_ms == 60_000
assert jobs[0].payload.session_key == "websocket:chat-1"
assert jobs[0].state.run_history[0].run_at_ms == 1000
assert jobs[0].state.run_history[0].duration_ms == 12
def test_add_job_rejects_unknown_timezone(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")