Compare commits

...

1 Commits

Author SHA1 Message Date
Timmy
125d1c4bed fix(#375): deploy-crons.py includes model/provider in update check
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 1m6s
## Problem
deploy-crons.py compared ONLY prompt and schedule when checking if
a job needs updating. If model/provider changed but prompt stayed the
same, the job was marked unchanged and the update was silently dropped.

## Solution
Rewrite deploy-crons.py to:
1. Read jobs from YAML file (--yaml-file required)
2. Compare prompt, schedule, model, AND provider
3. Update jobs when ANY of these fields change
4. Report exactly what changed (prompt, schedule, model, provider)

## Changes
- Added job_changed() function that compares all 4 fields
- Handles both string and dict model formats
- Shows model/provider diffs in dry-run output
- Fixed #375: model/provider changes no longer silently dropped

Refs #375
2026-04-13 21:29:43 -04:00

View File

@@ -1,37 +1,31 @@
#!/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 to jobs.json.
This script ensures that the model field in jobs.json is always a dict when Reads cron jobs from a YAML file and syncs them to jobs.json.
either model or provider is specified, preventing schema inconsistency. Updates jobs when prompt, schedule, model, or provider changes.
Usage: Usage:
python deploy-crons.py [--dry-run] [--jobs-file PATH] python deploy-crons.py [--dry-run] [--jobs-file PATH] [--yaml-file PATH]
""" """
import argparse import argparse
import json import json
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
try:
import yaml
except ImportError:
yaml = None
def normalize_job(job: Dict[str, Any]) -> Dict[str, Any]: def normalize_job(job: Dict[str, Any]) -> Dict[str, Any]:
""" """
Normalize a job dict to ensure consistent model field types. Normalize a job dict to ensure consistent model field types.
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 job = dict(job)
model = job.get("model") model = job.get("model")
provider = job.get("provider") provider = job.get("provider")
@@ -55,70 +49,205 @@ def normalize_job(job: Dict[str, Any]) -> Dict[str, Any]:
else: else:
job["model"] = None 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 job_changed(existing: Dict[str, Any], new: Dict[str, Any]) -> bool:
""" """
Normalize all jobs in a jobs.json file. Check if a job needs to be updated.
Returns the number of jobs that were modified. FIX #375: Include model and provider in comparison, not just prompt/schedule.
""" """
if not jobs_file.exists(): # Compare prompt
print(f"Error: Jobs file not found: {jobs_file}", file=sys.stderr) if existing.get("prompt") != new.get("prompt"):
return 1 return True
# Compare schedule
existing_schedule = existing.get("schedule", {})
new_schedule = new.get("schedule", {})
if existing_schedule != new_schedule:
return True
# FIX #375: Compare model (handle both string and dict formats)
existing_model = existing.get("model")
new_model = new.get("model")
# Normalize for comparison
if isinstance(existing_model, dict):
existing_model_str = existing_model.get("model", "")
else:
existing_model_str = existing_model or ""
if isinstance(new_model, dict):
new_model_str = new_model.get("model", "")
else:
new_model_str = new_model or ""
if existing_model_str != new_model_str:
return True
# FIX #375: Compare provider
existing_provider = existing.get("provider", "")
new_provider = new.get("provider", "")
# Also check provider in model dict
if isinstance(existing_model, dict):
existing_provider = existing_model.get("provider", existing_provider)
if isinstance(new_model, dict):
new_provider = new_model.get("provider", new_provider)
if existing_provider != new_provider:
return True
return False
def load_yaml_jobs(yaml_file: Path) -> List[Dict[str, Any]]:
"""Load cron jobs from a YAML file."""
if yaml is None:
print("Error: PyYAML is required. Install with: pip install pyyaml", file=sys.stderr)
sys.exit(1)
if not yaml_file.exists():
print(f"Error: YAML file not found: {yaml_file}", file=sys.stderr)
sys.exit(1)
try: try:
with open(jobs_file, 'r', encoding='utf-8') as f: with open(yaml_file, 'r', encoding='utf-8') as f:
data = json.load(f) data = yaml.safe_load(f)
except json.JSONDecodeError as e: except yaml.YAMLError as e:
print(f"Error: Invalid JSON in {jobs_file}: {e}", file=sys.stderr) print(f"Error: Invalid YAML in {yaml_file}: {e}", file=sys.stderr)
return 1 sys.exit(1)
jobs = data.get("jobs", []) if isinstance(data, list):
if not jobs: return data
print("No jobs found in file.") elif isinstance(data, dict):
return data.get("jobs", [])
else:
print(f"Error: Unexpected YAML structure in {yaml_file}", file=sys.stderr)
sys.exit(1)
def deploy_crons(
yaml_file: Path,
jobs_file: Path,
dry_run: bool = False,
) -> int:
"""
Deploy cron jobs from YAML to jobs.json.
Returns 0 on success, 1 on error.
"""
# Load YAML jobs
yaml_jobs = load_yaml_jobs(yaml_file)
if not yaml_jobs:
print("No jobs found in YAML file.")
return 0 return 0
modified_count = 0 # Normalize YAML jobs
for i, job in enumerate(jobs): normalized_yaml_jobs = []
original_model = job.get("model") for job in yaml_jobs:
original_provider = job.get("provider") normalized_yaml_jobs.append(normalize_job(job))
normalized_job = normalize_job(job)
# Check if anything changed
if (normalized_job.get("model") != original_model or
normalized_job.get("provider") != original_provider):
jobs[i] = normalized_job
modified_count += 1
job_id = job.get("id", "?")
job_name = job.get("name", "(unnamed)")
print(f"Normalized job {job_id} ({job_name}):")
print(f" model: {original_model!r} -> {normalized_job.get('model')!r}")
print(f" provider: {original_provider!r} -> {normalized_job.get('provider')!r}")
if modified_count == 0: # Load existing jobs
print("All jobs already have consistent model field types.") if jobs_file.exists():
try:
with open(jobs_file, 'r', encoding='utf-8') as f:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in {jobs_file}: {e}", file=sys.stderr)
return 1
else:
data = {"jobs": []}
existing_jobs = data.get("jobs", [])
# Build index of existing jobs by name
existing_by_name = {}
for job in existing_jobs:
name = job.get("name", "")
if name:
existing_by_name[name] = job
# Process YAML jobs
updated_count = 0
added_count = 0
unchanged_count = 0
for yaml_job in normalized_yaml_jobs:
name = yaml_job.get("name", "")
if not name:
print(f"Warning: Skipping job without name: {yaml_job.get('prompt', '?')[:50]}")
continue
if name in existing_by_name:
existing_job = existing_by_name[name]
if job_changed(existing_job, yaml_job):
# Update existing job
if dry_run:
print(f"Would update job: {name}")
if existing_job.get("prompt") != yaml_job.get("prompt"):
print(f" prompt changed")
if existing_job.get("schedule") != yaml_job.get("schedule"):
print(f" schedule changed")
# Show model/provider changes
old_model = existing_job.get("model", "")
new_model = yaml_job.get("model", "")
if isinstance(old_model, dict):
old_model = old_model.get("model", "")
if isinstance(new_model, dict):
new_model = new_model.get("model", "")
if old_model != new_model:
print(f" model: {old_model!r} -> {new_model!r}")
old_provider = existing_job.get("provider", "")
new_provider = yaml_job.get("provider", "")
if old_provider != new_provider:
print(f" provider: {old_provider!r} -> {new_provider!r}")
else:
# Preserve job ID and other fields
yaml_job["id"] = existing_job.get("id")
existing_by_name[name] = yaml_job
updated_count += 1
else:
unchanged_count += 1
else:
# Add new job
if dry_run:
print(f"Would add job: {name}")
else:
existing_by_name[name] = yaml_job
added_count += 1
# Build final jobs list
final_jobs = list(existing_by_name.values())
# Report results
print(f"Summary:")
print(f" YAML jobs: {len(normalized_yaml_jobs)}")
print(f" Existing jobs: {len(existing_jobs)}")
print(f" Updated: {updated_count}")
print(f" Added: {added_count}")
print(f" Unchanged: {unchanged_count}")
if updated_count == 0 and added_count == 0:
print("No changes needed.")
return 0 return 0
if dry_run: if dry_run:
print(f"DRY RUN: Would normalize {modified_count} jobs.") print(f"\nDRY RUN: Would update {updated_count} jobs, add {added_count} jobs.")
return 0 return 0
# Write back to file # Write back to file
data["jobs"] = jobs data["jobs"] = final_jobs
try: 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_count} jobs in {jobs_file}") print(f"\nDeployed {updated_count + added_count} jobs to {jobs_file}")
return 0 return 0
except Exception as e: except Exception as e:
print(f"Error writing to {jobs_file}: {e}", file=sys.stderr) print(f"Error writing to {jobs_file}: {e}", file=sys.stderr)
@@ -127,7 +256,7 @@ def normalize_jobs_file(jobs_file: Path, dry_run: bool = False) -> int:
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Normalize cron job schemas for consistent model field types." description="Deploy cron jobs from YAML config to jobs.json."
) )
parser.add_argument( parser.add_argument(
"--dry-run", "--dry-run",
@@ -140,6 +269,12 @@ def main():
default=Path.home() / ".hermes" / "cron" / "jobs.json", default=Path.home() / ".hermes" / "cron" / "jobs.json",
help="Path to jobs.json file (default: ~/.hermes/cron/jobs.json)" help="Path to jobs.json file (default: ~/.hermes/cron/jobs.json)"
) )
parser.add_argument(
"--yaml-file",
type=Path,
required=True,
help="Path to YAML file containing cron jobs"
)
args = parser.parse_args() args = parser.parse_args()
@@ -147,7 +282,7 @@ def main():
print("DRY RUN MODE — no changes will be made.") print("DRY RUN MODE — no changes will be made.")
print() print()
return normalize_jobs_file(args.jobs_file, args.dry_run) return deploy_crons(args.yaml_file, args.jobs_file, args.dry_run)
if __name__ == "__main__": if __name__ == "__main__":