170 lines
5.2 KiB
Python
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 []
|