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 = []
|
jobs = []
|
||||||
version = data.get("version", 1)
|
version = data.get("version", 1)
|
||||||
for j in data.get("jobs", []):
|
for j in data.get("jobs", []):
|
||||||
job = CronJob(
|
job = CronJob.from_store_dict(j)
|
||||||
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),
|
|
||||||
)
|
|
||||||
_normalize_agent_turn_job(job)
|
_normalize_agent_turn_job(job)
|
||||||
jobs.append(job)
|
jobs.append(job)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"""Cron types."""
|
"""Cron types."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from nanobot.utils.dict_keys import get_camel_snake
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CronSchedule:
|
class CronSchedule:
|
||||||
@@ -17,6 +21,16 @@ class CronSchedule:
|
|||||||
# Timezone for cron expressions
|
# Timezone for cron expressions
|
||||||
tz: str | None = None
|
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
|
@dataclass
|
||||||
class CronPayload:
|
class CronPayload:
|
||||||
@@ -33,6 +47,25 @@ class CronPayload:
|
|||||||
origin_chat_id: str | None = None
|
origin_chat_id: str | None = None
|
||||||
origin_metadata: dict[str, Any] = field(default_factory=dict)
|
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
|
@dataclass
|
||||||
class CronRunRecord:
|
class CronRunRecord:
|
||||||
@@ -42,6 +75,15 @@ class CronRunRecord:
|
|||||||
duration_ms: int = 0
|
duration_ms: int = 0
|
||||||
error: str | None = None
|
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
|
@dataclass
|
||||||
class CronJobState:
|
class CronJobState:
|
||||||
@@ -52,6 +94,22 @@ class CronJobState:
|
|||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
run_history: list[CronRunRecord] = field(default_factory=list)
|
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
|
@dataclass
|
||||||
class CronJob:
|
class CronJob:
|
||||||
@@ -78,6 +136,23 @@ class CronJob:
|
|||||||
kwargs["state"] = CronJobState(**state_kwargs)
|
kwargs["state"] = CronJobState(**state_kwargs)
|
||||||
return cls(**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
|
@dataclass
|
||||||
class CronStore:
|
class CronStore:
|
||||||
|
|||||||
@@ -6,15 +6,11 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from nanobot.utils.dict_keys import get_camel_snake as _get
|
||||||
|
|
||||||
TriggerStatus = Literal["ok", "error"]
|
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
|
@dataclass
|
||||||
class TriggerRunRecord:
|
class TriggerRunRecord:
|
||||||
"""A single local trigger delivery record."""
|
"""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:
|
def test_add_job_rejects_unknown_timezone(tmp_path) -> None:
|
||||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user