Merge pull request #5388 from nesquena/gate-rebase/4682-cross-profile-cron

Release — Tasks panel: read-only other-profile cron visibility (#4682, #3947)
This commit is contained in:
nesquena-hermes 2026-07-01 18:11:45 -07:00 committed by GitHub
commit 747e42e788
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 925 additions and 58 deletions

View file

@ -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)

View file

@ -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

View file

@ -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 = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('cron_no_jobs'))}</div>`;
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
? '<span class="cron-status disabled" title="Read-only from another profile">Read-only</span>'
: '';
item.innerHTML = `
<div class="cron-header">
${isNewRun ? '<span class="cron-new-dot" title="New run"></span>' : ''}
${isAgentMode ? '<span class="cron-agent-badge" title="Agent mode">🤖</span>' : `<span class="cron-script-badge" title="${esc(t('cron_script_badge_title') || 'Script job (no agent)')}">📜</span>`}
<span class="cron-name" title="${esc(job.name)}">${esc(job.name)}</span>
<span class="cron-profile-badge" title="${esc(profileTitle)}">${esc(profileLabel)}</span>
<span class="cron-profile-badge" title="${esc(ownerProfileTitle)}">${esc(ownerProfileLabel)}</span>
<span class="cron-status ${status.listClass}">${esc(status.label)}</span>
${readOnlyBadge}
</div>`;
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 = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(emptyText)}</div>`;
_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 ? `<div class="detail-row"><div class="detail-row-label">${esc(t('error_prefix').replace(/:\s*$/,''))}</div><div class="detail-row-value" style="color:var(--accent-text)">${esc(job.last_error)}</div></div>` : '';
const attention = status.state === 'needs_attention' || status.state === 'schedule_error';
const croniterHint = job.last_error && /croniter/i.test(job.last_error)
? `<p>${esc(t('cron_attention_croniter_hint'))}</p>`
: '';
const attentionBanner = attention ? `
const attentionBanner = !isReadOnly && attention ? `
<div class="detail-alert cron-attention-panel">
<div class="detail-alert-title">${esc(t('cron_status_needs_attention'))}</div>
<p>${esc(t('cron_attention_desc'))}</p>
@ -857,6 +936,11 @@ function _renderCronDetail(job){
<button type="button" class="cron-btn" onclick="copyCurrentCronDiagnostics()">${esc(t('cron_attention_copy_diagnostics'))}</button>
</div>
</div>` : '';
const readOnlyBanner = isReadOnly ? `
<div class="detail-alert">
<div class="detail-alert-title">Read-only from another profile</div>
<p>Switch to ${esc(ownerProfileLabel)} to run, edit, or inspect live status and output for this cron job.</p>
</div>` : '';
const toastNotifications = job.toast_notifications !== false;
const outputTitle = _cronOutputTitle(job);
const skillsRow = isNoAgent ? '' : `<div class="detail-row"><div class="detail-row-label">${esc(t('cron_skills_label') || 'Skills')}</div><div class="detail-row-value">${esc(skills)}</div></div>`;
@ -864,6 +948,7 @@ function _renderCronDetail(job){
body.innerHTML = `
<div class="main-view-content">
${attentionBanner}
${readOnlyBanner}
${isNoAgent ? _cronScriptJobBannerHtml() : ''}
<div class="detail-card">
<div class="detail-card-title">${esc(t('cron_status_active').replace(/./,c=>c.toUpperCase()))}</div>
@ -873,15 +958,16 @@ function _renderCronDetail(job){
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_last'))}</div><div class="detail-row-value">${esc(lastRun)}</div></div>
<div class="detail-row"><div class="detail-row-label">Deliver</div><div class="detail-row-value">${esc(deliver)}</div></div>
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_mode_label') || 'Mode')}</div><div class="detail-row-value"><span class="detail-badge cron-mode-badge ${isNoAgent ? 'script' : 'agent'}" id="cronJobMode">${esc(cronJobMode)}</span>${modelProvider ? ` <code>${modelProvider}</code>` : ''}</div></div>
${showOwnerRow ? `<div class="detail-row"><div class="detail-row-label">Owner profile</div><div class="detail-row-value"><span class="detail-badge active" title="${esc(ownerProfileTitle)}">${esc(ownerProfileLabel)}</span></div></div>` : ''}
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_profile_label') || 'Profile')}</div><div class="detail-row-value"><span class="detail-badge active" title="${esc(profileTitle)}">${esc(profileLabel)}</span></div></div>
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_toast_notifications_label') || 'Completion toasts')}</div><div class="detail-row-value"><span class="detail-badge ${toastNotifications ? 'active' : ''}">${esc(toastNotifications ? (t('cron_toast_notifications_enabled') || 'Enabled') : (t('cron_toast_notifications_disabled') || 'Disabled'))}</span></div></div>
${skillsRow}
${lastError}
</div>
${instructionCard}
<div class="detail-card ${_cronNewJobIds.has(String(job.id)) ? 'has-new-run' : ''}" id="cronDetailRuns">
<div class="detail-card ${!isReadOnly && _cronNewJobIds.has(String(job.id)) ? 'has-new-run' : ''}" id="cronDetailRuns">
<div class="detail-card-title">${esc(outputTitle)}</div>
<div style="color:var(--muted);font-size:12px">${esc(t('loading'))}</div>
<div style="color:var(--muted);font-size:12px">${esc(isReadOnly ? 'Live output is available only when this profile is active here.' : (t('loading') || 'Loading'))}</div>
</div>
</div>`;
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();

View file

@ -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

View file

@ -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():

View file

@ -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