""" Cron job storage and execution. Handles reading/writing CRONS.json and running jobs when they fire. Imported by scheduler.py (to load jobs at startup) and tools/cron.py (to add/remove jobs at runtime). Job schema: { "id": "c_abc123", "label": "Human-readable name", "schedule": "daily:09:00", # see parse_schedule() for all formats "type": "remind" | "note", "payload": "Text to write when the job fires", "enabled": true, "created_at": "ISO 8601", "last_run": null | "ISO 8601" } Job types: remind → appends to inara/REMINDERS.md (auto-loaded into Inara's context) note → appends to inara/SCRATCH.md (read on demand via scratch_read) """ import logging from datetime import datetime, timezone from pathlib import Path from config import settings logger = logging.getLogger(__name__) _DEFAULT_HOUR = 9 _DEFAULT_MINUTE = 0 _DOW = { "mon": "mon", "tue": "tue", "wed": "wed", "thu": "thu", "fri": "fri", "sat": "sat", "sun": "sun", "monday": "mon", "tuesday": "tue", "wednesday": "wed", "thursday": "thu", "friday": "fri", "saturday": "sat", "sunday": "sun", } # --------------------------------------------------------------------------- # Storage # --------------------------------------------------------------------------- def crons_path() -> Path: return settings.inara_path() / "CRONS.json" def load_crons() -> list[dict]: p = crons_path() if not p.exists(): return [] try: import json return json.loads(p.read_text()) except Exception: return [] def save_crons(crons: list[dict]) -> None: import json crons_path().write_text(json.dumps(crons, indent=2) + "\n") # --------------------------------------------------------------------------- # Schedule parsing # --------------------------------------------------------------------------- def parse_schedule(schedule: str) -> dict: """ Convert a human schedule string to APScheduler cron kwargs. Formats: "hourly" → every hour at :00 "daily" → every day at 09:00 "daily:HH:MM" → every day at HH:MM "weekly:DOW" → every DOW at 09:00 "weekly:DOW:HH:MM" → every DOW at HH:MM """ s = schedule.strip().lower() if s == "hourly": return {"minute": 0} if s == "daily": return {"hour": _DEFAULT_HOUR, "minute": _DEFAULT_MINUTE} if s.startswith("daily:"): h, m = _parse_hhmm(s[6:], schedule) return {"hour": h, "minute": m} if s.startswith("weekly:"): rest = s[7:].split(":") dow = _DOW.get(rest[0]) if not dow: raise ValueError( f"Unknown day of week {rest[0]!r}. " f"Use: mon tue wed thu fri sat sun" ) if len(rest) == 3: h, m = _parse_hhmm(f"{rest[1]}:{rest[2]}", schedule) else: h, m = _DEFAULT_HOUR, _DEFAULT_MINUTE return {"day_of_week": dow, "hour": h, "minute": m} raise ValueError( f"Unrecognised schedule {schedule!r}. " f"Valid formats: hourly | daily | daily:HH:MM | weekly:DOW | weekly:DOW:HH:MM" ) def _parse_hhmm(s: str, original: str) -> tuple[int, int]: parts = s.split(":") if len(parts) != 2: raise ValueError(f"Expected HH:MM in {original!r}, got {s!r}") return int(parts[0]), int(parts[1]) # --------------------------------------------------------------------------- # Execution # --------------------------------------------------------------------------- def _now_label() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") async def run_job(job: dict) -> None: """Execute a cron job. Called by APScheduler when the job fires.""" job_type = job.get("type") payload = job.get("payload", "").strip() label = job.get("label", job.get("id", "cron")) section = f"\n## {label} — {_now_label()}\n\n{payload}\n" inara_dir = settings.inara_path() if job_type == "remind": p = inara_dir / "REMINDERS.md" existing = p.read_text() if p.exists() else "" p.write_text(existing.rstrip() + "\n" + section) logger.info("cron [remind] fired: %s", label) elif job_type == "note": p = inara_dir / "SCRATCH.md" existing = p.read_text() if p.exists() else "" p.write_text(existing.rstrip() + "\n" + section) logger.info("cron [note] fired: %s", label) else: logger.warning("cron: unknown type %r (job %s)", job_type, job.get("id")) return # Record last_run crons = load_crons() for c in crons: if c["id"] == job["id"]: c["last_run"] = datetime.now(timezone.utc).isoformat() break save_crons(crons)