Add an optional 'script' parameter to cron jobs that references a Python script. The script runs before each agent turn, and its stdout is injected into the prompt as context. This enables stateful monitoring — the script handles data collection and change detection, the LLM analyzes and reports. - cron/jobs.py: add script field to create_job(), stored in job dict - cron/scheduler.py: add _run_job_script() executor with timeout handling, inject script output/errors into _build_job_prompt() - tools/cronjob_tools.py: add script to tool schema, create/update handlers, _format_job display - hermes_cli/cron.py: add --script to create/edit, display in list/edit output - hermes_cli/main.py: add --script argparse for cron create/edit subcommands - tests/cron/test_cron_script.py: 20 tests covering job CRUD, script execution, path resolution, error handling, prompt injection, tool API Script paths can be absolute or relative (resolved against ~/.hermes/scripts/). Scripts run with a 120s timeout. Failures are injected as error context so the LLM can report the problem. Empty string clears an attached script.
276 lines
9.6 KiB
Python
276 lines
9.6 KiB
Python
"""
|
|
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}")
|
|
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)
|