Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 134 additions & 26 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2560,73 +2560,181 @@ class CronJobUpdate(BaseModel):
updates: dict


_CRON_PROFILE_LOCK = threading.RLock()


def _cron_profile_dicts() -> List[Dict[str, Any]]:
"""Return dashboard profile records, falling back to a directory scan."""
from hermes_cli import profiles as profiles_mod
try:
return [_profile_to_dict(p) for p in profiles_mod.list_profiles()]
except Exception:
_log.exception("Failed to list profiles for cron dashboard; falling back to directory scan")
return _fallback_profile_dicts(profiles_mod)


def _cron_profile_home(profile: Optional[str]) -> Tuple[str, Path]:
"""Resolve a profile query value to (profile_name, HERMES_HOME)."""
from hermes_cli import profiles as profiles_mod

raw = (profile or "default").strip() or "default"
try:
canon = profiles_mod.normalize_profile_name(raw)
profiles_mod.validate_profile_name(canon)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not profiles_mod.profile_exists(canon):
raise HTTPException(status_code=404, detail=f"Profile '{canon}' does not exist.")
return canon, profiles_mod.get_profile_dir(canon)


def _annotate_cron_job(job: Dict[str, Any], profile: str, home: Path) -> Dict[str, Any]:
annotated = dict(job)
annotated["profile"] = profile
annotated["profile_name"] = profile
annotated["hermes_home"] = str(home)
annotated["is_default_profile"] = profile == "default"
return annotated


def _call_cron_for_profile(profile: Optional[str], func_name: str, *args, **kwargs):
"""Run cron.jobs helpers against the selected profile's cron directory.

cron.jobs keeps CRON_DIR/JOBS_FILE/OUTPUT_DIR as module globals resolved
from the process HERMES_HOME at import time. The dashboard is a single
process that can inspect many profiles, so temporarily retarget those
globals while holding a lock and restore them immediately after the call.
"""
profile_name, home = _cron_profile_home(profile)
with _CRON_PROFILE_LOCK:
from cron import jobs as cron_jobs

old_cron_dir = cron_jobs.CRON_DIR
old_jobs_file = cron_jobs.JOBS_FILE
old_output_dir = cron_jobs.OUTPUT_DIR
cron_jobs.CRON_DIR = home / "cron"
cron_jobs.JOBS_FILE = cron_jobs.CRON_DIR / "jobs.json"
cron_jobs.OUTPUT_DIR = cron_jobs.CRON_DIR / "output"
try:
result = getattr(cron_jobs, func_name)(*args, **kwargs)
finally:
cron_jobs.CRON_DIR = old_cron_dir
cron_jobs.JOBS_FILE = old_jobs_file
cron_jobs.OUTPUT_DIR = old_output_dir

if isinstance(result, list):
return [_annotate_cron_job(j, profile_name, home) for j in result]
if isinstance(result, dict):
return _annotate_cron_job(result, profile_name, home)
return result


def _find_cron_job_profile(job_id: str) -> Optional[str]:
for profile in _cron_profile_dicts():
name = str(profile.get("name") or "")
if not name:
continue
jobs = _call_cron_for_profile(name, "list_jobs", True)
if any(j.get("id") == job_id or j.get("name") == job_id for j in jobs):
return name
return None


@app.get("/api/cron/jobs")
async def list_cron_jobs():
from cron.jobs import list_jobs
return list_jobs(include_disabled=True)
async def list_cron_jobs(profile: str = "all"):
requested = (profile or "all").strip()
if requested.lower() != "all":
return _call_cron_for_profile(requested, "list_jobs", True)

jobs: List[Dict[str, Any]] = []
for item in _cron_profile_dicts():
name = str(item.get("name") or "")
if not name:
continue
try:
jobs.extend(_call_cron_for_profile(name, "list_jobs", True))
except Exception:
_log.exception("Failed to list cron jobs for profile %s", name)
return jobs


