"""Cron job status service — read OpenClaw cron data from the gateway. Calls ``cron.list`` and ``cron.status`` RPC methods and normalises the results. This is read-only; cron toggle/run endpoints are out of scope for now. Parser is deliberately defensive: - Unknown keys are ignored (schema drift doesn't break anything). - Missing name → job entry skipped entirely. - All other missing fields default to None. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any from app.core.logging import get_logger from app.services.openclaw.gateway_rpc import ( GatewayConfig, OpenClawGatewayError, openclaw_call, ) logger = get_logger(__name__) # --------------------------------------------------------------------------- # Internal data type # --------------------------------------------------------------------------- @dataclass class CronJob: """Normalised representation of one cron job entry.""" name: str schedule: str = "" enabled: bool = True last_run: str | None = None # ISO-8601 string or None next_run: str | None = None # ISO-8601 string or None last_duration_ms: int | None = None last_status: str | None = None # "success", "error", "running", None last_error: str | None = None # --------------------------------------------------------------------------- # Status helper # --------------------------------------------------------------------------- _STATUS_MAP = { "success": "ok", "ok": "ok", "done": "ok", "error": "error", "failed": "error", "fail": "error", "running": "running", "active": "running", } def compute_job_status(job: CronJob) -> str: """Return a normalised status string for display. Returns: ``"disabled"`` | ``"pending"`` | ``"ok"`` | ``"error"`` | ``"running"`` | ``"unknown"`` """ if not job.enabled: return "disabled" if job.last_run is None: return "pending" raw = (job.last_status or "").lower().strip() return _STATUS_MAP.get(raw, "unknown") # --------------------------------------------------------------------------- # Parser — pure function # --------------------------------------------------------------------------- def _get_str(d: dict[str, Any], *keys: str) -> str | None: for k in keys: v = d.get(k) if v is not None and str(v).strip(): return str(v).strip() return None def _get_int(d: dict[str, Any], *keys: str) -> int | None: for k in keys: v = d.get(k) if v is not None: try: return int(float(v)) except (TypeError, ValueError): pass return None def _get_bool(d: dict[str, Any], *keys: str, default: bool = True) -> bool: for k in keys: v = d.get(k) if v is None: continue if isinstance(v, bool): return v if isinstance(v, (int, float)): return bool(v) if isinstance(v, str): return v.lower() not in {"false", "0", "no", "off", "disabled"} return default def parse_cron_jobs(raw: object) -> list[CronJob]: """Parse a ``cron.list`` response into a list of CronJob objects. Handles both camelCase and snake_case key variants. Entries without a recognisable name are silently dropped. Non-list input returns an empty list. """ if not isinstance(raw, list): return [] jobs: list[CronJob] = [] for item in raw: if not isinstance(item, dict): continue name = _get_str(item, "name", "title", "id", "key") if not name: continue job = CronJob( name=name, schedule=_get_str(item, "schedule", "cron", "expression") or "", enabled=_get_bool(item, "enabled", "active", "isEnabled"), last_run=_get_str(item, "lastRun", "last_run", "lastRunAt", "ran_at"), next_run=_get_str(item, "nextRun", "next_run", "nextRunAt", "next_at"), last_duration_ms=_get_int( item, "lastDuration", "last_duration", "duration_ms", "durationMs" ), last_status=_get_str( item, "lastStatus", "last_status", "result", "status" ), last_error=_get_str(item, "lastError", "last_error", "error"), ) jobs.append(job) return jobs # --------------------------------------------------------------------------- # Gateway fetch # --------------------------------------------------------------------------- async def fetch_cron_jobs(config: GatewayConfig) -> list[CronJob]: """Fetch and parse cron jobs from the gateway. Returns an empty list if the gateway does not support cron, the file is missing, or any error occurs. """ try: raw = await openclaw_call("cron.list", config=config) jobs = parse_cron_jobs(raw) logger.debug("cron_status.fetched count=%d", len(jobs)) return jobs except (OpenClawGatewayError, TimeoutError, OSError, RuntimeError) as exc: logger.debug("cron_status.fetch_failed error=%s", exc) return [] except Exception as exc: logger.warning("cron_status.fetch_unexpected error=%s", exc) return []