From afed32b01324bca54444e32ba3f8b5c512b97298 Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:30:17 -0700 Subject: [PATCH] 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). --- nanobot/cron/service.py | 57 +------------------------ nanobot/cron/types.py | 75 +++++++++++++++++++++++++++++++++ nanobot/triggers/local_types.py | 8 +--- nanobot/utils/dict_keys.py | 17 ++++++++ tests/cron/test_cron_service.py | 40 ++++++++++++++++++ 5 files changed, 135 insertions(+), 62 deletions(-) create mode 100644 nanobot/utils/dict_keys.py diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index ccf86565..336e7a6f 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -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: diff --git a/nanobot/cron/types.py b/nanobot/cron/types.py index 75e1a4fb..1b615f31 100644 --- a/nanobot/cron/types.py +++ b/nanobot/cron/types.py @@ -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: diff --git a/nanobot/triggers/local_types.py b/nanobot/triggers/local_types.py index ca16bc78..0b8bc0f0 100644 --- a/nanobot/triggers/local_types.py +++ b/nanobot/triggers/local_types.py @@ -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.""" diff --git a/nanobot/utils/dict_keys.py b/nanobot/utils/dict_keys.py new file mode 100644 index 00000000..eab89262 --- /dev/null +++ b/nanobot/utils/dict_keys.py @@ -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) diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py index 5e58cfbb..5534c8d0 100644 --- a/tests/cron/test_cron_service.py +++ b/tests/cron/test_cron_service.py @@ -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")