| """ |
| Cron subcommand for hermes CLI. |
| |
| Handles standalone cron management commands like list, create, edit, |
| pause/resume/run/remove, status, and tick. |
| """ |
|
|
| import json |
| import sys |
| from pathlib import Path |
| from typing import Iterable, List, Optional |
|
|
| PROJECT_ROOT = Path(__file__).parent.parent.resolve() |
| sys.path.insert(0, str(PROJECT_ROOT)) |
|
|
| from hermes_cli.colors import Colors, color |
|
|
|
|
| def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]: |
| if skills is None: |
| if single_skill is None: |
| return None |
| raw_items = [single_skill] |
| else: |
| raw_items = list(skills) |
|
|
| normalized: List[str] = [] |
| for item in raw_items: |
| text = str(item or "").strip() |
| if text and text not in normalized: |
| normalized.append(text) |
| return normalized |
|
|
|
|
| def _cron_api(**kwargs): |
| from tools.cronjob_tools import cronjob as cronjob_tool |
|
|
| return json.loads(cronjob_tool(**kwargs)) |
|
|
|
|
| def cron_list(show_all: bool = False): |
| """List all scheduled jobs.""" |
| from cron.jobs import list_jobs |
|
|
| jobs = list_jobs(include_disabled=show_all) |
|
|
| if not jobs: |
| print(color("No scheduled jobs.", Colors.DIM)) |
| print(color("Create one with 'hermes cron create ...' or the /cron command in chat.", Colors.DIM)) |
| return |
|
|
| print() |
| print(color("┌─────────────────────────────────────────────────────────────────────────┐", Colors.CYAN)) |
| print(color("│ Scheduled Jobs │", Colors.CYAN)) |
| print(color("└─────────────────────────────────────────────────────────────────────────┘", Colors.CYAN)) |
| print() |
|
|
| for job in jobs: |
| job_id = job.get("id", "?") |
| name = job.get("name", "(unnamed)") |
| schedule = job.get("schedule_display", job.get("schedule", {}).get("value", "?")) |
| state = job.get("state", "scheduled" if job.get("enabled", True) else "paused") |
| next_run = job.get("next_run_at", "?") |
|
|
| repeat_info = job.get("repeat", {}) |
| repeat_times = repeat_info.get("times") |
| repeat_completed = repeat_info.get("completed", 0) |
| repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else "∞" |
|
|
| deliver = job.get("deliver", ["local"]) |
| if isinstance(deliver, str): |
| deliver = [deliver] |
| deliver_str = ", ".join(deliver) |
|
|
| skills = job.get("skills") or ([job["skill"]] if job.get("skill") else []) |
| if state == "paused": |
| status = color("[paused]", Colors.YELLOW) |
| elif state == "completed": |
| status = color("[completed]", Colors.BLUE) |
| elif job.get("enabled", True): |
| status = color("[active]", Colors.GREEN) |
| else: |
| status = color("[disabled]", Colors.RED) |
|
|
| print(f" {color(job_id, Colors.YELLOW)} {status}") |
| print(f" Name: {name}") |
| print(f" Schedule: {schedule}") |
| print(f" Repeat: {repeat_str}") |
| print(f" Next run: {next_run}") |
| print(f" Deliver: {deliver_str}") |
| if skills: |
| print(f" Skills: {', '.join(skills)}") |
| script = job.get("script") |
| if script: |
| print(f" Script: {script}") |
|
|
| |
| last_status = job.get("last_status") |
| if last_status: |
| last_run = job.get("last_run_at", "?") |
| if last_status == "ok": |
| status_display = color("ok", Colors.GREEN) |
| else: |
| status_display = color(f"{last_status}: {job.get('last_error', '?')}", Colors.RED) |
| print(f" Last run: {last_run} {status_display}") |
|
|
| delivery_err = job.get("last_delivery_error") |
| if delivery_err: |
| print(f" {color('⚠ Delivery failed:', Colors.YELLOW)} {delivery_err}") |
|
|
| print() |
|
|
| from hermes_cli.gateway import find_gateway_pids |
| if not find_gateway_pids(): |
| print(color(" ⚠ Gateway is not running — jobs won't fire automatically.", Colors.YELLOW)) |
| print(color(" Start it with: hermes gateway install", Colors.DIM)) |
| print(color(" sudo hermes gateway install --system # Linux servers", Colors.DIM)) |
| print() |
|
|
|
|
| def cron_tick(): |
| """Run due jobs once and exit.""" |
| from cron.scheduler import tick |
| tick(verbose=True) |
|
|
|
|
| def cron_status(): |
| """Show cron execution status.""" |
| from cron.jobs import list_jobs |
| from hermes_cli.gateway import find_gateway_pids |
|
|
| print() |
|
|
| pids = find_gateway_pids() |
| if pids: |
| print(color("✓ Gateway is running — cron jobs will fire automatically", Colors.GREEN)) |
| print(f" PID: {', '.join(map(str, pids))}") |
| else: |
| print(color("✗ Gateway is not running — cron jobs will NOT fire", Colors.RED)) |
| print() |
| print(" To enable automatic execution:") |
| print(" hermes gateway install # Install as a user service") |
| print(" sudo hermes gateway install --system # Linux servers: boot-time system service") |
| print(" hermes gateway # Or run in foreground") |
|
|
| print() |
|
|
| jobs = list_jobs(include_disabled=False) |
| if jobs: |
| next_runs = [j.get("next_run_at") for j in jobs if j.get("next_run_at")] |
| print(f" {len(jobs)} active job(s)") |
| if next_runs: |
| print(f" Next run: {min(next_runs)}") |
| else: |
| print(" No active jobs") |
|
|
| print() |
|
|
|
|
| def cron_create(args): |
| result = _cron_api( |
| action="create", |
| schedule=args.schedule, |
| prompt=args.prompt, |
| name=getattr(args, "name", None), |
| deliver=getattr(args, "deliver", None), |
| repeat=getattr(args, "repeat", None), |
| skill=getattr(args, "skill", None), |
| skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)), |
| script=getattr(args, "script", None), |
| ) |
| if not result.get("success"): |
| print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED)) |
| return 1 |
| print(color(f"Created job: {result['job_id']}", Colors.GREEN)) |
| print(f" Name: {result['name']}") |
| print(f" Schedule: {result['schedule']}") |
| if result.get("skills"): |
| print(f" Skills: {', '.join(result['skills'])}") |
| job_data = result.get("job", {}) |
| if job_data.get("script"): |
| print(f" Script: {job_data['script']}") |
| print(f" Next run: {result['next_run_at']}") |
| return 0 |
|
|
|
|
| def cron_edit(args): |
| from cron.jobs import get_job |
|
|
| job = get_job(args.job_id) |
| if not job: |
| print(color(f"Job not found: {args.job_id}", Colors.RED)) |
| return 1 |
|
|
| existing_skills = list(job.get("skills") or ([] if not job.get("skill") else [job.get("skill")])) |
| replacement_skills = _normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)) |
| add_skills = _normalize_skills(None, getattr(args, "add_skills", None)) or [] |
| remove_skills = set(_normalize_skills(None, getattr(args, "remove_skills", None)) or []) |
|
|
| final_skills = None |
| if getattr(args, "clear_skills", False): |
| final_skills = [] |
| elif replacement_skills is not None: |
| final_skills = replacement_skills |
| elif add_skills or remove_skills: |
| final_skills = [skill for skill in existing_skills if skill not in remove_skills] |
| for skill in add_skills: |
| if skill not in final_skills: |
| final_skills.append(skill) |
|
|
| result = _cron_api( |
| action="update", |
| job_id=args.job_id, |
| schedule=getattr(args, "schedule", None), |
| prompt=getattr(args, "prompt", None), |
| name=getattr(args, "name", None), |
| deliver=getattr(args, "deliver", None), |
| repeat=getattr(args, "repeat", None), |
| skills=final_skills, |
| script=getattr(args, "script", None), |
| ) |
| if not result.get("success"): |
| print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED)) |
| return 1 |
|
|
| updated = result["job"] |
| print(color(f"Updated job: {updated['job_id']}", Colors.GREEN)) |
| print(f" Name: {updated['name']}") |
| print(f" Schedule: {updated['schedule']}") |
| if updated.get("skills"): |
| print(f" Skills: {', '.join(updated['skills'])}") |
| else: |
| print(" Skills: none") |
| if updated.get("script"): |
| print(f" Script: {updated['script']}") |
| return 0 |
|
|
|
|
| def _job_action(action: str, job_id: str, success_verb: str) -> int: |
| result = _cron_api(action=action, job_id=job_id) |
| if not result.get("success"): |
| print(color(f"Failed to {action} job: {result.get('error', 'unknown error')}", Colors.RED)) |
| return 1 |
| job = result.get("job") or result.get("removed_job") or {} |
| print(color(f"{success_verb} job: {job.get('name', job_id)} ({job_id})", Colors.GREEN)) |
| if action in {"resume", "run"} and result.get("job", {}).get("next_run_at"): |
| print(f" Next run: {result['job']['next_run_at']}") |
| if action == "run": |
| print(" It will run on the next scheduler tick.") |
| return 0 |
|
|
|
|
| def cron_command(args): |
| """Handle cron subcommands.""" |
| subcmd = getattr(args, 'cron_command', None) |
|
|
| if subcmd is None or subcmd == "list": |
| show_all = getattr(args, 'all', False) |
| cron_list(show_all) |
| return 0 |
|
|
| if subcmd == "status": |
| cron_status() |
| return 0 |
|
|
| if subcmd == "tick": |
| cron_tick() |
| return 0 |
|
|
| if subcmd in {"create", "add"}: |
| return cron_create(args) |
|
|
| if subcmd == "edit": |
| return cron_edit(args) |
|
|
| if subcmd == "pause": |
| return _job_action("pause", args.job_id, "Paused") |
|
|
| if subcmd == "resume": |
| return _job_action("resume", args.job_id, "Resumed") |
|
|
| if subcmd == "run": |
| return _job_action("run", args.job_id, "Triggered") |
|
|
| if subcmd in {"remove", "rm", "delete"}: |
| return _job_action("remove", args.job_id, "Removed") |
|
|
| print(f"Unknown cron command: {subcmd}") |
| print("Usage: hermes cron [list|create|edit|pause|resume|run|remove|status|tick]") |
| sys.exit(1) |
|
|