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:
+1
-56
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user