Pipeline/backend/app/services/openclaw/cron_status.py

170 lines
5.2 KiB
Python

"""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 []