From 1a8d025c393ac0225f72c20fc989af6afcdf76fe Mon Sep 17 00:00:00 2001 From: NocGeek Date: Thu, 30 Apr 2026 14:38:10 -0400 Subject: [PATCH] feat: add profile-aware cron panel --- api/routes.py | 268 ++++++++++++++++++++------ static/index.html | 1 + static/panels.js | 176 +++++++++++++---- static/style.css | 8 + tests/test_cron_profile_visibility.py | 97 ++++++++++ 5 files changed, 447 insertions(+), 103 deletions(-) create mode 100644 tests/test_cron_profile_visibility.py diff --git a/api/routes.py b/api/routes.py index cb80185fb25..811c5da267e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -13,6 +13,7 @@ import threading import time import uuid +from contextlib import contextmanager from pathlib import Path from urllib.parse import parse_qs @@ -22,33 +23,167 @@ # Track job IDs currently being executed so the frontend can poll status. _RUNNING_CRON_JOBS: dict[str, float] = {} # job_id → start_timestamp _RUNNING_CRON_LOCK = threading.Lock() +_CRON_PROFILE_LOCK = threading.RLock() -def _mark_cron_running(job_id: str): +def _cron_run_key(job_id: str, profile: str | None = None) -> str: + return f"{profile}:{job_id}" if profile else job_id + + +def _mark_cron_running(job_id: str, profile: str | None = None): with _RUNNING_CRON_LOCK: - _RUNNING_CRON_JOBS[job_id] = time.time() + _RUNNING_CRON_JOBS[_cron_run_key(job_id, profile)] = time.time() -def _mark_cron_done(job_id: str): +def _mark_cron_done(job_id: str, profile: str | None = None): with _RUNNING_CRON_LOCK: - _RUNNING_CRON_JOBS.pop(job_id, None) + _RUNNING_CRON_JOBS.pop(_cron_run_key(job_id, profile), None) -def _is_cron_running(job_id: str) -> tuple[bool, float]: +def _is_cron_running(job_id: str, profile: str | None = None) -> tuple[bool, float]: """Return (is_running, elapsed_seconds).""" with _RUNNING_CRON_LOCK: - t = _RUNNING_CRON_JOBS.get(job_id) + t = _RUNNING_CRON_JOBS.get(_cron_run_key(job_id, profile)) if t is None: return False, 0.0 return True, time.time() - t -def _run_cron_tracked(job): +def _run_cron_tracked(job, profile: str | None = None, track_profile: str | None = None): """Wrapper that tracks running state around cron.scheduler.run_job.""" try: - run_job(job) + with _cron_profile_context(profile, include_scheduler=True): + from cron.scheduler import run_job + from cron.jobs import mark_job_run, save_job_output + + success, output, final_response, error = run_job(job) + save_job_output(job.get("id", ""), output) + if success and not final_response: + success = False + error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)" + mark_job_run(job.get("id", ""), success, error) + except Exception as e: + logger.exception("Manual cron run failed for job %s", job.get("id", "")) + try: + with _cron_profile_context(profile): + from cron.jobs import mark_job_run + + mark_job_run(job.get("id", ""), False, f"{type(e).__name__}: {e}") + except Exception: + logger.debug("Failed to mark manual cron run failure for %s", job.get("id", "")) finally: - _mark_cron_done(job.get("id", "")) + _mark_cron_done(job.get("id", ""), track_profile) + + +def _active_cron_profile() -> str: + try: + from api.profiles import get_active_profile_name + + return get_active_profile_name() or "default" + except Exception: + return "default" + + +def _cron_profile_home(profile: str | None) -> tuple[str, Path]: + name = (profile or _active_cron_profile() or "default").strip() or "default" + try: + from api.profiles import get_hermes_home_for_profile + + home = get_hermes_home_for_profile(name) + default_home = get_hermes_home_for_profile("default") + except Exception: + home = Path.home() / ".hermes" + default_home = home + name = "default" + if name != "default" and home == default_home: + # get_hermes_home_for_profile intentionally falls back for unknown or + # invalid names. Keep explicit requests from masquerading as a profile. + name = "default" + return name, home + + +@contextmanager +def _cron_profile_context(profile: str | None, *, include_scheduler: bool = False): + """Point cron module path globals at one profile for a single operation.""" + name, home = _cron_profile_home(profile) + with _CRON_PROFILE_LOCK: + import cron.jobs as cron_jobs + + jobs_state = { + "HERMES_DIR": cron_jobs.HERMES_DIR, + "CRON_DIR": cron_jobs.CRON_DIR, + "JOBS_FILE": cron_jobs.JOBS_FILE, + "OUTPUT_DIR": cron_jobs.OUTPUT_DIR, + } + scheduler = None + scheduler_state = {} + + cron_dir = home / "cron" + cron_jobs.HERMES_DIR = home + cron_jobs.CRON_DIR = cron_dir + cron_jobs.JOBS_FILE = cron_dir / "jobs.json" + cron_jobs.OUTPUT_DIR = cron_dir / "output" + if include_scheduler: + try: + import cron.scheduler as scheduler + + scheduler_state = { + "_hermes_home": scheduler._hermes_home, + "_LOCK_DIR": scheduler._LOCK_DIR, + "_LOCK_FILE": scheduler._LOCK_FILE, + } + except Exception: + scheduler = None + if scheduler is not None: + scheduler._hermes_home = home + scheduler._LOCK_DIR = cron_dir + scheduler._LOCK_FILE = cron_dir / ".tick.lock" + try: + yield name, home + finally: + for key, value in jobs_state.items(): + setattr(cron_jobs, key, value) + if scheduler is not None: + for key, value in scheduler_state.items(): + setattr(scheduler, key, value) + + +def _cron_profiles_from_query(parsed) -> list[str]: + qs = parse_qs(parsed.query) + raw_profiles = qs.get("profiles", [""])[0] + names = [] + if raw_profiles: + names.extend(p.strip() for p in raw_profiles.split(",") if p.strip()) + else: + raw_profile = qs.get("profile", [""])[0].strip() + if raw_profile: + names.append(raw_profile) + if not names: + names.append(_active_cron_profile()) + deduped = [] + for name in names: + if name not in deduped: + deduped.append(name) + return deduped + + +def _annotate_cron_job(job: dict, profile: str) -> dict: + annotated = dict(job) + annotated["profile"] = profile + return annotated + + +def _list_cron_jobs_for_profiles(profiles: list[str]) -> list[dict]: + from cron.jobs import list_jobs + + jobs = [] + for requested in profiles: + with _cron_profile_context(requested) as (profile, _home): + jobs.extend( + _annotate_cron_job(job, profile) + for job in list_jobs(include_disabled=True) + ) + return jobs _PROVIDER_ALIASES = { "claude": "anthropic", @@ -1148,9 +1283,7 @@ def handle_get(handler, parsed) -> bool: # ── Cron API (GET) ── if parsed.path == "/api/crons": - from cron.jobs import list_jobs - - return j(handler, {"jobs": list_jobs(include_disabled=True)}) + return j(handler, {"jobs": _list_cron_jobs_for_profiles(_cron_profiles_from_query(parsed))}) if parsed.path == "/api/crons/output": return _handle_cron_output(handler, parsed) @@ -2859,33 +2992,37 @@ def _make_label(mid): def _handle_cron_output(handler, parsed): - from cron.jobs import OUTPUT_DIR as CRON_OUT - qs = parse_qs(parsed.query) + profile = qs.get("profile", [""])[0].strip() or None job_id = qs.get("job_id", [""])[0] limit = int(qs.get("limit", ["5"])[0]) if not job_id: return j(handler, {"error": "job_id required"}, status=400) - out_dir = CRON_OUT / job_id - outputs = [] - if out_dir.exists(): - files = sorted(out_dir.glob("*.md"), reverse=True)[:limit] - for f in files: - try: - txt = f.read_text(encoding="utf-8", errors="replace") - outputs.append({"filename": f.name, "content": txt[:8000]}) - except Exception: - logger.debug("Failed to read cron output file %s", f) - return j(handler, {"job_id": job_id, "outputs": outputs}) + with _cron_profile_context(profile) as (resolved_profile, _home): + from cron.jobs import OUTPUT_DIR as CRON_OUT + + out_dir = CRON_OUT / job_id + outputs = [] + if out_dir.exists(): + files = sorted(out_dir.glob("*.md"), reverse=True)[:limit] + for f in files: + try: + txt = f.read_text(encoding="utf-8", errors="replace") + outputs.append({"filename": f.name, "content": txt[:8000]}) + except Exception: + logger.debug("Failed to read cron output file %s", f) + return j(handler, {"job_id": job_id, "profile": resolved_profile, "outputs": outputs}) def _handle_cron_status(handler, parsed): """Return running status for one or all cron jobs.""" qs = parse_qs(parsed.query) + profile = qs.get("profile", [""])[0].strip() or None job_id = qs.get("job_id", [""])[0] if job_id: - running, elapsed = _is_cron_running(job_id) - return j(handler, {"job_id": job_id, "running": running, "elapsed": round(elapsed, 1)}) + resolved_profile, _home = _cron_profile_home(profile) + running, elapsed = _is_cron_running(job_id, resolved_profile if profile else None) + return j(handler, {"job_id": job_id, "profile": resolved_profile, "running": running, "elapsed": round(elapsed, 1)}) # Return status for all running jobs with _RUNNING_CRON_LOCK: all_running = {jid: round(time.time() - t, 1) for jid, t in _RUNNING_CRON_JOBS.items()} @@ -2899,11 +3036,8 @@ def _handle_cron_recent(handler, parsed): qs = parse_qs(parsed.query) since = float(qs.get("since", ["0"])[0]) try: - from cron.jobs import list_jobs - - jobs = list_jobs(include_disabled=True) completions = [] - for job in jobs: + for job in _list_cron_jobs_for_profiles(_cron_profiles_from_query(parsed)): last_run = job.get("last_run_at") if not last_run: continue @@ -2920,6 +3054,7 @@ def _handle_cron_recent(handler, parsed): completions.append( { "job_id": job.get("id", ""), + "profile": job.get("profile", "default"), "name": job.get("name", "Unknown"), "status": job.get("last_status", "unknown"), "completed_at": ts, @@ -3358,17 +3493,18 @@ def _handle_cron_create(handler, body): except ValueError as e: return bad(handler, str(e)) try: - from cron.jobs import create_job - - job = create_job( - prompt=body["prompt"], - schedule=body["schedule"], - name=body.get("name") or None, - deliver=body.get("deliver") or "local", - skills=body.get("skills") or [], - model=body.get("model") or None, - ) - return j(handler, {"ok": True, "job": job}) + with _cron_profile_context(body.get("profile") or None) as (profile, _home): + from cron.jobs import create_job + + job = create_job( + prompt=body["prompt"], + schedule=body["schedule"], + name=body.get("name") or None, + deliver=body.get("deliver") or "local", + skills=body.get("skills") or [], + model=body.get("model") or None, + ) + return j(handler, {"ok": True, "job": _annotate_cron_job(job, profile)}) except Exception as e: return j(handler, {"error": str(e)}, status=400) @@ -3378,13 +3514,14 @@ def _handle_cron_update(handler, body): require(body, "job_id") except ValueError as e: return bad(handler, str(e)) - from cron.jobs import update_job + with _cron_profile_context(body.get("profile") or None) as (profile, _home): + from cron.jobs import update_job - updates = {k: v for k, v in body.items() if k != "job_id" and v is not None} - job = update_job(body["job_id"], updates) + updates = {k: v for k, v in body.items() if k not in ("job_id", "profile") and v is not None} + job = update_job(body["job_id"], updates) if not job: return bad(handler, "Job not found", 404) - return j(handler, {"ok": True, "job": job}) + return j(handler, {"ok": True, "job": _annotate_cron_job(job, profile)}) def _handle_cron_delete(handler, body): @@ -3392,43 +3529,47 @@ def _handle_cron_delete(handler, body): require(body, "job_id") except ValueError as e: return bad(handler, str(e)) - from cron.jobs import remove_job + with _cron_profile_context(body.get("profile") or None) as (profile, _home): + from cron.jobs import remove_job - ok = remove_job(body["job_id"]) + ok = remove_job(body["job_id"]) if not ok: return bad(handler, "Job not found", 404) - return j(handler, {"ok": True, "job_id": body["job_id"]}) + return j(handler, {"ok": True, "job_id": body["job_id"], "profile": profile}) def _handle_cron_run(handler, body): job_id = body.get("job_id", "") if not job_id: return bad(handler, "job_id required") - from cron.jobs import get_job - from cron.scheduler import run_job + requested_profile = body.get("profile") or None + with _cron_profile_context(requested_profile) as (profile, _home): + from cron.jobs import get_job - job = get_job(job_id) + job = get_job(job_id) if not job: return bad(handler, "Job not found", 404) # Prevent double-run: reject if the job is already tracked as running - already_running, elapsed = _is_cron_running(job_id) + track_profile = profile if requested_profile else None + already_running, elapsed = _is_cron_running(job_id, track_profile) if already_running: - return j(handler, {"ok": False, "job_id": job_id, "status": "already_running", + return j(handler, {"ok": False, "job_id": job_id, "profile": profile, "status": "already_running", "elapsed": round(elapsed, 1)}) - _mark_cron_running(job_id) - threading.Thread(target=_run_cron_tracked, args=(job,), daemon=True).start() - return j(handler, {"ok": True, "job_id": job_id, "status": "running"}) + _mark_cron_running(job_id, track_profile) + threading.Thread(target=_run_cron_tracked, args=(job, profile, track_profile), daemon=True).start() + return j(handler, {"ok": True, "job_id": job_id, "profile": profile, "status": "running"}) def _handle_cron_pause(handler, body): job_id = body.get("job_id", "") if not job_id: return bad(handler, "job_id required") - from cron.jobs import pause_job + with _cron_profile_context(body.get("profile") or None) as (profile, _home): + from cron.jobs import pause_job - result = pause_job(job_id, reason=body.get("reason")) + result = pause_job(job_id, reason=body.get("reason")) if result: - return j(handler, {"ok": True, "job": result}) + return j(handler, {"ok": True, "job": _annotate_cron_job(result, profile)}) return bad(handler, "Job not found", 404) @@ -3436,11 +3577,12 @@ def _handle_cron_resume(handler, body): job_id = body.get("job_id", "") if not job_id: return bad(handler, "job_id required") - from cron.jobs import resume_job + with _cron_profile_context(body.get("profile") or None) as (profile, _home): + from cron.jobs import resume_job - result = resume_job(job_id) + result = resume_job(job_id) if result: - return j(handler, {"ok": True, "job": result}) + return j(handler, {"ok": True, "job": _annotate_cron_job(result, profile)}) return bad(handler, "Job not found", 404) diff --git a/static/index.html b/static/index.html index d557724b21f..a21db7a8ba7 100644 --- a/static/index.html +++ b/static/index.html @@ -126,6 +126,7 @@ +
Loading...
diff --git a/static/panels.js b/static/panels.js index eeb1cee4fd2..8cc0078459e 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5,6 +5,7 @@ let _cronList = null; // cached cron jobs (array) let _currentCronDetail = null; // full cron job object let _cronMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit' let _cronPreFormDetail = null; // snapshot of prior selection when entering a form +const CRON_VISIBLE_PROFILES_KEY = 'hermes.cron.visibleProfiles'; let _currentWorkspaceDetail = null; // { path, name, is_default } let _workspaceMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit' let _workspacePreFormDetail = null; @@ -243,6 +244,7 @@ function _cronStatusMeta(job) { function _cronDiagnostics(job) { const fields = { + profile: job.profile || 'default', id: job.id, name: job.name || null, schedule: job.schedule || null, @@ -260,6 +262,83 @@ function _cronDiagnostics(job) { return JSON.stringify(fields, null, 2); } +function _readCronVisibleProfiles() { + try { + const raw = localStorage.getItem(CRON_VISIBLE_PROFILES_KEY); + const parsed = raw ? JSON.parse(raw) : null; + return Array.isArray(parsed) ? parsed.filter(Boolean).map(String) : []; + } catch(e) { + return []; + } +} + +function _writeCronVisibleProfiles(names) { + try { + localStorage.setItem(CRON_VISIBLE_PROFILES_KEY, JSON.stringify(names || [])); + } catch(e) {} +} + +function _cronVisibleProfiles(profiles) { + const available = new Set((profiles || []).map(p => p.name)); + const saved = _readCronVisibleProfiles().filter(name => !available.size || available.has(name)); + if (saved.length) return saved; + const active = S.activeProfile || 'default'; + if (!available.size || available.has(active)) return [active]; + return ['default']; +} + +function _cronProfileQuery(profiles) { + const names = _cronVisibleProfiles(profiles); + return names.length ? `?profiles=${encodeURIComponent(names.join(','))}` : ''; +} + +function _renderCronProfileFilter(profiles) { + const wrap = $('cronProfileFilter'); + if (!wrap) return; + if (!profiles || profiles.length <= 1) { + wrap.innerHTML = ''; + wrap.style.display = 'none'; + return; + } + wrap.style.display = ''; + const selected = new Set(_cronVisibleProfiles(profiles)); + wrap.innerHTML = profiles.map(p => { + const active = selected.has(p.name); + const gw = p.gateway_running ? '' : ''; + return ``; + }).join(''); + wrap.querySelectorAll('.cron-profile-toggle').forEach(btn => { + btn.onclick = async () => { + const name = btn.dataset.profile; + const next = new Set(_cronVisibleProfiles(profiles)); + if (next.has(name)) next.delete(name); + else next.add(name); + _writeCronVisibleProfiles([...next]); + _clearCronDetail(); + await loadCrons(true); + }; + }); +} + +async function _loadCronProfilesForFilter() { + try { + const data = await api('/api/profiles'); + _renderCronProfileFilter(data.profiles || []); + return data.profiles || []; + } catch(e) { + _renderCronProfileFilter([]); + return []; + } +} + +function _cronJobProfile(job) { + return (job && job.profile) || 'default'; +} + +function _cronProfileBadge(job) { + return `[${esc(_cronJobProfile(job))}]`; +} + async function loadCrons(animate) { const box = $('cronList'); const refreshBtn = $('cronRefreshBtn'); @@ -268,7 +347,8 @@ async function loadCrons(animate) { refreshBtn.disabled = true; } try { - const data = await api('/api/crons'); + const profiles = await _loadCronProfilesForFilter(); + const data = await api('/api/crons' + _cronProfileQuery(profiles)); _cronList = data.jobs || []; if (!_cronList.length) { box.innerHTML = `
${esc(t('cron_no_jobs'))}
`; @@ -280,21 +360,23 @@ async function loadCrons(animate) { const item = document.createElement('div'); item.className = 'cron-item'; item.id = 'cron-' + job.id; + item.dataset.profile = _cronJobProfile(job); const status = _cronStatusMeta(job); const isNewRun = _cronNewJobIds.has(String(job.id)); item.innerHTML = `
${isNewRun ? '' : ''} + ${_cronProfileBadge(job)} ${esc(job.name)} ${esc(status.label)}
`; - item.onclick = () => openCronDetail(job.id, item); - if (_currentCronDetail && _currentCronDetail.id === job.id) item.classList.add('active'); + item.onclick = () => openCronDetail(job.id, item, job.profile); + if (_currentCronDetail && _currentCronDetail.id === job.id && _cronJobProfile(_currentCronDetail) === _cronJobProfile(job)) item.classList.add('active'); box.appendChild(item); } // Re-render current detail with fresh data if we have one and we're not in a form if (_currentCronDetail && _cronMode !== 'create' && _cronMode !== 'edit') { - const refreshed = _cronList.find(j => j.id === _currentCronDetail.id); + const refreshed = _cronList.find(j => j.id === _currentCronDetail.id && _cronJobProfile(j) === _cronJobProfile(_currentCronDetail)); if (refreshed) _renderCronDetail(refreshed); else _clearCronDetail(); } @@ -315,6 +397,7 @@ function _renderCronDetail(job){ if (!title || !body) return; title.textContent = job.name || job.schedule_display || '(unnamed)'; const status = _cronStatusMeta(job); + const profile = _cronJobProfile(job); const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : t('not_available'); const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : t('never'); const schedule = job.schedule_display || (job.schedule && job.schedule.expression) || ''; @@ -341,6 +424,7 @@ function _renderCronDetail(job){ ${attentionBanner}
${esc(t('cron_status_active').replace(/./,c=>c.toUpperCase()))}
+
Profile
${esc(profile)}
Status
${esc(status.label)}
Schedule
${esc(schedule)}
${esc(t('cron_next'))}
${esc(nextRun)}
@@ -363,7 +447,7 @@ function _renderCronDetail(job){ _cronMode = 'read'; _setCronHeaderButtons('read', job); // Load runs asynchronously - _loadCronDetailRuns(job.id); + _loadCronDetailRuns(job.id, profile); } function _setCronHeaderButtons(mode, job) { @@ -395,10 +479,11 @@ function _setCronHeaderButtons(mode, job) { } } -async function _loadCronDetailRuns(jobId){ +async function _loadCronDetailRuns(jobId, profile){ try { - const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=20`); - if (!_currentCronDetail || _currentCronDetail.id !== jobId) return; + const profileParam = profile ? `&profile=${encodeURIComponent(profile)}` : ''; + const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=20${profileParam}`); + if (!_currentCronDetail || _currentCronDetail.id !== jobId || _cronJobProfile(_currentCronDetail) !== (profile || 'default')) return; const card = $('cronDetailRuns'); if (!card) return; if (!data.outputs || !data.outputs.length) { @@ -418,11 +503,13 @@ async function _loadCronDetailRuns(jobId){ } catch(e) { /* ignore */ } } -function openCronDetail(id, el){ - const job = _cronList ? _cronList.find(j => j.id === id) : null; +function openCronDetail(id, el, profile){ + const job = _cronList ? _cronList.find(j => j.id === id && (!profile || _cronJobProfile(j) === profile)) : null; if (!job) return; document.querySelectorAll('.cron-item').forEach(e => e.classList.remove('active')); - const target = el || $('cron-' + id); + const target = el || (profile + ? document.querySelector(`.cron-item[data-profile="${CSS.escape(profile)}"][id="cron-${CSS.escape(id)}"]`) + : $('cron-' + id)); if (target) target.classList.add('active'); // Remove new-run dot from this job since user is now viewing it _clearCronUnreadForJob(id); @@ -432,7 +519,7 @@ function openCronDetail(id, el){ _editingCronId = null; _stopCronWatch(); _renderCronDetail(job); - _checkCronWatchOnDetail(id); + _checkCronWatchOnDetail(id, _cronJobProfile(job)); } function _clearCronDetail(){ @@ -448,9 +535,9 @@ function _clearCronDetail(){ _setCronHeaderButtons('empty'); } -async function runCurrentCron(){ if (_currentCronDetail) await cronRun(_currentCronDetail.id); } -async function pauseCurrentCron(){ if (_currentCronDetail) await cronPause(_currentCronDetail.id); } -async function resumeCurrentCron(){ if (_currentCronDetail) await cronResume(_currentCronDetail.id); } +async function runCurrentCron(){ if (_currentCronDetail) await cronRun(_currentCronDetail.id, _cronJobProfile(_currentCronDetail)); } +async function pauseCurrentCron(){ if (_currentCronDetail) await cronPause(_currentCronDetail.id, _cronJobProfile(_currentCronDetail)); } +async function resumeCurrentCron(){ if (_currentCronDetail) await cronResume(_currentCronDetail.id, _cronJobProfile(_currentCronDetail)); } async function copyCurrentCronDiagnostics(){ if (!_currentCronDetail) return; try { @@ -501,7 +588,7 @@ async function deleteCurrentCron(){ const _ok = await showConfirmDialog({title:t('cron_delete_confirm_title'),message:t('cron_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true}); if(!_ok) return; try { - await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})}); + await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id, profile: _cronJobProfile(_currentCronDetail)})}); showToast(t('cron_job_deleted')); _clearCronDetail(); await loadCrons(); @@ -670,19 +757,20 @@ async function saveCronForm(){ if(!prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;} try{ if (_editingCronId) { - const updates = {job_id: _editingCronId, schedule, prompt}; + const updates = {job_id: _editingCronId, profile: _cronPreFormDetail ? _cronJobProfile(_cronPreFormDetail) : 'default', schedule, prompt}; if (name) updates.name = name; await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)}); const editedId = _editingCronId; + const editedProfile = updates.profile; _editingCronId = null; _cronPreFormDetail = null; showToast(t('cron_job_updated')); await loadCrons(); - const job = _cronList && _cronList.find(j => j.id === editedId); - if (job) openCronDetail(editedId); + const job = _cronList && _cronList.find(j => j.id === editedId && _cronJobProfile(j) === editedProfile); + if (job) openCronDetail(editedId, null, editedProfile); return; } - const body={schedule,prompt,deliver}; + const body={schedule,prompt,deliver,profile:S.activeProfile||'default'}; if(_cronIsDuplicate) body.enabled=false; if(name)body.name=name; if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills; @@ -692,8 +780,12 @@ async function saveCronForm(){ showToast(t('cron_job_created')); await loadCrons(); const newId = res && (res.id || (res.job && res.job.id)); - if (newId) openCronDetail(newId); - else if (_cronList && _cronList.length) openCronDetail(_cronList[_cronList.length - 1].id); + const newProfile = (res && res.job && res.job.profile) || body.profile; + if (newId) openCronDetail(newId, null, newProfile); + else if (_cronList && _cronList.length) { + const newest = _cronList[_cronList.length - 1]; + openCronDetail(newest.id, null, _cronJobProfile(newest)); + } }catch(e){ errEl.textContent=t('error_prefix')+e.message;errEl.style.display=''; } @@ -716,21 +808,22 @@ let _cronWatchInterval = null; let _cronWatchStart = null; let _cronWatchTimerInterval = null; -function _startCronWatch(jobId) { +function _startCronWatch(jobId, profile) { _stopCronWatch(); _cronWatchStart = Date.now(); _cronWatchInterval = setInterval(async () => { try { - const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`); + const profileParam = profile ? `&profile=${encodeURIComponent(profile)}` : ''; + const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}${profileParam}`); if (!data.running) { _stopCronWatch(); - if (_currentCronDetail && _currentCronDetail.id === jobId) { - _loadCronDetailRuns(jobId); + if (_currentCronDetail && _currentCronDetail.id === jobId && _cronJobProfile(_currentCronDetail) === (profile || 'default')) { + _loadCronDetailRuns(jobId, profile); } return; } // Still running — update elapsed - if (_currentCronDetail && _currentCronDetail.id === jobId) { + if (_currentCronDetail && _currentCronDetail.id === jobId && _cronJobProfile(_currentCronDetail) === (profile || 'default')) { const el = $('cronRunningIndicator'); if (el) el.querySelector('.cron-watch-elapsed').textContent = _formatElapsed(data.elapsed); } @@ -744,7 +837,7 @@ function _startCronWatch(jobId) { } }, 1000); // Inject running indicator into detail card - if (_currentCronDetail && _currentCronDetail.id === jobId) { + if (_currentCronDetail && _currentCronDetail.id === jobId && _cronJobProfile(_currentCronDetail) === (profile || 'default')) { _injectRunningIndicator(); } } @@ -774,34 +867,35 @@ function _formatElapsed(seconds) { return m + 'm ' + s + 's'; } -function _checkCronWatchOnDetail(jobId) { +function _checkCronWatchOnDetail(jobId, profile) { // When opening a detail view, check if job is running - api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`).then(data => { - if (data.running && _currentCronDetail && _currentCronDetail.id === jobId) { - _startCronWatch(jobId); + const profileParam = profile ? `&profile=${encodeURIComponent(profile)}` : ''; + api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}${profileParam}`).then(data => { + if (data.running && _currentCronDetail && _currentCronDetail.id === jobId && _cronJobProfile(_currentCronDetail) === (profile || 'default')) { + _startCronWatch(jobId, profile); } }).catch(() => {}); } -async function cronRun(id) { +async function cronRun(id, profile) { try { - await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})}); + await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id, profile})}); showToast(t('cron_job_triggered')); - _startCronWatch(id); + _startCronWatch(id, profile); } catch(e) { showToast(t('failed_colon') + e.message, 4000); } } -async function cronPause(id) { +async function cronPause(id, profile) { try { - await api('/api/crons/pause', {method:'POST', body: JSON.stringify({job_id: id})}); + await api('/api/crons/pause', {method:'POST', body: JSON.stringify({job_id: id, profile})}); showToast(t('cron_job_paused')); await loadCrons(); } catch(e) { showToast(t('failed_colon') + e.message, 4000); } } -async function cronResume(id) { +async function cronResume(id, profile) { try { - await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})}); + await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id, profile})}); showToast(t('cron_job_resumed')); await loadCrons(); } catch(e) { showToast(t('failed_colon') + e.message, 4000); } @@ -3167,7 +3261,9 @@ function startCronPolling(){ _cronPollTimer=setInterval(async()=>{ if(document.hidden) return; // don't poll when tab is in background try{ - const data=await api(`/api/crons/recent?since=${_cronPollSince}`); + const profiles = await _loadCronProfilesForFilter(); + const profileQuery = _cronProfileQuery(profiles); + const data=await api(`/api/crons/recent${profileQuery}${profileQuery ? '&' : '?'}since=${_cronPollSince}`); if(data.completions&&data.completions.length>0){ for(const c of data.completions){ showToast(t('cron_completion_status', c.name, c.status==='error' ? t('status_failed') : t('status_completed')),4000); diff --git a/static/style.css b/static/style.css index e63253f173f..ad705a1db3c 100644 --- a/static/style.css +++ b/static/style.css @@ -530,9 +530,17 @@ .panel-view.active{display:flex;} /* Cron panel */ .cron-list{flex:1;overflow-y:auto;padding:8px;} + .cron-profile-filter{display:flex;gap:6px;flex-wrap:wrap;padding:8px;border-bottom:1px solid var(--border);} + .cron-profile-toggle{display:inline-flex;align-items:center;gap:6px;min-width:0;max-width:100%;border:1px solid var(--border);background:rgba(255,255,255,.03);color:var(--muted);border-radius:8px;padding:5px 8px;font-size:11px;font-weight:600;cursor:pointer;} + .cron-profile-toggle span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} + .cron-profile-toggle:hover{border-color:var(--border2);color:var(--text);} + .cron-profile-toggle.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--accent-text);} + .cron-profile-dot{width:7px;height:7px;border-radius:50%;background:rgba(255,255,255,.2);flex-shrink:0;} + .cron-profile-dot.running{background:#4caf50;box-shadow:0 0 4px rgba(76,175,80,.5);} .cron-item{width:100%;min-width:0;box-sizing:border-box;border-radius:10px;border:1px solid var(--border);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);cursor:pointer;} .cron-item:hover{border-color:var(--border2);} .cron-header{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;} + .cron-profile-label{font-size:10px;font-weight:700;color:var(--muted);flex-shrink:0;} .cron-name{flex:1;font-size:13px;color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .cron-status{font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;flex-shrink:0;} .cron-status.active{background:rgba(34,197,94,.15);color:var(--success);} diff --git a/tests/test_cron_profile_visibility.py b/tests/test_cron_profile_visibility.py new file mode 100644 index 00000000000..9de93ef1459 --- /dev/null +++ b/tests/test_cron_profile_visibility.py @@ -0,0 +1,97 @@ +import os +import sys +import types + + +def test_cron_listing_reads_each_profile_without_mutating_env(tmp_path, monkeypatch): + import api.profiles as profiles + import api.routes as routes + + default_home = tmp_path + cronbot_home = tmp_path / "profiles" / "cronbot" + cronbot_home.mkdir(parents=True) + + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", default_home) + monkeypatch.setattr(profiles, "_active_profile", "default") + monkeypatch.setattr(profiles._tls, "profile", None, raising=False) + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.HERMES_DIR = default_home + cron_jobs.CRON_DIR = default_home / "cron" + cron_jobs.JOBS_FILE = default_home / "cron" / "jobs.json" + cron_jobs.OUTPUT_DIR = default_home / "cron" / "output" + + def list_jobs(include_disabled=False): + return [{ + "id": cron_jobs.HERMES_DIR.name or "default", + "name": f"job in {cron_jobs.HERMES_DIR}", + "prompt": "hello", + "enabled": True, + "schedule_display": "every 1h", + }] + + cron_jobs.list_jobs = list_jobs + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + monkeypatch.delitem(sys.modules, "cron.scheduler", raising=False) + monkeypatch.setenv("HERMES_HOME", "/should/not/change") + + jobs = routes._list_cron_jobs_for_profiles(["default", "cronbot"]) + + assert [job["profile"] for job in jobs] == ["default", "cronbot"] + assert str(default_home) in jobs[0]["name"] + assert str(cronbot_home) in jobs[1]["name"] + assert os.environ["HERMES_HOME"] == "/should/not/change" + assert cron_jobs.HERMES_DIR == default_home + + +def test_cron_query_supports_profile_and_profiles_params(): + from urllib.parse import urlparse + import api.routes as routes + + single = routes._cron_profiles_from_query(urlparse("/api/crons?profile=cronbot")) + multi = routes._cron_profiles_from_query(urlparse("/api/crons?profiles=default,cronbot,default")) + + assert single == ["cronbot"] + assert multi == ["default", "cronbot"] + + +def test_manual_cron_run_saves_output_and_marks_job(tmp_path, monkeypatch): + import api.profiles as profiles + import api.routes as routes + + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path) + monkeypatch.setattr(profiles, "_active_profile", "default") + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.HERMES_DIR = tmp_path + cron_jobs.CRON_DIR = tmp_path / "cron" + cron_jobs.JOBS_FILE = tmp_path / "cron" / "jobs.json" + cron_jobs.OUTPUT_DIR = tmp_path / "cron" / "output" + calls = [] + + cron_jobs.save_job_output = lambda job_id, output: calls.append(("save", job_id, output)) + cron_jobs.mark_job_run = lambda job_id, success, error=None: calls.append(("mark", job_id, success, error)) + + cron_scheduler = types.ModuleType("cron.scheduler") + cron_scheduler._hermes_home = tmp_path + cron_scheduler._LOCK_DIR = tmp_path / "cron" + cron_scheduler._LOCK_FILE = tmp_path / "cron" / ".tick.lock" + cron_scheduler.run_job = lambda job: (True, "output doc", "final response", None) + + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + monkeypatch.setitem(sys.modules, "cron.scheduler", cron_scheduler) + + routes._mark_cron_running("job123", "default") + routes._run_cron_tracked({"id": "job123"}, "default", "default") + + assert calls == [ + ("save", "job123", "output doc"), + ("mark", "job123", True, None), + ] + assert routes._is_cron_running("job123", "default") == (False, 0.0)