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 = `
${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)}
${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