diff --git a/api/profiles.py b/api/profiles.py index 11511da3..b56868a1 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -258,6 +258,24 @@ class cron_profile_context_for_home: _cj.OUTPUT_DIR = _cj.CRON_DIR / 'output' except (ImportError, AttributeError): logger.debug("cron_profile_context_for_home: cron.jobs unavailable") + + # cron.scheduler snapshots _hermes_home at import time and run_job() + # reads config/.env from that module global. Patch it alongside + # cron.jobs so manual WebUI runs actually execute under the selected + # profile, not merely write output metadata there (#617). + self._prev_cs = None + try: + import cron.scheduler as _cs + self._prev_cs = ( + getattr(_cs, '_hermes_home', None), + getattr(_cs, '_LOCK_DIR', None), + getattr(_cs, '_LOCK_FILE', None), + ) + _cs._hermes_home = self._home + _cs._LOCK_DIR = self._home / 'cron' + _cs._LOCK_FILE = _cs._LOCK_DIR / '.tick.lock' + except (ImportError, AttributeError): + logger.debug("cron_profile_context_for_home: cron.scheduler unavailable") except Exception: _cron_env_lock.release() raise @@ -275,6 +293,12 @@ class cron_profile_context_for_home: _cj.HERMES_DIR, _cj.CRON_DIR, _cj.JOBS_FILE, _cj.OUTPUT_DIR = self._prev_cj except (ImportError, AttributeError): pass + if getattr(self, '_prev_cs', None) is not None: + try: + import cron.scheduler as _cs + _cs._hermes_home, _cs._LOCK_DIR, _cs._LOCK_FILE = self._prev_cs + except (ImportError, AttributeError): + pass finally: _cron_env_lock.release() return False @@ -313,6 +337,20 @@ class cron_profile_context: _cj.OUTPUT_DIR = _cj.CRON_DIR / 'output' except (ImportError, AttributeError): logger.debug("cron_profile_context: cron.jobs unavailable; env-var only") + + self._prev_cs = None + try: + import cron.scheduler as _cs + self._prev_cs = ( + getattr(_cs, '_hermes_home', None), + getattr(_cs, '_LOCK_DIR', None), + getattr(_cs, '_LOCK_FILE', None), + ) + _cs._hermes_home = home + _cs._LOCK_DIR = home / 'cron' + _cs._LOCK_FILE = _cs._LOCK_DIR / '.tick.lock' + except (ImportError, AttributeError): + logger.debug("cron_profile_context: cron.scheduler unavailable; env-var only") except Exception: _cron_env_lock.release() raise @@ -333,6 +371,12 @@ class cron_profile_context: _cj.HERMES_DIR, _cj.CRON_DIR, _cj.JOBS_FILE, _cj.OUTPUT_DIR = self._prev_cj except (ImportError, AttributeError): pass + if getattr(self, '_prev_cs', None) is not None: + try: + import cron.scheduler as _cs + _cs._hermes_home, _cs._LOCK_DIR, _cs._LOCK_FILE = self._prev_cs + except (ImportError, AttributeError): + pass finally: _cron_env_lock.release() return False @@ -462,6 +506,14 @@ def _set_hermes_home(home: Path): except (ImportError, AttributeError): logger.debug("Failed to patch cron.jobs module") + try: + import cron.scheduler as _cs + _cs._hermes_home = home + _cs._LOCK_DIR = home / 'cron' + _cs._LOCK_FILE = _cs._LOCK_DIR / '.tick.lock' + except (ImportError, AttributeError): + logger.debug("Failed to patch cron.scheduler module") + def _reload_dotenv(home: Path): """Load .env from the profile dir into os.environ with profile isolation. diff --git a/api/routes.py b/api/routes.py index 5ae91e30..7f278512 100644 --- a/api/routes.py +++ b/api/routes.py @@ -245,52 +245,119 @@ def _cron_output_content_window(text: str, limit: int = _CRON_OUTPUT_CONTENT_LIM return text[-limit:] -def _run_cron_tracked(job, profile_home=None): + + +def _cron_job_for_api(job: dict) -> dict: + """Return a cron job payload with the #617 optional profile field present. + + Legacy jobs intentionally persist without ``profile`` so they keep the + scheduler's server-default behavior. The API still returns ``profile: None`` + so the UI can label that state explicitly instead of guessing. + """ + payload = dict(job or {}) + payload.setdefault("profile", None) + return payload + + +def _cron_jobs_for_api(jobs) -> list[dict]: + return [_cron_job_for_api(job) for job in (jobs or [])] + + +def _available_cron_profile_names() -> set[str]: + from api.profiles import list_profiles_api + + names = {"default"} + for profile in list_profiles_api(): + try: + name = str(profile.get("name") or "").strip() + except AttributeError: + continue + if name: + names.add(name) + return names + + +def _normalize_cron_profile_value(value) -> str | None: + if value is None: + return None + profile = str(value).strip() + if not profile: + return None + if profile not in _available_cron_profile_names(): + raise ValueError(f"Unknown profile: {profile}") + return profile + + +def _profile_home_for_cron_job(job: dict): + """Resolve the execution profile for a cron job, with graceful fallback. + + A missing/blank profile preserves legacy server-default behavior. If a job + points at a profile that was deleted after save, fall back to the active + server profile and log a warning instead of crashing the Run Now path. + """ + from api.profiles import get_active_hermes_home, get_hermes_home_for_profile + + raw = str((job or {}).get("profile") or "").strip() + if not raw: + return get_active_hermes_home() + if raw not in _available_cron_profile_names(): + logger.warning( + "Cron job %s references missing profile %r; falling back to server default", + (job or {}).get("id", "?"), raw, + ) + return get_active_hermes_home() + return get_hermes_home_for_profile(raw) + + +def _run_cron_tracked(job, profile_home=None, execution_profile_home=None): """Wrapper that tracks running state around cron.scheduler.run_job. - ``profile_home`` pins HERMES_HOME for this worker thread so output files - and run metadata land in the profile that triggered the run, not the - process-global default. Captured at dispatch time because the thread runs - after the HTTP request (and its TLS profile) has already been cleared. + ``profile_home`` is the cron store that owns the job row/output metadata. + ``execution_profile_home`` is the selected per-job profile used to load + agent config/.env while running. When no job profile is selected, both homes + are the same and legacy server-default behavior is preserved. """ from cron.scheduler import run_job # import here — runs inside a worker thread from cron.jobs import mark_job_run, save_job_output job_id = job.get("id", "") + execution_profile_home = execution_profile_home or profile_home - # Pin HERMES_HOME for the duration of this thread using a dedicated - # context manager variant that accepts the profile home directly - # (threads have no TLS, so get_active_hermes_home() can't resolve). - ctx = None - if profile_home is not None: + def _with_cron_home(home, fn): + if home is None: + return fn() from api.profiles import cron_profile_context_for_home - ctx = cron_profile_context_for_home(profile_home) - ctx.__enter__() + with cron_profile_context_for_home(home): + return fn() try: - success, output, final_response, error = run_job(job) - save_job_output(job_id, output) + success, output, final_response, error = _with_cron_home( + execution_profile_home, lambda: run_job(job) + ) - # Match the scheduled cron path: an apparently successful run with no - # final response should not leave the job looking healthy. - if success and not final_response: - success = False - error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)" + # Persist output and run metadata back to the job's owning cron store, + # even when the selected execution profile is different. + def _persist_success(): + save_job_output(job_id, output) - mark_job_run(job_id, success, error) + # Match the scheduled cron path: an apparently successful run with no + # final response should not leave the job looking healthy. + _success, _error = success, error + if _success and not final_response: + _success = False + _error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)" + + mark_job_run(job_id, _success, _error) + + _with_cron_home(profile_home, _persist_success) except Exception as e: logger.exception("Manual cron run failed for job %s", job_id) try: - mark_job_run(job_id, False, str(e)) + _with_cron_home(profile_home, lambda: mark_job_run(job_id, False, str(e))) except Exception: logger.debug("Failed to mark manual cron run failure for %s", job_id) finally: - if ctx is not None: - try: - ctx.__exit__(None, None, None) - except Exception: - logger.debug("Failed to release cron_profile_context for %s", job_id) _mark_cron_done(job_id) _PROVIDER_ALIASES = { @@ -2612,7 +2679,7 @@ def handle_get(handler, parsed) -> bool: from api.profiles import cron_profile_context with cron_profile_context(): - return j(handler, {"jobs": list_jobs(include_disabled=True)}) + return j(handler, {"jobs": _cron_jobs_for_api(list_jobs(include_disabled=True))}) if parsed.path == "/api/crons/output": from api.profiles import cron_profile_context @@ -5729,8 +5796,9 @@ def _handle_cron_create(handler, body): except ValueError as e: return bad(handler, str(e)) try: - from cron.jobs import create_job + from cron.jobs import create_job, update_job + profile = _normalize_cron_profile_value(body.get("profile")) job = create_job( prompt=body["prompt"], schedule=body["schedule"], @@ -5739,7 +5807,9 @@ def _handle_cron_create(handler, body): skills=body.get("skills") or [], model=body.get("model") or None, ) - return j(handler, {"ok": True, "job": job}) + if profile is not None: + job = update_job(job["id"], {"profile": profile}) or job + return j(handler, {"ok": True, "job": _cron_job_for_api(job)}) except Exception as e: return j(handler, {"error": str(e)}, status=400) @@ -5751,11 +5821,21 @@ def _handle_cron_update(handler, body): return bad(handler, str(e)) from cron.jobs import update_job - updates = {k: v for k, v in body.items() if k != "job_id" and v is not None} + try: + updates = {} + for k, v in body.items(): + if k == "job_id": + continue + if k == "profile": + updates[k] = _normalize_cron_profile_value(v) + elif v is not None: + updates[k] = v + except ValueError as e: + return bad(handler, str(e)) 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": _cron_job_for_api(job)}) def _handle_cron_delete(handler, body): @@ -5801,7 +5881,8 @@ def _handle_cron_run(handler, body): from api.profiles import get_active_hermes_home _profile_home = get_active_hermes_home() - threading.Thread(target=_run_cron_tracked, args=(job, _profile_home), daemon=True).start() + _execution_profile_home = _profile_home_for_cron_job(job) + threading.Thread(target=_run_cron_tracked, args=(job, _profile_home, _execution_profile_home), daemon=True).start() return j(handler, {"ok": True, "job_id": job_id, "status": "running"}) diff --git a/docs/pr-media/617/task-profile-badges.png b/docs/pr-media/617/task-profile-badges.png new file mode 100644 index 00000000..ae54288c Binary files /dev/null and b/docs/pr-media/617/task-profile-badges.png differ diff --git a/docs/pr-media/617/task-profile-selector.png b/docs/pr-media/617/task-profile-selector.png new file mode 100644 index 00000000..81f067e2 Binary files /dev/null and b/docs/pr-media/617/task-profile-selector.png differ diff --git a/static/i18n.js b/static/i18n.js index a67a924c..777e575f 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -904,6 +904,9 @@ const LOCALES = { cron_prompt_label: 'Prompt', cron_deliver_label: 'Deliver output to', cron_deliver_local: 'Local (save output only)', + cron_profile_label: 'Profile', + cron_profile_server_default: 'server default', + cron_profile_server_default_hint: 'Uses the WebUI server default profile at run time. Existing jobs without a profile keep this legacy behavior.', cron_skills_label: 'Skills', cron_skills_placeholder: 'Add skills (optional)…', cron_skills_edit_hint: 'Skill list is not editable after creation.', @@ -1874,6 +1877,9 @@ const LOCALES = { cron_prompt_label: 'プロンプト', cron_deliver_label: '出力先', cron_deliver_local: 'ローカル (出力を保存のみ)', + cron_profile_label: 'プロフィール', + cron_profile_server_default: 'サーバーデフォルト', + cron_profile_server_default_hint: '実行時に WebUI サーバーのデフォルトプロフィールを使用します。プロフィールのない既存ジョブはこの従来の動作を維持します。', cron_skills_label: 'スキル', cron_skills_placeholder: 'スキルを追加 (任意)…', cron_skills_edit_hint: 'スキル一覧は作成後に編集できません。', @@ -2657,6 +2663,9 @@ const LOCALES = { cron_prompt_label: 'Запрос', cron_deliver_label: 'Доставлять вывод', cron_deliver_local: 'Локально (только сохранение)', + cron_profile_label: 'Профиль', + cron_profile_server_default: 'по умолчанию сервера', + cron_profile_server_default_hint: 'Использует профиль WebUI-сервера по умолчанию во время запуска. Существующие задания без профиля сохраняют это поведение.', cron_skills_label: 'Навыки', cron_skills_placeholder: 'Добавить навыки (необязательно)…', cron_skills_edit_hint: 'Список навыков нельзя изменить после создания.', @@ -3549,6 +3558,9 @@ const LOCALES = { cron_prompt_label: 'Prompt', cron_deliver_label: 'Entregar salida a', cron_deliver_local: 'Local (solo guardar salida)', + cron_profile_label: 'Perfil', + cron_profile_server_default: 'predeterminado del servidor', + cron_profile_server_default_hint: 'Usa el perfil predeterminado del servidor WebUI durante la ejecución. Los trabajos existentes sin perfil conservan este comportamiento heredado.', cron_skills_label: 'Habilidades', cron_skills_placeholder: 'Añadir habilidades (opcional)…', cron_skills_edit_hint: 'La lista de habilidades no es editable después de crear.', @@ -4186,6 +4198,9 @@ const LOCALES = { cron_prompt_label: 'Prompt', cron_deliver_label: 'Ausgabe senden an', cron_deliver_local: 'Lokal (nur speichern)', + cron_profile_label: 'Profil', + cron_profile_server_default: 'Serverstandard', + cron_profile_server_default_hint: 'Verwendet zur Laufzeit das Standardprofil des WebUI-Servers. Bestehende Jobs ohne Profil behalten dieses Legacy-Verhalten.', cron_skills_label: 'Fähigkeiten', cron_skills_placeholder: 'Fähigkeiten hinzufügen (optional)…', cron_skills_edit_hint: 'Die Fähigkeitenliste kann nach der Erstellung nicht bearbeitet werden.', @@ -5355,6 +5370,9 @@ const LOCALES = { cron_prompt_label: '提示词', cron_deliver_label: '输出位置', cron_deliver_local: '本地(仅保存输出)', + cron_profile_label: '配置档', + cron_profile_server_default: '服务器默认', + cron_profile_server_default_hint: '运行时使用 WebUI 服务器默认配置档。没有配置档的现有作业会保留此旧行为。', cron_skills_label: '技能', cron_skills_placeholder: '添加技能(可选)…', cron_skills_edit_hint: '创建后无法再编辑技能列表。', @@ -6405,6 +6423,9 @@ const LOCALES = { cron_prompt_label: '提示', cron_deliver_label: '發送至', cron_deliver_local: '僅本地儲存', + cron_profile_label: '設定檔', + cron_profile_server_default: '伺服器預設', + cron_profile_server_default_hint: '執行時使用 WebUI 伺服器預設設定檔。沒有設定檔的既有工作會保留此舊行為。', cron_skills_label: '技能', cron_skills_placeholder: '選用技能(逗號分隔)', cron_skills_edit_hint: '定義要載入的技能', @@ -7215,6 +7236,9 @@ const LOCALES = { cron_prompt_label: 'Prompt', cron_deliver_label: 'Entregar output para', cron_deliver_local: 'Local (salvar output apenas)', + cron_profile_label: 'Perfil', + cron_profile_server_default: 'padrão do servidor', + cron_profile_server_default_hint: 'Usa o perfil padrão do servidor WebUI no momento da execução. Tarefas existentes sem perfil mantêm esse comportamento legado.', cron_deliver_origin: 'Origem (mesmo chat)', cron_deliver_telegram: 'Telegram', cron_deliver_discord: 'Discord', @@ -8153,6 +8177,9 @@ const LOCALES = { cron_prompt_label: 'Prompt', cron_deliver_label: 'Deliver output to', cron_deliver_local: 'Local (save output only)', + cron_profile_label: 'Profile', + cron_profile_server_default: 'server default', + cron_profile_server_default_hint: 'Uses the WebUI server default profile at run time. Existing jobs without a profile keep this legacy behavior.', cron_skills_label: 'Skills', cron_skills_placeholder: 'Add skills (optional)…', cron_skills_edit_hint: 'Skill list is not editable after creation.', diff --git a/static/panels.js b/static/panels.js index ec5bf078..043d98b2 100644 --- a/static/panels.js +++ b/static/panels.js @@ -270,6 +270,58 @@ function _cronStatusMeta(job) { }; } + +function _cronProfileName(profile){ + return (profile || '').toString().trim(); +} + +function _cronProfileLabel(profile){ + const name = _cronProfileName(profile); + return name || (t('cron_profile_server_default') || 'server default'); +} + +function _cronProfileTitle(profile){ + const name = _cronProfileName(profile); + if (name) return (t('cron_profile_label') || 'Profile') + ': ' + name; + return t('cron_profile_server_default_hint') || 'Uses the WebUI server default profile at run time'; +} + +async function loadCronProfiles(){ + if (_cronProfilesCache) return _cronProfilesCache; + try { + const data = await api('/api/profiles'); + _cronProfilesCache = Array.isArray(data.profiles) ? data.profiles : []; + } catch(e) { + _cronProfilesCache = []; + } + return _cronProfilesCache; +} + +function _cronProfileOptions(selected){ + const current = _cronProfileName(selected); + const profiles = Array.isArray(_cronProfilesCache) ? _cronProfilesCache : []; + const seen = new Set(['']); + const opts = [``]; + for (const p of profiles) { + const name = _cronProfileName(p && p.name); + if (!name || seen.has(name)) continue; + seen.add(name); + const label = p && p.is_default ? `${name} (${t('default') || 'default'})` : name; + opts.push(``); + } + if (current && !seen.has(current)) { + opts.push(``); + } + return opts.join(''); +} + +function _refreshCronProfileSelect(selected){ + const sel = $('cronFormProfile'); + if (!sel) return; + const keep = selected === undefined ? sel.value : selected; + sel.innerHTML = _cronProfileOptions(keep); +} + function _cronDiagnostics(job) { const fields = { id: job.id, @@ -297,6 +349,7 @@ async function loadCrons(animate) { refreshBtn.disabled = true; } try { + await loadCronProfiles(); const data = await api('/api/crons'); _cronList = data.jobs || []; if (!_cronList.length) { @@ -311,10 +364,13 @@ async function loadCrons(animate) { item.id = 'cron-' + job.id; const status = _cronStatusMeta(job); const isNewRun = _cronNewJobIds.has(String(job.id)); + const profileLabel = _cronProfileLabel(job.profile); + const profileTitle = _cronProfileTitle(job.profile); item.innerHTML = `
${isNewRun ? '' : ''} ${esc(job.name)} + ${esc(profileLabel)} ${esc(status.label)}
`; item.onclick = () => openCronDetail(job.id, item); @@ -349,6 +405,8 @@ function _renderCronDetail(job){ const schedule = job.schedule_display || (job.schedule && job.schedule.expression) || ''; const skills = Array.isArray(job.skills) && job.skills.length ? job.skills.join(', ') : '—'; const deliver = job.deliver || 'local'; + const profileLabel = _cronProfileLabel(job.profile); + const profileTitle = _cronProfileTitle(job.profile); const lastError = job.last_error ? `
${esc(t('error_prefix').replace(/:\s*$/,''))}
${esc(job.last_error)}
` : ''; const attention = status.state === 'needs_attention' || status.state === 'schedule_error'; const croniterHint = job.last_error && /croniter/i.test(job.last_error) @@ -375,6 +433,7 @@ function _renderCronDetail(job){
${esc(t('cron_next'))}
${esc(nextRun)}
${esc(t('cron_last'))}
${esc(lastRun)}
Deliver
${esc(deliver)}
+
${esc(t('cron_profile_label') || 'Profile')}
${esc(profileLabel)}
Skills
${esc(skills)}
${lastError} @@ -557,6 +616,7 @@ function duplicateCurrentCron(){ schedule: job.schedule_display || (job.schedule && job.schedule.expression) || '', prompt: job.prompt || '', deliver: job.deliver || 'local', + profile: job.profile || '', isEdit: false, }); if (!_cronSkillsCache) { @@ -581,6 +641,7 @@ async function deleteCurrentCron(){ let _cronSelectedSkills=[]; let _cronIsDuplicate = false; let _cronSkillsCache=null; +let _cronProfilesCache=null; function openCronCreate(){ if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks'); @@ -589,9 +650,10 @@ function openCronCreate(){ _cronMode = 'create'; _cronIsDuplicate = false; _cronSelectedSkills = []; - _renderCronForm({ name:'', schedule:'', prompt:'', deliver:'local', isEdit:false }); + _renderCronForm({ name:'', schedule:'', prompt:'', deliver:'local', profile:'', isEdit:false }); _cronSkillsCache = null; api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{}); + loadCronProfiles().then(()=>_refreshCronProfileSelect('')).catch(()=>{}); } function openCronEdit(job){ @@ -605,6 +667,7 @@ function openCronEdit(job){ schedule: job.schedule_display || (job.schedule && job.schedule.expression) || '', prompt: job.prompt || '', deliver: job.deliver || 'local', + profile: job.profile || '', isEdit: true, }); if (!_cronSkillsCache) { @@ -612,9 +675,10 @@ function openCronEdit(job){ } else { _bindCronSkillPicker(); } + loadCronProfiles().then(()=>_refreshCronProfileSelect(job.profile || '')).catch(()=>{}); } -function _renderCronForm({ name, schedule, prompt, deliver, isEdit }){ +function _renderCronForm({ name, schedule, prompt, deliver, profile, isEdit }){ const title = $('taskDetailTitle'); const body = $('taskDetailBody'); const empty = $('taskDetailEmpty'); @@ -645,6 +709,13 @@ function _renderCronForm({ name, schedule, prompt, deliver, isEdit }){ ${deliverOpt('telegram','Telegram')} +
+ + +
${esc(t('cron_profile_server_default_hint') || 'Uses the WebUI server default profile at run time')}
+
@@ -729,18 +800,20 @@ async function saveCronForm(){ const schEl=$('cronFormSchedule'); const promptEl=$('cronFormPrompt'); const delivEl=$('cronFormDeliver'); + const profileEl=$('cronFormProfile'); const errEl=$('cronFormError'); if(!schEl||!promptEl||!errEl) return; const name=(nameEl?nameEl.value:'').trim(); const schedule=schEl.value.trim(); const prompt=promptEl.value.trim(); const deliver=delivEl?delivEl.value:'local'; + const profile=profileEl?profileEl.value:''; errEl.style.display='none'; if(!schedule){errEl.textContent=t('cron_schedule_required_example');errEl.style.display='';return;} 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, schedule, prompt, profile: profile}; if (name) updates.name = name; await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)}); const editedId = _editingCronId; @@ -752,7 +825,7 @@ async function saveCronForm(){ if (job) openCronDetail(editedId); return; } - const body={schedule,prompt,deliver}; + const body={schedule,prompt,deliver,profile: profile}; if(_cronIsDuplicate) body.enabled=false; if(name)body.name=name; if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills; diff --git a/static/style.css b/static/style.css index 7d6be764..32a7a5b0 100644 --- a/static/style.css +++ b/static/style.css @@ -645,6 +645,7 @@ .cron-header{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;} .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-profile-badge{font-size:10px;font-weight:650;padding:2px 7px;border-radius:99px;flex-shrink:0;max-width:92px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border:1px solid var(--border);color:var(--muted);background:var(--surface-subtle);} .cron-status.active{background:rgba(34,197,94,.15);color:var(--success);} .cron-status.paused{background:var(--accent-bg-strong);color:var(--accent-text);} .cron-status.disabled{background:rgba(255,255,255,.07);color:var(--muted);} diff --git a/tests/test_issue617_cron_profile_selector.py b/tests/test_issue617_cron_profile_selector.py new file mode 100644 index 00000000..4d6e9643 --- /dev/null +++ b/tests/test_issue617_cron_profile_selector.py @@ -0,0 +1,224 @@ +"""Regression coverage for issue #617 scheduled-job profile selection.""" + +import io +import json +import sys +import types +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent + + +class _JSONHandler: + def __init__(self): + self.status = None + self.headers = {} + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + pass + + +def _payload(handler): + return json.loads(handler.wfile.getvalue().decode("utf-8")) + + +def test_cron_api_serializes_legacy_profile_as_explicit_server_default(): + from api.routes import _cron_job_for_api + + legacy = {"id": "legacy", "name": "Legacy job"} + payload = _cron_job_for_api(legacy) + + assert payload["profile"] is None + assert "profile" not in legacy, "API serialization must not mutate stored legacy jobs" + + +def test_cron_profile_value_validates_against_existing_profiles(monkeypatch): + import api.profiles as profiles + from api.routes import _normalize_cron_profile_value + + monkeypatch.setattr( + profiles, + "list_profiles_api", + lambda: [ + {"name": "default"}, + {"name": "research"}, + ], + ) + + assert _normalize_cron_profile_value(" research ") == "research" + assert _normalize_cron_profile_value("") is None + assert _normalize_cron_profile_value(None) is None + with pytest.raises(ValueError, match="Unknown profile: missing"): + _normalize_cron_profile_value("missing") + + +def test_cron_create_api_persists_profile_and_returns_it(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + created = { + "id": "job617", + "name": "Profiled job", + "prompt": "ping", + "schedule": {"kind": "interval", "minutes": 60}, + } + updated = {**created, "profile": "research"} + calls = [] + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.create_job = lambda **kwargs: calls.append(("create", kwargs)) or dict(created) + cron_jobs.update_job = lambda job_id, updates: calls.append(("update", job_id, updates)) or dict(updated) + + monkeypatch.setattr(profiles, "list_profiles_api", lambda: [{"name": "research"}]) + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + handler = _JSONHandler() + routes._handle_cron_create( + handler, + { + "name": "Profiled job", + "prompt": "ping", + "schedule": "every 60m", + "deliver": "local", + "profile": "research", + }, + ) + + body = _payload(handler) + assert handler.status == 200 + assert body["ok"] is True + assert body["job"]["profile"] == "research" + assert calls[0][0] == "create" + assert calls[1] == ("update", "job617", {"profile": "research"}) + + +def test_cron_create_api_rejects_unknown_profile_before_persisting(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.create_job = lambda **kwargs: pytest.fail("invalid profiles must not create jobs") + cron_jobs.update_job = lambda *args, **kwargs: pytest.fail("invalid profiles must not update jobs") + + monkeypatch.setattr(profiles, "list_profiles_api", lambda: [{"name": "research"}]) + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + handler = _JSONHandler() + routes._handle_cron_create( + handler, + {"prompt": "ping", "schedule": "every 60m", "profile": "missing"}, + ) + + assert handler.status == 400 + assert "Unknown profile: missing" in _payload(handler)["error"] + + +def test_cron_update_api_accepts_profile_clear_and_rejects_unknown(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + calls = [] + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + + def update_job(job_id, updates): + calls.append((job_id, updates)) + return {"id": job_id, "name": "Updated", **updates} + + cron_jobs.update_job = update_job + monkeypatch.setattr(profiles, "list_profiles_api", lambda: [{"name": "research"}]) + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + handler = _JSONHandler() + routes._handle_cron_update(handler, {"job_id": "job617", "profile": ""}) + assert handler.status == 200 + assert _payload(handler)["job"]["profile"] is None + assert calls == [("job617", {"profile": None})] + + bad_handler = _JSONHandler() + routes._handle_cron_update(bad_handler, {"job_id": "job617", "profile": "ghost"}) + assert bad_handler.status == 400 + assert "Unknown profile: ghost" in _payload(bad_handler)["error"] + assert calls == [("job617", {"profile": None})] + + +def test_manual_cron_run_uses_execution_profile_but_persists_to_owning_store(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + events = [] + + class Ctx: + def __init__(self, home): + self.home = str(home) + + def __enter__(self): + events.append(("enter", self.home)) + + def __exit__(self, exc_type, exc, tb): + events.append(("exit", self.home)) + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.save_job_output = lambda job_id, output: events.append(("save", job_id, output)) + cron_jobs.mark_job_run = lambda job_id, success, error=None: events.append(("mark", job_id, success, error)) + cron_scheduler = types.ModuleType("cron.scheduler") + cron_scheduler.run_job = lambda job: events.append(("run", job["id"])) or (True, "output", "final", None) + + monkeypatch.setattr(profiles, "cron_profile_context_for_home", Ctx) + 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("job617") + routes._run_cron_tracked( + {"id": "job617"}, + profile_home="/hermes/default", + execution_profile_home="/hermes/profiles/research", + ) + + assert events == [ + ("enter", "/hermes/profiles/research"), + ("run", "job617"), + ("exit", "/hermes/profiles/research"), + ("enter", "/hermes/default"), + ("save", "job617", "output"), + ("mark", "job617", True, None), + ("exit", "/hermes/default"), + ] + assert routes._is_cron_running("job617") == (False, 0.0) + + +def test_cron_profile_selector_source_hooks_present(): + panels = (REPO / "static" / "panels.js").read_text(encoding="utf-8") + css = (REPO / "static" / "style.css").read_text(encoding="utf-8") + i18n = (REPO / "static" / "i18n.js").read_text(encoding="utf-8") + + assert "async function loadCronProfiles()" in panels + assert "api('/api/profiles')" in panels + assert "id=\"cronFormProfile\"" in panels + assert "profile: profile" in panels + assert "job.profile" in panels + assert "cron-profile-badge" in panels + assert ".cron-profile-badge" in css + assert "cron_profile_server_default" in i18n + assert "cron_profile_server_default_hint" in i18n diff --git a/tests/test_scheduled_jobs_profile_isolation.py b/tests/test_scheduled_jobs_profile_isolation.py index ec765c81..0d05cc4d 100644 --- a/tests/test_scheduled_jobs_profile_isolation.py +++ b/tests/test_scheduled_jobs_profile_isolation.py @@ -215,7 +215,7 @@ def test_cron_worker_does_not_silently_fall_back_on_profile_context_failure(): from pathlib import Path src = (Path(__file__).resolve().parent.parent / "api" / "routes.py").read_text(encoding="utf-8") - idx = src.find("def _run_cron_tracked(job, profile_home=None):") + idx = src.find("def _run_cron_tracked(job") assert idx != -1, "_run_cron_tracked not found" body = src[idx : idx + 2000]