diff --git a/cron/jobs.py b/cron/jobs.py index 566db1e6dbc..a7c87d223e1 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -72,6 +72,65 @@ def _apply_skill_fields(job: Dict[str, Any]) -> Dict[str, Any]: return normalized +def _coerce_job_text(value: Any, fallback: str = "") -> str: + """Coerce legacy/hand-edited nullable cron fields to strings for readers.""" + if value is None: + return fallback + return str(value) + + +def _schedule_display_for_job(job: Dict[str, Any]) -> str: + display = _coerce_job_text(job.get("schedule_display")).strip() + if display: + return display + + schedule = job.get("schedule") + if isinstance(schedule, dict): + for key in ("display", "value", "expr", "run_at"): + text = _coerce_job_text(schedule.get(key)).strip() + if text: + return text + elif schedule is not None: + return str(schedule) + + return "?" + + +def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]: + """Return a read-safe cron job shape for UI/API/tool/scheduler consumers. + + Older or hand-edited jobs can have nullable fields like ``prompt``, + ``name``, or ``schedule_display``. Keep storage untouched on read, but + ensure consumers never crash while formatting or running those records. + """ + normalized = _apply_skill_fields(job) + job_id = _coerce_job_text(normalized.get("id"), "unknown") + prompt = _coerce_job_text(normalized.get("prompt")) + normalized["id"] = job_id + normalized["prompt"] = prompt + + name = _coerce_job_text(normalized.get("name")).strip() + if not name: + script = _coerce_job_text(normalized.get("script")).strip() + label_source = ( + prompt + or (normalized["skills"][0] if normalized.get("skills") else "") + or script + or job_id + or "cron job" + ) + name = label_source[:50].strip() or "cron job" + normalized["name"] = name + normalized["schedule_display"] = _schedule_display_for_job(normalized) + + state = _coerce_job_text(normalized.get("state")).strip() + if not state: + state = "scheduled" if normalized.get("enabled", True) else "paused" + normalized["state"] = state + + return normalized + + def _secure_dir(path: Path): """Set directory to owner-only access (0700). No-op on Windows.""" try: @@ -533,11 +592,12 @@ def create_job( else: context_from = None - label_source = (prompt or (normalized_skills[0] if normalized_skills else None) or (normalized_script if normalized_no_agent else None)) or "cron job" + prompt_text = _coerce_job_text(prompt) + label_source = (prompt_text or (normalized_skills[0] if normalized_skills else None) or (normalized_script if normalized_no_agent else None)) or "cron job" job = { "id": job_id, "name": name or label_source[:50].strip(), - "prompt": prompt, + "prompt": prompt_text, "skills": normalized_skills, "skill": normalized_skills[0] if normalized_skills else None, "model": normalized_model, @@ -581,13 +641,13 @@ def get_job(job_id: str) -> Optional[Dict[str, Any]]: jobs = load_jobs() for job in jobs: if job["id"] == job_id: - return _apply_skill_fields(job) + return _normalize_job_record(job) return None def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]: """List all jobs, optionally including disabled ones.""" - jobs = [_apply_skill_fields(j) for j in load_jobs()] + jobs = [_normalize_job_record(j) for j in load_jobs()] if not include_disabled: jobs = [j for j in jobs if j.get("enabled", True)] return jobs @@ -637,7 +697,7 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] jobs[i] = updated save_jobs(jobs) - return _apply_skill_fields(jobs[i]) + return _normalize_job_record(jobs[i]) return None diff --git a/cron/scheduler.py b/cron/scheduler.py index df5fdfbb629..7fda096031a 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -845,7 +845,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: result is used for prompt injection. When omitted, the script (if any) runs inline as before. """ - prompt = job.get("prompt", "") + prompt = str(job.get("prompt") or "") skills = job.get("skills") # Run data-collection script if configured, inject output as context. @@ -933,6 +933,8 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: if skills is None: legacy = job.get("skill") skills = [legacy] if legacy else [] + elif isinstance(skills, str): + skills = [skills] skill_names = [str(name).strip() for name in skills if str(name).strip()] if not skill_names: @@ -1015,7 +1017,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: Tuple of (success, full_output_doc, final_response, error_message) """ job_id = job["id"] - job_name = job["name"] + job_name = str(job.get("name") or job.get("prompt") or job_id or "cron job") # --------------------------------------------------------------- # no_agent short-circuit — the script IS the job, no LLM involvement. diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index 0405f997b14..af42ca444b2 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -207,6 +207,26 @@ class TestJobCRUD: jobs = list_jobs() assert len(jobs) == 2 + def test_list_jobs_normalizes_partial_legacy_records(self, tmp_cron_dir): + save_jobs([ + { + "id": "abc123deadbe", + "name": None, + "prompt": None, + "schedule_display": None, + "schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"}, + "enabled": True, + } + ]) + + jobs = list_jobs() + + assert jobs[0]["id"] == "abc123deadbe" + assert jobs[0]["name"] == "abc123deadbe" + assert jobs[0]["prompt"] == "" + assert jobs[0]["schedule_display"] == "every 60m" + assert jobs[0]["state"] == "scheduled" + def test_remove_job(self, tmp_cron_dir): job = create_job(prompt="Temp job", schedule="30m") assert remove_job(job["id"]) is True diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index ce213a9f396..e0cb1cc155e 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -1788,6 +1788,11 @@ class TestBuildJobPromptSilentHint: result = _build_job_prompt(job) assert "[SILENT]" in result + def test_hint_present_when_legacy_prompt_is_null(self): + job = {"id": "abc123deadbe", "name": None, "prompt": None} + result = _build_job_prompt(job) + assert "[SILENT]" in result + def test_delivery_guidance_present(self): """Cron hint tells agents their final response is auto-delivered.""" job = {"prompt": "Generate a report"} diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index ab6f8eef08a..ccb01edc56b 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -122,6 +122,28 @@ class TestUnifiedCronjobTool: assert listing["jobs"][0]["name"] == "Server Check" assert listing["jobs"][0]["state"] == "scheduled" + def test_list_handles_partial_legacy_job_records(self): + from cron.jobs import save_jobs + + save_jobs([ + { + "id": "abc123deadbe", + "name": None, + "prompt": None, + "schedule_display": None, + "schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"}, + "repeat": {"times": None, "completed": 0}, + "enabled": True, + } + ]) + + listing = json.loads(cronjob(action="list")) + + assert listing["success"] is True + assert listing["jobs"][0]["name"] == "abc123deadbe" + assert listing["jobs"][0]["prompt_preview"] == "" + assert listing["jobs"][0]["schedule"] == "every 60m" + def test_pause_and_resume(self): created = json.loads(cronjob(action="create", prompt="Check", schedule="every 1h")) job_id = created["job_id"] diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index b4cc4f69ecc..c9d0e9ade7e 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -220,18 +220,20 @@ def _validate_cron_script_path(script: Optional[str]) -> Optional[str]: def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: - prompt = job.get("prompt", "") + prompt = str(job.get("prompt") or "") skills = _canonical_skills(job.get("skill"), job.get("skills")) + job_id = str(job.get("id") or "unknown") + name = str(job.get("name") or prompt[:50] or (skills[0] if skills else "") or job_id or "cron job") result = { - "job_id": job["id"], - "name": job["name"], + "job_id": job_id, + "name": name, "skill": skills[0] if skills else None, "skills": skills, "prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt, "model": job.get("model"), "provider": job.get("provider"), "base_url": job.get("base_url"), - "schedule": job.get("schedule_display"), + "schedule": job.get("schedule_display") or "?", "repeat": _repeat_display(job), "deliver": job.get("deliver", "local"), "next_run_at": job.get("next_run_at"),