mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
fix(cron): normalize partial job records
This commit is contained in:
parent
f2afa68a4a
commit
e407376c50
6 changed files with 122 additions and 11 deletions
70
cron/jobs.py
70
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue