diff --git a/cron/jobs.py b/cron/jobs.py index 1f5e84ad5383..c1c8f47cae78 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -429,21 +429,17 @@ def load_jobs() -> List[Dict[str, Any]]: if not JOBS_FILE.exists(): return [] + _strict_retry = False # track whether we used the strict=False fallback + try: with open(JOBS_FILE, 'r', encoding='utf-8') as f: data = json.load(f) - return data.get("jobs", []) except json.JSONDecodeError: # Retry with strict=False to handle bare control chars in string values + _strict_retry = True try: with open(JOBS_FILE, 'r', encoding='utf-8') as f: data = json.loads(f.read(), strict=False) - jobs = data.get("jobs", []) - if jobs: - # Auto-repair: rewrite with proper escaping - save_jobs(jobs) - logger.warning("Auto-repaired jobs.json (had invalid control characters)") - return jobs except Exception as e: logger.error("Failed to auto-repair jobs.json: %s", e) raise RuntimeError(f"Cron database corrupted and unrepairable: {e}") from e @@ -451,6 +447,25 @@ def load_jobs() -> List[Dict[str, Any]]: logger.error("IOError reading jobs.json: %s", e) raise RuntimeError(f"Failed to read cron database: {e}") from e + # Validate top-level JSON shape: accept dict (expected) or bare list (auto-repair). + if isinstance(data, dict): + jobs = data.get("jobs", []) + if _strict_retry and jobs: + # We hit control-character corruption; rewrite with proper escaping + save_jobs(jobs) + logger.warning("Auto-repaired jobs.json (had invalid control characters)") + return jobs + if isinstance(data, list): + # Bare array — likely saved/edited outside save_jobs(). Auto-repair. + if data: + save_jobs(data) + logger.warning("Auto-repaired jobs.json (bare list → wrapped as dict)") + return data + + raise RuntimeError( + f"Cron database corrupted: expected {{'jobs': [...]}}, got {type(data).__name__}" + ) + def save_jobs(jobs: List[Dict[str, Any]]): """Save all jobs to storage."""