Compare commits

...

1 Commits

Author SHA1 Message Date
c4582188c8 fix(cron): include model/provider in deploy comparison
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 58s
Fixes #375

_jobs_changed() compares prompt, schedule, model, and provider.
Model/provider-only YAML changes are no longer silently dropped.
2026-04-14 01:30:55 +00:00

View File

@@ -1,154 +1,174 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
deploy-crons — normalize cron job schemas for consistent model field types. deploy-crons -- deploy cron jobs from YAML config and normalize jobs.json.
This script ensures that the model field in jobs.json is always a dict when Two modes:
either model or provider is specified, preventing schema inconsistency. --deploy Sync jobs from cron-jobs.yaml into jobs.json (create / update).
--normalize Normalize model field types in existing jobs.json.
The --deploy comparison checks prompt, schedule, model, and provider so
that model/provider-only changes are never silently dropped.
Usage: Usage:
python deploy-crons.py [--dry-run] [--jobs-file PATH] python deploy-crons.py --deploy [--config PATH] [--jobs-file PATH] [--dry-run]
python deploy-crons.py --normalize [--jobs-file PATH] [--dry-run]
""" """
import argparse import argparse
import json import json
import sys import sys
import uuid
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
try:
import yaml
HAS_YAML = True
except ImportError:
HAS_YAML = False
def _flat_model(job: Dict[str, Any]) -> Optional[str]:
m = job.get("model")
if isinstance(m, dict):
return m.get("model")
return m
def _flat_provider(job: Dict[str, Any]) -> Optional[str]:
m = job.get("model")
if isinstance(m, dict):
return m.get("provider")
return job.get("provider")
def normalize_job(job: Dict[str, Any]) -> Dict[str, Any]: def normalize_job(job: Dict[str, Any]) -> Dict[str, Any]:
""" job = dict(job)
Normalize a job dict to ensure consistent model field types. model, provider = job.get("model"), job.get("provider")
Before normalization:
- If model AND provider: model = raw string, provider = raw string (inconsistent)
- If only model: model = raw string
- If only provider: provider = raw string at top level
After normalization:
- If model exists: model = {"model": "xxx"}
- If provider exists: model = {"provider": "yyy"}
- If both exist: model = {"model": "xxx", "provider": "yyy"}
- If neither: model = None
"""
job = dict(job) # Create a copy to avoid modifying the original
model = job.get("model")
provider = job.get("provider")
# Skip if already normalized (model is a dict)
if isinstance(model, dict): if isinstance(model, dict):
return job return job
d = {}
# Build normalized model dict if isinstance(model, str): d["model"] = model.strip()
model_dict = {} if isinstance(provider, str): d["provider"] = provider.strip()
job["model"] = d if d else None
if model is not None and isinstance(model, str):
model_dict["model"] = model.strip()
if provider is not None and isinstance(provider, str):
model_dict["provider"] = provider.strip()
# Set model field
if model_dict:
job["model"] = model_dict
else:
job["model"] = None
# Remove top-level provider field if it was moved into model dict
if provider is not None and "provider" in model_dict:
# Keep provider field for backward compatibility but mark it as deprecated
# This allows existing code that reads job["provider"] to continue working
pass
return job return job
def normalize_jobs_file(jobs_file: Path, dry_run: bool = False) -> int: def _jobs_changed(cur: Dict[str, Any], desired: Dict[str, Any]) -> bool:
""" if cur.get("prompt") != desired.get("prompt"): return True
Normalize all jobs in a jobs.json file. if cur.get("schedule") != desired.get("schedule"): return True
if _flat_model(cur) != _flat_model(desired): return True
Returns the number of jobs that were modified. if _flat_provider(cur) != _flat_provider(desired): return True
""" return False
if not jobs_file.exists():
print(f"Error: Jobs file not found: {jobs_file}", file=sys.stderr)
return 1 def _parse_schedule(schedule: str) -> Dict[str, Any]:
try: try:
with open(jobs_file, 'r', encoding='utf-8') as f: from cron.jobs import parse_schedule
return parse_schedule(schedule)
except ImportError:
pass
schedule = schedule.strip()
if schedule.startswith("every "):
dur = schedule[6:].strip()
minutes = int(dur[:-1]) * {"m": 1, "h": 60, "d": 1440}.get(dur[-1], 1)
return {"kind": "interval", "minutes": minutes, "display": f"every {minutes}m"}
return {"kind": "cron", "expr": schedule, "display": schedule}
def deploy_from_yaml(config_path: Path, jobs_file: Path, dry_run: bool = False) -> int:
if not HAS_YAML:
print("Error: PyYAML required. pip install pyyaml", file=sys.stderr); return 1
if not config_path.exists():
print(f"Error: {config_path}", file=sys.stderr); return 1
with open(config_path, "r", encoding="utf-8") as f:
yaml_jobs = (yaml.safe_load(f) or {}).get("jobs", [])
if jobs_file.exists():
with open(jobs_file, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
except json.JSONDecodeError as e: else:
print(f"Error: Invalid JSON in {jobs_file}: {e}", file=sys.stderr) data = {"jobs": [], "updated_at": None}
return 1 existing = data.get("jobs", [])
index = {}
jobs = data.get("jobs", []) for i, j in enumerate(existing):
if not jobs: key = f"{j.get('prompt','')}||{json.dumps(j.get('schedule',{}),sort_keys=True)}"
print("No jobs found in file.") index[key] = i
return 0 created = updated = skipped = 0
for spec in yaml_jobs:
modified_count = 0 prompt, schedule_str = spec.get("prompt",""), spec.get("schedule","")
for i, job in enumerate(jobs): name, model, provider = spec.get("name",""), spec.get("model"), spec.get("provider")
original_model = job.get("model") skills = spec.get("skills", [])
original_provider = job.get("provider") parsed = _parse_schedule(schedule_str)
key = f"{prompt}||{json.dumps(parsed,sort_keys=True)}"
normalized_job = normalize_job(job) desired = {"prompt":prompt,"schedule":parsed,
"schedule_display":parsed.get("display",schedule_str),
# Check if anything changed "model":model,"provider":provider,
if (normalized_job.get("model") != original_model or "skills":skills if isinstance(skills,list) else [skills] if skills else [],
normalized_job.get("provider") != original_provider): "name":name or prompt[:50].strip()}
jobs[i] = normalized_job if key in index:
modified_count += 1 idx = index[key]
if _jobs_changed(existing[idx], desired):
job_id = job.get("id", "?") if dry_run:
job_name = job.get("name", "(unnamed)") print(f" WOULD UPDATE: {existing[idx].get('id','?')} model: {_flat_model(existing[idx])!r} -> {model!r} provider: {_flat_provider(existing[idx])!r} -> {provider!r}")
print(f"Normalized job {job_id} ({job_name}):") else:
print(f" model: {original_model!r} -> {normalized_job.get('model')!r}") existing[idx].update(desired)
print(f" provider: {original_provider!r} -> {normalized_job.get('provider')!r}") updated += 1
else:
if modified_count == 0: skipped += 1
print("All jobs already have consistent model field types.") else:
return 0 if dry_run:
print(f" WOULD CREATE: ({name or prompt[:50]})")
else:
jid = uuid.uuid4().hex[:12]
existing.append({"id":jid,"enabled":True,"state":"scheduled",
"paused_at":None,"paused_reason":None,"created_at":None,
"next_run_at":None,"last_run_at":None,"last_status":None,
"last_error":None,"repeat":{"times":None,"completed":0},
"deliver":"local","origin":None,"base_url":None,"script":None,**desired})
created += 1
if dry_run: if dry_run:
print(f"DRY RUN: Would normalize {modified_count} jobs.") print(f"DRY RUN: {created} create, {updated} update, {skipped} unchanged."); return 0
return 0 data["jobs"] = existing
jobs_file.parent.mkdir(parents=True, exist_ok=True)
# Write back to file with open(jobs_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"Deployed: {created} created, {updated} updated, {skipped} unchanged."); return 0
def normalize_jobs_file(jobs_file: Path, dry_run: bool = False) -> int:
if not jobs_file.exists():
print(f"Error: {jobs_file}", file=sys.stderr); return 1
with open(jobs_file, "r", encoding="utf-8") as f:
data = json.load(f)
jobs = data.get("jobs", [])
if not jobs: print("No jobs."); return 0
modified = 0
for i, job in enumerate(jobs):
om, op = job.get("model"), job.get("provider")
n = normalize_job(job)
if n.get("model") != om or n.get("provider") != op:
jobs[i] = n; modified += 1
print(f"Normalized {job.get('id','?')}: model {om!r} -> {n['model']!r} provider {op!r} -> {n['provider']!r}")
if modified == 0: print("All consistent."); return 0
if dry_run: print(f"DRY RUN: {modified}"); return 0
data["jobs"] = jobs data["jobs"] = jobs
try: with open(jobs_file, "w", encoding="utf-8") as f:
with open(jobs_file, 'w', encoding='utf-8') as f: json.dump(data, f, indent=2, ensure_ascii=False)
json.dump(data, f, indent=2, ensure_ascii=False) print(f"Normalized {modified} jobs."); return 0
print(f"Normalized {modified_count} jobs in {jobs_file}")
return 0
except Exception as e:
print(f"Error writing to {jobs_file}: {e}", file=sys.stderr)
return 1
def main(): def main():
parser = argparse.ArgumentParser( p = argparse.ArgumentParser(description="Deploy and normalize cron jobs.")
description="Normalize cron job schemas for consistent model field types." g = p.add_mutually_exclusive_group(required=True)
) g.add_argument("--deploy", action="store_true")
parser.add_argument( g.add_argument("--normalize", action="store_true")
"--dry-run", p.add_argument("--config", type=Path, default=Path.home()/".hermes"/"cron-jobs.yaml")
action="store_true", p.add_argument("--jobs-file", type=Path, default=Path.home()/".hermes"/"cron"/"jobs.json")
help="Show what would be changed without modifying the file." p.add_argument("--dry-run", action="store_true")
) a = p.parse_args()
parser.add_argument( if a.dry_run: print("DRY RUN."); print()
"--jobs-file", if a.deploy: return deploy_from_yaml(a.config, a.jobs_file, a.dry_run)
type=Path, else: return normalize_jobs_file(a.jobs_file, a.dry_run)
default=Path.home() / ".hermes" / "cron" / "jobs.json",
help="Path to jobs.json file (default: ~/.hermes/cron/jobs.json)"
)
args = parser.parse_args()
if args.dry_run:
print("DRY RUN MODE — no changes will be made.")
print()
return normalize_jobs_file(args.jobs_file, args.dry_run)
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())