diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7d6b92..a54621de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added +- **The Tasks panel can show your scheduled cron jobs from other profiles (read-only).** Mirroring the session sidebar's cross-profile visibility, the Tasks panel now offers an opt-in "Show N from other profiles" affordance (default-hidden): it enumerates cron jobs across your profiles server-side and renders foreign-profile rows read-only. Cron mutations (pause/run/delete/edit) and `/api/crons/recent` stay strictly active-profile-only — a foreign `job_id` doesn't resolve in the active-profile mutation context — and detail/history/watch state is pinned to a composite `{owner_profile, job_id}` identity so duplicate IDs across profiles can't cross-leak. Hidden root/default rows are skipped for named profiles, and stale cron detail state is cleared on profile switch. Thanks @rodboev. (#4682, #3947) + - **Extensions can opt into a consent-gated loopback sidecar proxy.** An extension whose manifest declares a `127.0.0.1`/`localhost` sidecar origin can now, after an explicit per-origin user consent, have WebUI proxy same-origin browser requests to that local backend — the first supported mechanism for an extension to reach a co-located sidecar without shipping its own server route. The surface is deliberately narrow and fail-closed: loopback-only origin (no SSRF; hex/decimal/userinfo/port bypasses rejected), path traversal and URL/scheme smuggling rejected on the fully-decoded form, consent bound to the exact declared origin (an origin change forces reconsent, bounded at 512 entries), auth/CSRF-gated consent, credential isolation (Cookie/Authorization/CSRF/Host/Origin/Referer stripped outbound, Set-Cookie stripped inbound, hop-by-hop headers stripped both ways), ambient proxies disabled, redirects confined to the declared origin, a 512KB response cap on both the success and error paths, and same-origin **browser provenance required on every proxied method** (not just GET). Documented in `docs/EXTENSIONS.md`. Thanks @rodboev. (#5228, #4747) - **Push-to-talk hold gesture for dictation.** Hold the mic button (or its keyboard activation) to dictate and release to stop, in addition to the existing click-to-toggle mode — a more reliable way to capture a quick voice note. The browser-reserved `Ctrl+Shift+D` chord that an earlier draft proposed was dropped (it collides with browser bookmark shortcuts); only the page-safe hold gesture ships, and the restart/teardown paths are race-hardened so there's no stuck or zombie microphone. Thanks @rodboev. (#5310, #3700) diff --git a/api/routes.py b/api/routes.py index d68887d3..7bb5ebec 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1415,12 +1415,86 @@ def _ensure_agent_cron_import_path() -> None: sys.path.append(agent_path) _AGENT_CRON_IMPORT_PATH_READY = agent_path - if cron_mod is not None and not cron_is_agent: + # Keep in-memory test doubles or namespace stubs intact; only evict a + # real on-disk shadow package so the agent's cron package can import. + if cron_mod is not None and cron_file and not cron_is_agent: for name in list(sys.modules): if name == "cron" or name.startswith("cron."): sys.modules.pop(name, None) +def _cron_jobs_cross_profile(active_profile: str) -> tuple[list[dict], list[dict]]: + """Return active-profile rows plus foreign rows for the Tasks panel. + + Row ownership is intentionally distinct from a cron job's persisted + ``profile`` field. The persisted field controls where the job executes; + ``owner_profile`` tells the UI which profile home the row came from. + """ + from cron.jobs import list_jobs + from api.profiles import ( + cron_profile_context_for_home, + get_hermes_home_for_profile, + list_profiles_api, + ) + + def _home_key(path: Path) -> str: + try: + return str(Path(path).expanduser().resolve(strict=False)) + except Exception: + return str(Path(path).expanduser()) + + names: list[str] = [] + seen_names: set[str] = set() + + def _add_name(raw_name) -> None: + name = str(raw_name or "").strip() + if not name: + return + folded = name.casefold() + if folded in seen_names: + return + seen_names.add(folded) + names.append(name) + + _add_name(active_profile) + for row in list_profiles_api(): + if not isinstance(row, dict): + continue + name = str(row.get("name") or "").strip() + if not name: + continue + if row.get("visible") is False and not _profiles_match(name, active_profile): + continue + _add_name(name) + + active_jobs: list[dict] = [] + other_jobs: list[dict] = [] + seen_homes: set[str] = set() + for owner_profile in names: + home = Path(get_hermes_home_for_profile(owner_profile)) + home_key = _home_key(home) + if home_key in seen_homes: + continue + seen_homes.add(home_key) + is_active = _profiles_match(owner_profile, active_profile) + try: + with cron_profile_context_for_home(home): + jobs = _cron_jobs_for_api(list_jobs(include_disabled=True)) + except Exception: + if not is_active: + continue + raise + for job in jobs: + row = dict(job) + row["owner_profile"] = owner_profile + row["read_only"] = not is_active + if is_active: + active_jobs.append(row) + else: + other_jobs.append(row) + return active_jobs, other_jobs + + def _available_cron_profile_names() -> set[str]: from api.profiles import list_profiles_api @@ -12225,9 +12299,9 @@ def handle_get(handler, parsed) -> bool: return bad(handler, str(e), 404) # ── Cron API (GET) ── - # All cron handlers touch cron.jobs which resolves HERMES_HOME from - # os.environ (process-global) at call time. Wrap in cron_profile_context - # so the TLS-active profile's jobs.json is read, not the process default. + # Cron reads are active-profile-scoped by default. The list route now + # aggregates per visible profile home so the UI can surface hidden-row + # counts and, when opted in, read-only foreign rows. if parsed.path == "/api/crons": # #4768: in split-container / minimal Docker deployments the WebUI image may # not ship the agent's `cron` package on its import path. Degrade gracefully @@ -12236,16 +12310,22 @@ def handle_get(handler, parsed) -> bool: # ModuleNotFoundError whose missing module is an internal dependency of an # existing cron/jobs.py is a real bug and must still surface. _ensure_agent_cron_import_path() + active_profile = _get_active_profile_name() or "default" try: - from cron.jobs import list_jobs + active_jobs, other_jobs = _cron_jobs_cross_profile(active_profile) except ModuleNotFoundError as exc: if exc.name in ("cron", "cron.jobs"): return j(handler, {"jobs": [], "cron_unavailable": True}) raise - from api.profiles import cron_profile_context - - with cron_profile_context(): - return j(handler, {"jobs": _cron_jobs_for_api(list_jobs(include_disabled=True))}) + all_profiles = _all_profiles_enabled(parsed) + jobs = active_jobs + other_jobs if all_profiles else active_jobs + hidden_other_count = 0 if all_profiles else len(other_jobs) + return j(handler, { + "jobs": jobs, + "all_profiles": all_profiles, + "active_profile": active_profile, + "other_profile_count": hidden_other_count, + }) if parsed.path == "/api/crons/output": from api.profiles import cron_profile_context diff --git a/static/panels.js b/static/panels.js index d5286907..7aa95e8a 100644 --- a/static/panels.js +++ b/static/panels.js @@ -22,8 +22,11 @@ let _kanbanEventSourceFailures = 0; let _skillsData = null; // cached skills list let _cronList = null; // cached cron jobs (array) let _currentCronDetail = null; // full cron job object +let _currentCronDetailKey = ''; let _cronMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit' let _cronPreFormDetail = null; // snapshot of prior selection when entering a form +let _showAllCronProfiles = false; +let _cronOtherProfileCount = 0; let _currentWorkspaceDetail = null; // { path, name, is_default } let _workspaceMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit' let _workspacePreFormDetail = null; @@ -536,6 +539,57 @@ function _cronProfileTitle(profile){ return t('cron_profile_server_default_hint') || 'Uses the WebUI server default profile at run time'; } +function _cronOwnerProfileName(job){ + return _cronProfileName(job && (job.owner_profile ?? job.profile)); +} + +function _cronJobKey(job){ + return `${_cronOwnerProfileName(job)}\u0000${String(job && job.id || '')}`; +} + +function _cronItemId(job){ + return 'cron-' + encodeURIComponent(_cronJobKey(job)); +} + +function _cronDetailMatches(jobId, detailKey){ + return !!( + detailKey && + _currentCronDetail && + !_currentCronDetail.read_only && + String(_currentCronDetail.id) === String(jobId) && + _currentCronDetailKey === detailKey && + _cronJobKey(_currentCronDetail) === detailKey + ); +} + +function _findCronJob(jobOrId){ + if (jobOrId && typeof jobOrId === 'object') return jobOrId; + const id = String(jobOrId || ''); + if (!_cronList || !id) return null; + return _cronList.find(j => !j.read_only && String(j.id) === id) || + _cronList.find(j => String(j.id) === id) || + null; +} + +function _appendCronProfileToggle(parent){ + if (!parent || (!_showAllCronProfiles && _cronOtherProfileCount <= 0)) return; + const wrap = document.createElement('div'); + wrap.style.cssText = 'padding:10px 0 0'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'sm-btn'; + btn.style.cssText = 'width:100%;justify-content:center'; + btn.textContent = _showAllCronProfiles + ? 'Show active profile only' + : `Show ${_cronOtherProfileCount} from other profiles`; + btn.onclick = async () => { + _showAllCronProfiles = !_showAllCronProfiles; + await loadCrons(); + }; + wrap.appendChild(btn); + parent.appendChild(wrap); +} + async function loadCronProfiles(){ if (_cronProfilesCache) return _cronProfilesCache; try { @@ -657,12 +711,13 @@ async function loadCrons(animate) { } try { await loadCronProfiles(); - const data = await api('/api/crons'); + const allProfilesQS = _showAllCronProfiles ? '?all_profiles=1' : ''; + const data = await api('/api/crons' + allProfilesQS); _cronList = data.jobs || []; - if (!_cronList.length) { - box.innerHTML = `
${esc(t('cron_no_jobs'))}
`; - if (_cronMode !== 'create' && _cronMode !== 'edit') _clearCronDetail(); - return; + _cronOtherProfileCount = Number(data.other_profile_count || 0); + if (_showAllCronProfiles && !_cronList.some(job => job && job.read_only)) { + _showAllCronProfiles = false; + _cronOtherProfileCount = 0; } box.innerHTML = ''; // Partition active vs paused so paused jobs don't drown the list (#4026). @@ -677,23 +732,40 @@ async function loadCrons(animate) { const _appendCronItem = (parent, { job, status }) => { const item = document.createElement('div'); item.className = 'cron-item'; - item.id = 'cron-' + job.id; - const isNewRun = _cronNewJobIds.has(String(job.id)); + item.id = _cronItemId(job); + if (job.read_only) { + item.classList.add('readonly'); + item.style.opacity = '0.78'; + } + const isNewRun = !job.read_only && _cronNewJobIds.has(String(job.id)); const isAgentMode = !job.no_agent; - const profileLabel = _cronProfileLabel(job.profile); - const profileTitle = _cronProfileTitle(job.profile); + const ownerProfileLabel = _cronProfileLabel(_cronOwnerProfileName(job)); + const ownerProfileTitle = `Owner profile: ${ownerProfileLabel}`; + const readOnlyBadge = job.read_only + ? 'Read-only' + : ''; item.innerHTML = `
${isNewRun ? '' : ''} ${isAgentMode ? '🤖' : `📜`} ${esc(job.name)} - ${esc(profileLabel)} + ${esc(ownerProfileLabel)} ${esc(status.label)} + ${readOnlyBadge}
`; - item.onclick = () => openCronDetail(job.id, item); - if (_currentCronDetail && _currentCronDetail.id === job.id) item.classList.add('active'); + item.onclick = () => openCronDetail(job, item); + if (_currentCronDetailKey && _currentCronDetailKey === _cronJobKey(job)) item.classList.add('active'); parent.appendChild(item); }; + if (!_cronList.length) { + const emptyText = (!_showAllCronProfiles && _cronOtherProfileCount > 0) + ? 'No cron jobs in the active profile.' + : (t('cron_no_jobs') || 'No jobs yet'); + box.innerHTML = `
${esc(emptyText)}
`; + _appendCronProfileToggle(box); + if (_cronMode !== 'create' && _cronMode !== 'edit') _clearCronDetail(); + return; + } for (const entry of _activeJobs) _appendCronItem(box, entry); if (_pausedJobs.length) { let collapsed = true; @@ -716,9 +788,10 @@ async function loadCrons(animate) { for (const entry of _pausedJobs) _appendCronItem(inner, entry); box.appendChild(details); } + _appendCronProfileToggle(box); // 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 => _cronJobKey(j) === _currentCronDetailKey); if (refreshed) _renderCronDetail(refreshed); else _clearCronDetail(); } @@ -821,6 +894,7 @@ function _cronAgentPromptCardHtml(job){ function _renderCronDetail(job){ _currentCronDetail = job; + _currentCronDetailKey = _cronJobKey(job); const title = $('taskDetailTitle'); const body = $('taskDetailBody'); const empty = $('taskDetailEmpty'); @@ -833,6 +907,7 @@ function _renderCronDetail(job){ const skills = Array.isArray(job.skills) && job.skills.length ? job.skills.join(', ') : '—'; const deliver = job.deliver || 'local'; const isNoAgent = _isCronScriptJob(job); + const isReadOnly = !!job.read_only; const cronJobMode = _cronModeLabel(job); const modelProvider = job.provider && job.model ? `${esc(job.provider)}/${esc(job.model)}` : @@ -841,12 +916,16 @@ function _renderCronDetail(job){ isNoAgent ? '' : esc(t('cron_model_use_default') || 'Use profile default'); const profileLabel = _cronProfileLabel(job.profile); const profileTitle = _cronProfileTitle(job.profile); + const ownerProfileName = _cronOwnerProfileName(job); + const ownerProfileLabel = _cronProfileLabel(ownerProfileName); + const ownerProfileTitle = `Owner profile: ${ownerProfileLabel}`; + const showOwnerRow = !!ownerProfileName && (isReadOnly || ownerProfileName !== _cronProfileName(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) ? `

${esc(t('cron_attention_croniter_hint'))}

` : ''; - const attentionBanner = attention ? ` + const attentionBanner = !isReadOnly && attention ? `
${esc(t('cron_status_needs_attention'))}

${esc(t('cron_attention_desc'))}

@@ -857,6 +936,11 @@ function _renderCronDetail(job){
` : ''; + const readOnlyBanner = isReadOnly ? ` +
+
Read-only from another profile
+

Switch to ${esc(ownerProfileLabel)} to run, edit, or inspect live status and output for this cron job.

+
` : ''; const toastNotifications = job.toast_notifications !== false; const outputTitle = _cronOutputTitle(job); const skillsRow = isNoAgent ? '' : `
${esc(t('cron_skills_label') || 'Skills')}
${esc(skills)}
`; @@ -864,6 +948,7 @@ function _renderCronDetail(job){ body.innerHTML = `
${attentionBanner} + ${readOnlyBanner} ${isNoAgent ? _cronScriptJobBannerHtml() : ''}
${esc(t('cron_status_active').replace(/./,c=>c.toUpperCase()))}
@@ -873,15 +958,16 @@ function _renderCronDetail(job){
${esc(t('cron_last'))}
${esc(lastRun)}
Deliver
${esc(deliver)}
${esc(t('cron_mode_label') || 'Mode')}
${esc(cronJobMode)}${modelProvider ? ` ${modelProvider}` : ''}
+ ${showOwnerRow ? `
Owner profile
${esc(ownerProfileLabel)}
` : ''}
${esc(t('cron_profile_label') || 'Profile')}
${esc(profileLabel)}
${esc(t('cron_toast_notifications_label') || 'Completion toasts')}
${esc(toastNotifications ? (t('cron_toast_notifications_enabled') || 'Enabled') : (t('cron_toast_notifications_disabled') || 'Disabled'))}
${skillsRow} ${lastError}
${instructionCard} -
+
${esc(outputTitle)}
-
${esc(t('loading'))}
+
${esc(isReadOnly ? 'Live output is available only when this profile is active here.' : (t('loading') || 'Loading'))}
`; body.style.display = ''; @@ -889,7 +975,7 @@ function _renderCronDetail(job){ _cronMode = 'read'; _setCronHeaderButtons('read', job); // Load runs asynchronously - _loadCronDetailRuns(job.id); + if (!isReadOnly) _loadCronDetailRuns(job.id, _currentCronDetailKey); } function _setCronHeaderButtons(mode, job) { @@ -906,6 +992,10 @@ function _setCronHeaderButtons(mode, job) { const show = b => b && (b.style.display = ''); if (mode === 'read') { if (header) header.style.display = 'flex'; + if (job && job.read_only) { + [runBtn,pauseBtn,resumeBtn,editBtn,dupBtn,delBtn,cancelBtn,saveBtn].forEach(hide); + return; + } show(runBtn); const status = job ? _cronStatusMeta(job) : null; const resumable = job && ( @@ -925,10 +1015,10 @@ function _setCronHeaderButtons(mode, job) { } } -async function _loadCronDetailRuns(jobId){ +async function _loadCronDetailRuns(jobId, detailKey){ try { const data = await api(`/api/crons/history?job_id=${encodeURIComponent(jobId)}&limit=50`); - if (!_currentCronDetail || _currentCronDetail.id !== jobId) return; + if (!_cronDetailMatches(jobId, detailKey)) return; const card = $('cronDetailRuns'); if (!card) return; const outputTitle = _cronOutputTitle(_currentCronDetail); @@ -1039,26 +1129,28 @@ async function _loadRunContent(jobId, filename, runId){ } } -function openCronDetail(id, el){ - const job = _cronList ? _cronList.find(j => j.id === id) : null; +function openCronDetail(jobOrId, el){ + const job = _findCronJob(jobOrId); if (!job) return; + const detailKey = _cronJobKey(job); document.querySelectorAll('.cron-item').forEach(e => e.classList.remove('active')); - const target = el || $('cron-' + id); + const target = el || $(_cronItemId(job)); if (target) target.classList.add('active'); // Remove new-run dot from this job since user is now viewing it - _clearCronUnreadForJob(id); + if (!job.read_only) _clearCronUnreadForJob(job.id); const dot = target && target.querySelector('.cron-new-dot'); - if (dot) dot.remove(); + if (dot && !job.read_only) dot.remove(); _cronPreFormDetail = null; _editingCronId = null; _stopCronWatch(); _renderCronDetail(job); - _checkCronWatchOnDetail(id); + if (!job.read_only) _checkCronWatchOnDetail(job.id, detailKey); _closeMobileSidebarAfterPanelSelection(); } function _clearCronDetail(){ _currentCronDetail = null; + _currentCronDetailKey = ''; _cronMode = 'empty'; _stopCronWatch(); const title = $('taskDetailTitle'); @@ -1490,7 +1582,7 @@ async function saveCronForm(){ showToast(t('cron_job_updated')); await loadCrons(); const job = _cronList && _cronList.find(j => j.id === editedId); - if (job) openCronDetail(editedId); + if (job) openCronDetail(job); return; } const body={schedule,prompt,deliver,profile: profile, toast_notifications: toastNotifications}; @@ -1516,7 +1608,7 @@ async function saveCronForm(){ 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); + else if (_cronList && _cronList.length) openCronDetail(_cronList[_cronList.length - 1]); }catch(e){ errEl.textContent=t('error_prefix')+e.message;errEl.style.display=''; } @@ -1560,7 +1652,7 @@ let _cronWatchInterval = null; let _cronWatchStart = null; let _cronWatchTimerInterval = null; -function _startCronWatch(jobId) { +function _startCronWatch(jobId, detailKey) { _stopCronWatch(); _cronWatchStart = Date.now(); _cronWatchInterval = setInterval(async () => { @@ -1568,13 +1660,13 @@ function _startCronWatch(jobId) { const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false}); if (!data.running) { _stopCronWatch(); - if (_currentCronDetail && _currentCronDetail.id === jobId) { - _loadCronDetailRuns(jobId); + if (_cronDetailMatches(jobId, detailKey)) { + _loadCronDetailRuns(jobId, detailKey); } return; } // Still running — update elapsed - if (_currentCronDetail && _currentCronDetail.id === jobId) { + if (_cronDetailMatches(jobId, detailKey)) { const el = $('cronRunningIndicator'); if (el) el.querySelector('.cron-watch-elapsed').textContent = _formatElapsed(data.elapsed); } @@ -1582,13 +1674,13 @@ function _startCronWatch(jobId) { }, 3000); // Timer update every second _cronWatchTimerInterval = setInterval(() => { - if (_currentCronDetail && _cronWatchStart) { + if (_cronDetailMatches(jobId, detailKey) && _cronWatchStart) { const el = $('cronRunningIndicator'); if (el) el.querySelector('.cron-watch-elapsed').textContent = _formatElapsed((Date.now() - _cronWatchStart) / 1000); } }, 1000); // Inject running indicator into detail card - if (_currentCronDetail && _currentCronDetail.id === jobId) { + if (_cronDetailMatches(jobId, detailKey)) { _injectRunningIndicator(); } } @@ -1618,11 +1710,11 @@ function _formatElapsed(seconds) { return m + 'm ' + s + 's'; } -function _checkCronWatchOnDetail(jobId) { +function _checkCronWatchOnDetail(jobId, detailKey) { // When opening a detail view, check if job is running api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false}).then(data => { - if (data.running && _currentCronDetail && _currentCronDetail.id === jobId) { - _startCronWatch(jobId); + if (data.running && _cronDetailMatches(jobId, detailKey)) { + _startCronWatch(jobId, detailKey); } }).catch(() => {}); } @@ -1631,7 +1723,7 @@ async function cronRun(id) { try { await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})}); showToast(t('cron_job_triggered')); - _startCronWatch(id); + _startCronWatch(id, _currentCronDetailKey); } catch(e) { showToast(t('failed_colon') + e.message, 4000); } } @@ -5833,6 +5925,14 @@ let _profileSwitchGeneration = 0; let _profileDropdownTrigger = null; // tracks which element triggered the dropdown async function _profileSwitchPanelLoad(){ + // Cross-profile cron visibility is an active-profile opt-in; never carry it + // into the next profile when the Tasks panel wasn't the visible panel. + _showAllCronProfiles = false; + _cronOtherProfileCount = 0; + _cronPreFormDetail = null; + _editingCronId = null; + _cronIsDuplicate = false; + _clearCronDetail(); if (_currentPanel === 'skills') await loadSkills(); if (_currentPanel === 'memory') await loadMemory(); if (_currentPanel === 'tasks') await loadCrons(); diff --git a/tests/test_issue3947_tasks_cross_profile_visibility.py b/tests/test_issue3947_tasks_cross_profile_visibility.py new file mode 100644 index 00000000..5cc67be0 --- /dev/null +++ b/tests/test_issue3947_tasks_cross_profile_visibility.py @@ -0,0 +1,680 @@ +"""Focused coverage for issue #3947, cross-profile cron visibility in Tasks.""" + +from __future__ import annotations + +import io +import json +import shutil +import subprocess +import sys +import tempfile +import types +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + + +class _JSONHandler: + def __init__(self): + self.status = None + 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: _JSONHandler) -> dict: + return json.loads(handler.wfile.getvalue().decode("utf-8")) + + +def _extract_function(src: str, name: str) -> str: + marker = f"function {name}(" + start = src.find(marker) + assert start >= 0, f"{name} not found in panels.js" + open_brace = src.find("{", start) + assert open_brace >= 0, f"{name} opening brace not found" + depth = 0 + for idx in range(open_brace, len(src)): + char = src[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return src[start : idx + 1] + raise AssertionError(f"{name} closing brace not found") + + +def _run_node(script: str) -> dict: + if NODE is None: + pytest.skip("node not on PATH") + with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as handle: + handle.write(script) + path = Path(handle.name) + try: + result = subprocess.run( + [NODE, str(path)], + capture_output=True, + text=True, + timeout=20, + cwd=str(REPO_ROOT), + ) + finally: + path.unlink(missing_ok=True) + if result.returncode != 0: + raise RuntimeError(f"node failed: {result.stderr}\nscript:\n{script}") + return json.loads(result.stdout) + + +def _install_cron_jobs(monkeypatch, jobs_by_home, current_home): + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + + def _list_jobs(include_disabled=True): + return [dict(job) for job in jobs_by_home[current_home["value"]]] + + cron_jobs.list_jobs = _list_jobs + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + +def test_crons_route_hides_other_profiles_by_default_but_reports_count(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + current_home = {"value": None} + jobs_by_home = { + "alpha-home": [{"id": "job-shared", "name": "Alpha", "profile": "worker"}], + "default-home": [], + "beta-home": [{"id": "job-shared", "name": "Beta", "profile": None}], + } + _install_cron_jobs(monkeypatch, jobs_by_home, current_home) + + class _Ctx: + def __init__(self, home): + self.home = str(home) + self.prev = None + + def __enter__(self): + self.prev = current_home["value"] + current_home["value"] = self.home + return self + + def __exit__(self, exc_type, exc, tb): + current_home["value"] = self.prev + return False + + monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "alpha") + monkeypatch.setattr(profiles, "list_profiles_api", lambda: [ + {"name": "alpha", "visible": True}, + {"name": "beta", "visible": True}, + ]) + monkeypatch.setattr( + profiles, + "get_hermes_home_for_profile", + lambda name: Path({"alpha": "alpha-home", "beta": "beta-home", "default": "default-home"}[name]), + ) + monkeypatch.setattr(profiles, "cron_profile_context_for_home", _Ctx) + + handler = _JSONHandler() + assert routes.handle_get(handler, SimpleNamespace(path="/api/crons", query="")) is not False + body = _payload(handler) + + assert handler.status == 200 + assert body["all_profiles"] is False + assert body["active_profile"] == "alpha" + assert body["other_profile_count"] == 1 + assert [job["name"] for job in body["jobs"]] == ["Alpha"] + assert body["jobs"][0]["owner_profile"] == "alpha" + assert body["jobs"][0]["read_only"] is False + assert body["jobs"][0]["profile"] == "worker" + + handler = _JSONHandler() + assert routes.handle_get(handler, SimpleNamespace(path="/api/crons", query="all_profiles=1")) is not False + body = _payload(handler) + + assert handler.status == 200 + assert body["all_profiles"] is True + assert body["other_profile_count"] == 0 + assert [job["owner_profile"] for job in body["jobs"]] == ["alpha", "beta"] + assert body["jobs"][0]["read_only"] is False + assert body["jobs"][1]["read_only"] is True + assert body["jobs"][1]["profile"] is None + + +def test_crons_route_dedupes_root_aliases_by_resolved_home(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + current_home = {"value": None} + jobs_by_home = { + "base-home": [{"id": "root-job", "name": "Root job", "profile": None}], + "beta-home": [{"id": "beta-job", "name": "Beta job", "profile": "beta"}], + } + calls = {"base-home": 0, "beta-home": 0} + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + + def _list_jobs(include_disabled=True): + calls[current_home["value"]] += 1 + return [dict(job) for job in jobs_by_home[current_home["value"]]] + + cron_jobs.list_jobs = _list_jobs + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + class _Ctx: + def __init__(self, home): + self.home = str(home) + self.prev = None + + def __enter__(self): + self.prev = current_home["value"] + current_home["value"] = self.home + return self + + def __exit__(self, exc_type, exc, tb): + current_home["value"] = self.prev + return False + + monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "rootalias") + monkeypatch.setattr(profiles, "list_profiles_api", lambda: [ + {"name": "default", "visible": True}, + {"name": "beta", "visible": True}, + ]) + monkeypatch.setattr( + profiles, + "get_hermes_home_for_profile", + lambda name: Path({ + "rootalias": "base-home", + "default": "base-home", + "beta": "beta-home", + }[name]), + ) + monkeypatch.setattr(profiles, "cron_profile_context_for_home", _Ctx) + + handler = _JSONHandler() + assert routes.handle_get(handler, SimpleNamespace(path="/api/crons", query="all_profiles=1")) is not False + body = _payload(handler) + + assert calls["base-home"] == 1, "root alias + default must not double-read the same home" + assert calls["beta-home"] == 1 + assert [job["owner_profile"] for job in body["jobs"]] == ["rootalias", "beta"] + + +def test_crons_route_skips_hidden_default_profile_when_inactive(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + current_home = {"value": None} + jobs_by_home = { + "alpha-home": [{"id": "alpha-job", "name": "Alpha", "profile": None}], + "default-home": [{"id": "root-job", "name": "Root", "profile": None}], + } + _install_cron_jobs(monkeypatch, jobs_by_home, current_home) + + class _Ctx: + def __init__(self, home): + self.home = str(home) + self.prev = None + + def __enter__(self): + self.prev = current_home["value"] + current_home["value"] = self.home + return self + + def __exit__(self, exc_type, exc, tb): + current_home["value"] = self.prev + return False + + monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "alpha") + monkeypatch.setattr(profiles, "list_profiles_api", lambda: [ + {"name": "default", "visible": False}, + {"name": "alpha", "visible": True}, + ]) + monkeypatch.setattr( + profiles, + "get_hermes_home_for_profile", + lambda name: Path({"alpha": "alpha-home", "default": "default-home"}[name]), + ) + monkeypatch.setattr(profiles, "cron_profile_context_for_home", _Ctx) + + handler = _JSONHandler() + assert routes.handle_get(handler, SimpleNamespace(path="/api/crons", query="all_profiles=1")) is not False + body = _payload(handler) + + assert handler.status == 200 + assert body["all_profiles"] is True + assert body["other_profile_count"] == 0 + assert [job["owner_profile"] for job in body["jobs"]] == ["alpha"] + + +def test_crons_route_ignores_all_profiles_toggle_in_isolated_mode(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + current_home = {"value": None} + jobs_by_home = { + "alpha-home": [{"id": "alpha-job", "name": "Alpha", "profile": None}], + } + _install_cron_jobs(monkeypatch, jobs_by_home, current_home) + lookups = [] + + class _Ctx: + def __init__(self, home): + self.home = str(home) + self.prev = None + + def __enter__(self): + self.prev = current_home["value"] + current_home["value"] = self.home + return self + + def __exit__(self, exc_type, exc, tb): + current_home["value"] = self.prev + return False + + monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "alpha") + monkeypatch.setattr(routes, "_is_isolated_profile_mode", lambda: True) + monkeypatch.setattr(profiles, "list_profiles_api", lambda: [{"name": "alpha", "visible": True}]) + monkeypatch.setattr( + profiles, + "get_hermes_home_for_profile", + lambda name: lookups.append(name) or Path("alpha-home"), + ) + monkeypatch.setattr(profiles, "cron_profile_context_for_home", _Ctx) + + handler = _JSONHandler() + assert routes.handle_get(handler, SimpleNamespace(path="/api/crons", query="all_profiles=1")) is not False + body = _payload(handler) + + assert handler.status == 200 + assert body["all_profiles"] is False + assert body["other_profile_count"] == 0 + assert [job["owner_profile"] for job in body["jobs"]] == ["alpha"] + assert lookups == ["alpha"] + + +def test_cron_jobs_cross_profile_skips_foreign_failures_but_reraises_active_failure(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + current_home = {"value": None} + jobs_by_home = { + "alpha-home": [{"id": "alpha-job", "name": "Alpha", "profile": None}], + "beta-home": [{"id": "beta-job", "name": "Beta", "profile": None}], + "gamma-home": [{"id": "gamma-job", "name": "Gamma", "profile": None}], + } + failing_homes = {"beta-home"} + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + + def _list_jobs(include_disabled=True): + home = current_home["value"] + if home in failing_homes: + raise RuntimeError(f"boom-{home}") + return [dict(job) for job in jobs_by_home[home]] + + cron_jobs.list_jobs = _list_jobs + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + class _Ctx: + def __init__(self, home): + self.home = str(home) + self.prev = None + + def __enter__(self): + self.prev = current_home["value"] + current_home["value"] = self.home + return self + + def __exit__(self, exc_type, exc, tb): + current_home["value"] = self.prev + return False + + monkeypatch.setattr(profiles, "list_profiles_api", lambda: [ + {"name": "alpha", "visible": True}, + {"name": "beta", "visible": True}, + {"name": "gamma", "visible": True}, + ]) + monkeypatch.setattr( + profiles, + "get_hermes_home_for_profile", + lambda name: Path({ + "alpha": "alpha-home", + "beta": "beta-home", + "gamma": "gamma-home", + }[name]), + ) + monkeypatch.setattr(profiles, "cron_profile_context_for_home", _Ctx) + + active_jobs, other_jobs = routes._cron_jobs_cross_profile("alpha") + + assert [job["owner_profile"] for job in active_jobs] == ["alpha"] + assert [job["owner_profile"] for job in other_jobs] == ["gamma"] + assert active_jobs[0]["read_only"] is False + assert other_jobs[0]["read_only"] is True + + failing_homes.clear() + failing_homes.add("alpha-home") + + with pytest.raises(RuntimeError, match="boom-alpha-home"): + routes._cron_jobs_cross_profile("alpha") + + +def test_panels_toggle_button_flips_state_and_refetches(): + append_toggle = _extract_function(PANELS_JS, "_appendCronProfileToggle") + script = f""" +const results = {{}}; +let _showAllCronProfiles = false; +let _cronOtherProfileCount = 3; +let loadCalls = 0; +async function loadCrons() {{ loadCalls += 1; }} +const document = {{ + createElement(tag) {{ + return {{ + tag, + type: '', + className: '', + textContent: '', + onclick: null, + style: {{}}, + children: [], + appendChild(child) {{ this.children.push(child); }}, + }}; + }}, +}}; +const parent = {{ + children: [], + appendChild(child) {{ this.children.push(child); }}, +}}; +{append_toggle} +(async () => {{ + _appendCronProfileToggle(parent); + const wrap = parent.children[0]; + const btn = wrap.children[0]; + results.initialText = btn.textContent; + await btn.onclick(); + results.toggled = _showAllCronProfiles; + results.loadCalls = loadCalls; + _cronOtherProfileCount = 0; + const parent2 = {{ children: [], appendChild(child) {{ this.children.push(child); }} }}; + _appendCronProfileToggle(parent2); + results.showActiveOnlyText = parent2.children[0].children[0].textContent; + results.sourceUsesQuery = {json.dumps("?all_profiles=1" in PANELS_JS)}; + process.stdout.write(JSON.stringify(results)); +}})().catch((err) => {{ + console.error(err); + process.exit(1); +}}); +""" + result = _run_node(script) + + assert result["initialText"] == "Show 3 from other profiles" + assert result["toggled"] is True + assert result["loadCalls"] == 1 + assert result["showActiveOnlyText"] == "Show active profile only" + assert result["sourceUsesQuery"] is True + + +def test_profile_switch_resets_cross_profile_tasks_toggle(): + profile_switch_panel_load = _extract_function(PANELS_JS, "_profileSwitchPanelLoad").replace( + "function _profileSwitchPanelLoad", + "async function _profileSwitchPanelLoad", + 1, + ) + script = f""" +const results = {{}}; +let _showAllCronProfiles = true; +let _cronOtherProfileCount = 9; +let _currentPanel = 'chat'; +let skillLoads = 0; +let memoryLoads = 0; +let taskLoads = 0; +let kanbanLoads = 0; +let profileLoads = 0; +let workspaceLoads = 0; +let clearCronDetailCalls = 0; +let _editingCronId = 'old-job'; +let _cronPreFormDetail = {{ id: 'old-job' }}; +let _cronIsDuplicate = true; +async function loadSkills() {{ skillLoads += 1; }} +async function loadMemory() {{ memoryLoads += 1; }} +async function loadCrons() {{ taskLoads += 1; }} +async function loadKanban() {{ kanbanLoads += 1; }} +async function loadProfilesPanel() {{ profileLoads += 1; }} +async function loadWorkspacesPanel() {{ workspaceLoads += 1; }} +function _clearCronDetail() {{ clearCronDetailCalls += 1; }} +{profile_switch_panel_load} +(async () => {{ + await _profileSwitchPanelLoad(); + results.chatPanelToggle = _showAllCronProfiles; + results.chatPanelCount = _cronOtherProfileCount; + results.chatPanelTaskLoads = taskLoads; + results.chatPanelClears = clearCronDetailCalls; + results.chatPanelEditing = _editingCronId; + results.chatPanelPreForm = _cronPreFormDetail; + results.chatPanelDuplicate = _cronIsDuplicate; + _showAllCronProfiles = true; + _cronOtherProfileCount = 4; + _currentPanel = 'tasks'; + _editingCronId = 'second-job'; + _cronPreFormDetail = {{ id: 'second-job' }}; + _cronIsDuplicate = true; + await _profileSwitchPanelLoad(); + results.tasksPanelToggle = _showAllCronProfiles; + results.tasksPanelCount = _cronOtherProfileCount; + results.tasksPanelTaskLoads = taskLoads; + results.tasksPanelClears = clearCronDetailCalls; + results.tasksPanelEditing = _editingCronId; + results.tasksPanelPreForm = _cronPreFormDetail; + results.tasksPanelDuplicate = _cronIsDuplicate; + process.stdout.write(JSON.stringify(results)); +}})().catch((err) => {{ + console.error(err); + process.exit(1); +}}); +""" + result = _run_node(script) + + assert result["chatPanelToggle"] is False + assert result["chatPanelCount"] == 0 + assert result["chatPanelTaskLoads"] == 0 + assert result["chatPanelClears"] == 1 + assert result["chatPanelEditing"] is None + assert result["chatPanelPreForm"] is None + assert result["chatPanelDuplicate"] is False + assert result["tasksPanelToggle"] is False + assert result["tasksPanelCount"] == 0 + assert result["tasksPanelTaskLoads"] == 1 + assert result["tasksPanelClears"] == 2 + assert result["tasksPanelEditing"] is None + assert result["tasksPanelPreForm"] is None + assert result["tasksPanelDuplicate"] is False + + +def test_panels_js_uses_composite_cron_row_identity(): + assert "function _cronJobKey(job)" in PANELS_JS + assert "_currentCronDetailKey" in PANELS_JS + assert "function _cronDetailMatches(jobId, detailKey)" in PANELS_JS + assert "openCronDetail(job, item)" in PANELS_JS + + +def test_panels_read_only_rows_hide_actions_and_skip_unread_side_effects(): + profile_name = _extract_function(PANELS_JS, "_cronProfileName") + owner_name = _extract_function(PANELS_JS, "_cronOwnerProfileName") + job_key = _extract_function(PANELS_JS, "_cronJobKey") + set_buttons = _extract_function(PANELS_JS, "_setCronHeaderButtons") + open_detail = _extract_function(PANELS_JS, "openCronDetail") + script = f""" +const results = {{}}; +const buttons = Object.create(null); +const header = {{ style: {{ display: 'initial' }} }}; +function $(id) {{ + if (id === 'mainTasks') return {{ querySelector() {{ return header; }} }}; + if (!buttons[id]) buttons[id] = {{ style: {{ display: 'initial' }} }}; + return buttons[id]; +}} +let unreadClears = 0; +let watchCalls = 0; +let stopCalls = 0; +let rendered = null; +let _cronPreFormDetail = 'before'; +let _editingCronId = 'existing'; +function _findCronJob() {{ return {{ id: 'job-1', read_only: true, owner_profile: 'beta' }}; }} +function _cronItemId() {{ return 'cron-beta'; }} +function _clearCronUnreadForJob() {{ unreadClears += 1; }} +function _stopCronWatch() {{ stopCalls += 1; }} +function _renderCronDetail(job) {{ rendered = job; }} +function _checkCronWatchOnDetail() {{ watchCalls += 1; }} +function _closeMobileSidebarAfterPanelSelection() {{}} +const dot = {{ removed: false, remove() {{ this.removed = true; }} }}; +const activeEl = {{ + classList: {{ add() {{}}, remove() {{}} }}, + querySelector() {{ return dot; }}, +}}; +const document = {{ + querySelectorAll() {{ return [activeEl]; }}, +}}; +{profile_name} +{owner_name} +{job_key} +{set_buttons} +{open_detail} +_setCronHeaderButtons('read', {{ read_only: true }}); +openCronDetail('job-1', activeEl); +results.hidden = Object.fromEntries(Object.entries(buttons).map(([key, value]) => [key, value.style.display])); +results.unreadClears = unreadClears; +results.watchCalls = watchCalls; +results.stopCalls = stopCalls; +results.dotRemoved = dot.removed; +results.renderedReadOnly = !!(rendered && rendered.read_only); +process.stdout.write(JSON.stringify(results)); +""" + result = _run_node(script) + + expected_hidden = { + "btnRunTaskDetail", + "btnPauseTaskDetail", + "btnResumeTaskDetail", + "btnEditTaskDetail", + "btnDuplicateTaskDetail", + "btnDeleteTaskDetail", + "btnCancelTaskDetail", + "btnSaveTaskDetail", + } + assert expected_hidden.issubset(result["hidden"].keys()) + assert all(result["hidden"][button] == "none" for button in expected_hidden) + assert result["unreadClears"] == 0 + assert result["watchCalls"] == 0 + assert result["stopCalls"] == 1 + assert result["dotRemoved"] is False + assert result["renderedReadOnly"] is True + assert "if (!isReadOnly) _loadCronDetailRuns(job.id, _currentCronDetailKey);" in PANELS_JS + assert "Read-only from another profile" in PANELS_JS + + +def test_history_race_ignores_stale_active_response_after_foreign_row_switch(): + profile_name = _extract_function(PANELS_JS, "_cronProfileName") + owner_name = _extract_function(PANELS_JS, "_cronOwnerProfileName") + job_key = _extract_function(PANELS_JS, "_cronJobKey") + detail_matches = _extract_function(PANELS_JS, "_cronDetailMatches") + load_runs = _extract_function(PANELS_JS, "_loadCronDetailRuns").replace( + "function _loadCronDetailRuns", + "async function _loadCronDetailRuns", + 1, + ) + script = f""" +const results = {{}}; +let resolveHistory; +let _currentCronDetail = {{ id: 'job-shared', owner_profile: 'alpha', read_only: false }}; +let _currentCronDetailKey = 'alpha\\u0000job-shared'; +const card = {{ innerHTML: 'read-only placeholder' }}; +function $(id) {{ return id === 'cronDetailRuns' ? card : null; }} +function api() {{ + return new Promise((resolve) => {{ resolveHistory = resolve; }}); +}} +function _cronOutputTitle() {{ return 'Output'; }} +function _isCronScriptJob() {{ return false; }} +function t(key) {{ return key; }} +function esc(value) {{ return String(value); }} +{profile_name} +{owner_name} +{job_key} +{detail_matches} +{load_runs} +(async () => {{ + const pending = _loadCronDetailRuns('job-shared', _currentCronDetailKey); + _currentCronDetail = {{ id: 'job-shared', owner_profile: 'beta', read_only: true }}; + _currentCronDetailKey = 'beta\\u0000job-shared'; + resolveHistory({{ runs: [], total: 0 }}); + await pending; + results.cardHtml = card.innerHTML; + process.stdout.write(JSON.stringify(results)); +}})().catch((err) => {{ + console.error(err); + process.exit(1); +}}); +""" + result = _run_node(script) + + assert result["cardHtml"] == "read-only placeholder" + + +def test_status_probe_race_does_not_start_watch_for_foreign_row_with_same_job_id(): + profile_name = _extract_function(PANELS_JS, "_cronProfileName") + owner_name = _extract_function(PANELS_JS, "_cronOwnerProfileName") + job_key = _extract_function(PANELS_JS, "_cronJobKey") + detail_matches = _extract_function(PANELS_JS, "_cronDetailMatches") + check_watch = _extract_function(PANELS_JS, "_checkCronWatchOnDetail") + script = f""" +const results = {{}}; +let resolveStatus; +let _currentCronDetail = {{ id: 'job-shared', owner_profile: 'alpha', read_only: false }}; +let _currentCronDetailKey = 'alpha\\u0000job-shared'; +let watchStarts = 0; +function api() {{ + return new Promise((resolve) => {{ resolveStatus = resolve; }}); +}} +function _startCronWatch() {{ watchStarts += 1; }} +{profile_name} +{owner_name} +{job_key} +{detail_matches} +{check_watch} +(async () => {{ + _checkCronWatchOnDetail('job-shared', _currentCronDetailKey); + _currentCronDetail = {{ id: 'job-shared', owner_profile: 'beta', read_only: true }}; + _currentCronDetailKey = 'beta\\u0000job-shared'; + resolveStatus({{ running: true }}); + await Promise.resolve(); + await Promise.resolve(); + results.watchStarts = watchStarts; + process.stdout.write(JSON.stringify(results)); +}})().catch((err) => {{ + console.error(err); + process.exit(1); +}}); +""" + result = _run_node(script) + + assert result["watchStarts"] == 0 diff --git a/tests/test_issue4026_collapsible_paused_crons.py b/tests/test_issue4026_collapsible_paused_crons.py index ac322c0b..9fd4b2aa 100644 --- a/tests/test_issue4026_collapsible_paused_crons.py +++ b/tests/test_issue4026_collapsible_paused_crons.py @@ -70,7 +70,7 @@ def test_cron_list_remains_single_source_of_truth(): unchanged when grouping is introduced.""" src = _read("static/panels.js") # The detail-refresh lookup post-render must still scan the whole _cronList. - assert "_cronList.find(j => j.id === _currentCronDetail.id)" in src + assert "_cronList.find(j => _cronJobKey(j) === _currentCronDetailKey)" in src def test_paused_section_styling_present(): diff --git a/tests/test_issue4768_cron_module_missing.py b/tests/test_issue4768_cron_module_missing.py index cae1d879..0af637bc 100644 --- a/tests/test_issue4768_cron_module_missing.py +++ b/tests/test_issue4768_cron_module_missing.py @@ -21,12 +21,18 @@ def _api_crons_branch() -> str: return ROUTES[marker:nxt] +def _cross_profile_helper() -> str: + """Return the source of _cron_jobs_cross_profile.""" + marker = ROUTES.index("def _cron_jobs_cross_profile(") + nxt = ROUTES.index("\ndef ", marker + 1) + return ROUTES[marker:nxt] + + def test_api_crons_guards_missing_cron_module(): - """The /api/crons GET branch must wrap the cron.jobs import in a guard that + """The /api/crons GET branch must wrap the cron helper in a guard that returns a graceful payload instead of letting ModuleNotFoundError 500 — but only for a genuinely-absent cron package, not an internal import bug.""" branch = _api_crons_branch() - assert "from cron.jobs import list_jobs" in branch assert "except ModuleNotFoundError as exc" in branch, ( "GET /api/crons must catch ModuleNotFoundError so the Tasks tab does not " "500 when the cron package is absent (#4768)." @@ -40,17 +46,16 @@ def test_api_crons_guards_missing_cron_module(): ) # ...and re-raises everything else (a real cron bug is not swallowed). assert "\n raise" in branch - # The try guards the import specifically (not some unrelated block). - try_idx = branch.index("try:") - import_idx = branch.index("from cron.jobs import list_jobs") - except_idx = branch.index("except ModuleNotFoundError") - assert try_idx < import_idx < except_idx + # The try in the branch guards the cron helper call. + assert "try:" in branch def test_api_crons_still_lists_jobs_on_happy_path(): """The guard must not remove the normal behavior: when cron imports fine, the branch still calls list_jobs(include_disabled=True) under cron_profile_context.""" - branch = _api_crons_branch() - assert "list_jobs(include_disabled=True)" in branch - assert "cron_profile_context()" in branch - assert "_cron_jobs_for_api(" in branch + helper = _cross_profile_helper() + assert "list_jobs(include_disabled=True)" in helper + assert "cron_profile_context_for_home" in helper + assert "_cron_jobs_for_api(" in helper + # The import is lazy (inside the function body, not at module level). + assert "from cron.jobs import list_jobs" in helper