@app.get("/api/cron/jobs/{job_id}")
async def get_cron_job(job_id: str):
from cron.jobs import get_job
job = get_job(job_id)
async def get_cron_job(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _call_cron_for_profile(selected, "get_job", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job


@app.post("/api/cron/jobs")
async def create_cron_job(body: CronJobCreate):
from cron.jobs import create_job
async def create_cron_job(body: CronJobCreate, profile: str = "default"):
try:
job = create_job(prompt=body.prompt, schedule=body.schedule,
name=body.name, deliver=body.deliver)
return job
return _call_cron_for_profile(
profile,
"create_job",
prompt=body.prompt,
schedule=body.schedule,
name=body.name,
deliver=body.deliver,
)
except Exception as e:
_log.exception("POST /api/cron/jobs failed")
raise HTTPException(status_code=400, detail=str(e))


@app.put("/api/cron/jobs/{job_id}")
async def update_cron_job(job_id: str, body: CronJobUpdate):
from cron.jobs import update_job
job = update_job(job_id, body.updates)
async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _call_cron_for_profile(selected, "update_job", job_id, body.updates)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job


@app.post("/api/cron/jobs/{job_id}/pause")
async def pause_cron_job(job_id: str):
from cron.jobs import pause_job
job = pause_job(job_id)
async def pause_cron_job(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _call_cron_for_profile(selected, "pause_job", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job


@app.post("/api/cron/jobs/{job_id}/resume")
async def resume_cron_job(job_id: str):
from cron.jobs import resume_job
job = resume_job(job_id)
async def resume_cron_job(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _call_cron_for_profile(selected, "resume_job", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job


@app.post("/api/cron/jobs/{job_id}/trigger")
async def trigger_cron_job(job_id: str):
from cron.jobs import trigger_job
job = trigger_job(job_id)
async def trigger_cron_job(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _call_cron_for_profile(selected, "trigger_job", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job


@app.delete("/api/cron/jobs/{job_id}")
async def delete_cron_job(job_id: str):
from cron.jobs import remove_job
if not remove_job(job_id):
async def delete_cron_job(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
if not _call_cron_for_profile(selected, "remove_job", job_id):
raise HTTPException(status_code=404, detail="Job not found")
return {"ok": True}

Expand Down
172 changes: 172 additions & 0 deletions tests/hermes_cli/test_web_server_cron_profiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""Regression tests for dashboard cron job profile routing."""

import pytest
from fastapi import HTTPException


@pytest.fixture()
def isolated_profiles(tmp_path, monkeypatch):
"""Give profile discovery an isolated default home with one named profile."""
from hermes_cli import profiles

default_home = tmp_path / ".hermes"
profiles_root = default_home / "profiles"
worker_home = profiles_root / "worker_alpha"

for home in (default_home, worker_home):
(home / "cron").mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("model: test-model\n", encoding="utf-8")

monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
return {"default": default_home, "worker_alpha": worker_home}


def test_call_cron_for_profile_routes_storage_and_restores_globals(isolated_profiles):
from cron import jobs as cron_jobs
from hermes_cli import web_server

old_cron_dir = cron_jobs.CRON_DIR
old_jobs_file = cron_jobs.JOBS_FILE
old_output_dir = cron_jobs.OUTPUT_DIR

job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="run scheduled task",
schedule="every 1h",
name="worker-alpha-scan",
)

assert job["profile"] == "worker_alpha"
assert job["profile_name"] == "worker_alpha"
assert job["hermes_home"] == str(isolated_profiles["worker_alpha"])
assert job["is_default_profile"] is False
assert (isolated_profiles["worker_alpha"] / "cron" / "jobs.json").exists()
assert not (isolated_profiles["default"] / "cron" / "jobs.json").exists()

assert cron_jobs.CRON_DIR == old_cron_dir
assert cron_jobs.JOBS_FILE == old_jobs_file
assert cron_jobs.OUTPUT_DIR == old_output_dir


@pytest.mark.asyncio
async def test_list_cron_jobs_all_includes_default_and_named_profiles(isolated_profiles):
from hermes_cli import web_server

default_job = web_server._call_cron_for_profile(
"default",
"create_job",
prompt="default heartbeat",
schedule="every 2h",
name="default-heartbeat",
)
worker_job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="worker heartbeat",
schedule="every 3h",
name="worker-alpha-heartbeat",
)

jobs = await web_server.list_cron_jobs(profile="all")
by_id = {job["id"]: job for job in jobs}

assert set(by_id) >= {default_job["id"], worker_job["id"]}
assert by_id[default_job["id"]]["profile"] == "default"
assert by_id[default_job["id"]]["is_default_profile"] is True
assert by_id[default_job["id"]]["hermes_home"] == str(isolated_profiles["default"])
assert by_id[worker_job["id"]]["profile"] == "worker_alpha"
assert by_id[worker_job["id"]]["is_default_profile"] is False
assert by_id[worker_job["id"]]["hermes_home"] == str(isolated_profiles["worker_alpha"])


@pytest.mark.asyncio
async def test_list_cron_jobs_specific_profile_filters_results(isolated_profiles):
from hermes_cli import web_server

web_server._call_cron_for_profile(
"default",
"create_job",
prompt="default only",
schedule="every 2h",
name="default-only",
)
worker_job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="worker only",
schedule="every 3h",
name="worker-only",
)

jobs = await web_server.list_cron_jobs(profile="worker_alpha")

assert [job["id"] for job in jobs] == [worker_job["id"]]
assert jobs[0]["profile"] == "worker_alpha"


@pytest.mark.asyncio
async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_profiles):
from hermes_cli import web_server

worker_job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
schedule="every 1h",
name="named-profile-job",
)

paused = await web_server.pause_cron_job(worker_job["id"])
assert paused["profile"] == "worker_alpha"
assert paused["enabled"] is False

default_jobs = await web_server.list_cron_jobs(profile="default")
worker_jobs = await web_server.list_cron_jobs(profile="worker_alpha")

assert default_jobs == []
assert len(worker_jobs) == 1
assert worker_jobs[0]["id"] == worker_job["id"]
assert worker_jobs[0]["enabled"] is False


@pytest.mark.asyncio
async def test_cron_delete_with_profile_deletes_only_target_profile(isolated_profiles):
from hermes_cli import web_server

default_job = web_server._call_cron_for_profile(
"default",
"create_job",
prompt="same-ish default",
schedule="every 1h",
name="shared-name",
)
worker_job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="same-ish worker",
schedule="every 1h",
name="shared-name-worker",
)

deleted = await web_server.delete_cron_job(worker_job["id"], profile="worker_alpha")
assert deleted == {"ok": True}

remaining_default = await web_server.list_cron_jobs(profile="default")
remaining_worker = await web_server.list_cron_jobs(profile="worker_alpha")
assert [job["id"] for job in remaining_default] == [default_job["id"]]
assert remaining_worker == []


@pytest.mark.asyncio
async def test_cron_profile_validation_errors(isolated_profiles):
from hermes_cli import web_server

with pytest.raises(HTTPException) as bad_name:
await web_server.list_cron_jobs(profile="../bad")
assert bad_name.value.status_code == 400

with pytest.raises(HTTPException) as missing:
await web_server.list_cron_jobs(profile="missing_profile")
assert missing.value.status_code == 404
Loading
Loading