Skip to content
Open
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
28 changes: 27 additions & 1 deletion hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6780,6 +6780,30 @@ def _call_cron_for_profile(profile: Optional[str], func_name: str, *args, **kwar
return result


def _call_cron_scheduler_for_profile(profile: Optional[str], func_name: str, *args, **kwargs):
"""Run cron.scheduler helpers against the selected profile home."""
profile_name, home = _cron_profile_home(profile)
with _CRON_PROFILE_LOCK:
from cron import jobs as cron_jobs
from cron import scheduler as cron_scheduler

old_cron_dir = cron_jobs.CRON_DIR
old_jobs_file = cron_jobs.JOBS_FILE
old_output_dir = cron_jobs.OUTPUT_DIR
old_scheduler_home = cron_scheduler._hermes_home
cron_jobs.CRON_DIR = home / "cron"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main deliberately makes these globals fallback-only and routes cross-profile calls through use_cron_store() to avoid retargeting concurrent ticker I/O (cron/jobs.py:67-69, 118-134). Please rework this around the existing profile-scoped _fire_cron_job_for_profile path instead of restoring process-global routing.

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


def _find_cron_job_profile(job_id: str) -> Optional[str]:
for profile in _cron_profile_dicts():
name = str(profile.get("name") or "")
Expand Down Expand Up @@ -6955,7 +6979,9 @@ async def trigger_cron_job(job_id: str, profile: Optional[str] = None):
job = _call_cron_for_profile(selected, "trigger_job", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
_call_cron_scheduler_for_profile(selected, "tick")
refreshed = _call_cron_for_profile(selected, "get_job", job_id)
return refreshed or job

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tick() fires every due job, not just this job (cron/scheduler.py:3593), and its default synchronous mode waits for all dispatched jobs (cron/scheduler.py:3747-3757). This route should fire only the selected job through the existing profile-safe helper.


@app.delete("/api/cron/jobs/{job_id}")
Expand Down
65 changes: 65 additions & 0 deletions tests/hermes_cli/test_web_server_cron_profiles.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Regression tests for dashboard cron job profile routing."""

from unittest.mock import patch

import pytest
from fastapi import HTTPException

Expand Down Expand Up @@ -50,6 +52,37 @@ def test_call_cron_for_profile_routes_storage_and_restores_globals(isolated_prof
assert cron_jobs.OUTPUT_DIR == old_output_dir


def test_call_cron_scheduler_for_profile_routes_home_and_restores_globals(isolated_profiles):
from cron import jobs as cron_jobs
from cron import scheduler as cron_scheduler
from hermes_cli import web_server

observed = {}
old_cron_dir = cron_jobs.CRON_DIR
old_jobs_file = cron_jobs.JOBS_FILE
old_output_dir = cron_jobs.OUTPUT_DIR
old_scheduler_home = cron_scheduler._hermes_home

def fake_tick():
observed["cron_dir"] = cron_jobs.CRON_DIR
observed["jobs_file"] = cron_jobs.JOBS_FILE
observed["output_dir"] = cron_jobs.OUTPUT_DIR
observed["scheduler_home"] = cron_scheduler._hermes_home

with patch("cron.scheduler.tick", side_effect=fake_tick):
web_server._call_cron_scheduler_for_profile("worker_alpha", "tick")

assert observed["cron_dir"] == isolated_profiles["worker_alpha"] / "cron"
assert observed["jobs_file"] == isolated_profiles["worker_alpha"] / "cron" / "jobs.json"
assert observed["output_dir"] == isolated_profiles["worker_alpha"] / "cron" / "output"
assert observed["scheduler_home"] == isolated_profiles["worker_alpha"]

assert cron_jobs.CRON_DIR == old_cron_dir
assert cron_jobs.JOBS_FILE == old_jobs_file
assert cron_jobs.OUTPUT_DIR == old_output_dir
assert cron_scheduler._hermes_home == old_scheduler_home


@pytest.mark.asyncio
async def test_list_cron_jobs_all_includes_default_and_named_profiles(isolated_profiles):
from hermes_cli import web_server
Expand Down Expand Up @@ -131,6 +164,38 @@ async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_pr
assert worker_jobs[0]["enabled"] is False


@pytest.mark.asyncio
async def test_trigger_cron_job_ticks_selected_profile_and_returns_refreshed_job(isolated_profiles):
from cron import jobs as cron_jobs
from hermes_cli import web_server

worker_job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="run immediately",
schedule="every 1h",
name="run-now-worker",
)

def fake_tick():
cron_jobs.mark_job_run(worker_job["id"], success=True)

with patch("cron.scheduler.tick", side_effect=fake_tick) as tick_mock:
triggered = await web_server.trigger_cron_job(
worker_job["id"], profile="worker_alpha"
)

tick_mock.assert_called_once_with()
assert triggered["profile"] == "worker_alpha"
assert triggered["last_status"] == "ok"
assert triggered["last_run_at"] is not None

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 [job["id"] for job in worker_jobs] == [worker_job["id"]]


@pytest.mark.asyncio
async def test_update_cron_job_rejects_id_mutation(isolated_profiles):
"""Dashboard surfaces a 400 (not a 500 or silent rename) when an
Expand Down
Loading