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
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

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
(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.

View file

@ -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

View file

@ -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"}

View file

@ -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"]

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]:
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"),