diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e2d8e7946bd..52f7dd79333 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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} diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py new file mode 100644 index 00000000000..b992a69755f --- /dev/null +++ b/tests/hermes_cli/test_web_server_cron_profiles.py @@ -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 diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2b571b62771..b7e2ba6c575 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -138,21 +138,22 @@ export const api = { }, // Cron jobs - getCronJobs: () => fetchJSON("/api/cron/jobs"), - createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) => - fetchJSON("/api/cron/jobs", { + getCronJobs: (profile = "all") => + fetchJSON(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`), + createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }, profile = "default") => + fetchJSON(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(job), }), - pauseCronJob: (id: string) => - fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }), - resumeCronJob: (id: string) => - fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }), - triggerCronJob: (id: string) => - fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }), - deleteCronJob: (id: string) => - fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }), + pauseCronJob: (id: string, profile = "default") => + fetchJSON(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }), + resumeCronJob: (id: string, profile = "default") => + fetchJSON(`/api/cron/jobs/${encodeURIComponent(id)}/resume?profile=${encodeURIComponent(profile)}`, { method: "POST" }), + triggerCronJob: (id: string, profile = "default") => + fetchJSON(`/api/cron/jobs/${encodeURIComponent(id)}/trigger?profile=${encodeURIComponent(profile)}`, { method: "POST" }), + deleteCronJob: (id: string, profile = "default") => + fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }), // Profiles (minimal) getProfiles: () => @@ -553,6 +554,10 @@ export interface ModelsAnalyticsResponse { export interface CronJob { id: string; + profile?: string | null; + profile_name?: string | null; + hermes_home?: string | null; + is_default_profile?: boolean; name?: string | null; prompt?: string | null; script?: string | null; diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index ec2de520fb0..d5dffbc314b 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -6,7 +6,7 @@ import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { H2 } from "@/components/NouiTypography"; import { api } from "@/lib/api"; -import type { CronJob } from "@/lib/api"; +import type { CronJob, ProfileInfo } from "@/lib/api"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { useToast } from "@/hooks/useToast"; import { useConfirmDelete } from "@/hooks/useConfirmDelete"; @@ -69,6 +69,24 @@ function getJobState(job: CronJob): string { return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled"); } +function getJobProfile(job: CronJob): string { + return asText(job.profile) || asText(job.profile_name) || "default"; +} + +function getJobKey(job: CronJob): string { + return `${getJobProfile(job)}:${job.id}`; +} + +function splitJobKey(key: string): { profile: string; id: string } { + const idx = key.indexOf(":"); + if (idx === -1) return { profile: "default", id: key }; + return { profile: key.slice(0, idx) || "default", id: key.slice(idx + 1) }; +} + +function profileLabel(profile: string): string { + return profile === "default" ? "default" : profile; +} + const STATUS_TONE: Record = { enabled: "success", scheduled: "success", @@ -79,6 +97,8 @@ const STATUS_TONE: Record = { export default function CronPage() { const [jobs, setJobs] = useState([]); + const [profiles, setProfiles] = useState([]); + const [selectedProfile, setSelectedProfile] = useState("all"); const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); const { t } = useI18n(); @@ -96,14 +116,22 @@ export default function CronPage() { }); const [deliver, setDeliver] = useState("local"); const [creating, setCreating] = useState(false); + const createProfile = selectedProfile === "all" ? "default" : selectedProfile; const loadJobs = useCallback(() => { api - .getCronJobs() + .getCronJobs(selectedProfile) .then(setJobs) .catch(() => showToast(t.common.loading, "error")) .finally(() => setLoading(false)); - }, [showToast, t.common.loading]); + }, [selectedProfile, showToast, t.common.loading]); + + useEffect(() => { + api + .getProfiles() + .then((res) => setProfiles(res.profiles)) + .catch(() => setProfiles([])); + }, []); useEffect(() => { loadJobs(); @@ -116,12 +144,15 @@ export default function CronPage() { } setCreating(true); try { - await api.createCronJob({ - prompt: prompt.trim(), - schedule: schedule.trim(), - name: name.trim() || undefined, - deliver, - }); + await api.createCronJob( + { + prompt: prompt.trim(), + schedule: schedule.trim(), + name: name.trim() || undefined, + deliver, + }, + createProfile, + ); showToast(t.common.create + " ✓", "success"); setPrompt(""); setSchedule(""); @@ -139,14 +170,15 @@ export default function CronPage() { const handlePauseResume = async (job: CronJob) => { try { const isPaused = getJobState(job) === "paused"; + const profile = getJobProfile(job); if (isPaused) { - await api.resumeCronJob(job.id); + await api.resumeCronJob(job.id, profile); showToast( `${t.cron.resume}: "${truncateText(getJobTitle(job), 30)}"`, "success", ); } else { - await api.pauseCronJob(job.id); + await api.pauseCronJob(job.id, profile); showToast( `${t.cron.pause}: "${truncateText(getJobTitle(job), 30)}"`, "success", @@ -160,7 +192,7 @@ export default function CronPage() { const handleTrigger = async (job: CronJob) => { try { - await api.triggerCronJob(job.id); + await api.triggerCronJob(job.id, getJobProfile(job)); showToast( `${t.cron.triggerNow}: "${truncateText(getJobTitle(job), 30)}"`, "success", @@ -173,10 +205,11 @@ export default function CronPage() { const jobDelete = useConfirmDelete({ onDelete: useCallback( - async (id: string) => { - const job = jobs.find((j) => j.id === id); + async (key: string) => { + const { profile, id } = splitJobKey(key); + const job = jobs.find((j) => getJobKey(j) === key); try { - await api.deleteCronJob(id); + await api.deleteCronJob(id, profile); showToast( `${t.common.delete}: "${job ? truncateText(getJobTitle(job), 30) : id}"`, "success", @@ -216,7 +249,7 @@ export default function CronPage() { } const pendingJob = jobDelete.pendingId - ? jobs.find((j) => j.id === jobDelete.pendingId) + ? jobs.find((j) => getJobKey(j) === jobDelete.pendingId) : null; return ( @@ -270,6 +303,21 @@ export default function CronPage() {
+
+ + +
+
-

- - {t.cron.scheduledJobs} ({jobs.length}) -

+
+

+ + {t.cron.scheduledJobs} ({jobs.length}) +

+ +
+ + +
+
{jobs.length === 0 && ( @@ -367,9 +433,11 @@ export default function CronPage() { const title = getJobTitle(job); const hasName = Boolean(getJobName(job)); const deliver = asText(job.deliver); + const profile = getJobProfile(job); + const jobKey = getJobKey(job); return ( - +
@@ -379,6 +447,7 @@ export default function CronPage() { {state} + {profileLabel(profile)} {deliver && deliver !== "local" && ( {deliver} )} @@ -436,7 +505,7 @@ export default function CronPage() { size="icon" title={t.common.delete} aria-label={t.common.delete} - onClick={() => jobDelete.requestDelete(job.id)} + onClick={() => jobDelete.requestDelete(jobKey)} >