fix(cron): normalize partial job records

This commit is contained in:
helix4u 2026-05-09 00:25:30 -06:00 committed by kshitij
parent f2afa68a4a
commit e407376c50
6 changed files with 122 additions and 11 deletions

View file

@ -72,6 +72,65 @@ def _apply_skill_fields(job: Dict[str, Any]) -> Dict[str, Any]:
return normalized 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): def _secure_dir(path: Path):
"""Set directory to owner-only access (0700). No-op on Windows.""" """Set directory to owner-only access (0700). No-op on Windows."""
try: try:
@ -533,11 +592,12 @@ def create_job(
else: else:
context_from = None 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 = { job = {
"id": job_id, "id": job_id,
"name": name or label_source[:50].strip(), "name": name or label_source[:50].strip(),
"prompt": prompt, "prompt": prompt_text,
"skills": normalized_skills, "skills": normalized_skills,
"skill": normalized_skills[0] if normalized_skills else None, "skill": normalized_skills[0] if normalized_skills else None,
"model": normalized_model, "model": normalized_model,
@ -581,13 +641,13 @@ def get_job(job_id: str) -> Optional[Dict[str, Any]]:
jobs = load_jobs() jobs = load_jobs()
for job in jobs: for job in jobs:
if job["id"] == job_id: if job["id"] == job_id:
return _apply_skill_fields(job) return _normalize_job_record(job)
return None return None
def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]: def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]:
"""List all jobs, optionally including disabled ones.""" """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: if not include_disabled:
jobs = [j for j in jobs if j.get("enabled", True)] jobs = [j for j in jobs if j.get("enabled", True)]
return jobs return jobs
@ -637,7 +697,7 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
jobs[i] = updated jobs[i] = updated
save_jobs(jobs) save_jobs(jobs)
return _apply_skill_fields(jobs[i]) return _normalize_job_record(jobs[i])
return None return None

View file

@ -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 result is used for prompt injection. When omitted, the script
(if any) runs inline as before. (if any) runs inline as before.
""" """
prompt = job.get("prompt", "") prompt = str(job.get("prompt") or "")
skills = job.get("skills") skills = job.get("skills")
# Run data-collection script if configured, inject output as context. # 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: if skills is None:
legacy = job.get("skill") legacy = job.get("skill")
skills = [legacy] if legacy else [] skills = [legacy] if legacy else []
elif isinstance(skills, str):
skills = [skills]
skill_names = [str(name).strip() for name in skills if str(name).strip()] skill_names = [str(name).strip() for name in skills if str(name).strip()]
if not skill_names: 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) Tuple of (success, full_output_doc, final_response, error_message)
""" """
job_id = job["id"] 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. # no_agent short-circuit — the script IS the job, no LLM involvement.

View file

@ -207,6 +207,26 @@ class TestJobCRUD:
jobs = list_jobs() jobs = list_jobs()
assert len(jobs) == 2 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): def test_remove_job(self, tmp_cron_dir):
job = create_job(prompt="Temp job", schedule="30m") job = create_job(prompt="Temp job", schedule="30m")
assert remove_job(job["id"]) is True assert remove_job(job["id"]) is True

View file

@ -1788,6 +1788,11 @@ class TestBuildJobPromptSilentHint:
result = _build_job_prompt(job) result = _build_job_prompt(job)
assert "[SILENT]" in result 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): def test_delivery_guidance_present(self):
"""Cron hint tells agents their final response is auto-delivered.""" """Cron hint tells agents their final response is auto-delivered."""
job = {"prompt": "Generate a report"} job = {"prompt": "Generate a report"}

View file

@ -122,6 +122,28 @@ class TestUnifiedCronjobTool:
assert listing["jobs"][0]["name"] == "Server Check" assert listing["jobs"][0]["name"] == "Server Check"
assert listing["jobs"][0]["state"] == "scheduled" 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): def test_pause_and_resume(self):
created = json.loads(cronjob(action="create", prompt="Check", schedule="every 1h")) created = json.loads(cronjob(action="create", prompt="Check", schedule="every 1h"))
job_id = created["job_id"] job_id = created["job_id"]

View file

@ -220,18 +220,20 @@ def _validate_cron_script_path(script: Optional[str]) -> Optional[str]:
def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: 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")) 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 = { result = {
"job_id": job["id"], "job_id": job_id,
"name": job["name"], "name": name,
"skill": skills[0] if skills else None, "skill": skills[0] if skills else None,
"skills": skills, "skills": skills,
"prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt, "prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt,
"model": job.get("model"), "model": job.get("model"),
"provider": job.get("provider"), "provider": job.get("provider"),
"base_url": job.get("base_url"), "base_url": job.get("base_url"),
"schedule": job.get("schedule_display"), "schedule": job.get("schedule_display") or "?",
"repeat": _repeat_display(job), "repeat": _repeat_display(job),
"deliver": job.get("deliver", "local"), "deliver": job.get("deliver", "local"),
"next_run_at": job.get("next_run_at"), "next_run_at": job.get("next_run_at"),