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:
commit
747e42e788
6 changed files with 925 additions and 58 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
174
static/panels.js
174
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 = `<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();
|
||||
|
|
|
|||
680
tests/test_issue3947_tasks_cross_profile_visibility.py
Normal file
680
tests/test_issue3947_tasks_cross_profile_visibility.py
Normal 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
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue