pub(crate) fn live_client_js() -> &'static str {
r#"
function esc(s) {
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function fmtUptime(secs) {
secs = Math.max(0, secs|0);
if (secs < 60) return 'up ' + secs + 's';
const m = Math.floor(secs / 60), s = secs % 60;
if (secs < 3600) return 'up ' + m + 'm ' + s + 's';
const h = Math.floor(secs / 3600);
return 'up ' + h + 'h ' + (Math.floor((secs % 3600) / 60)) + 'm';
}
const SESSION_START_TIMEOUT_SECS = 30;
const SESSION_IDLE_TIMEOUT_SECS = 30 * 60;
function sessionHasIncompleteProcs(session) {
const procs = session.procs || [];
return procs.some(p => p.status === 'running' || p.status === 'waiting');
}
// Mirrors Session::proc_next_attempt: explicit attempt lineage first; same-name inference
// exists only for persisted records written before `previous_attempt`.
function procNextAttempt(session, p) {
const linked = (session.procs || []).find(q => q.previous_attempt === p.index);
if (linked) return linked;
if (p.previous_attempt != null) return null;
if (!p.skill_name) return null;
const later = (session.procs || []).filter(q =>
q.previous_attempt == null && q.index > p.index &&
(q.kind || 'skill') === (p.kind || 'skill') && q.skill_name === p.skill_name);
later.sort((a, b) => a.index - b.index);
return later[0] || null;
}
function procPreviousAttempt(session, p) {
const procs = session.procs || [];
if (p.previous_attempt != null) return procs.find(q => q.index === p.previous_attempt) || null;
if (procs.some(q => q.previous_attempt === p.index)) return null;
if (!p.skill_name) return null;
const earlier = procs.filter(q => q.index < p.index &&
(q.kind || 'skill') === (p.kind || 'skill') && q.skill_name === p.skill_name);
earlier.sort((a, b) => b.index - a.index);
return earlier[0] || null;
}
function procFirstAttempt(session, p) {
let first = p;
const seen = new Set([first.index]);
while (true) {
const previous = procPreviousAttempt(session, first);
if (!previous || seen.has(previous.index)) return first;
seen.add(previous.index);
first = previous;
}
}
// Mirrors Session::proc_is_superseded: a failed attempt whose route was re-run by a
// later proc is not the route's authoritative outcome.
function procIsSuperseded(session, p) {
return procNextAttempt(session, p) != null;
}
// Mirrors Session::proc_attempt: [ordinal, total] attempts for this proc's route.
function procAttempt(session, p) {
const procs = session.procs || [];
if (p.previous_attempt != null || procs.some(q => q.previous_attempt === p.index)) {
let root = p;
const seen = new Set([root.index]);
while (root.previous_attempt != null) {
const previous = procs.find(q => q.index === root.previous_attempt);
if (!previous || seen.has(previous.index)) break;
seen.add(previous.index);
root = previous;
}
let ordinal = 1, total = 1, current = root;
const forwardSeen = new Set([root.index]);
while (true) {
const next = procs.find(q => q.previous_attempt === current.index);
if (!next || forwardSeen.has(next.index)) break;
forwardSeen.add(next.index);
total += 1;
if (next.index <= p.index) ordinal += 1;
current = next;
}
return [ordinal, total];
}
if (!p.skill_name) return [1, 1];
let ordinal = 0, total = 0;
(session.procs || []).forEach(q => {
if ((q.kind || 'skill') === (p.kind || 'skill') && q.skill_name === p.skill_name) {
total += 1;
if (q.index <= p.index) ordinal += 1;
}
});
return [Math.max(1, ordinal), Math.max(1, total)];
}
// Mirror of attempt_chip_html in session.rs: retries visibly say they are retries.
function attemptChipHtml(session, p) {
const attempt = procAttempt(session, p)[0];
if (attempt <= 1) return '';
return ' <span class="chamfer agent-badge attempt-chip"><span>attempt ' + attempt + '</span></span>';
}
// Mirror of retry_link_html in session.rs: a failed attempt cross-links its retry.
function retryLinkHtml(session, p) {
if (p.fail_reason === 'restart_requested') {
return ' <span class="proc-retry-pending">replacement starting…</span>';
}
if (p.status !== 'fail') return '';
const next = procNextAttempt(session, p);
if (!next) return '';
return ' <a class="proc-retry-link" href="' + '#proc-' + esc(String(next.index)) +
'" title="This attempt failed and was retried; the newest attempt is authoritative">superseded — see attempt ' +
procAttempt(session, next)[0] + ' ↓</a>';
}
function originalAttemptLinkHtml(session, p) {
const original = procFirstAttempt(session, p);
if (!original || original.index === p.index) return '';
return ' <a class="proc-original-link" href="' + '#proc-' + esc(String(original.index)) +
'" title="Jump to the original run in this attempt chain">original attempt ↑</a>';
}
function sessionLifecycle(session, nowUnix) {
// The daemon owns lifecycle. Browser-side derivation exists only for an old persisted
// snapshot or the brief server-rendered interval before the first live API snapshot.
if (session.lifecycle && session.lifecycle !== 'running') {
return { label: session.lifecycle_label || session.lifecycle, class: session.lifecycle };
}
if (session.ended_at) {
if (sessionHasIncompleteProcs(session)) return { label: 'cancelled', class: 'cancelled' };
const failed = (session.procs || []).filter(p => p.status === 'fail' && !procIsSuperseded(session, p));
const interrupted = failed.length > 0 && failed.every(p =>
p.fail_reason === 'force_stopped' || p.fail_reason === 'force_restarted' ||
p.fail_reason === 'session_end_before_proc_finish');
if (interrupted) return { label: 'cancelled', class: 'cancelled' };
if (failed.length) return { label: 'failed', class: 'failed' };
return { label: 'completed', class: 'completed' };
}
const procs = session.procs || [];
const started = procs.some(p => p.started_at || p.status !== 'waiting');
const deadline = started
? (session.last_seen_at || session.started_at || 0) + SESSION_IDLE_TIMEOUT_SECS
: (session.started_at || 0) + SESSION_START_TIMEOUT_SECS;
if (nowUnix > deadline) return { label: 'failed', class: 'failed' };
if (session.lifecycle === 'running') {
return { label: session.lifecycle_label || 'running', class: 'running' };
}
return { label: 'running', class: 'running' };
}
function sessionStatus(session) {
return sessionLifecycle(session, Date.now() / 1000).label;
}
function sortSessionIds(sessions, nowUnix) {
const ids = Object.keys(sessions || {});
ids.sort((a, b) => {
const sa = sessions[a], sb = sessions[b];
const aLive = sessionLifecycle(sa, nowUnix).class === 'running';
const bLive = sessionLifecycle(sb, nowUnix).class === 'running';
if (aLive !== bLive) return aLive ? -1 : 1;
return (sb.started_at || 0) - (sa.started_at || 0);
});
return ids;
}
function formatRelative(secsAgo) {
secsAgo = Math.max(0, Math.floor(secsAgo || 0));
if (secsAgo < 60) return secsAgo + 's ago';
const m = Math.floor(secsAgo / 60);
if (secsAgo < 3600) return m + 'm ago';
const h = Math.floor(secsAgo / 3600);
return h + 'h ' + Math.floor((secsAgo % 3600) / 60) + 'm ago';
}
function sessionDurationLabel(session, nowUnix, lifecycle) {
const start = session.started_at || 0;
if (session.ended_at && start) return formatDuration(session.ended_at - start);
if (lifecycle.class === 'running' && start) return formatDuration(nowUnix - start) + ' so far';
return '—';
}
function sessionStatusBadge(lifecycle) {
return '<span class="chamfer session-status ' + esc(lifecycle.class) + '"><span>' +
esc(lifecycle.label) + '</span></span>';
}
function setBtnLabel(btn, text) {
const span = btn.querySelector(':scope > span');
if (span) span.textContent = text;
else btn.textContent = text;
}
// In-app confirm dialog (Promise<boolean>). Replaces the browser confirm dialog for Force stop UX.
function scshConfirm(opts) {
const title = (opts && opts.title) || 'Confirm';
const body = (opts && opts.body) || '';
const confirmLabel = (opts && opts.confirmLabel) || 'Confirm';
const cancelLabel = (opts && opts.cancelLabel) || 'Cancel';
const danger = !!(opts && opts.danger);
return new Promise((resolve) => {
const existing = document.getElementById('scsh-dialog');
if (existing) existing.remove();
const backdrop = document.createElement('div');
backdrop.id = 'scsh-dialog';
backdrop.className = 'scsh-dialog-backdrop';
const panel = document.createElement('div');
panel.className = 'chamfer scsh-dialog';
panel.setAttribute('role', 'alertdialog');
panel.setAttribute('aria-modal', 'true');
panel.setAttribute('aria-labelledby', 'scsh-dialog-title');
const h = document.createElement('p');
h.id = 'scsh-dialog-title';
h.className = 'scsh-dialog-title';
h.textContent = title;
const p = document.createElement('p');
p.className = 'scsh-dialog-body';
p.textContent = body;
const actions = document.createElement('div');
actions.className = 'scsh-dialog-actions';
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'chamfer btn btn--sm btn--muted';
cancelBtn.innerHTML = '<span></span>';
cancelBtn.querySelector('span').textContent = cancelLabel;
const okBtn = document.createElement('button');
okBtn.type = 'button';
okBtn.className = 'chamfer btn btn--sm ' + (danger ? 'btn--red' : 'btn--cyan');
okBtn.innerHTML = '<span></span>';
okBtn.querySelector('span').textContent = confirmLabel;
actions.appendChild(cancelBtn);
actions.appendChild(okBtn);
panel.appendChild(h);
panel.appendChild(p);
panel.appendChild(actions);
backdrop.appendChild(panel);
// The dialog steals focus onto OK below, so remember where the user was and put
// them back on close — otherwise keyboard focus is dumped at <body> and a screen
// reader loses its place in the page.
const prevFocus = document.activeElement;
const finish = (ok) => {
document.removeEventListener('keydown', onKey, true);
backdrop.remove();
if (prevFocus && document.contains(prevFocus) && typeof prevFocus.focus === 'function') prevFocus.focus();
resolve(ok);
};
const onKey = (ev) => {
if (ev.key === 'Escape') { ev.preventDefault(); finish(false); }
else if (ev.key === 'Enter' && document.activeElement === okBtn) { ev.preventDefault(); finish(true); }
else if (ev.key === 'Tab') {
// Trap Tab inside the modal: aria-modal promises assistive tech that the page
// behind is inert, so Tab must cycle Cancel ⇄ OK instead of wandering out.
const focusables = panel.querySelectorAll('button:not(:disabled)');
if (!focusables.length) return;
const first = focusables[0], last = focusables[focusables.length - 1];
if (!panel.contains(document.activeElement)) { ev.preventDefault(); first.focus(); }
else if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
}
};
backdrop.addEventListener('click', (ev) => { if (ev.target === backdrop) finish(false); });
cancelBtn.addEventListener('click', () => finish(false));
okBtn.addEventListener('click', () => finish(true));
document.addEventListener('keydown', onKey, true);
document.body.appendChild(backdrop);
okBtn.focus();
});
}
function sessionStartedCell(session, nowUnix) {
const ts = session.started_at || 0;
const abs = formatUnixTime(ts);
const rel = formatRelative(nowUnix - ts);
return '<span class="session-started" data-started="' + esc(String(ts)) + '">' +
'<span class="session-started-abs">' + esc(abs) + '</span><br>' +
'<span class="dim session-started-rel">' + esc(rel) + '</span></span>';
}
function procRunHref(jobId, session, proc) {
const nodes = session && session.workflow && session.workflow.nodes;
const node = nodes && nodes.find(n => n.proc_index === proc.index);
const fragment = node ? '#task-' + encodeURIComponent(node.id) : '#proc-' + encodeURIComponent(proc.index);
return '/job/' + encodeURIComponent(jobId) + fragment;
}
// Mirrors harness_chips_html in index.rs — keep the markup identical. A running chip's
// tooltip duration lives OUT of the markup (data-tip-running + the tip module's ticker),
// so live re-renders compare equal and the hover survives.
function harnessChipsHtml(jobId, session) {
let out = '';
const procs = (session.procs || []).filter(p => (p.kind || 'skill') === 'skill' && p.harness);
procs.slice(0, 8).forEach((p) => {
const done = (p.status === 'ok' || p.status === 'graceful' || p.status === 'fail' || p.status === 'skipped');
const skill = p.skill_name || p.label || '';
const base = p.harness + ' · ' + skill;
let tip = base, runningAttr = '';
if (p.status === 'running' && p.started_at) runningAttr = ' data-tip-running="' + esc(String(p.started_at)) + '"';
else if (p.status === 'running') tip = base + '\nrunning';
else if (p.status === 'waiting') tip = base + '\nwaiting';
else if (p.status === 'ok') tip = base + '\ndone';
else if (p.status === 'graceful') tip = base + '\ngraceful shutdown';
else if (p.status === 'fail') tip = base + '\nfailed';
else tip = base + '\nskipped';
out += '<a class="chamfer hchip hchip--' + esc(p.harness) + (done ? ' hchip--done' : '') + '" href="' +
esc(procRunHref(jobId, session, p)) + '" data-tip="' + esc(tip) + '"' + runningAttr + '>' +
esc(p.harness.charAt(0).toUpperCase()) + '</a>';
});
if (procs.length > 8) out += '<span class="chip-overflow">+ ' + (procs.length - 8) + '</span>';
return out;
}
function chipCountHtml(n) {
return '<span class="chip-count" data-tip="' + n + ' run' + (n === 1 ? '' : 's') + ' in this job">' + n + '</span>';
}
function indexRowHtml(id, session, nowUnix, overflow) {
const lifecycle = sessionLifecycle(session, nowUnix);
const profile = session.profile || 'default';
const n = (session.procs || []).length;
const duration = sessionDurationLabel(session, nowUnix, lifecycle);
return '<tr' + (overflow ? ' class="jobs-overflow"' : '') +
' data-session-id="' + esc(id) + '"><td><a class="job-id" href="/job/' + esc(id) + '">' + esc(id) + '</a></td>' +
'<td class="session-status-cell">' + sessionStatusBadge(lifecycle) + '</td>' +
'<td class="session-started-cell">' + sessionStartedCell(session, nowUnix) + '</td>' +
'<td class="session-duration-cell">' + esc(duration) + '</td>' +
'<td>' + esc(profile) + '</td><td class="session-procs-cell">' + chipCountHtml(n) +
harnessChipsHtml(id, session) + '</td>' +
'<td class="dim repo-path session-repo-path"><button type="button" class="repo-copy" data-copy-value="' +
esc(session.repo || '') + '" data-tip="' + esc(session.repo || '') +
'" aria-label="Copy full repository path">' + esc(session.repo || '') + '</button></td></tr>';
}
function syncIndexRow(row, session, nowUnix) {
const lifecycle = sessionLifecycle(session, nowUnix);
const statusCell = row.querySelector('.session-status-cell');
if (statusCell) statusCell.innerHTML = sessionStatusBadge(lifecycle);
const startedCell = row.querySelector('.session-started-cell');
if (startedCell) startedCell.innerHTML = sessionStartedCell(session, nowUnix);
const durationCell = row.querySelector('.session-duration-cell');
if (durationCell) setTextUnlessSelecting(durationCell, sessionDurationLabel(session, nowUnix, lifecycle));
const procsCell = row.querySelector('.session-procs-cell');
if (procsCell) {
const next = chipCountHtml((session.procs || []).length) + harnessChipsHtml(row.dataset.sessionId || '', session);
if (procsCell.innerHTML !== next) procsCell.innerHTML = next;
}
const repoCell = row.querySelector('.repo-path');
if (repoCell) {
const copy = repoCell.querySelector('.repo-copy');
if (copy) {
setTextUnlessSelecting(copy, session.repo || '');
copy.setAttribute('data-copy-value', session.repo || '');
// A live tick must not erase the short "Copied!" acknowledgement mid-flight.
if (!copy._scshCopyTimer) copy.setAttribute('data-tip', session.repo || '');
}
}
}
// A bare repo-relative artifact path (a system pointer like tmp/scsh/<id>/add.json), as
// opposed to an agent's prose answer. Mirrored by the server-side renderer in session.rs.
function looksLikeArtifactPath(text) {
return /^(\/|tmp\/|\.harness\/)\S+$/.test(text || '');
}
// Compact single-unit age for dense lists — mirrors format_short_age in format.rs.
function formatShortAge(secsAgo) {
secsAgo = Math.max(0, Math.floor(secsAgo || 0));
if (secsAgo < 60) return secsAgo + 's';
if (secsAgo < 3600) return Math.floor(secsAgo / 60) + 'm';
if (secsAgo < 86400) return Math.floor(secsAgo / 3600) + 'h';
return Math.floor(secsAgo / 86400) + 'd';
}
function formatUnixTime(unix) {
if (!unix) return '—';
const d = new Date(unix * 1000);
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' });
}
function formatDuration(secs) {
secs = Math.max(0, Math.floor(secs || 0));
if (secs < 60) return secs + 's';
const m = Math.floor(secs / 60);
const s = secs % 60;
if (secs < 3600) return m + 'm ' + s + 's';
const h = Math.floor(secs / 3600);
return h + 'h ' + Math.floor((secs % 3600) / 60) + 'm ' + s + 's';
}
function selectionInside(node) {
const sel = document.getSelection();
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return false;
const range = sel.getRangeAt(0);
return node.contains(range.commonAncestorContainer);
}
function setTextUnlessSelecting(el, text) {
if (!el || selectionInside(el)) return;
if (el.textContent !== text) el.textContent = text;
}
function formatElapsedClock(elapsed) {
if (elapsed == null) return '—';
const secs = Math.floor(elapsed);
const pad2 = n => String(n).padStart(2, '0');
if (secs >= 3600) {
const hours = Math.floor(secs / 3600), minutes = Math.floor((secs % 3600) / 60), seconds = secs % 60;
if (seconds > 0) return hours + 'h' + pad2(minutes) + 'm' + pad2(seconds) + 's';
if (minutes > 0) return hours + 'h' + pad2(minutes) + 'm';
return hours + 'h';
}
if (secs >= 60) {
const minutes = Math.floor(secs / 60), seconds = secs % 60;
return seconds > 0 ? minutes + 'm' + pad2(seconds) + 's' : minutes + 'm';
}
return secs + 's';
}
// Mirrors elapsed_phrase in proc.rs — status-aware text before the timer.
function elapsedPhrase(status, elapsed, failReason) {
const clock = elapsed == null ? null : formatElapsedClock(elapsed);
if (failReason === 'restart_requested') {
return clock ? 'restarting · ' + clock : 'restarting';
}
if ((status === 'running' || status === 'waiting') &&
failReason === 'stop_requested') {
return clock ? 'terminating · ' + clock : 'terminating';
}
if (status === 'waiting') return clock ? 'waiting · ' + clock : 'waiting';
if (status === 'running') return clock ? 'running for ' + clock : 'running';
if (status === 'ok') return clock ? 'done in ' + clock : 'done';
if (status === 'graceful') return clock ? 'graceful shutdown in ' + clock : 'graceful shutdown';
if (status === 'skipped') return 'skipped';
if (status === 'fail') {
if (failReason === 'force_stopped') return clock ? 'stopped after ' + clock : 'stopped';
if (failReason === 'force_restarted') return clock ? 'restarted after ' + clock : 'restarted';
if (failReason === 'container_inactive') return clock ? 'stalled after ' + clock : 'stalled';
if (failReason === 'harness_startup_stalled') return clock ? 'stalled at startup after ' + clock : 'stalled at startup';
if (failReason === 'container_timeout') return clock ? 'timed out after ' + clock : 'timed out';
return clock ? 'failed in ' + clock : 'failed';
}
return clock || '—';
}
function formatIdleClock(secs) {
if (secs == null || secs < 1) return '';
return ' · idle ' + Math.floor(secs) + 's';
}
function sessionRunning(session) {
const procs = session.procs || [];
if (procs.some(p => p.status === 'running' || p.status === 'waiting')) return true;
if (!session.ended_at && procs.length === 0) return true;
return false;
}
function sessionLivenessDeadline(session) {
const procs = session.procs || [];
const started = procs.some(p => p.started_at || p.status !== 'waiting');
return started
? (session.last_seen_at || session.started_at || 0) + SESSION_IDLE_TIMEOUT_SECS
: (session.started_at || 0) + SESSION_START_TIMEOUT_SECS;
}
// Wall-clock seconds for the job meta — mirrors Session::duration_secs. A liveness
// failure freezes at the deadline that actually declared the job failed.
function sessionDurationSecs(session, nowUnix) {
const start = session.started_at || 0;
if (session.ended_at) return Math.max(0, session.ended_at - start);
const life = sessionLifecycle(session, nowUnix);
if (life.class === 'running') return Math.max(0, nowUnix - start);
if (life.class === 'failed' && !session.ended_at) {
return Math.max(0, sessionLivenessDeadline(session) - start);
}
return 0;
}
function sessionEndedLabel(session, nowUnix) {
if (session.ended_at) return formatUnixTime(session.ended_at);
const life = sessionLifecycle(session, nowUnix);
if (life.class === 'running') return 'still running';
if (life.class === 'failed') return formatUnixTime(sessionLivenessDeadline(session));
return '—';
}
function renderSessionMeta(session, nowUnix) {
const el = document.getElementById('session-meta');
if (!el || !session) return;
const started = formatUnixTime(session.started_at);
const ended = sessionEndedLabel(session, nowUnix);
const repo = session.repo || el.dataset.repo || '';
const branch = session.branch || el.dataset.branch || '—';
el.dataset.started = String(session.started_at || '');
el.dataset.ended = session.ended_at ? String(session.ended_at) : '';
el.dataset.lastSeen = String(session.last_seen_at || session.started_at || '');
el.dataset.repo = repo;
el.dataset.branch = branch;
if (!el.querySelector('[data-session-duration]')) {
el.innerHTML =
'<dt>Started</dt><dd data-session-started>' + esc(started) + '</dd>' +
'<dt>Ended</dt><dd data-session-ended>' + esc(ended) + '</dd>' +
'<dt>Duration</dt><dd data-session-duration>' +
esc(formatDuration(sessionDurationSecs(session, nowUnix))) + '</dd>' +
'<dt>Repo</dt><dd data-session-repo><code class="repo-path">' + esc(repo) + '</code></dd>' +
'<dt>Branch</dt><dd data-session-branch><code>' + esc(branch) + '</code></dd>';
} else {
setTextUnlessSelecting(el.querySelector('[data-session-ended]'), ended);
setTextUnlessSelecting(el.querySelector('[data-session-branch] code'), branch);
const repoEl = el.querySelector('[data-session-repo] code');
setTextUnlessSelecting(repoEl, repo);
}
syncSessionDuration(session, nowUnix);
syncSupervisorMeta(session, nowUnix);
}
// Mirrors supervisor_meta_html: an unused restart policy stays hidden, then the row is
// created and kept live as the daemon schedules or fires a job restart.
function syncSupervisorMeta(session, nowUnix) {
const meta = document.getElementById('session-meta');
if (!meta || !session) return;
const sup = session.supervisor || {};
const attempt = Math.max(1, Number(sup.job_attempt) || 1);
const active = attempt > 1 || sup.next_retry_at != null || sup.gave_up || sup.restarted_as;
let label = meta.querySelector('[data-session-supervisor-label]');
let value = meta.querySelector('[data-session-supervisor]');
if (!active) {
if (label) label.remove();
if (value) value.remove();
return;
}
if (!label || !value) {
meta.insertAdjacentHTML('beforeend',
'<dt data-session-supervisor-label>Job restarts</dt><dd data-session-supervisor></dd>');
label = meta.querySelector('[data-session-supervisor-label]');
value = meta.querySelector('[data-session-supervisor]');
}
let restarts, state;
if (sup.gave_up) {
restarts = Math.max(0, attempt - 1);
state = 'gave up — ' + esc(sup.gave_up);
} else if (sup.restarted_as) {
restarts = attempt;
const id = esc(sup.restarted_as);
state = 'continued as <a href="/job/' + id + '"><code class="job-id">' + id + '</code></a>';
} else if (sup.next_retry_at != null) {
restarts = attempt;
state = 'scheduled in ' + esc(formatDuration(Math.max(0, sup.next_retry_at - nowUnix)));
} else {
restarts = Math.max(0, attempt - 1);
state = 'running replacement';
}
if (value && !selectionInside(value)) value.innerHTML = restarts + ' of ' + (sup.retries || 0) + ' · ' + state;
}
function syncSessionDuration(session, nowUnix) {
const el = document.getElementById('session-meta');
if (!el) return;
setTextUnlessSelecting(
el.querySelector('[data-session-duration]'),
formatDuration(sessionDurationSecs(session, nowUnix))
);
}
function initSessionMetaFromDom() {
const el = document.getElementById('session-meta');
if (!el || !el.dataset.started) return;
const session = {
started_at: Number(el.dataset.started) || 0,
ended_at: el.dataset.ended ? Number(el.dataset.ended) : null,
last_seen_at: Number(el.dataset.lastSeen || el.dataset.started) || 0,
lifecycle: el.dataset.lifecycle || null,
lifecycle_label: el.dataset.lifecycleLabel || null,
repo: el.dataset.repo || '',
branch: el.dataset.branch || '',
procs: [],
};
renderSessionMeta(session, Date.now() / 1000);
}
function setScshVersion(version, git) {
const el = document.getElementById('status-scsh-version');
if (!el || !version) return;
if (git) {
el.innerHTML = 'scsh ' + esc(version) + ' · <code>' + esc(git) + '</code>';
} else {
el.textContent = 'scsh ' + version;
}
}
function setDaemonStatus(kind, label, uptime) {
const bar = document.getElementById('daemon-status');
const lbl = document.getElementById('status-label');
const up = document.getElementById('status-uptime');
bar.className = 'chamfer daemon-status ' + kind;
lbl.textContent = label;
up.textContent = uptime != null ? fmtUptime(uptime) : '';
}
// Jobs-table pagination: how many rows are revealed right now. Grows by a page per
// "Show N more" click and survives live re-renders. Mirrors JOBS_PAGE_SIZE in index.rs.
const JOBS_PAGE = 50;
let jobsVisible = JOBS_PAGE;
// Mirrors jobs_load_more_row in index.rs — keep the markup identical.
function jobsLoadMoreRowHtml(hidden) {
const step = Math.min(hidden, JOBS_PAGE);
const of = hidden > step ? ' of ' + hidden : '';
return '<tr class="jobs-more-row"><td colspan="7">' +
'<button type="button" class="chamfer btn btn--cyan btn--sm jobs-load-more">' +
'<span>Show ' + step + ' more' + of + '</span></button></td></tr>';
}
(function initJobsLoadMore() {
document.addEventListener('click', (e) => {
const btn = e.target && e.target.closest ? e.target.closest('.jobs-load-more') : null;
if (!btn) return;
jobsVisible += JOBS_PAGE;
// Reveal in place — this works on the server-rendered table before the first live
// tick, and the next renderIndex rebuilds with the same revealed count.
const body = document.getElementById('sessions-body');
if (!body) return;
let left = 0;
body.querySelectorAll('tr.jobs-overflow').forEach((row, i) => {
if (i < JOBS_PAGE) row.classList.remove('jobs-overflow');
else left++;
});
const moreRow = body.querySelector('tr.jobs-more-row');
if (moreRow) {
if (left > 0) moreRow.outerHTML = jobsLoadMoreRowHtml(left);
else moreRow.remove();
}
});
})();
function renderIndex(sessions, nowUnix) {
const body = document.getElementById('sessions-body');
if (!body || sessions == null) return;
nowUnix = nowUnix ?? (Date.now() / 1000);
const filter = parseIndexFilter(location.pathname);
const wantRepo = filter && filter.repo;
const filtered = {};
Object.keys(sessions).forEach(id => {
const s = sessions[id];
if (!s || s.parent_session) return;
if (wantRepo && s.repo !== wantRepo) return;
filtered[id] = s;
});
const ids = sortSessionIds(filtered, nowUnix);
if (!ids.length) {
body.innerHTML = wantRepo
? '<tr><td colspan="7" class="dim">No jobs for this project or repository.</td></tr>'
: '<tr><td colspan="7" class="dim">No jobs yet — run <code>scsh run</code> to start one.</td></tr>';
return;
}
const existing = new Map();
body.querySelectorAll('tr[data-session-id]').forEach(row => {
existing.set(row.getAttribute('data-session-id'), row);
});
const hidden = Math.max(0, ids.length - jobsVisible);
const rowsHtml = ids.map((id, i) => indexRowHtml(id, filtered[id], nowUnix, i >= jobsVisible)).join('') +
(hidden > 0 ? jobsLoadMoreRowHtml(hidden) : '');
if (existing.size === 0) {
body.innerHTML = rowsHtml;
return;
}
const nextHtml = rowsHtml;
if (body.innerHTML !== nextHtml) {
body.innerHTML = nextHtml;
} else {
ids.forEach(id => {
const row = existing.get(id);
if (row) syncIndexRow(row, filtered[id], nowUnix);
});
}
}
function lineCountLabel(n) {
return n + ' line' + (n === 1 ? '' : 's');
}
function lastLineAt(p) {
return (p.lines || []).reduce((m, l) => Math.max(m, Number(l.at) || 0), 0);
}
function isCacheHit(p) {
return typeof p.detail === 'string' && p.detail.includes('(cached');
}
function hasSeparateCacheProvenance(p) {
return typeof p.detail === 'string' && p.detail.includes('source run took ');
}
function procElapsed(p, nowUnix) {
if (isCacheHit(p) && !hasSeparateCacheProvenance(p)) return null;
if (p.elapsed != null) return Number(p.elapsed);
if (p.status === 'running' && p.started_at != null) return Math.max(0, nowUnix - p.started_at);
return null;
}
function idleSinceLine(p, nowUnix) {
if (isCacheHit(p)) return null;
const elapsed = procElapsed(p, nowUnix);
if (elapsed == null) return null;
return Math.max(0, elapsed - lastLineAt(p));
}
function procStatHtml(p, nowUnix) {
const n = (p.lines || []).length;
const idle = formatIdleClock(idleSinceLine(p, nowUnix));
return '<span class="proc-stat" data-proc-stat="' + esc(String(p.index)) + '">' +
'<span class="line-count">' + esc(lineCountLabel(n)) + '</span>' +
'<span class="idle">' + idle + '</span></span>';
}
let liveSessions = null;
let lastProcClockSec = null;
function syncProcStat(stat, p, nowUnix, skipIdle) {
if (!stat) return;
const lc = stat.querySelector('.line-count');
setTextUnlessSelecting(lc, lineCountLabel((p.lines || []).length));
if (!skipIdle) {
setTextUnlessSelecting(stat.querySelector('.idle'), formatIdleClock(idleSinceLine(p, nowUnix)));
}
}
function syncProcElapsed(meta, p, nowUnix, liveClock) {
if (!meta) return;
if (liveClock && p.status === 'running') return;
setTextUnlessSelecting(meta, procElapsedPhrase(p, nowUnix));
}
function procElapsedPhrase(p, nowUnix) {
const elapsed = procElapsed(p, nowUnix);
if (isCacheHit(p)) return elapsed == null ? 'cache hit' : 'cache hit in ' + formatElapsedClock(elapsed);
return elapsedPhrase(p.status, elapsed, p.fail_reason);
}
function updateProcClocks(nowUnixSec) {
if (nowUnixSec === lastProcClockSec) return;
lastProcClockSec = nowUnixSec;
if (!SESSION_ID || !liveSessions) return;
const session = liveSessions[SESSION_ID];
if (!session) return;
syncSessionDuration(session, nowUnixSec);
(session.procs || []).forEach(p => {
if (p.status !== 'running') return;
const det = document.querySelector('details.proc[data-index="' + CSS.escape(String(p.index)) + '"]');
if (!det) return;
const stat = det.querySelector('[data-proc-stat="' + CSS.escape(String(p.index)) + '"]');
syncProcStat(stat, p, nowUnixSec, false);
setTextUnlessSelecting(stat && stat.querySelector('.idle'), formatIdleClock(idleSinceLine(p, nowUnixSec)));
const meta = det.querySelector('[data-proc-elapsed="' + CSS.escape(String(p.index)) + '"]');
setTextUnlessSelecting(meta, procElapsedPhrase(p, nowUnixSec));
});
}
function startProcClock() {
const tick = () => updateProcClocks(Math.floor(Date.now() / 1000));
tick();
setInterval(tick, 1000);
}
function procMetaHtml(p) {
if (p.kind === 'build') {
if (!p.harness) return '';
return '<div class="proc-meta"><span><strong>harness</strong> ' + esc(p.harness) + '</span> ' +
'<span class="dim">image build</span></div>';
}
if (p.kind === 'skill') {
let skillName = p.skill_name;
if (skillName && p.skill_source && /^[A-Za-z0-9_]+-(?:repeat|while-[A-Za-z0-9_]+)-\d+$/.test(skillName)) {
// Keep generated loop ids in anchors and graph wiring, but show the authored action name.
skillName = p.skill_source;
}
let harness = p.harness;
if (!skillName || !harness) {
const m = String(p.label || '').match(/^([^:]+):\s*(.+)$/);
if (m) {
harness = harness || m[1].trim();
skillName = skillName || m[2].trim();
}
}
const parts = [];
if (skillName) parts.push('<span><strong>skill</strong> <code>' + esc(skillName) + '</code></span>');
if (harness) parts.push('<span><strong>harness</strong> ' + esc(harness) + '</span>');
const model = p.model ? esc(p.model) : '<span class="dim">(harness default)</span>';
parts.push('<span><strong>model</strong> ' + model + '</span>');
if (p.fail_reason) parts.push('<span><strong>fail reason</strong> <code>' + esc(p.fail_reason) + '</code></span>');
return '<div class="proc-meta">' + parts.join(' · ') + '</div>';
}
return '';
}
function procIsLive(status) {
return status === 'running' || status === 'waiting';
}
function uiPrefsKey() {
return 'scsh.ui.' + (typeof SESSION_ID === 'string' && SESSION_ID ? SESSION_ID : 'index');
}
function loadUiPrefs() {
try { return JSON.parse(localStorage.getItem(uiPrefsKey()) || '{}') || {}; }
catch (_) { return {}; }
}
function saveUiPrefs(patch) {
const next = Object.assign(loadUiPrefs(), patch);
try { localStorage.setItem(uiPrefsKey(), JSON.stringify(next)); } catch (_) {}
return next;
}
function containerRuntimeName(runtime) {
if (runtime === 'container') return 'Apple Containers';
if (runtime === 'docker') return 'Docker';
if (runtime === 'podman') return 'Podman';
if (runtime) return 'Other runtime';
return 'Not recorded (legacy run)';
}
function containerDetailsHtml(p) {
return '<span class="container-runtime-label">runtime</span> <span class="container-runtime-name">' +
esc(containerRuntimeName(p.container_runtime)) + '</span> · container: ' + esc(p.container_name);
}
// A restart needs the owning run process to respawn the route. When that process is gone the
// control stays visible but inert, and says why — the alternative is a click that travels to
// the daemon only to come back refused.
const RESTART_GONE_TITLE =
'The run client is gone, so nothing is left to respawn this route — restart the whole job instead';
function setRestartBlocked(btn, blocked) {
if (!btn) return;
btn.disabled = !!blocked;
btn.classList.toggle('is-blocked', !!blocked);
if (blocked) {
btn.title = RESTART_GONE_TITLE;
setBtnLabel(btn, 'Restart unavailable');
} else if (btn.title === RESTART_GONE_TITLE) {
btn.title =
'Force-restart this run only — the container is killed and a fresh attempt of the same route starts; the rest of the job continues';
setBtnLabel(btn, 'Force restart');
}
}
function updateProcFields(det, p, nowUnix) {
const terminating = p.fail_reason === 'stop_requested' || p.fail_reason === 'restart_requested';
det.className = 'chamfer proc ' + (terminating ? 'terminating' : p.status);
const labelEl = det.querySelector('summary .label');
if (labelEl) labelEl.textContent = p.label || '';
const stat = det.querySelector('[data-proc-stat="' + CSS.escape(String(p.index)) + '"]');
syncProcStat(stat, p, nowUnix, p.status === 'running');
const meta = det.querySelector('[data-proc-elapsed="' + CSS.escape(String(p.index)) + '"]');
syncProcElapsed(meta, p, nowUnix, p.status === 'running');
// A retry registering mid-run supersedes this row after it was first painted: give
// the failed attempt its cross-link (and the retry its chip) on the tick that
// introduces them, exactly as a fresh render would.
const session = (SESSION_ID && liveSessions ? liveSessions[SESSION_ID] : null) || { procs: [] };
if (labelEl && !det.querySelector('summary .attempt-chip')) {
const chip = attemptChipHtml(session, p);
if (chip) labelEl.insertAdjacentHTML('afterend', chip);
}
if (meta) {
const current = det.querySelector('summary .proc-retry-link, summary .proc-retry-pending');
const link = retryLinkHtml(session, p);
if (current && !link) current.remove();
else if (link) {
const wrap = document.createElement('span');
wrap.innerHTML = link.trim();
const replacement = wrap.firstElementChild;
if (current && replacement) current.replaceWith(replacement);
else if (!current && replacement) meta.insertAdjacentElement('afterend', replacement);
}
}
const noteEl = det.querySelector('summary .note');
// Finished rows show their ANSWER (the finish detail) in the collapsed summary; only
// rows still working show the transient note. A bare artifact path is SYSTEM info and
// renders as code; anything else is the agent's own text.
const finished = p.status !== 'running' && p.status !== 'waiting';
if (noteEl) {
const text = (finished && p.detail) ? p.detail : (p.note || '');
if (finished && looksLikeArtifactPath(text)) noteEl.innerHTML = '<code>' + esc(text) + '</code>';
else noteEl.textContent = text;
}
// Per-proc Force restart / Force stop: show only while the step is live; remove once it
// finishes (or once a stop/restart request is in flight). Restart is skill runs only.
const killEl = det.querySelector('button[data-proc-stop]');
const restartEl = det.querySelector('button[data-proc-restart]');
const live = (p.status === 'running' || p.status === 'waiting') && !terminating;
const wantRestart = live && (p.kind || 'skill') === 'skill';
// Keep the button, but blocked: vanishing silently would read as a missing feature, while
// an enabled one promises a respawn the daemon cannot perform.
if (restartEl && wantRestart) setRestartBlocked(restartEl, RUN_CLIENT_GONE);
if (restartEl && !wantRestart) restartEl.remove();
else if (!restartEl && wantRestart) {
const actions = ensureProcActions(det);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chamfer btn btn--orange btn--sm proc-restart';
btn.setAttribute('data-proc-restart', String(p.index));
btn.setAttribute('data-session', SESSION_ID);
btn.title = 'Force-restart this run only — the container is killed and a fresh attempt of the same route starts; the rest of the job continues';
btn.innerHTML = '<span>Force restart</span>';
setRestartBlocked(btn, RUN_CLIENT_GONE);
const kill = det.querySelector('button[data-proc-stop]');
if (kill && kill.parentElement === actions) actions.insertBefore(btn, kill);
else actions.appendChild(btn);
btn.addEventListener('click', () => restartProc(btn));
}
if (killEl && !live) killEl.remove();
else if (!killEl && live) {
const actions = ensureProcActions(det);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chamfer btn btn--red btn--sm proc-kill';
btn.setAttribute('data-proc-stop', String(p.index));
btn.setAttribute('data-proc-kind', p.kind || 'skill');
btn.setAttribute('data-session', SESSION_ID);
btn.title = p.kind === 'annotate'
? 'Stop this annotation — the recording remains unchanged'
: 'Force-stop this container only — the rest of the job continues';
btn.innerHTML = '<span>' + (p.kind === 'annotate' ? 'Stop annotation' : 'Force stop') + '</span>';
actions.appendChild(btn);
btn.addEventListener('click', () => killProc(btn));
}
// Run snapshot label tracks live vs finished; the link itself is unhidden once frames exist.
const exportLink = det.querySelector('a[data-cast-export]');
if (exportLink) {
const label = live ? 'Incomplete run ⬇' : 'Run snapshot ⬇';
const span = exportLink.querySelector('span');
if (span) span.textContent = label;
else exportLink.textContent = label;
}
// A step whose commits were integrated gains its "⇄ commits diff" chip. Integration
// (and the packdiff pack) happens after the step finished, so this lands on a late tick.
if (p.diff_path && !det.querySelector('a[data-proc-diff]')) {
const actions = ensureProcActions(det);
actions.insertAdjacentHTML('afterbegin', procDiffBtnHtml(p));
wireProcDiff(actions.querySelector('a[data-proc-diff]'));
}
const detailEl = det.querySelector('.detail');
if (detailEl) detailEl.textContent = p.detail || '';
const containerEl = det.querySelector('.container');
if (p.container_name) {
if (containerEl) containerEl.innerHTML = containerDetailsHtml(p);
else {
const div = document.createElement('div');
div.className = 'container dim';
div.innerHTML = containerDetailsHtml(p);
// A slim row (no recording yet) has no body element to anchor on, so the container
// line simply closes out the row until the cast embed appears above it.
const before = det.querySelector('.cast');
if (before) det.insertBefore(div, before);
else det.appendChild(div);
}
} else if (containerEl) containerEl.remove();
// A proc that gained a cast upgrades its slim row to the embed and gets a run-snapshot
// link above Force stop.
const castEl = det.querySelector('.cast');
if (hasCast(p) && !castEl) {
ensureProcSnapshot(det, p);
det.insertAdjacentHTML('beforeend', castEmbedHtml(p));
} else if (castEl && castEl.dataset.status !== p.status) {
// On finish, reload once so the player has the complete recording, not the partial
// one; keep the viewer's position and leave live mode (player remounts without live).
const wasRunning = castEl.dataset.status === 'running' || castEl.dataset.status === 'waiting';
castEl.dataset.status = p.status;
if (wasRunning && (p.status === 'ok' || p.status === 'graceful' || p.status === 'fail')) {
castEl.dataset.ended = String(Math.round(Date.now() / 1000));
if (castEl._live) setCastLive(castEl, false);
createCastPlayer(castEl, castEl._player ? castEl._player.getCurrentTime() : null);
}
} else if (hasCast(p)) {
ensureProcSnapshot(det, p);
}
const metaBlock = det.querySelector('.proc-meta');
const metaHtml = procMetaHtml(p);
if (metaHtml) {
if (metaBlock) metaBlock.outerHTML = metaHtml;
else {
const summary = det.querySelector('summary');
if (summary) summary.insertAdjacentHTML('afterend', metaHtml);
}
} else if (metaBlock) metaBlock.remove();
}
function hasCast(p) { return !!p.cast_path && SESSION_ID != null; }
// The per-proc top-right action stack, created on first need.
function ensureProcActions(det) {
let actions = det.querySelector('.proc-actions');
if (!actions) {
actions = document.createElement('div');
actions.className = 'proc-actions';
const summary = det.querySelector('summary');
if (summary) det.insertBefore(actions, summary);
else det.prepend(actions);
}
return actions;
}
// Insert the run-snapshot link above Force stop when a cast appears mid-job.
function ensureProcSnapshot(det, p) {
if (det.querySelector('a[data-cast-export]')) return;
let actions = det.querySelector('.proc-actions');
if (!actions) {
actions = document.createElement('div');
actions.className = 'proc-actions';
const kill = det.querySelector('button[data-proc-stop]');
if (kill && kill.parentElement === det) {
det.insertBefore(actions, kill);
actions.appendChild(kill);
} else {
const summary = det.querySelector('summary');
if (summary) det.insertBefore(actions, summary);
else det.prepend(actions);
if (kill) actions.appendChild(kill);
}
}
const live = p.status === 'running' || p.status === 'waiting';
const label = live ? 'Incomplete run ⬇' : 'Run snapshot ⬇';
const href = '/cast/' + encodeURIComponent(SESSION_ID) + '/' + p.index + '/export.html';
const a = document.createElement('a');
a.className = 'chamfer btn btn--cyan btn--sm proc-snapshot';
a.href = href;
a.setAttribute('data-cast-export', '');
a.setAttribute('download', '');
a.hidden = true;
a.title = 'Offline HTML snapshot of this run';
a.innerHTML = '<span>' + label + '</span>';
actions.appendChild(a);
}
// Mirrors proc_diff_btn_html in session.rs.
function procDiffBtnHtml(p) {
return '<a class="chamfer btn btn--purple btn--sm proc-diff" data-proc-diff href="/diff/' +
encodeURIComponent(SESSION_ID) + '/' + p.index +
'" title="Browse the commits this step brought into your branch — one self-contained review page"><span>⇄ commits diff</span></a>';
}
// The chip lives inside the <summary>; keep a click on it from toggling the details row.
function wireProcDiff(a) {
if (a) a.addEventListener('click', (ev) => ev.stopPropagation());
}
function initProcDiffs(root) {
(root || document).querySelectorAll('a[data-proc-diff]').forEach(wireProcDiff);
}
function castEmbedHtml(p) {
const base = '/cast/' + encodeURIComponent(SESSION_ID) + '/' + p.index;
const ended = (p.started_at && p.elapsed != null && p.status !== 'running' && p.status !== 'waiting')
? ' data-ended="' + Math.round(p.started_at + p.elapsed) + '"' : '';
return '<div class="cast" data-cast-url="' + esc(base) + '" data-proc="' + esc(String(p.index)) +
'" data-status="' + esc(p.status) + '"' + ended + '>' +
'<div class="cast-toolbar">' +
'<a href="' + esc(base) + '?dl=1" download>⬇ .cast</a>' +
'<span class="cast-keys dim">space · ←/→ seek · </> speed<span data-chapter-keys></span> · f fullscreen</span>' +
'</div><div class="cast-player"></div></div>';
}
// Mount an asciinema player into each not-yet-initialised .cast box, and wire its toolbar.
// fit:'both' scales the terminal to fit its box in both dimensions (inline and fullscreen).
function initCasts(root) {
if (typeof BeeCastPlayer === 'undefined') return;
root.querySelectorAll('.cast:not([data-ready])').forEach(box => {
box.dataset.ready = '1';
// Opening a section hands its player the keyboard: space plays, f fullscreens.
const det = box.closest('details');
if (det) det.addEventListener('toggle', () => { if (det.open) focusCastPlayer(box); });
// Still-running recordings start live (player toolbar ● Live; seek back to leave).
// The player owns live state and suppresses the play overlay while following.
box.addEventListener('beecast-livechange', (e) => {
box._live = !!(e.detail && e.detail.live);
});
if (box.dataset.status === 'running') { box._live = true; createCastPlayer(box, 'end'); }
else createCastPlayer(box);
});
}
// The available duration and event count of loaded asciicast text (complete lines only —
// the cast endpoint truncates to whole lines). scsh records asciicast v3, where event
// times are intervals (duration = sum); a legacy v2 header (absolute times) takes the max.
function castEventStats(text) {
let version = 3, duration = 0, events = 0;
for (const raw of String(text || '').split('\n')) {
const line = raw.trim();
if (!line || line[0] === '#') continue;
if (line[0] === '{') {
try { version = Number(JSON.parse(line).version) || 3; } catch (_) {}
continue;
}
if (line[0] !== '[') continue;
const t = parseFloat(line.slice(1));
if (!isFinite(t)) continue;
events++;
duration = version === 3 ? duration + t : Math.max(duration, t);
}
return { events, duration };
}
function castPlaceholderHtml(status) {
const live = status === 'running' || status === 'waiting';
return '<div class="cast-placeholder dim">' +
(live ? 'Recording in progress — no frames yet.' : 'No recorded frames.') + '</div>';
}
function castOwnsFullscreen(box) {
const fullscreen = document.fullscreenElement || document.webkitFullscreenElement;
return !!(fullscreen && box.contains(fullscreen));
}
// (Re-)create the player for a .cast box: fetch the cast text (cache-busted) and the
// chapters sidecar together, then either mount the player over the inline data or — for a
// cast with no complete event lines yet (a run that just started) — show a calm placeholder
// instead of letting the player error on the empty/404 cast. The placeholder upgrades to a
// real player on the next reload (a WS cast_growth notification, or the finish reload).
// Browser fullscreen belongs to the exact player DOM node: removing it exits fullscreen.
// Preserve that node and coalesce refreshes until the viewer leaves fullscreen voluntarily.
function createCastPlayer(box, startAt, autoplay) {
if (typeof BeeCastPlayer === 'undefined') return;
if (castOwnsFullscreen(box)) {
box._deferredPlayerRefresh = { startAt, autoplay };
return;
}
const mount = box.querySelector('.cast-player');
if (box._player) { try { box._player.dispose(); } catch (_) {} box._player = null; }
box._loading = true;
const proc = box.dataset.proc;
const chaptersUrl = '/cast/' + encodeURIComponent(SESSION_ID) + '/' + proc + '/chapters';
Promise.all([
// ?ts= busts any HTTP cache so a reload of a still-growing cast fetches fresh bytes.
fetch(box.dataset.castUrl + '?ts=' + Date.now()).then(r => r.ok ? r.text() : null).catch(() => null),
// The analysis sidecar (summary + chapters): chapters become markers on the timeline
// (YouTube-style highlights; [ / ] jump between them).
fetch(chaptersUrl).then(r => r.ok ? r.json() : {}).catch(() => ({})),
]).then(([text, meta]) => {
box._loading = false;
const stats = text == null ? { events: 0, duration: 0 } : castEventStats(text);
box._loadedDuration = stats.events ? stats.duration : null;
// The .html export needs at least one complete frame (the server 404s otherwise), so
// the download link rides the same no-frames state as the placeholder.
const det = box.closest('details.proc');
const exportLink = (det || box).querySelector('[data-cast-export]');
if (exportLink) exportLink.hidden = !stats.events;
if (!stats.events) {
mount.innerHTML = castPlaceholderHtml(box.dataset.status);
return;
}
mount.innerHTML = '';
const chapters = (meta.chapters || []).filter(c => typeof c.t === 'number');
setChapterKeys(box, chapters.length > 0);
// Chapters are player chrome (the ☰ panel + seek-bar ticks + [/] keys): markers are
// ALL the wiring they need. scsh renders only the one-line summary above the player.
const markers = chapters.map(c => [c.t, String(c.title || '')]);
// Beecast owns fullscreen. Its player root fills the display without carrying the
// surrounding scsh toolbar into a mode whose only purpose is watching the recording.
const running = box.dataset.status === 'running';
const opts = {
fit: 'both',
controls: running ? { live: true } : true,
idleTimeLimit: 2,
markers,
accessibility: 'snapshot',
// Still-running: start declared-live (no play overlay) until the viewer seeks back.
live: !!(box._live || running),
};
if (startAt === 'end') startAt = stats.duration;
if (startAt != null) opts.startAt = Math.max(0, Math.min(startAt, stats.duration));
// The text is passed inline ({ data }) — it was already fetched to decide placeholder
// vs player, so the player must not fetch it a second time. `_loadedChars` marks how
// much of the recording the player holds; live growth appends only the suffix.
box._player = BeeCastPlayer.create({ data: text }, mount, opts);
box._loadedChars = text.length;
if (autoplay) { try { box._player.play(); } catch (_) {} }
// Keyboard-first: a player mounting into an OPEN section takes focus, so space
// (play/pause) and f (fullscreen) work immediately. Never steal focus from something
// the user is actually in — only take it from the body or from this box's own
// (just-disposed) previous player.
const active = document.activeElement;
if ((!det || det.open) && (!active || active === document.body || box.contains(active))) focusCastPlayer(box);
if (box._live || running) setCastLive(box, true);
renderCastSummary(box, meta.summary);
renderAnnotationLink(box, meta);
// Chapters are written by the annotation pass AFTER the run ends; a finished cast with
// none yet shows a clear "summarizing…" element and swaps the chapters in live when the
// sidecar lands — no browser refresh. (Polling stops quietly if annotation never comes,
// e.g. no annotator on the host.) An annotator recording is already the act of
// producing chapters for another cast; recursively summarizing it is nonsensical.
if (!chapters.length && box.dataset.status !== 'running' && box.dataset.kind !== 'annotate') {
pollForChapters(box, chaptersUrl);
}
});
}
function focusCastPlayer(box) {
const root = box.querySelector('.beecast-player');
if (!root) return;
try { root.focus({ preventScroll: true }); } catch (_) { try { root.focus(); } catch (_) {} }
}
function setChapterKeys(box, hasChapters) {
const hint = box.querySelector('[data-chapter-keys]');
if (hint) hint.textContent = hasChapters ? ' · [/] chapter · c chapters' : '';
}
// The annotation pass starts right after the run ends, so chapters land within minutes or
// never (no annotator on the host, or a recording from before annotation existed). Show the
// indicator and poll only inside that window — an old cast gets neither.
const CHAPTERS_WAIT_SECS = 300;
// Persistent annotation state: an animated pen while active, then a durable colored link.
function renderAnnotationLink(box, meta) {
if (!box || box.dataset.kind === 'annotate') return;
const bar = box.querySelector('.cast-toolbar');
if (!bar) return;
let link = bar.querySelector('.annotation-link');
const job = meta && meta.annotation_job;
const status = meta && meta.annotation_status;
if (!job || !status) { if (link) link.remove(); return; }
if (!link) { link = document.createElement('a'); bar.appendChild(link); }
link.href = '/job/' + encodeURIComponent(job) + '#proc-' + Number(meta.annotation_proc || 0);
link.className = 'annotation-link annotation-link--' + status + (status === 'running' ? ' chap-pending' : '');
link.innerHTML = status === 'running'
? '<span aria-hidden="true">🖊</span> annotating<span class="annotation-dots" aria-hidden="true"></span>'
: (status === 'ok' ? '✓ annotation complete' : '✗ annotation failed');
}
function pollForChapters(box, chaptersUrl) {
if (box._chapPoll) return;
const endedAt = Number(box.dataset.ended || 0);
const sinceEnd = () => Date.now() / 1000 - endedAt;
if (!endedAt || sinceEnd() > CHAPTERS_WAIT_SECS) return;
// liveSessions is null until the first WS tick — never index it bare (SESSION_ID is a
// property name; `null[SESSION_ID]` throws TypeError in the console).
const sessionForChapters = () => (SESSION_ID && liveSessions ? liveSessions[SESSION_ID] : null);
{
const s = sessionForChapters();
if (s) syncChaptersPending(s);
}
box._chapPoll = setInterval(() => {
if (sinceEnd() > CHAPTERS_WAIT_SECS) { clearInterval(box._chapPoll); box._chapPoll = null; const s = sessionForChapters(); if (s) syncChaptersPending(s); return; }
fetch(chaptersUrl).then(r => r.ok ? r.json() : {}).then(meta => {
renderAnnotationLink(box, meta);
const chapters = (meta.chapters || []).filter(c => typeof c.t === 'number');
if (!chapters.length) {
return;
}
clearInterval(box._chapPoll);
box._chapPoll = null;
// Re-create at the same position so the timeline gains its markers too.
createCastPlayer(box, box._player ? box._player.getCurrentTime() : null);
const s = sessionForChapters();
if (s) syncChaptersPending(s);
}).catch(() => {});
}, 5000);
}
// Live mode: while the proc runs, follow the tail of the recording as it grows.
//
// Mechanism, chosen deliberately: rather than a dedicated per-cast streaming WS endpoint
// next to the daemon's single JSON broadcast hub, live mode rides the hub's existing
// cast_growth notifications: each one re-fetches the cast (cheap; the bytes are local), re-creates
// the player seeked to where the previous load ended, and plays the newly appended tail.
// When the proc finishes, the status-change reload loads the complete cast and the
// toggle turns off and hides.
function setCastLive(box, on) {
box._live = !!on;
// Declared-live (player.setLive): parked at the growing edge, appends pinned
// unconditionally, the bar full-width in live green. The player drops it itself on a
// rewind (beecast-livechange re-syncs box._live); ● Live lives in the player toolbar.
if (!box._player) return;
if (box._live) {
followCastGrowth(box);
box._player.setLive(true);
} else {
box._player.setLive(false);
}
}
// A server-pushed cast_growth notification for this session: upgrade a placeholder to a
// player as soon as the first frames exist, otherwise append the newly recorded suffix in
// place — the player grows smoothly, with no re-creation, no seek, and no reload banner.
// A viewer parked at the live edge sees the new frames immediately; one who paused or
// seeked back just watches the duration grow. The final running:false notice needs no
// action — the finish reload is driven by the proc's status change in the tick payload.
function onCastGrowth(msg) {
if (!SESSION_ID || msg.session !== SESSION_ID) return;
const det = document.querySelector('details.proc[data-index="' + CSS.escape(String(msg.proc)) + '"]');
const box = det && det.querySelector('.cast[data-ready]');
if (!box || box._loading) return;
if (msg.running === false) return;
if (box._loadedDuration == null) { createCastPlayer(box); return; }
followCastGrowth(box);
}
// Fetch the (local, append-only) recording and hand the player only the bytes it has not
// seen; partial trailing lines are the player's problem (it buffers them internally).
function followCastGrowth(box) {
if (!box._player || box._appending) return;
box._appending = true;
fetch(box.dataset.castUrl).then(r => r.ok ? r.text() : null).then(text => {
box._appending = false;
if (text == null || !box._player) return;
const prev = box._loadedChars || 0;
if (text.length <= prev) return;
box._player.append(text.slice(prev));
box._loadedChars = text.length;
box._loadedDuration = box._player.cast.duration;
}).catch(() => { box._appending = false; });
}
function renderCastSummary(box, summary) {
let el = box.querySelector('.cast-summary');
if (summary) {
if (!el) { el = document.createElement('div'); el.className = 'cast-summary'; box.insertBefore(el, box.firstChild); }
el.textContent = summary;
} else if (el) el.remove();
}
// Entering/exiting fullscreen changes the box size: refit the player (beecast-player
// re-lays-out on window resize). Chapters are player chrome now — the ☰ panel rides
// into fullscreen with the player; no scsh-side sidebar to manage. A refresh deferred to
// preserve the fullscreen DOM node is safe only after that box no longer owns fullscreen.
function onCastFullscreenChange() {
try { window.dispatchEvent(new Event('resize')); } catch (_) {}
document.querySelectorAll('.cast[data-ready]').forEach(box => {
const refresh = box._deferredPlayerRefresh;
if (!refresh || castOwnsFullscreen(box)) return;
box._deferredPlayerRefresh = null;
createCastPlayer(box, refresh.startAt, refresh.autoplay);
});
}
document.addEventListener('fullscreenchange', onCastFullscreenChange);
document.addEventListener('webkitfullscreenchange', onCastFullscreenChange);
function procHtml(p, isOpen, nowUnix) {
const container = p.container_name ? '<div class="container dim">' + containerDetailsHtml(p) + '</div>' : '';
// Mirrors the server-rendered shape (session.rs): recorded procs embed the player; a
// proc without a recording — an annotate row, say — stays a slim summary-only row.
// There is deliberately no text-log body: the cast IS the output format.
const body = hasCast(p) ? castEmbedHtml(p) : '';
const elapsedText = procElapsedPhrase(p, nowUnix);
const step = workflowStepIdForProc(p);
const taskAttrs = step ? ' data-workflow-step="' + esc(step) + '"' : '';
const taskAnchor = step ? '<span class="proc-task-anchor" id="task-' + esc(step) + '" aria-hidden="true"></span>' : '';
const terminating = p.fail_reason === 'stop_requested' || p.fail_reason === 'restart_requested';
const live = (p.status === 'running' || p.status === 'waiting') && !terminating;
const snapLabel = live ? 'Incomplete run ⬇' : 'Run snapshot ⬇';
const snap = hasCast(p)
? '<a class="chamfer btn btn--cyan btn--sm proc-snapshot" href="/cast/' + encodeURIComponent(SESSION_ID) +
'/' + p.index + '/export.html" data-cast-export download hidden title="Offline HTML snapshot of this run"><span>' +
snapLabel + '</span></a>'
: '';
const diff = p.diff_path ? procDiffBtnHtml(p) : '';
const restart = live && (p.kind || 'skill') === 'skill'
? '<button type="button" class="chamfer btn btn--orange btn--sm proc-restart" data-proc-restart="' +
esc(String(p.index)) + '" data-session="' + esc(SESSION_ID) +
'" title="Force-restart this run only — the container is killed and a fresh attempt of the same route starts; the rest of the job continues"><span>Force restart</span></button>'
: '';
const kill = live
? '<button type="button" class="chamfer btn btn--red btn--sm proc-kill" data-proc-stop="' +
esc(String(p.index)) + '" data-proc-kind="' + esc(p.kind || 'skill') + '" data-session="' + esc(SESSION_ID) +
'" title="' + (p.kind === 'annotate' ? 'Stop this annotation — the recording remains unchanged' :
'Force-stop this container only — the rest of the job continues') + '"><span>' +
(p.kind === 'annotate' ? 'Stop annotation' : 'Force stop') + '</span></button>'
: '';
const session = (SESSION_ID && liveSessions ? liveSessions[SESSION_ID] : null) || { procs: [] };
const summaryOpen = '<details class="chamfer proc ' + esc(terminating ? 'terminating' : p.status) + '" id="proc-' + esc(String(p.index)) + '" data-index="' + esc(String(p.index)) + '"' + taskAttrs +
(isOpen ? ' open' : '') + '>' +
((diff || snap || restart || kill) ? '<div class="proc-actions">' + diff + snap + restart + kill + '</div>' : '') +
'<summary>' + taskAnchor +
'<span class="triangle" aria-hidden="true"></span> ' +
'<span class="label">' + esc(p.label) + '</span>' + attemptChipHtml(session, p) + ' ' + procStatHtml(p, nowUnix) +
' <span class="meta" data-proc-elapsed="' + esc(String(p.index)) + '">' + esc(elapsedText) + '</span>' + retryLinkHtml(session, p) + originalAttemptLinkHtml(session, p) + ' ' +
'<span class="note dim">' + esc(p.note || '') + '</span></summary>';
return summaryOpen + procMetaHtml(p) + '<div class="detail">' + esc(p.detail || '') + '</div>' +
container + body + '</details>';
}
function workflowStepIdForProc(p) {
const session = SESSION_ID && liveSessions ? liveSessions[SESSION_ID] : null;
const nodes = session && session.workflow && session.workflow.nodes;
if (nodes) {
const hit = nodes.find(n => n.proc_index === p.index);
if (hit) return hit.id;
const step = p.kind === 'build' ? (p.harness ? ('build_' + p.harness) : 'build_base') :
(p.skill_name || p.skill_source || null);
const unbound = step && nodes.find(n => n.id === step && n.proc_index == null);
return unbound ? unbound.id : null;
}
if (p.kind === 'build') return p.harness ? ('build_' + p.harness) : 'build_base';
return p.skill_name || p.skill_source || null;
}
function wfStateIcon(state) {
return ({waiting:'◇',queued:'◈',running:'◆',terminating:'◆',done:'✓',graceful:'!',failed:'✗',stopped:'✕',skipped:'⊘',stalled:'!'})[state] || '◇';
}
function wfStateLabel(state) {
return ({waiting:'Waiting',queued:'Queued',running:'Running',terminating:'Terminating',done:'Succeeded',graceful:'Graceful shutdown',failed:'Failed',
stopped:'Stopped',skipped:'Skipped',stalled:'Abandoned'})[state] || state;
}
function wfJobOutcome(session, nowUnix) {
const life = sessionLifecycle(session, nowUnix);
const skills = (session.procs || []).filter(p => p.kind === 'skill');
const terminal = p => p.status === 'ok' || p.status === 'graceful' || p.status === 'fail' || p.status === 'skipped';
const finalizing = life.class === 'running' && skills.length > 0 && skills.every(terminal);
const text = finalizing ? 'Finalizing recordings' :
(({running:'Job running',completed:'Job succeeded',failed:'Job failed',
cancelled:'Job cancelled'})[life.class] || ('Job ' + life.label));
return { className: life.class, text };
}
function wfJobOutcomeHtml(session, nowUnix) {
const outcome = wfJobOutcome(session, nowUnix);
return '<span class="chamfer workflow-outcome workflow-outcome--' + outcome.className +
'" data-workflow-outcome>' + outcome.text + '</span>';
}
function wfUnmetNeedIds(session, node) {
const nodes = (session.workflow && session.workflow.nodes) || [];
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
const procs = session.procs || [];
const terminal = s => s === 'ok' || s === 'graceful' || s === 'fail' || s === 'skipped';
return (node.needs || []).filter(need => {
const n = byId[need];
if (!n || n.proc_index == null) return true;
const p = procs.find(x => x.index === n.proc_index);
return !p || !terminal(p.status);
});
}
function wfUnmetNeeds(session, node) {
return wfUnmetNeedIds(session, node).length;
}
function wfNodeTitle(id) {
if (id === 'build_base') return 'base';
if (id.indexOf('build_') === 0) return id.slice(6);
const looped = id.match(/^([A-Za-z0-9_]+)-(?:repeat|while-[A-Za-z0-9_]+)-(\d+)$/);
if (looped) return looped[1] + ' · iteration ' + looped[2];
return id;
}
function wfBlockerLine(session, id, nowUnix) {
const nodes = (session.workflow && session.workflow.nodes) || [];
const dep = nodes.find(n => n.id === id);
const title = wfNodeTitle(id);
const isBuild = id === 'build_base' || id.indexOf('build_') === 0;
const kind = isBuild ? 'image build' : 'task';
if (!dep) return title + ' (missing)';
const p = (session.procs || []).find(x => x.index === dep.proc_index);
if (!p) return title + ' (' + kind + ', not registered yet)';
const st = wfDisplayState(session, dep, nowUnix);
const bits = [];
if (!isBuild && p.harness) bits.push(p.harness);
bits.push(kind, st);
return title + ' (' + bits.join(' · ') + ')';
}
function wfNodeTip(session, node, state, unmetIds, nowUnix) {
const title = wfNodeTitle(node.id);
const lines = [title];
const p = node.proc_index != null ? (session.procs || []).find(x => x.index === node.proc_index) : null;
if (state === 'waiting' && unmetIds.length) {
lines.push('Waiting on:');
unmetIds.forEach(id => lines.push('• ' + wfBlockerLine(session, id, nowUnix)));
} else if (state === 'waiting') lines.push('Waiting to start');
else if (state === 'queued') lines.push('Queued — dependencies finished; waiting for the scheduler to start this task');
else if (state === 'running') {
lines.push((node.id === 'build_base' || node.id.indexOf('build_') === 0) ? 'Image build running' : 'Running');
} else if (state === 'terminating') lines.push(p && p.fail_reason === 'restart_requested'
? 'Restarting — replacement attempt is starting'
: 'Terminating — stop requested');
else if (state === 'done') lines.push('Succeeded');
else if (state === 'graceful') lines.push('Graceful shutdown — valid result survived a teardown issue');
else if (state === 'failed') lines.push('Failed');
else if (state === 'stopped') lines.push('Stopped from the session browser');
else if (state === 'skipped') lines.push('Skipped');
else if (state === 'stalled') lines.push('Abandoned — job stopped updating');
if (node.conditional && state !== 'skipped') lines.push('Runs only when its gate passes');
// Mirrors node_html in workflow.rs: the node is bound to the route's newest attempt.
if (p) {
const attempt = procAttempt(session, p);
if (attempt[1] > 1) {
lines.push('Attempt ' + attempt[0] + ' of ' + attempt[1] + ' — an earlier attempt failed and was retried');
}
}
return lines.join('\n');
}
function wfDisplayState(session, node, nowUnix) {
const life = sessionLifecycle(session, nowUnix).class;
// Queued/Running only while the job is live — cancelled/terminated/failed must not keep a
// waiting step looking like it is about to start ("queued — not started yet").
const live = life === 'running';
const procs = session.procs || [];
const p = node.proc_index != null ? procs.find(x => x.index === node.proc_index) : null;
if (!p) return live ? 'waiting' : 'stalled';
if (p.fail_reason === 'stop_requested' || p.fail_reason === 'restart_requested') return 'terminating';
if (p.status === 'ok') return 'done';
if (p.status === 'graceful') return 'graceful';
if (p.status === 'fail') {
return (p.fail_reason === 'force_stopped' || p.fail_reason === 'force_restarted' ||
p.fail_reason === 'session_end_before_proc_finish') ? 'stopped' : 'failed';
}
if (p.status === 'skipped') return 'skipped';
if (p.status === 'running') return live ? 'running' : 'stalled';
if (p.status === 'waiting') {
if (!live) return 'stalled';
return wfUnmetNeeds(session, node) === 0 ? 'queued' : 'waiting';
}
return 'waiting';
}
function wfLegendHtml(present) {
const order = ['running','terminating','done','graceful','failed','stopped','stalled','waiting','queued','skipped'];
const items = order.filter(s => present[s]).map(s =>
'<li class="wf-leg wf-leg-' + s + '"><span class="wf-ico" aria-hidden="true">' +
wfStateIcon(s) + '</span> ' + wfStateLabel(s) + '</li>'
).join('');
return items ? '<ul class="chamfer workflow-legend" aria-label="Status legend">' + items + '</ul>' : '';
}
function wfSummaryHtml(counts, total, first) {
const parts = [total + (total === 1 ? ' task' : ' tasks')];
const shown = (key) => key === 'done' ? 'succeeded' : (key === 'stalled' ? 'abandoned' :
(key === 'graceful' ? 'graceful shutdown' : key));
for (const [n, label] of [[counts.done,'done'],[counts.graceful,'graceful'],[counts.running,'running'],[counts.terminating,'terminating'],[counts.waiting,'waiting'],
[counts.queued,'queued'],[counts.failed,'failed'],[counts.stopped,'stopped'],
[counts.stalled,'stalled'],[counts.skipped,'skipped']]) {
if (n <= 0) continue;
const id = first && first[label];
const word = shown(label);
if (id) {
parts.push('<a class="wf-jump" href="' + '#task-' + encodeURIComponent(id) +
'" data-wf-status="' + label + '" title="Jump to first ' + word + ' task">' +
n + ' ' + word + '</a>');
} else {
parts.push(n + ' ' + word);
}
}
return parts.join(' · ');
}
function wfFirstIdByState(session, nodes, nowUnix) {
const layout = wfLayoutNodes(session, nodes, nowUnix).slice().sort((a, b) => a.y - b.y || a.x - b.x);
const first = Object.create(null);
layout.forEach(pos => {
const node = nodes.find(n => n.id === pos.id);
if (!node) return;
const st = wfDisplayState(session, node, nowUnix);
if (first[st] == null) first[st] = node.id;
});
return first;
}
// Layout constants — keep in lockstep with src/daemon/html/workflow.rs.
const WF_NODE_W = 200, WF_NODE_H = 72, WF_GAP_X = 56, WF_GAP_Y = 28, WF_PAD = 16;
const WF_BOOKEND_W = 48, WF_BOOKEND_H = 48;
const WF_START_ID = '__start', WF_FINISH_ID = '__finish';
let pendingWorkflowStep = null;
let wfHistorySilent = false;
let workflowZoom = 1;
let workflowExpanded = false;
function wfNodeRanks(nodes) {
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
const ranks = Object.create(null);
function rankOf(id) {
if (ranks[id] != null) return ranks[id];
const node = byId[id];
if (!node) { ranks[id] = 0; return 0; }
const needs = node.needs || [];
const r = needs.length ? (1 + Math.max(...needs.map(rankOf))) : 0;
ranks[id] = r;
return r;
}
return nodes.map(n => rankOf(n.id));
}
function wfStatusStackRank(state) {
return ({done:0,failed:1,stopped:2,skipped:3,terminating:4,running:5,stalled:6,queued:7,waiting:8})[state] ?? 10;
}
function wfLayoutNodes(session, nodes, nowUnix) {
const ranks = wfNodeRanks(nodes);
const byRank = Object.create(null);
nodes.forEach((n, i) => {
const r = ranks[i];
(byRank[r] || (byRank[r] = [])).push(i);
});
Object.keys(byRank).forEach(r => byRank[r].sort((a, b) => {
const sa = wfStatusStackRank(wfDisplayState(session, nodes[a], nowUnix));
const sb = wfStatusStackRank(wfDisplayState(session, nodes[b], nowUnix));
return sa - sb || (nodes[a].order || 0) - (nodes[b].order || 0) ||
String(nodes[a].id).localeCompare(String(nodes[b].id));
}));
const maxInRank = Math.max(1, ...Object.keys(byRank).map(r => byRank[r].length));
const colH = maxInRank * WF_NODE_H + (maxInRank - 1) * WF_GAP_Y;
const out = [];
Object.keys(byRank).map(Number).sort((a, b) => a - b).forEach(rank => {
const idxs = byRank[rank];
const n = idxs.length;
const blockH = n * WF_NODE_H + (n - 1) * WF_GAP_Y;
const y0 = WF_PAD + (colH - blockH) / 2;
const x = WF_PAD + rank * (WF_NODE_W + WF_GAP_X);
idxs.forEach((i, row) => {
out.push({ id: nodes[i].id, x, y: y0 + row * (WF_NODE_H + WF_GAP_Y), order: nodes[i].order || 0, index: i, w: WF_NODE_W, h: WF_NODE_H });
});
});
out.sort((a, b) => a.order - b.order);
return out;
}
function wfLayoutWithBookends(session, nodes, nowUnix) {
const layout = wfLayoutNodes(session, nodes, nowUnix);
const shift = WF_BOOKEND_W + WF_GAP_X;
const loopTop = nodes.some(n => /^[A-Za-z0-9_]+-(?:repeat|while-[A-Za-z0-9_]+)-\d+$/.test(n.id)) ? 36 : 0;
layout.forEach(n => { n.x += shift; n.y += loopTop; });
const rootIds = wfGraphRoots(nodes), sinkIds = wfGraphSinks(nodes);
const centerY = ids => {
const rows = layout.filter(n => ids.includes(n.id));
return rows.length ? rows.reduce((sum, n) => sum + n.y + n.h / 2, 0) / rows.length : WF_PAD + WF_NODE_H / 2;
};
const startY = Math.max(WF_PAD, centerY(rootIds) - WF_BOOKEND_H / 2);
const finishY = Math.max(WF_PAD, centerY(sinkIds) - WF_BOOKEND_H / 2);
const start = { id: WF_START_ID, x: WF_PAD, y: startY, order: 0, w: WF_BOOKEND_W, h: WF_BOOKEND_H };
const finishX = Math.max(WF_PAD + WF_BOOKEND_W, ...layout.map(n => n.x + n.w)) + WF_GAP_X;
const finish = { id: WF_FINISH_ID, x: finishX, y: finishY, order: 1e9, w: WF_BOOKEND_W, h: WF_BOOKEND_H };
return { layout, start, finish };
}
function wfGraphRoots(nodes) {
const ids = new Set(nodes.map(n => n.id));
return nodes.filter(n => (n.needs || []).every(need => !ids.has(need))).map(n => n.id);
}
function wfGraphSinks(nodes) {
const ids = new Set(nodes.map(n => n.id));
const dependedOn = new Set();
nodes.forEach(n => (n.needs || []).forEach(need => { if (ids.has(need)) dependedOn.add(need); }));
return nodes.filter(n => !dependedOn.has(n.id)).map(n => n.id);
}
function wfPortY(nodeY, nodeH, index, count) {
if (count <= 1) return nodeY + nodeH / 2;
const margin = nodeH * 0.22;
const usable = nodeH - 2 * margin;
return nodeY + margin + usable * index / (count - 1);
}
function wfEdgesSvg(nodes, layout, start, finish) {
const byId = Object.fromEntries(layout.map(n => [n.id, n]));
byId[WF_START_ID] = start;
byId[WF_FINISH_ID] = finish;
const pairs = [];
nodes.forEach(node => {
(node.needs || []).forEach(need => {
if (byId[need] && byId[node.id]) pairs.push([need, node.id]);
});
});
wfGraphRoots(nodes).forEach(id => { if (byId[id]) pairs.push([WF_START_ID, id]); });
wfGraphSinks(nodes).forEach(id => { if (byId[id]) pairs.push([id, WF_FINISH_ID]); });
const outN = Object.create(null), inN = Object.create(null);
pairs.forEach(([s, d]) => { outN[s] = (outN[s] || 0) + 1; inN[d] = (inN[d] || 0) + 1; });
const outRank = Object.create(null), inRank = Object.create(null);
pairs.forEach((p, i) => {
(outRank[p[0]] || (outRank[p[0]] = [])).push(i);
(inRank[p[1]] || (inRank[p[1]] = [])).push(i);
});
Object.keys(outRank).forEach(src => outRank[src].sort((a, b) => byId[pairs[a][1]].y - byId[pairs[b][1]].y || a - b));
Object.keys(inRank).forEach(dst => inRank[dst].sort((a, b) => byId[pairs[a][0]].y - byId[pairs[b][0]].y || a - b));
const outPort = Object.create(null), inPort = Object.create(null);
Object.keys(outRank).forEach(src => outRank[src].forEach((ei, port) => { outPort[ei] = port; }));
Object.keys(inRank).forEach(dst => inRank[dst].forEach((ei, port) => { inPort[ei] = port; }));
return pairs.map((p, i) => {
const src = byId[p[0]], dst = byId[p[1]];
const x1 = src.x + (src.w || WF_NODE_W);
const y1 = wfPortY(src.y, src.h || WF_NODE_H, outPort[i], outN[p[0]]);
const x2 = dst.x - 1.5;
const y2 = wfPortY(dst.y, dst.h || WF_NODE_H, inPort[i], inN[p[1]]);
if (Math.abs(y1 - y2) < 0.5) {
return '<path class="wf-edge" d="M' + x1.toFixed(1) + ',' + y1.toFixed(1) +
' L' + x2.toFixed(1) + ',' + y1.toFixed(1) + '" marker-end="url(#wf-arrow)" />';
}
const gap = Math.max(1, x2 - x1);
const runway = Math.max(12, Math.min(20, gap * 0.24));
const curveStart = x1 + runway, curveEnd = x2 - runway;
const curveDx = Math.max(1, curveEnd - curveStart);
const c1x = curveStart + curveDx * 0.42, c2x = curveEnd - curveDx * 0.42;
return '<path class="wf-edge" d="M' + x1.toFixed(1) + ',' + y1.toFixed(1) +
' L' + curveStart.toFixed(1) + ',' + y1.toFixed(1) +
' C' + c1x.toFixed(1) + ',' + y1.toFixed(1) + ' ' + c2x.toFixed(1) + ',' + y2.toFixed(1) +
' ' + curveEnd.toFixed(1) + ',' + y2.toFixed(1) + ' L' + x2.toFixed(1) + ',' + y2.toFixed(1) +
'" marker-end="url(#wf-arrow)" />';
}).join('');
}
function wfBookendHtml(pos, isStart) {
const cls = isStart ? 'chamfer wf-bookend wf-start' : 'chamfer wf-bookend wf-finish';
const id = isStart ? WF_START_ID : WF_FINISH_ID;
const title = isStart ? 'Start' : 'Finish';
const glyph = isStart
? '<span class="wf-start-play" aria-hidden="true"></span>'
: '<span class="wf-finish-flag" aria-hidden="true"></span>';
return '<div class="' + cls + '" id="wf-node-' + id + '" style="left:' + pos.x.toFixed(1) +
'px;top:' + pos.y.toFixed(1) + 'px;width:' + pos.w.toFixed(0) + 'px;min-height:' + WF_BOOKEND_H +
'px" title="' + title + '" aria-hidden="true">' + glyph + '</div>';
}
function wfLoopProgressText(plan, shown, lifecycle) {
const iterations = n => n === 1 ? 'iteration' : 'iterations';
if (plan && plan.exact) {
if (plan.max_iterations == null) return '↻ more iterations planned';
const remaining = Math.max(0, plan.max_iterations - shown);
return remaining === 0
? '✓ all ' + plan.max_iterations + ' ' + iterations(plan.max_iterations) + ' shown'
: '↻ ' + remaining + ' more ' + iterations(remaining) + ' planned';
}
if (plan && plan.max_iterations != null) {
if (shown >= plan.max_iterations) return '! safety limit reached after ' + shown + ' ' + iterations(shown);
const remaining = plan.max_iterations - shown;
if (lifecycle === 'completed') return '✓ stopped here · up to ' + remaining + ' more possible';
return lifecycle === 'running'
? '↻ may continue · up to ' + remaining + ' more'
: '↻ loop permits up to ' + remaining + ' more';
}
if (lifecycle === 'completed') return '✓ stopped after ' + shown + ' ' + iterations(shown);
return lifecycle === 'running' ? '↻ more iterations may follow' : '↻ loop permits more iterations';
}
function wfLoopIslandsHtml(session, layout) {
const groups = Object.create(null);
const plans = session.workflow_loops || [];
const lifecycle = sessionLifecycle(session, Date.now() / 1000).class;
layout.forEach(pos => {
const match = pos.id.match(/^([A-Za-z0-9_]+)-(repeat|while-([A-Za-z0-9_]+))-(\d+)$/);
if (!match) return;
// do-while islands group by the loop (its final step); repeat islands by the one step.
const key = match[3] ? 'while-' + match[3] : 'repeat-' + match[1];
(groups[key] || (groups[key] = [])).push(pos);
});
return Object.entries(groups).map(([key, items]) => {
// Name a do-while island for its whole body — "first → final" — via the lowest-order
// member; a repeat (or single-step) island keeps the single step name.
let label, loopId;
if (key.indexOf('while-') === 0) {
const end = key.slice('while-'.length);
loopId = end;
const first = items.slice().sort((a, b) => a.order - b.order)[0];
const firstBase = (first && (first.id.match(/^([A-Za-z0-9_]+)-while-/) || [])[1]) || end;
label = 'do-while · ' + (firstBase !== end ? firstBase + ' → ' + end : end);
} else {
loopId = key.slice('repeat-'.length);
label = 'repeat · ' + loopId;
}
const shown = Math.max(...items.map(item => Number((item.id.match(/-(\d+)$/) || [])[1]) || 1));
const progress = wfLoopProgressText(plans.find(plan => plan.id === loopId), shown, lifecycle);
const pad = 14, bottomPad = 38, labelH = 22;
const left = Math.min(...items.map(p => p.x)) - pad;
const top = Math.min(...items.map(p => p.y)) - pad - labelH;
const right = Math.max(...items.map(p => p.x + (p.w || WF_NODE_W))) + pad;
const bottom = Math.max(...items.map(p => p.y + (p.h || WF_NODE_H))) + bottomPad;
return '<div class="chamfer wf-loop-island" data-loop-id="' + esc(loopId) + '" data-loop-shown="' + shown +
'" style="left:' + left.toFixed(1) + 'px;top:' + top.toFixed(1) +
'px;width:' + (right - left).toFixed(1) + 'px;height:' + (bottom - top).toFixed(1) +
'px"><span class="wf-loop-title">' + esc(label) + '</span><span class="chamfer wf-loop-progress">' +
esc(progress) + '</span></div>';
}).join('');
}
function annotationForProc(session, proc) {
if (!session || !proc || !proc.cast_path || !liveSessions) return null;
const sourceName = String(proc.cast_path).split('/').pop();
for (const [jobId, candidate] of Object.entries(liveSessions)) {
if (!candidate || (jobId !== session.id && candidate.parent_session !== session.id)) continue;
for (const ann of (candidate.procs || [])) {
if (ann.kind !== 'annotate' || !ann.annotate_target) continue;
const targetName = String(ann.annotate_target).split('/').pop();
if (ann.annotate_target !== proc.cast_path && targetName !== sourceName) continue;
const jobIsLive = sessionLifecycle(candidate, Date.now() / 1000).class === 'running';
const status = (ann.status === 'running' || ann.status === 'waiting') && jobIsLive ? 'running'
: (ann.status === 'ok' ? 'ok' : 'fail');
return { jobId, proc: ann.index, status };
}
}
return null;
}
function wfAnnotationHtml(session, proc) {
const ann = annotationForProc(session, proc);
if (!ann) return '';
const text = ann.status === 'running' ? '🖊 annotating' : (ann.status === 'ok' ? '✓ annotation complete' : '✗ annotation failed');
const dots = ann.status === 'running' ? '<span class="annotation-dots" aria-hidden="true"></span>' : '';
return '<span class="wf-annotation wf-annotation--' + ann.status + '">' + text + dots + '</span>';
}
function wfBuildGraphHtml(session, nowUnix) {
const nodes = (session.workflow && session.workflow.nodes) || [];
if (!nodes.length) return '';
const { layout, start, finish } = wfLayoutWithBookends(session, nodes, nowUnix);
const all = layout.concat([start, finish]);
const w = Math.max(...all.map(n => n.x + n.w)) + WF_PAD;
const h = Math.max(...all.map(n => n.y + (n.h || WF_NODE_H))) + WF_PAD;
const present = Object.create(null);
const counts = { done: 0, graceful: 0, running: 0, terminating: 0, waiting: 0, queued: 0, failed: 0, stopped: 0, stalled: 0, skipped: 0 };
const byId = Object.fromEntries(layout.map(n => [n.id, n]));
const nodesHtml = wfBookendHtml(start, true) + nodes.map(node => {
const pos = byId[node.id];
if (!pos) return '';
const state = wfDisplayState(session, node, nowUnix);
present[state] = true;
if (state === 'done') counts.done++;
else if (state === 'graceful') counts.graceful++;
else if (state === 'running') counts.running++;
else if (state === 'terminating') counts.terminating++;
else if (state === 'waiting') counts.waiting++;
else if (state === 'queued') counts.queued++;
else if (state === 'failed') counts.failed++;
else if (state === 'stopped') counts.stopped++;
else if (state === 'stalled') counts.stalled++;
else if (state === 'skipped') counts.skipped++;
const p = (session.procs || []).find(x => x.index === node.proc_index);
const isBuild = node.id === 'build_base' || node.id.indexOf('build_') === 0;
const title = wfNodeTitle(node.id);
const unmetIds = wfUnmetNeedIds(session, node);
const tip = wfNodeTip(session, node, state, unmetIds, nowUnix);
const bits = [];
if (isBuild) bits.push('image build');
else if (p && p.harness) bits.push(p.harness);
if (p && p.model) bits.push(p.model);
if (state === 'waiting' && unmetIds.length === 1) bits.push('waiting on ' + wfNodeTitle(unmetIds[0]));
else if (state === 'waiting' && unmetIds.length > 1 && unmetIds.length <= 3) {
bits.push('waiting on ' + unmetIds.map(wfNodeTitle).join(', '));
} else if (state === 'waiting' && unmetIds.length > 3) bits.push('waiting on ' + unmetIds.length + ' tasks');
if (state === 'queued') bits.push('queued');
const gate = node.conditional
? '<span class="chamfer wf-gate" data-tip="Runs only when its gate passes" aria-label="Runs only when its gate passes">when</span>'
: '';
const procAttr = node.proc_index != null ? ' data-proc-index="' + esc(String(node.proc_index)) + '"' : '';
const tipRunning = (state === 'running' && p && p.started_at)
? ' data-tip-running="' + esc(String(p.started_at)) + '"' : '';
const elapsed = p ? procElapsed(p, nowUnix) : null;
const showElapsed = ['running','terminating','done','graceful','failed','stopped','stalled'].includes(state);
const stateElapsed = elapsed != null && showElapsed ? ' · ' + formatElapsedClock(elapsed) : '';
const attempt = p ? procAttempt(session, p) : [1, 1];
const attemptHtml = attempt[1] > 1 ? '<span class="wf-attempt"> · attempt ' + attempt[0] + '</span>' : '';
return '<a class="chamfer wf-node wf-' + state + (isBuild ? ' wf-build' : '') +
'" href="' + '#task-' + encodeURIComponent(node.id) + '" id="wf-node-' + esc(node.id) +
'" data-workflow-step="' + esc(node.id) + '" data-wf-state="' + state + '"' + procAttr +
' style="left:' + pos.x.toFixed(1) + 'px;top:' + pos.y.toFixed(1) + 'px;width:' + (pos.w || WF_NODE_W) +
'px;min-height:' + WF_NODE_H + 'px" data-tip="' + esc(tip) + '"' + tipRunning +
' aria-label="' + esc(tip.replace(/\n/g, ', ')) +
'"><span class="wf-state"><span class="wf-ico" aria-hidden="true">' + wfStateIcon(state) +
'</span><span class="wf-state-label">' + wfStateLabel(state) + '</span><span class="wf-state-elapsed">' +
stateElapsed + '</span>' + attemptHtml + '</span><span class="wf-id">' +
esc(title) + gate + '</span>' + wfAnnotationHtml(session, p) +
'<span class="wf-meta dim">' + esc(bits.join(' · ')) + '</span></a>';
}).join('') + wfBookendHtml(finish, false);
return '<div class="chamfer card card--accent-left-orange workflow-card" id="workflow-graph" data-workflow-graph>' +
'<div class="workflow-head"><h2 class="workflow-title">Job graph</h2>' +
wfJobOutcomeHtml(session, nowUnix) +
'<p class="workflow-summary dim">' + wfSummaryHtml(counts, nodes.length, wfFirstIdByState(session, nodes, nowUnix)) + '</p>' +
'<div class="workflow-zoom" aria-label="Graph view controls">' +
'<button type="button" class="chamfer" data-wf-zoom-out aria-label="Zoom out">−</button>' +
'<button type="button" class="chamfer" data-wf-zoom-reset>100%</button>' +
'<button type="button" class="chamfer" data-wf-zoom-in aria-label="Zoom in">+</button>' +
'<button type="button" class="chamfer" data-wf-zoom-fit>Fit</button>' +
'<button type="button" class="chamfer" data-wf-expand aria-label="Open graph in large view" aria-pressed="false">Full screen</button>' +
'</div></div><div class="workflow-visual">' + wfLegendHtml(present) +
'<div class="chamfer workflow-scroll" role="region" aria-label="Job dependency graph" tabindex="0">' +
'<div class="workflow-stage" style="width:' + w.toFixed(0) + 'px;height:' + h.toFixed(0) + 'px">' +
wfLoopIslandsHtml(session, layout) +
'<svg class="workflow-edges" width="' + w.toFixed(0) + '" height="' + h.toFixed(0) +
'" viewBox="0 0 ' + w.toFixed(1) + ' ' + h.toFixed(1) + '" aria-hidden="true"><defs>' +
'<marker id="wf-arrow" viewBox="0 0 14 14" refX="12" refY="7" markerWidth="9" markerHeight="9" orient="auto" markerUnits="userSpaceOnUse">' +
'<path class="wf-arrowhead" d="M3.5 2.5 L11 7 L3.5 11.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' +
'</marker></defs>' + wfEdgesSvg(nodes, layout, start, finish) + '</svg>' +
'<div class="workflow-nodes">' + nodesHtml + '</div></div></div></div></div>';
}
function ensureWorkflowGraphMounted(session, nowUnix) {
const nodes = (session && session.workflow && session.workflow.nodes) || [];
let root = document.querySelector('[data-workflow-graph]');
if (!nodes.length) {
if (root) root.remove();
workflowExpanded = false;
document.body.classList.remove('wf-modal-open');
return null;
}
const liveIds = nodes.map(n => {
const st = wfDisplayState(session, n, nowUnix);
return n.id + '\t' + wfStatusStackRank(st);
}).slice().sort().join('\0');
let preserved = null;
if (root) {
const domIds = Array.from(root.querySelectorAll('[data-workflow-step]'))
.map(el => {
const id = el.getAttribute('data-workflow-step');
const st = el.getAttribute('data-wf-state') || '';
return id + '\t' + wfStatusStackRank(st);
}).filter(Boolean).sort().join('\0');
if (domIds === liveIds) return root;
const oldScroller = root.querySelector('.workflow-scroll');
// The graph card has a fixed viewport, so a remount cannot reflow the page — preserve
// only the card's own scroll. The page viewport never moves on its own.
preserved = {
left: oldScroller ? oldScroller.scrollLeft : 0,
top: oldScroller ? oldScroller.scrollTop : 0
};
root.remove();
root = null;
}
const html = wfBuildGraphHtml(session, nowUnix);
if (!html) return null;
const procs = document.getElementById('session-procs');
if (procs) procs.insertAdjacentHTML('beforebegin', html);
else {
const main = document.querySelector('.page-shell') || document.body;
main.insertAdjacentHTML('beforeend', html);
}
root = document.querySelector('[data-workflow-graph]');
if (root) {
delete root.dataset.bound;
initWorkflowGraph();
if (preserved) {
const newScroller = root.querySelector('.workflow-scroll');
if (newScroller) {
newScroller.scrollLeft = preserved.left;
newScroller.scrollTop = preserved.top;
}
}
}
return root;
}
function updateWorkflowGraph(session, nowUnix) {
const nodes = (session && session.workflow && session.workflow.nodes) || [];
if (!nodes.length) {
const gone = document.querySelector('[data-workflow-graph]');
if (gone) gone.remove();
workflowExpanded = false;
document.body.classList.remove('wf-modal-open');
return;
}
const root = ensureWorkflowGraphMounted(session, nowUnix);
if (!root) return;
const present = Object.create(null);
const counts = { done: 0, graceful: 0, running: 0, terminating: 0, waiting: 0, queued: 0, failed: 0, stopped: 0, stalled: 0, skipped: 0 };
nodes.forEach(node => {
const el = root.querySelector('.wf-node[data-workflow-step="' + CSS.escape(node.id) + '"]');
if (!el) return;
const state = wfDisplayState(session, node, nowUnix);
present[state] = true;
if (state === 'done') counts.done++;
else if (state === 'graceful') counts.graceful++;
else if (state === 'running') counts.running++;
else if (state === 'terminating') counts.terminating++;
else if (state === 'waiting') counts.waiting++;
else if (state === 'queued') counts.queued++;
else if (state === 'failed') counts.failed++;
else if (state === 'stopped') counts.stopped++;
else if (state === 'stalled') counts.stalled++;
else if (state === 'skipped') counts.skipped++;
const prev = el.dataset.wfState;
if (prev !== state) {
const build = el.classList.contains('wf-build') ? ' wf-build' : '';
el.className = 'chamfer wf-node wf-' + state + build;
el.dataset.wfState = state;
const ico = el.querySelector('.wf-ico');
const lab = el.querySelector('.wf-state-label');
if (ico) ico.textContent = wfStateIcon(state);
if (lab) lab.textContent = wfStateLabel(state);
}
if (node.proc_index != null) el.setAttribute('data-proc-index', String(node.proc_index));
const p = (session.procs || []).find(x => x.index === node.proc_index);
const oldAnnotation = el.querySelector('.wf-annotation');
if (oldAnnotation) oldAnnotation.remove();
const annotationHtml = wfAnnotationHtml(session, p);
if (annotationHtml) {
const meta = el.querySelector('.wf-meta');
if (meta) meta.insertAdjacentHTML('beforebegin', annotationHtml);
}
const unmetIds = wfUnmetNeedIds(session, node);
const tip = wfNodeTip(session, node, state, unmetIds, nowUnix);
el.setAttribute('data-tip', tip);
el.setAttribute('aria-label', tip.replace(/\n/g, ', '));
if (state === 'running' && p && p.started_at) el.setAttribute('data-tip-running', String(p.started_at));
else el.removeAttribute('data-tip-running');
const elapsed = p ? procElapsed(p, nowUnix) : null;
const showElapsed = ['running','terminating','done','graceful','failed','stopped','stalled'].includes(state);
const stateElapsed = el.querySelector('.wf-state-elapsed');
if (stateElapsed) stateElapsed.textContent = elapsed != null && showElapsed ? ' · ' + formatElapsedClock(elapsed) : '';
// The node rebinds to the retry when one registers mid-run: surface the attempt.
const attempt = p ? procAttempt(session, p) : [1, 1];
let attemptEl = el.querySelector('.wf-attempt');
if (attempt[1] > 1 && stateElapsed) {
if (!attemptEl) {
stateElapsed.insertAdjacentHTML('afterend', '<span class="wf-attempt"></span>');
attemptEl = el.querySelector('.wf-attempt');
}
attemptEl.textContent = ' · attempt ' + attempt[0];
} else if (attemptEl) {
attemptEl.remove();
}
const meta = el.querySelector('.wf-meta');
if (meta) {
const bits = [];
if (node.id === 'build_base' || node.id.indexOf('build_') === 0) bits.push('image build');
else if (p && p.harness) bits.push(p.harness);
if (p && p.model) bits.push(p.model);
if (state === 'waiting' && unmetIds.length === 1) bits.push('waiting on ' + wfNodeTitle(unmetIds[0]));
else if (state === 'waiting' && unmetIds.length > 1 && unmetIds.length <= 3) {
bits.push('waiting on ' + unmetIds.map(wfNodeTitle).join(', '));
} else if (state === 'waiting' && unmetIds.length > 3) bits.push('waiting on ' + unmetIds.length + ' tasks');
if (state === 'queued') bits.push('queued');
meta.textContent = bits.join(' · ');
}
});
const loopPlans = session.workflow_loops || [];
const lifecycle = sessionLifecycle(session, nowUnix).class;
root.querySelectorAll('.wf-loop-island[data-loop-id]').forEach(island => {
const id = island.getAttribute('data-loop-id');
const shown = Number(island.getAttribute('data-loop-shown')) || 1;
const progress = island.querySelector('.wf-loop-progress');
if (progress) progress.textContent = wfLoopProgressText(loopPlans.find(plan => plan.id === id), shown, lifecycle);
});
const head = root.querySelector('.workflow-head');
if (head) {
const outcome = wfJobOutcome(session, nowUnix);
let outcomeEl = head.querySelector('[data-workflow-outcome]');
if (!outcomeEl) {
const title = head.querySelector('.workflow-title');
if (title) title.insertAdjacentHTML('afterend', wfJobOutcomeHtml(session, nowUnix));
outcomeEl = head.querySelector('[data-workflow-outcome]');
}
if (outcomeEl) {
outcomeEl.className = 'chamfer workflow-outcome workflow-outcome--' + outcome.className;
outcomeEl.textContent = outcome.text;
}
const summary = head.querySelector('.workflow-summary');
if (summary) summary.innerHTML = wfSummaryHtml(counts, nodes.length, wfFirstIdByState(session, nodes, nowUnix));
}
const visual = root.querySelector('.workflow-visual');
if (visual) {
const next = wfLegendHtml(present);
const cur = visual.querySelector('.workflow-legend');
if (next) {
if (cur) cur.outerHTML = next;
else visual.insertAdjacentHTML('afterbegin', next);
} else if (cur) {
cur.remove();
}
}
// Resolve a pending pre-registration selection exactly once when its panel appears.
if (pendingWorkflowStep) {
const det = procPanelForWorkflowStep(pendingWorkflowStep);
if (det) {
const step = pendingWorkflowStep;
pendingWorkflowStep = null;
setWorkflowPendingStatus('');
// Resolved by a DATA update, not a fresh click — open the panel but never move the
// viewport: the page scrolls only in direct response to human input.
activateProcPanel(det, null, false, false);
markWorkflowNodeSelected(step);
}
}
}
function setWorkflowPendingStatus(msg) {
let el = document.getElementById('wf-pending-status');
if (!msg) {
if (el) el.remove();
return;
}
if (!el) {
el = document.createElement('p');
el.id = 'wf-pending-status';
el.className = 'dim';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
const root = document.querySelector('[data-workflow-graph] .workflow-head');
if (root) root.appendChild(el);
else return;
}
el.textContent = msg;
}
function activateProcPanel(det, hash, pushHistory, scroll) {
if (!det) return false;
det.open = true;
if (scroll !== false) {
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 'start' lands on the TOP of the island ('nearest' aligned its bottom edge when the
// panel sat below the viewport); scroll-margin-top clears the sticky status bar.
det.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
// A brief brightness flash answers "which island did the page just take me to?".
// Remove-then-reflow restarts the animation when the same panel is re-activated.
det.classList.remove('proc-flash');
void det.offsetWidth;
det.classList.add('proc-flash');
setTimeout(() => det.classList.remove('proc-flash'), 1100);
}
const summary = det.querySelector('summary');
if (summary) {
try { summary.focus({ preventScroll: true }); } catch (_) { try { summary.focus(); } catch (_) {} }
}
if (hash) {
const cur = location.hash || '';
if (pushHistory && cur !== hash) {
history.pushState({ task: hash }, '', hash);
} else if (!pushHistory && cur !== hash) {
history.replaceState({ task: hash }, '', hash);
}
}
persistOpenProcs();
return true;
}
function procPanelForWorkflowStep(stepId) {
const alias = document.getElementById('task-' + stepId);
return (alias && alias.closest('details.proc')) ||
document.querySelector('details.proc[data-workflow-step="' + CSS.escape(stepId) + '"]');
}
function activateWorkflowTask(stepId, opts) {
if (!stepId) return;
opts = opts || {};
const pushHistory = opts.pushHistory !== false && !opts.fromHistory;
const hash = '#task-' + encodeURIComponent(stepId);
const det = procPanelForWorkflowStep(stepId);
if (det) {
pendingWorkflowStep = null;
setWorkflowPendingStatus('');
if (pushHistory && (location.hash || '') === hash) {
activateProcPanel(det, null, false);
} else {
activateProcPanel(det, hash, pushHistory && !opts.fromHistory);
}
return;
}
// Pre-registration: remember selection; do not silently ignore the click.
pendingWorkflowStep = stepId;
setWorkflowPendingStatus('Task details are not available yet; waiting for the task to register.');
if (pushHistory && !opts.fromHistory) {
if ((location.hash || '') !== hash) history.pushState({ task: hash }, '', hash);
} else if ((location.hash || '') !== hash) {
history.replaceState({ task: hash }, '', hash);
}
}
function syncWorkflowTaskFromLocation() {
const m = /^#task-(.+)$/.exec(location.hash || '');
if (!m) return;
let step;
try { step = decodeURIComponent(m[1]); } catch (_) { return; }
activateWorkflowTask(step, { fromHistory: true, pushHistory: false });
}
function wfFitZoom(scroller, stage) {
if (!scroller || !stage) return 1;
const naturalWidth = parseFloat(stage.style.width) || stage.scrollWidth || 1;
const naturalHeight = parseFloat(stage.style.height) || stage.scrollHeight || 1;
const gutter = 16;
const widthZoom = Math.max(1, scroller.clientWidth - gutter) / naturalWidth;
const heightZoom = Math.max(1, scroller.clientHeight - gutter) / naturalHeight;
// The tighter axis wins, in both directions: a small graph in a large viewport fits
// UP (>1), a large graph fits down (<1). Callers bound it — Fit at the 2x zoom
// ceiling, and the zoom-out floor at 100% so fitting up never forbids zooming back
// to natural size.
return Math.min(widthZoom, heightZoom);
}
function initWorkflowGraph() {
const root = document.querySelector('[data-workflow-graph]');
if (!root || root.dataset.bound) return;
root.dataset.bound = '1';
const scroller = root.querySelector('.workflow-scroll');
if (scroller && !scroller.getAttribute('aria-label')) {
scroller.setAttribute('role', 'region');
scroller.setAttribute('aria-label', 'Job dependency graph');
scroller.setAttribute('tabindex', '0');
}
const stage = root.querySelector('.workflow-stage');
const reset = root.querySelector('[data-wf-zoom-reset]');
const zoomOut = root.querySelector('[data-wf-zoom-out]');
const expand = root.querySelector('[data-wf-expand]');
const applyExpanded = (expanded, focusButton) => {
workflowExpanded = expanded;
root.classList.toggle('wf-expanded', expanded);
document.body.classList.toggle('wf-modal-open', expanded);
if (expanded) {
root.setAttribute('role', 'dialog');
root.setAttribute('aria-modal', 'true');
root.setAttribute('aria-label', 'Job graph, large view');
} else {
root.removeAttribute('role');
root.removeAttribute('aria-modal');
root.removeAttribute('aria-label');
}
if (expand) {
expand.textContent = expanded ? 'Close' : 'Full screen';
expand.setAttribute('aria-label', expanded ? 'Close large graph view' : 'Open graph in large view');
expand.setAttribute('aria-pressed', expanded ? 'true' : 'false');
if (focusButton) expand.focus({ preventScroll: true });
}
// The modal changes both available dimensions. Re-apply the lower bound after layout.
// The first large-view entry at a given viewport size fits the graph; after that the
// viewer's chosen zoom wins, keyed per size so a resized window fits fresh once.
requestAnimationFrame(() => {
if (expanded && scroller) {
const key = scroller.clientWidth + 'x' + scroller.clientHeight;
window.__scshWfExpandFitDone = window.__scshWfExpandFitDone || {};
if (!window.__scshWfExpandFitDone[key]) {
window.__scshWfExpandFitDone[key] = true;
fit();
return;
}
}
applyZoom(workflowZoom);
});
};
const applyZoom = next => {
const fit = wfFitZoom(scroller, stage);
const minimum = Math.min(1, fit);
// Fit must be able to FILL the viewport as it is right now: when the live fit
// factor exceeds the manual 2x ceiling (window grown, full screen toggled), the
// ceiling lifts with it — a fixed cap silently re-fit to the original viewport.
const maximum = Math.max(2, fit);
workflowZoom = Math.max(minimum, Math.min(maximum, next));
// Zoom scales the stage INSIDE the fixed viewport; the card itself never changes size,
// so zooming (like graph growth) can never reflow the page around it.
if (stage) stage.style.zoom = String(workflowZoom);
if (reset) reset.textContent = Math.round(workflowZoom * 100) + '%';
if (zoomOut) zoomOut.disabled = workflowZoom <= minimum + 0.001;
};
const fit = () => {
if (!stage || !scroller) return;
applyZoom(wfFitZoom(scroller, stage));
scroller.scrollLeft = 0;
scroller.scrollTop = 0;
};
// Zoom anchored at a viewport point: the content under the pointer stays put. CSS zoom
// scales the scroll content linearly, so re-anchoring is pure arithmetic on the offsets.
const zoomAt = (next, clientX, clientY) => {
if (!scroller) { applyZoom(next); return; }
const rect = scroller.getBoundingClientRect();
const px = clientX - rect.left;
const py = clientY - rect.top;
const prev = workflowZoom;
applyZoom(next);
if (workflowZoom === prev) return;
const ratio = workflowZoom / prev;
scroller.scrollLeft = (scroller.scrollLeft + px) * ratio - px;
scroller.scrollTop = (scroller.scrollTop + py) * ratio - py;
};
// Animated variant for discrete gestures (double-click): glide to the target zoom over a
// few frames, keeping the same anchor point, instead of jumping. Wheel/pinch stays
// per-event — those gestures are already continuous.
const animateZoomAt = (target, clientX, clientY) => {
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { zoomAt(target, clientX, clientY); return; }
const from = workflowZoom;
const start = performance.now();
const ms = 200;
const step = now => {
const t = Math.min(1, (now - start) / ms);
const eased = 1 - Math.pow(1 - t, 3);
zoomAt(from + (target - from) * eased, clientX, clientY);
if (t < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
};
// Global modal handlers must always act on the current graph node. Live job updates preserve
// this card when possible, but a late graph mount can still introduce it after page load.
// Expose the closure on the node instead of capturing a stale element in a document listener.
root.__scshApplyWorkflowExpanded = applyExpanded;
// The page opens on the fitted graph. Only the first mount per page load fits: live
// updates remount the card, and a remount must keep the zoom the viewer chose.
if (!window.__scshWfInitialFitDone) {
window.__scshWfInitialFitDone = true;
fit();
} else {
applyZoom(workflowZoom);
}
zoomOut?.addEventListener('click', () => applyZoom(workflowZoom - 0.1));
root.querySelector('[data-wf-zoom-in]')?.addEventListener('click', () => applyZoom(workflowZoom + 0.1));
reset?.addEventListener('click', () => applyZoom(1));
root.querySelector('[data-wf-zoom-fit]')?.addEventListener('click', fit);
expand?.addEventListener('click', () => applyExpanded(!workflowExpanded, false));
applyExpanded(workflowExpanded, false);
root.__scshApplyWorkflowZoom = () => applyZoom(workflowZoom);
if (!window.__scshWfResizeBound) {
window.__scshWfResizeBound = true;
window.addEventListener('resize', () => {
const current = document.querySelector('[data-workflow-graph]');
if (current && current.__scshApplyWorkflowZoom) current.__scshApplyWorkflowZoom();
});
}
if (!window.__scshWfExpandBound) {
window.__scshWfExpandBound = true;
document.addEventListener('click', ev => {
if (!workflowExpanded) return;
const current = document.querySelector('[data-workflow-graph]');
// The body's fixed ::before layer is the visual backdrop. A click landing anywhere outside
// the inset card expresses dismissal; clicks on the graph, controls, or nodes stay inside.
if (!current || current.contains(ev.target)) return;
if (current.__scshApplyWorkflowExpanded) current.__scshApplyWorkflowExpanded(false, false);
});
document.addEventListener('keydown', ev => {
if (!workflowExpanded) return;
const current = document.querySelector('[data-workflow-graph]');
if (ev.key === 'Tab' && current) {
const focusable = Array.from(current.querySelectorAll('a[href],button,[tabindex]:not([tabindex="-1"])'))
.filter(el => !el.disabled && el.getClientRects().length > 0);
if (focusable.length) {
const first = focusable[0], last = focusable[focusable.length - 1];
if (ev.shiftKey && document.activeElement === first) {
ev.preventDefault();
last.focus();
} else if (!ev.shiftKey && document.activeElement === last) {
ev.preventDefault();
first.focus();
}
}
return;
}
if (ev.key !== 'Escape') return;
const button = current && current.querySelector('[data-wf-expand]');
workflowExpanded = false;
document.body.classList.remove('wf-modal-open');
if (current) {
current.classList.remove('wf-expanded');
current.removeAttribute('role');
current.removeAttribute('aria-modal');
current.removeAttribute('aria-label');
}
if (button) {
button.textContent = 'Full screen';
button.setAttribute('aria-label', 'Open graph in large view');
button.setAttribute('aria-pressed', 'false');
button.focus({ preventScroll: true });
}
});
}
scroller?.addEventListener('wheel', ev => {
ev.preventDefault();
ev.stopPropagation();
if (ev.ctrlKey || ev.metaKey) {
// Trackpad pinch arrives as ctrlKey wheel events: zoom toward the fingers, not the
// viewport center.
zoomAt(workflowZoom + (ev.deltaY < 0 ? 0.1 : -0.1), ev.clientX, ev.clientY);
return;
}
scroller.scrollLeft += ev.deltaX;
scroller.scrollTop += ev.deltaY;
}, { passive: false });
scroller?.addEventListener('dblclick', ev => {
// Double-clicking a node or control keeps its own meaning; empty graph area zooms in
// at the clicked point.
if (ev.target.closest('a.wf-node, a.wf-jump, button')) return;
ev.preventDefault();
animateZoomAt(workflowZoom * 1.5, ev.clientX, ev.clientY);
});
// Mouse drag on empty graph area pans the viewport — wheel-less mice and rough touchpads
// need a way to move around. Touch keeps the container's native scrolling (touch-action);
// a 3px dead zone leaves double-click jitter alone.
scroller?.addEventListener('pointerdown', ev => {
if (ev.pointerType !== 'mouse' || ev.button !== 0) return;
if (ev.target.closest('a.wf-node, a.wf-jump, button')) return;
ev.preventDefault();
const startX = ev.clientX, startY = ev.clientY;
const startLeft = scroller.scrollLeft, startTop = scroller.scrollTop;
let panning = false;
const move = e => {
const dx = e.clientX - startX, dy = e.clientY - startY;
if (!panning && Math.abs(dx) + Math.abs(dy) < 3) return;
panning = true;
scroller.classList.add('wf-panning');
scroller.scrollLeft = startLeft - dx;
scroller.scrollTop = startTop - dy;
};
const up = () => {
scroller.removeEventListener('pointermove', move);
scroller.removeEventListener('pointerup', up);
scroller.removeEventListener('pointercancel', up);
scroller.classList.remove('wf-panning');
};
try { scroller.setPointerCapture(ev.pointerId); } catch (_) {}
scroller.addEventListener('pointermove', move);
scroller.addEventListener('pointerup', up);
scroller.addEventListener('pointercancel', up);
});
root.addEventListener('click', (ev) => {
const jump = ev.target.closest('a.wf-jump');
if (jump && root.contains(jump)) {
const href = jump.getAttribute('href') || '';
const m = /^#task-(.+)$/.exec(href);
if (!m) return;
ev.preventDefault();
let step;
try { step = decodeURIComponent(m[1]); } catch (_) { return; }
if (workflowExpanded) applyExpanded(false, false);
activateWorkflowTask(step, { pushHistory: true });
return;
}
const a = ev.target.closest('a.wf-node');
if (!a || !root.contains(a)) return;
const step = a.getAttribute('data-workflow-step');
if (!step) return;
ev.preventDefault();
if (workflowExpanded) applyExpanded(false, false);
activateWorkflowTask(step, { pushHistory: true });
});
if (!window.__scshWfHistoryBound) {
window.__scshWfHistoryBound = true;
window.addEventListener('popstate', () => {
if (wfHistorySilent) return;
syncWorkflowTaskFromLocation();
});
window.addEventListener('hashchange', () => {
if (wfHistorySilent) return;
syncWorkflowTaskFromLocation();
});
}
// Initial fragment: replaceState semantics (no extra history entry).
if (/^#task-/.test(location.hash || '')) {
setTimeout(() => syncWorkflowTaskFromLocation(), 0);
}
}
function persistOpenProcs() {
if (typeof SESSION_ID !== 'string' || !SESSION_ID) return;
const open = [];
document.querySelectorAll('details.proc[data-index]').forEach((det) => {
if (det.open) open.push(det.dataset.index);
});
saveUiPrefs({ openProcs: open });
}
function restoreOpenProcs() {
if (typeof SESSION_ID !== 'string' || !SESSION_ID) return;
const open = loadUiPrefs().openProcs;
if (!Array.isArray(open)) return;
open.forEach((idx) => {
const det = document.querySelector('details.proc[data-index="' + CSS.escape(String(idx)) + '"]');
if (det) det.open = true;
});
}
function syncProcFromLocation() {
const match = /^#proc-([0-9]+)$/.exec(location.hash || '');
if (!match) return;
const det = document.querySelector('details.proc[data-index="' + CSS.escape(match[1]) + '"]');
activateProcPanel(det, null, false);
}
// The canonical permalink for one run row. A workflow run is addressed by its STEP id, which
// survives across retries and re-renders; a plain run falls back to its index. Single source of
// truth so a click and the scroll-spy below can never disagree about a row's address.
function procPermalinkHash(det) {
if (!det) return '';
const step = det.getAttribute('data-workflow-step');
if (step) return '#task-' + encodeURIComponent(step);
const index = det.getAttribute('data-index');
return index === null ? '' : '#proc-' + encodeURIComponent(index);
}
// Point the address bar at whatever run the reader just opened, so the URL is always a
// copyable permalink to what is on screen. replaceState, not pushState: this is addressing,
// not navigation — clicking through six runs must not bury the page you arrived from under
// six Back presses.
function addressProcRow(det) {
const hash = procPermalinkHash(det);
if (!hash || location.hash === hash) return;
history.replaceState(history.state, '', location.pathname + location.search + hash);
}
function bindSessionProcs(root) {
if (root.dataset.changeBound) return;
root.dataset.changeBound = '1';
root.addEventListener('toggle', (ev) => {
if (ev.target && ev.target.matches && ev.target.matches('details.proc')) persistOpenProcs();
}, true);
// Bound on click rather than `toggle` because only a click is the reader's own doing:
// `toggle` also fires for restoreOpenProcs() and for live re-renders, which must not
// hijack the address bar out from under whatever the reader is actually looking at.
root.addEventListener('click', (ev) => {
const summary = ev.target.closest && ev.target.closest('details.proc > summary');
if (!summary || !root.contains(summary)) return;
// Links inside the summary (retry / original-attempt cross-links) address a DIFFERENT
// run; let them navigate instead of stamping this row's hash over their destination.
if (ev.target.closest('a')) return;
addressProcRow(summary.parentElement);
});
}
// Only the owning `scsh run` can respawn a route: the daemon records a restart marker and
// that process consumes it. Once the run client is gone there is nothing to consume it, so
// the daemon refuses the request — and a button that can only be refused should not be
// offered as if it worked.
let RUN_CLIENT_GONE = false;
function renderSession(session, nowUnix) {
const root = document.getElementById('session-procs');
if (!root || !session) return;
RUN_CLIENT_GONE = session.client_connected === false;
const open = new Set([...root.querySelectorAll('details.proc')].filter(d => d.open).map(d => d.dataset.index));
const procs = session.procs || [];
procs.forEach(p => {
const idx = String(p.index);
const userOpen = open.has(idx);
let det = root.querySelector('details.proc[data-index="' + CSS.escape(idx) + '"]');
if (!det) {
const wrap = document.createElement('div');
wrap.innerHTML = procHtml(p, false, nowUnix);
det = wrap.firstElementChild;
root.appendChild(det);
initProcDiffs(det);
initProcKills(det);
} else {
det.open = userOpen;
updateProcFields(det, p, nowUnix);
const step = workflowStepIdForProc(p);
let taskAnchor = det.querySelector('.proc-task-anchor');
if (taskAnchor && (!step || taskAnchor.id !== 'task-' + step)) {
taskAnchor.remove();
taskAnchor = null;
}
if (step) {
det.setAttribute('data-workflow-step', step);
if (!taskAnchor) {
const summary = det.querySelector('summary');
if (summary) summary.insertAdjacentHTML('afterbegin',
'<span class="proc-task-anchor" id="task-' + esc(step) + '" aria-hidden="true"></span>');
}
} else {
det.removeAttribute('data-workflow-step');
}
}
});
initCasts(root);
syncSessionLede(session, nowUnix);
syncFleetSections(session, nowUnix);
updateWorkflowGraph(session, nowUnix);
}
// Mirror of session_lede_html in session.rs — the page heading ticks with the live
// snapshot. The task count is the larger of registered procs and planned skills, so a
// job whose procs have not registered yet reads its plan, never "0 tasks".
function syncSessionLede(session, nowUnix) {
const lede = document.querySelector('.page-lede');
if (!lede || !session) return;
const n = Math.max((session.procs || []).length, (session.skills || []).length);
const html = esc(session.kind || 'profile') + ' <strong>' + esc(session.profile || 'default') + '</strong> · ' +
esc(sessionLifecycle(session, nowUnix).label) + ' · ' + n + (n === 1 ? ' task' : ' tasks');
if (lede._scshLede === html) return;
lede._scshLede = html;
lede.innerHTML = html;
}
// Fleet comparison tables are server-rendered on page load; keep any that are on the
// page current from tick snapshots — row status, glyph, duration, and the result
// message once a route finishes, plus the one-line rollup. Grade parsing needs the
// daemon's result files, so a section that already shows grade/issue text keeps that
// richer server-rendered copy and only its status/duration columns tick.
function fleetGlyph(status) {
return ({ok:'✓',graceful:'!',fail:'✗',skipped:'⊘',running:'◆',waiting:'◇'})[status] || '◇';
}
function fleetProcFinished(status) {
return status === 'ok' || status === 'graceful' || status === 'fail' || status === 'skipped';
}
function syncFleetSections(session, nowUnix) {
const sections = document.querySelectorAll('section.fleet[data-skill-source]');
if (!sections.length) return;
const procs = (session && session.procs) || [];
const allMembers = [];
sections.forEach(sec => {
const source = sec.getAttribute('data-skill-source') || '';
const rich = !!sec.querySelector('.fleet-grade, .fleet-issues');
const members = [];
sec.querySelectorAll('tr.fleet-row').forEach(row => {
const jump = row.querySelector('.fleet-jump[data-proc]');
let p = jump && procs.find(q => String(q.index) === jump.getAttribute('data-proc'));
if (!p) return;
// A retry supersedes this row's attempt: re-point the row at the newest attempt
// (same route name), exactly what a fresh server render would show.
for (let next = procNextAttempt(session, p); next; next = procNextAttempt(session, p)) {
p = next;
}
jump.setAttribute('data-proc', String(p.index));
members.push(p);
allMembers.push(p);
row.className = 'fleet-row ' + (p.status || '');
const statusCell = row.querySelector('.fleet-status');
if (statusCell) {
const next = '<span class="glyph">' + fleetGlyph(p.status) + '</span> ' + esc(p.status || '');
if (statusCell.innerHTML !== next) statusCell.innerHTML = next;
}
const elapsedCell = row.querySelector('.fleet-elapsed');
const elapsed = procElapsed(p, nowUnix);
if (elapsedCell) setTextUnlessSelecting(elapsedCell, elapsed != null ? formatElapsedClock(elapsed) : '—');
const resultCell = row.querySelector('.fleet-result');
if (resultCell && !resultCell.querySelector('.fleet-grade, .fleet-issues')) {
const msg = fleetProcFinished(p.status) && p.detail ? p.detail : '';
const next = msg ? '<span class="fleet-msg">' + esc(msg) + '</span>' : '<span class="dim">—</span>';
if (resultCell.innerHTML !== next) resultCell.innerHTML = next;
}
});
const summaryEl = sec.querySelector('.fleet-summary');
if (!summaryEl || !members.length || rich) return;
// Mirrors fleet::summarize_group with tick data (detail stands in for the result
// message); do-while groups keep their cycle-iteration wording (fleet.rs).
const cycle = (sec.querySelector('.fleet-compare th')?.textContent || '') === 'Cycle iteration';
const noun = cycle ? 'cycle iterations' : 'routes';
const ok = members.filter(p => p.status === 'ok' || p.status === 'graceful').length;
const fail = members.filter(p => p.status === 'fail').length;
const msgs = members.filter(p => fleetProcFinished(p.status) && p.detail).map(p => p.detail);
const agree = msgs.length > 0 && msgs.every(m => m === msgs[0]);
const text = agree
? source + ': ' + ok + ' ok, ' + fail + ' fail — all ' + noun + ' agree: ' + msgs[0]
: source + ': ' + ok + ' ok, ' + fail + ' fail · ' + members.length + ' ' + noun;
setTextUnlessSelecting(summaryEl, text);
});
// The job-level verdict's counts span ticks from the same snapshots; its grade half
// (histogram, mean) needs the daemon's result files, so it keeps the server render.
const verdictCounts = document.querySelector('section.fleet-verdict .fv-counts');
if (verdictCounts && allMembers.length) {
const ok = allMembers.filter(p => p.status === 'ok' || p.status === 'graceful').length;
const fail = allMembers.filter(p => p.status === 'fail').length;
const pending = allMembers.filter(p => p.status === 'running' || p.status === 'waiting').length;
let text = ok + ' ok, ' + fail + ' fail';
if (pending) text += ', ' + pending + ' pending';
setTextUnlessSelecting(verdictCounts, text);
}
}
function onWsMessage(msg) {
if (msg.type === 'cast_growth') { onCastGrowth(msg); return; }
onTick(msg);
}
let lastTickSecs = 0;
function onTick(msg) {
if (msg.type !== 'tick') return;
// Render time must be monotonic: a stale frame (reconnect backlog, a superseded socket,
// a throttled tab flushing its queue) carries an old now_secs AND an old snapshot, and
// rendering it verbatim snaps every live duration backwards, then forwards on the next
// fresh frame — the "oscillating Duration" bug. Newer snapshots fully supersede older
// ones, so dropping stale frames loses nothing.
if ((msg.now_secs || 0) < lastTickSecs) return;
lastTickSecs = msg.now_secs || lastTickSecs;
const alive = msg.alive_clients ?? msg.active_clients ?? 0;
const nowUnix = msg.now_secs ?? (Date.now() / 1000);
if (msg.sessions) {
liveSessions = msg.sessions;
}
setScshVersion(msg.scsh_version, msg.scsh_git);
let label = 'daemon up · ' + msg.mode + ' · ' + alive + ' client' + (alive === 1 ? '' : 's');
if (msg.mode === 'ephemeral' && msg.shutdown_in_secs != null) {
label += ' · shutting down in ' + formatDuration(msg.shutdown_in_secs);
}
setDaemonStatus('live', label, msg.uptime_secs);
if (SESSION_ID) {
const session = liveSessions ? liveSessions[SESSION_ID] : null;
if (session) {
renderSessionMeta(session, nowUnix);
renderSession(session, nowUnix);
syncSessionStopButton(session);
}
} else {
const snapshot = msg.sessions ?? liveSessions;
if (snapshot) {
renderIndex(snapshot, nowUnix);
renderRepoJobs(snapshot, nowUnix);
renderInternalJobs(snapshot, nowUnix);
}
}
}
let ws;
let reconnectMs = 400;
function connectWs() {
// Retire any superseded socket completely — two live sockets would interleave a fresh
// stream with a lagging one and make every duration on the page see-saw.
if (ws) { try { ws.onclose = null; ws.onmessage = null; ws.onerror = null; ws.close(); } catch (_) {} }
setDaemonStatus('connecting', 'connecting…', null);
ws = new WebSocket('ws://127.0.0.1:' + WS_PORT + '/ws');
ws.onopen = () => { reconnectMs = 400; setDaemonStatus('connecting', 'connecting…', null); };
ws.onmessage = (ev) => { try { onWsMessage(JSON.parse(ev.data)); } catch (_) {} };
ws.onclose = () => {
setDaemonStatus('connecting', 'connecting…', null);
setTimeout(connectWs, reconnectMs);
reconnectMs = Math.min(reconnectMs * 2, 5000);
};
ws.onerror = () => { try { ws.close(); } catch (_) {} };
}
connectWs();
startProcClock();
(function initSessionPage() {
const root = document.getElementById('session-procs');
if (!root) return;
initSessionMetaFromDom();
bindSessionProcs(root);
initCasts(root);
initSessionStop();
initSessionRestart();
initProcKills(root);
initProcDiffs(root);
initHarnessStops();
initFleetJumps();
initWorkflowGraph();
restoreOpenProcs();
window.addEventListener('hashchange', syncProcFromLocation);
if (/^#proc-/.test(location.hash || '')) setTimeout(syncProcFromLocation, 0);
})();
function initFleetJumps() {
document.querySelectorAll('.fleet-jump').forEach((btn) => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
const idx = btn.getAttribute('data-proc');
if (idx == null) return;
const det = document.querySelector('details.proc[data-index="' + CSS.escape(idx) + '"]');
const step = det && det.getAttribute('data-workflow-step');
activateProcPanel(det, step ? '#task-' + step : null);
});
});
}
function syncSessionStopButton(session) {
const lifecycle = sessionLifecycle(session, Date.now() / 1000);
const running = lifecycle.class === 'running';
let btn = document.getElementById('session-stop');
// Force restart / Force stop only while running — remove them when the job settles (no
// grayed stubs). Restart is server-rendered only (a settled job never resurrects, so
// there is no add-it-back path).
if (!running) {
const restart = document.getElementById('session-restart');
if (restart) restart.remove();
}
if (!running) {
if (btn) btn.remove();
} else if (!btn) {
const actions = document.querySelector('.session-actions');
if (actions) {
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chamfer btn btn--red btn--sm';
btn.id = 'session-stop';
btn.setAttribute('data-session', session.id || SESSION_ID);
btn.title = 'Force-stop this job? Running containers will be killed.';
btn.innerHTML = '<span>Force stop</span>';
actions.appendChild(btn);
btn.addEventListener('click', () => forceStopSession(btn));
}
} else {
btn.disabled = false;
btn.title = 'Force-stop this job? Running containers will be killed.';
setBtnLabel(btn, 'Force stop');
}
syncChaptersPending(session);
const pending = chaptersPendingCount(session);
const exportBtn = document.querySelector('a.session-export span') || document.querySelector('a.session-export');
if (exportBtn) {
let label = 'Job snapshot ⬇';
if (lifecycle.class === 'running') label = 'Incomplete job ⬇';
else if (pending > 0) label = 'Chapters pending ⬇';
if (exportBtn.tagName === 'SPAN') exportBtn.textContent = label;
else setBtnLabel(exportBtn, label);
}
const actions = document.querySelector('.session-actions');
const hasDiff = !!(session.procs || []).find(p => p.diff_path);
let diff = document.querySelector('a[data-job-diff]');
if (hasDiff && !diff && actions) {
actions.insertAdjacentHTML('afterbegin', '<a class="chamfer btn btn--purple btn--sm job-diff" data-job-diff href="/diff/' + encodeURIComponent(session.id || SESSION_ID) + '/all" title="Browse the entire end-to-end commits diff"><span>⇄ all commits</span></a>');
} else if (!hasDiff && diff) {
diff.remove();
}
}
function chaptersPendingCount(session) {
let annotateLive = 0;
if (session && session.procs) {
for (const p of session.procs) {
if (p.kind === 'annotate' && (p.status === 'running' || p.status === 'waiting')) annotateLive++;
}
}
const domPending = document.querySelectorAll('.chap-pending').length;
if (annotateLive > 0 || domPending > 0) return Math.max(annotateLive, domPending);
const el = document.getElementById('chapters-pending');
const ssr = el ? (parseInt(el.getAttribute('data-pending') || '0', 10) || 0) : 0;
if (!ssr) return 0;
// Cast players loaded and none still summarizing → chapters settled (or never will).
if (document.querySelectorAll('.cast[data-ready]').length > 0) return 0;
return ssr;
}
function syncChaptersPending(session) {
const n = chaptersPendingCount(session);
let el = document.getElementById('chapters-pending');
const card = document.querySelector('.card.card--accent-left-purple');
if (n > 0) {
const text = n + ' cast' + (n === 1 ? '' : 's') + ' finalizing chapters';
if (!el && card) {
el = document.createElement('p');
el.className = 'chapters-pending dim';
el.id = 'chapters-pending';
card.appendChild(el);
}
if (el) {
el.setAttribute('data-pending', String(n));
el.textContent = text;
}
} else if (el) {
el.remove();
}
}
async function forceStopSession(btn) {
const id = btn.getAttribute('data-session') || SESSION_ID;
if (!id) return;
const ok = await scshConfirm({
title: 'Force stop this job?',
body: 'Running containers will be killed.',
confirmLabel: 'Force stop',
danger: true,
});
if (!ok) return;
btn.disabled = true;
setBtnLabel(btn, 'Stopping…');
try {
const resp = await fetch('/api/v1/session/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: id }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data.ok === false) {
btn.disabled = false;
setBtnLabel(btn, 'Force stop');
showToast(data.error || ('stop failed (HTTP ' + resp.status + ')'));
return;
}
setBtnLabel(btn, data.already_ended ? 'Already ended' : 'Stopped');
btn.remove();
} catch (e) {
btn.disabled = false;
setBtnLabel(btn, 'Force stop');
showToast(String(e));
}
}
function initSessionStop() {
const btn = document.getElementById('session-stop');
if (!btn) return;
btn.addEventListener('click', () => forceStopSession(btn));
}
// ---- restart (session page): force-restart while running, resume / from-scratch once failed ----
async function restartSessionJob(btn, mode, dialog) {
const id = btn.getAttribute('data-session');
if (!id) return;
const label = btn.querySelector('span') ? btn.querySelector('span').textContent : dialog.confirmLabel;
const ok = await scshConfirm({
title: dialog.title,
body: dialog.body,
confirmLabel: dialog.confirmLabel,
danger: true,
});
if (!ok) return;
btn.disabled = true;
setBtnLabel(btn, 'Restarting…');
try {
const body = { session: id };
if (mode) body.mode = mode;
const resp = await fetch('/api/v1/jobs/restart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data.ok === false || !data.session) {
btn.disabled = false;
setBtnLabel(btn, label);
showToast(data.error || ('restart failed (HTTP ' + resp.status + ')'));
return;
}
window.location.href = '/job/' + data.session;
} catch (e) {
btn.disabled = false;
setBtnLabel(btn, label);
showToast(String(e));
}
}
function initSessionRestart() {
const live = document.getElementById('session-restart');
if (live) live.addEventListener('click', () => restartSessionJob(live, null, {
title: 'Force restart this job?',
body: 'The current run is stopped — running containers are killed — and the same job starts fresh.',
confirmLabel: 'Force restart',
}));
const resume = document.getElementById('session-resume');
if (resume) resume.addEventListener('click', () => restartSessionJob(resume, 'resume', {
title: 'Restart the remaining steps?',
body: 'A fresh run of the same job starts; every step this run completed is restored from its result, and only the steps that never completed run again.',
confirmLabel: 'Restart remaining',
}));
const scratch = document.getElementById('session-restart-scratch');
if (scratch) scratch.addEventListener('click', () => restartSessionJob(scratch, 'scratch', {
title: 'Restart this job from scratch?',
body: 'The same job starts fresh — every step runs anew; nothing from this run is reused.',
confirmLabel: 'Restart from scratch',
}));
}
// ---- per-proc restart (session page) ----
async function restartProc(btn) {
const session = btn.getAttribute('data-session');
const proc = parseInt(btn.getAttribute('data-proc-restart'), 10);
if (!session || Number.isNaN(proc)) return;
const ok = await scshConfirm({
title: 'Force restart this run?',
body: 'The container is killed and a fresh attempt of the same route starts; the rest of the job continues.',
confirmLabel: 'Force restart',
danger: true,
});
if (!ok) return;
btn.disabled = true;
setBtnLabel(btn, 'Restarting…');
try {
const resp = await fetch('/api/v1/proc/restart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: session, proc: proc }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data.ok !== true) {
btn.disabled = false;
setBtnLabel(btn, 'Force restart');
showToast(data.error || ('restart failed (HTTP ' + resp.status + ')'));
return;
}
btn.remove();
} catch (e) {
btn.disabled = false;
setBtnLabel(btn, 'Force restart');
showToast(String(e));
}
}
// ---- per-proc kill (session page) ----
async function killProc(btn) {
const session = btn.getAttribute('data-session');
const proc = parseInt(btn.getAttribute('data-proc-stop'), 10);
const annotate = btn.getAttribute('data-proc-kind') === 'annotate';
if (!session || Number.isNaN(proc)) return;
const ok = await scshConfirm({
title: annotate ? 'Stop this annotation?' : 'Force stop this container?',
body: annotate ? 'The recording remains unchanged and no annotation will be added.' :
'Only this run stops; the rest of the job continues.',
confirmLabel: annotate ? 'Stop annotation' : 'Force stop',
danger: true,
});
if (!ok) return;
btn.disabled = true;
setBtnLabel(btn, 'Stopping…');
try {
const resp = await fetch('/api/v1/proc/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: session, proc: proc }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data.ok !== true) {
btn.disabled = false;
setBtnLabel(btn, annotate ? 'Stop annotation' : 'Force stop');
showToast(data.error || ('stop failed (HTTP ' + resp.status + ')'));
return;
}
btn.remove();
} catch (e) {
btn.disabled = false;
setBtnLabel(btn, annotate ? 'Stop annotation' : 'Force stop');
showToast(String(e));
}
}
// ---- stop-all-of-a-harness (index page) ----
async function stopHarness(btn) {
const harness = btn.getAttribute('data-harness-stop');
if (!harness) return;
const ok = await scshConfirm({
title: 'Stop all ' + harness + ' containers?',
body: 'Every running ' + harness + ' container across every job will be stopped.',
confirmLabel: 'Stop all ' + harness,
danger: true,
});
if (!ok) return;
const original = btn.textContent;
btn.disabled = true;
btn.classList.remove('btn--red');
btn.classList.add('btn--orange');
setBtnLabel(btn, 'Terminating all ' + harness + '\u2026');
showToast('Stop requested for all ' + harness + ' tasks');
try {
const resp = await fetch('/api/v1/harness/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ harness: harness }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data.ok !== true) {
btn.disabled = false;
btn.classList.remove('btn--orange');
btn.classList.add('btn--red');
setBtnLabel(btn, original);
showToast(data.error || ('stop failed (HTTP ' + resp.status + ')'));
return;
}
const n = data.stopped || 0;
btn.classList.remove('btn--orange');
btn.classList.add('btn--red');
setBtnLabel(btn, 'Stopped ' + n + ' ' + harness + ' task' + (n === 1 ? '' : 's'));
showToast('Stopped ' + n + ' ' + harness + ' task' + (n === 1 ? '' : 's'));
} catch (e) {
btn.disabled = false;
btn.classList.remove('btn--orange');
btn.classList.add('btn--red');
setBtnLabel(btn, original);
showToast(String(e));
}
}
function initHarnessStops() {
document.querySelectorAll('button[data-harness-stop]').forEach((btn) => {
btn.addEventListener('click', () => stopHarness(btn));
});
}
function initProcKills(root) {
(root || document).querySelectorAll('button[data-proc-stop]').forEach((btn) => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
killProc(btn);
});
});
(root || document).querySelectorAll('button[data-proc-restart]').forEach((btn) => {
// Rows built by procHtml (first paint, and every newly appended proc) go through here
// rather than updateProcFields, so they need the same gate.
setRestartBlocked(btn, RUN_CLIENT_GONE);
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
restartProc(btn);
});
});
}
// ---- Setup tab (index page only) ----
function imageStatusBadge(img) {
// 'unknown' means the engine is down, not that the image is gone — never red.
if (img.status === 'unknown') return '<span class="chamfer session-status cancelled"><span>unknown</span></span>';
if (!img.exists) return '<span class="chamfer session-status failed"><span>missing</span></span>';
if (!img.up_to_date) return '<span class="chamfer session-status cancelled"><span>stale</span></span>';
return '<span class="chamfer session-status completed"><span>up to date</span></span>';
}
function imageCheckingBadge() {
return '<span class="chamfer session-status checking"><span>checking…</span></span>';
}
function setupOverallBadge(overall, label) {
const text = label || overall || 'unknown';
let cls = 'cancelled';
if (overall === 'needs_build') cls = 'failed';
else if (overall === 'needs_login') cls = 'cancelled';
else if (overall === 'not_tested') cls = 'setup-ready';
else if (overall === 'ready') cls = 'completed';
return '<span class="chamfer session-status ' + cls + '"><span>' + esc(text) + '</span></span>';
}
function setupModelStatusHtml(status) {
if (!status || status === 'not_tested') return '';
const labels = {
passed: 'passed', failed: 'failed', testing: 'testing', queued: 'queued',
unavailable: 'unavailable', blocked: 'blocked', cancelled: 'cancelled',
};
const word = labels[status] || status;
return ' <span class="setup-model-status setup-model-status--' + esc(status) + '">' + esc(word) + '</span>';
}
function setupImageLayer(img) {
if (!img) return '<span class="dim">—</span>';
const word = img.status || (img.exists ? (img.up_to_date ? 'ready' : 'stale') : 'missing');
let cls = 'dim';
if (word === 'ready') cls = 'setup-ok';
else if (word === 'missing' || word === 'stale') cls = 'setup-warn';
return '<span class="' + cls + '">' + esc(word.charAt(0).toUpperCase() + word.slice(1)) + '</span>' +
(img.tag ? ' <code class="setup-tag">' + esc(img.tag) + '</code>' : '');
}
function setupLoginLayer(login) {
if (!login) return '<span class="dim">—</span>';
let cls = 'dim';
if (login.status === 'found') cls = 'setup-ok';
else if (login.status === 'missing' || login.status === 'expired' || login.status === 'disabled') cls = 'setup-warn';
const tip = login.hint ? ' data-tip="' + esc(login.hint) + '"' : '';
return '<span class="' + cls + '"' + tip + '>' + esc(login.label || login.status) + '</span>';
}
function setupModelsHtml(h) {
const harness = h.id;
const custom = (loadUiPrefs().setupCustomModels || {})[harness] || [];
const selected = (loadUiPrefs().setupSelectedModels || {})[harness];
const builtin = (h.models || []).map(m => ({
id: m.id,
kind: m.kind || 'builtin',
primary: !!(m.primary_smoke || m.kind === 'primary'),
status: m.status || 'not_tested',
}));
const seen = new Set(builtin.map(m => m.id));
custom.forEach(id => {
if (!seen.has(id)) {
builtin.push({ id, kind: 'custom', primary: false, status: 'not_tested' });
seen.add(id);
}
});
if (!builtin.length) return '<ul class="setup-models dim"><li>No curated models</li></ul>';
const rows = builtin.map(m => {
const checked = selected
? selected.indexOf(m.id) >= 0
: m.primary;
const kind = m.kind === 'custom' ? ' · custom' : (m.primary ? ' · primary smoke' : ' · optional');
const remove = m.kind === 'custom'
? ' <button type="button" class="setup-model-remove" data-setup-remove="' + esc(harness) +
'" data-model="' + esc(m.id) + '" title="Remove custom model">✕</button>'
: '';
return '<li><label class="setup-model-row">' +
'<input type="checkbox" class="setup-model-check" data-harness="' + esc(harness) +
'" data-model="' + esc(m.id) + '"' + (checked ? ' checked' : '') + '>' +
'<code>' + esc(m.id) + '</code>' +
'<span class="dim">' + esc(kind) + '</span>' +
setupModelStatusHtml(m.status) +
remove +
'</label></li>';
}).join('');
const hint = (h.overall === 'not_tested' || (h.action && h.action.kind === 'test'))
? '<p class="setup-models-hint dim">Check the models above, then click <strong>Test selected</strong> below.</p>'
: '';
return '<div class="setup-models-block">' +
'<span class="setup-layer-label">Models</span>' +
hint +
'<ul class="setup-models">' + rows + '</ul>' +
'<div class="setup-add-model">' +
'<input class="input setup-add-input" type="text" data-harness="' + esc(harness) +
'" placeholder="Add model id…" autocomplete="off" spellcheck="false">' +
'<button type="button" class="chamfer btn btn--purple btn--sm" data-setup-add="' +
esc(harness) + '"><span>Add model</span></button>' +
'</div></div>';
}
function setupCardActions(h) {
const a = h.action || {};
const bits = [];
if (a.kind === 'build' || a.kind === 'update') {
bits.push('<button type="button" class="chamfer btn btn--cyan btn--sm setup-build-btn" data-setup-build="' +
esc(h.id) + '" data-uptodate="' + (a.kind === 'update' ? '1' : '0') + '"><span>' +
esc(a.label || 'Build image') + '</span></button>');
}
if (a.kind === 'login' && a.hint) {
bits.push('<p class="setup-next dim">' + esc(a.hint) + '</p>');
}
// Engine down: no Build, no Test — both need a live runtime. The banner above the cards
// carries the command; the card just says why it is offering nothing.
if (a.kind === 'blocked') {
bits.push('<p class="setup-next dim">' + esc(a.hint || 'Container engine is not running.') + '</p>');
}
if (a.kind === 'test' || a.kind === 'none' || !a.kind) {
bits.push('<button type="button" class="chamfer btn btn--green btn--sm" data-setup-test="' +
esc(h.id) + '" title="Run a real container probe for each checked model"><span>Test selected</span></button>');
bits.push('<span class="setup-next dim">May incur provider cost</span>');
}
return bits.join(' ');
}
function setupCardHtml(h) {
return '<article class="chamfer setup-card" data-harness="' + esc(h.id) + '">' +
'<header class="setup-card-head">' +
'<strong class="setup-card-name">' + esc(h.name || h.id) + '</strong>' +
setupOverallBadge(h.overall, h.overall_label) +
'</header>' +
'<div class="setup-card-layers">' +
'<div><span class="setup-layer-label">Image</span> ' + setupImageLayer(h.image) + '</div>' +
'<div><span class="setup-layer-label">Login</span> ' + setupLoginLayer(h.login) + '</div>' +
'</div>' +
setupModelsHtml(h) +
'<div class="setup-card-actions">' + setupCardActions(h) + '</div>' +
'</article>';
}
function markSetupChecking() {
const cards = document.getElementById('setup-cards');
if (cards) {
cards.querySelectorAll('.setup-card').forEach(card => {
card.dataset.pending = '1';
const badge = card.querySelector('.setup-card-head .session-status');
if (badge) badge.outerHTML = imageCheckingBadge();
card.querySelectorAll('.setup-layer-value').forEach(el => {
el.textContent = 'checking…';
el.className = 'setup-layer-value dim';
});
});
}
const summary = document.getElementById('setup-summary');
if (summary) summary.textContent = 'checking agents…';
markImagesChecking();
}
// Keep every known image row visible while the runtime inspect runs (§13: no empty limbo).
function markImagesChecking() {
const body = document.getElementById('images-body');
if (!body) return;
body.querySelectorAll('tr[data-image]').forEach(tr => {
tr.dataset.pending = '1';
const status = tr.querySelector('.image-status-cell');
if (status) status.innerHTML = imageCheckingBadge();
const created = tr.querySelector('.image-created-cell');
if (created) { created.textContent = '—'; created.classList.add('dim'); }
const size = tr.querySelector('.image-size-cell');
if (size) { size.textContent = '—'; size.classList.add('dim'); }
const cb = tr.querySelector('.image-select');
if (cb) cb.disabled = true;
});
const note = document.getElementById('images-note');
if (note) note.textContent = 'checking container runtime…';
const btn = document.getElementById('images-build-selected');
if (btn) btn.disabled = true;
}
function imageRowHtml(img) {
const checkbox = '<input type="checkbox" class="image-select" value="' + esc(img.name) + '">';
// Per-row build: "Rebuild" (forced) once the image is up to date, "Build" otherwise —
// the base row included: `base` is a first-class image name, buildable on its own.
const upToDate = !!(img.exists && img.up_to_date);
const label = upToDate ? 'Rebuild' : 'Build';
const title = upToDate ? 'Force-rebuild this image' : 'Build this image';
const action = '<button type="button" class="chamfer image-build-btn" data-image-build="' + esc(img.name) +
'" data-uptodate="' + (upToDate ? '1' : '0') + '" title="' + title + '">' + label + '</button>';
return '<tr data-image="' + esc(img.name) + '"><td class="image-select-cell">' + checkbox + '</td>' +
'<td><code>' + esc(img.tag) + '</code></td>' +
'<td class="image-status-cell">' + imageStatusBadge(img) + '</td>' +
'<td class="image-created-cell">' + esc(img.created || '—') + '</td>' +
'<td class="image-size-cell">' + esc(img.size || '—') + '</td>' +
'<td class="image-action-cell">' + action + '</td></tr>';
}
function wireImageBuildButtons(body) {
body.querySelectorAll('button[data-image-build]').forEach(btn => btn.addEventListener('click', () => {
btn.disabled = true;
startImageBuildOne(btn.getAttribute('data-image-build'), btn.getAttribute('data-uptodate') === '1');
}));
}
function wireImageSelectButtons(body) {
const btn = document.getElementById('images-build-selected');
if (!btn || !body) return;
body.querySelectorAll('.image-select').forEach(cb => cb.addEventListener('change', () => {
btn.disabled = body.querySelectorAll('.image-select:checked').length === 0;
}));
btn.disabled = body.querySelectorAll('.image-select:checked').length === 0;
}
function wireSetupBuildButtons(root) {
(root || document).querySelectorAll('button[data-setup-build]').forEach(btn => {
btn.addEventListener('click', () => {
btn.disabled = true;
startImageBuildOne(btn.getAttribute('data-setup-build'), btn.getAttribute('data-uptodate') === '1');
});
});
}
function setupModelIdOk(id) {
const s = (id || '').trim();
if (!s || s.length > 128) return false;
if (/[\x00-\x1f"`'$]/.test(s)) return false;
return true;
}
function persistSetupSelection(harness, model, checked) {
const prefs = loadUiPrefs();
const map = Object.assign({}, prefs.setupSelectedModels || {});
const cur = new Set(map[harness] || []);
if (checked) cur.add(model); else cur.delete(model);
map[harness] = Array.from(cur);
saveUiPrefs({ setupSelectedModels: map });
}
function addCustomSetupModel(harness, raw) {
if (!setupModelIdOk(raw)) {
showToast('Enter a valid model id (no quotes/backticks; max 128 chars)');
return;
}
const id = raw.trim();
const prefs = loadUiPrefs();
const map = Object.assign({}, prefs.setupCustomModels || {});
const list = (map[harness] || []).slice();
if (list.indexOf(id) < 0) list.push(id);
map[harness] = list;
const sel = Object.assign({}, prefs.setupSelectedModels || {});
const selected = new Set(sel[harness] || []);
selected.add(id);
sel[harness] = Array.from(selected);
saveUiPrefs({ setupCustomModels: map, setupSelectedModels: sel });
refreshSetup();
}
function removeCustomSetupModel(harness, id) {
const prefs = loadUiPrefs();
const map = Object.assign({}, prefs.setupCustomModels || {});
map[harness] = (map[harness] || []).filter(x => x !== id);
const sel = Object.assign({}, prefs.setupSelectedModels || {});
sel[harness] = (sel[harness] || []).filter(x => x !== id);
saveUiPrefs({ setupCustomModels: map, setupSelectedModels: sel });
refreshSetup();
}
function collectSetupTests(harnessFilter) {
const cards = document.getElementById('setup-cards');
if (!cards) return [];
const tests = [];
cards.querySelectorAll('.setup-card').forEach(card => {
const harness = card.getAttribute('data-harness');
if (harnessFilter && harness !== harnessFilter) return;
card.querySelectorAll('.setup-model-check:checked').forEach(cb => {
tests.push({ harness, model: cb.getAttribute('data-model') });
});
});
return tests;
}
function collectPrimarySetupTests() {
const cards = document.getElementById('setup-cards');
if (!cards) return [];
const tests = [];
(window.__SETUP_HARNESSES || []).forEach(h => {
if (h.overall === 'needs_build' || h.overall === 'needs_login') return;
const primary = (h.models || []).find(m => m.primary_smoke || m.kind === 'primary');
if (primary) tests.push({ harness: h.id, model: primary.id });
});
return tests;
}
function startSetupTests(tests, btn) {
if (!tests.length) {
showToast('Select at least one model to test');
return;
}
if (btn) btn.disabled = true;
const body = { tests };
if (IMAGES_RUNTIME) body.runtime = IMAGES_RUNTIME;
fetch('/api/v1/setup/tests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(r => r.json().then(j => ({ ok: r.ok, status: r.status, j }))).then(({ ok, j }) => {
if (btn) btn.disabled = false;
if (!ok || !j.ok) {
showToast((j && j.error) || 'setup test failed to start');
return;
}
location.href = '/job/' + encodeURIComponent(j.session);
}).catch(e => {
if (btn) btn.disabled = false;
showToast(String(e));
});
}
function wireSetupModelControls(root) {
const scope = root || document;
scope.querySelectorAll('.setup-model-check').forEach(cb => {
cb.addEventListener('change', () => {
persistSetupSelection(cb.getAttribute('data-harness'), cb.getAttribute('data-model'), cb.checked);
});
});
scope.querySelectorAll('button[data-setup-add]').forEach(btn => {
btn.addEventListener('click', () => {
const harness = btn.getAttribute('data-setup-add');
const input = scope.querySelector('.setup-add-input[data-harness="' + harness + '"]');
addCustomSetupModel(harness, input ? input.value : '');
});
});
scope.querySelectorAll('button[data-setup-remove]').forEach(btn => {
btn.addEventListener('click', () => {
removeCustomSetupModel(btn.getAttribute('data-setup-remove'), btn.getAttribute('data-model'));
});
});
scope.querySelectorAll('button[data-setup-test]').forEach(btn => {
btn.addEventListener('click', () => {
startSetupTests(collectSetupTests(btn.getAttribute('data-setup-test')), btn);
});
});
}
function renderSetupSummary(data) {
const el = document.getElementById('setup-summary');
if (!el) return;
const s = data.summary || {};
const parts = [];
if (s.needs_build) parts.push(s.needs_build + ' need' + (s.needs_build === 1 ? 's' : '') + ' build');
if (s.needs_login) parts.push(s.needs_login + ' need' + (s.needs_login === 1 ? 's' : '') + ' login');
if (s.not_tested) parts.push(s.not_tested + ' ready to test');
const agents = s.agents || (data.harnesses || []).length || 0;
if (data.engine && !data.engine.running) parts.push('readiness unknown until the engine starts');
el.textContent = agents + ' agents' + (parts.length ? ' — ' + parts.join(' · ') : '');
const checked = document.getElementById('setup-checked');
if (checked && data.checked_at) {
checked.textContent = 'checked ' + formatDuration(Date.now() / 1000 - data.checked_at) + ' ago';
}
}
// "Installed" only means the binary is on $PATH. When the engine itself is down, say so
// once, in the one place that unblocks it — the exact command to type — instead of letting
// every card and row invent its own failure.
function renderEngineBanner(data) {
const box = document.getElementById('setup-engine');
if (!box) return;
const e = data.engine || {};
if (!e.running && data.engine) {
const name = e.name || 'The container engine';
const cmd = e.start_command
? ' Start it with <code>' + esc(e.start_command) + '</code>, then '
: ' Start it, then ';
box.innerHTML = '<strong>' + esc(name) + ' is installed but not running.</strong>' + cmd +
// The quote and the hash are concatenated, never adjacent: this whole file is one
// Rust raw string literal, and that two-character sequence would end it early.
'<a href="' + '#' + '" id="setup-engine-retry">refresh</a>.';
box.hidden = false;
box.querySelector('#setup-engine-retry')?.addEventListener('click', (ev) => {
ev.preventDefault();
refreshSetup();
});
} else {
box.hidden = true;
box.innerHTML = '';
}
// Nothing buildable or testable until the engine answers.
const stopped = !!(data.engine && !e.running);
['setup-test-all', 'images-build-selected', 'images-build-stale', 'images-build-all'].forEach(id => {
const b = document.getElementById(id);
// 'Build selected' owns its own disabled state (no selection = disabled); only ever
// add a reason here, never clear one.
if (b && (stopped || id !== 'images-build-selected')) b.disabled = stopped;
});
}
function renderImagesTable(data) {
const body = document.getElementById('images-body');
if (!body) return;
const note = document.getElementById('images-note');
if (data.error) {
body.querySelectorAll('tr[data-image]').forEach(tr => {
delete tr.dataset.pending;
const status = tr.querySelector('.image-status-cell');
if (status) status.innerHTML = '<span class="chamfer session-status failed"><span>unavailable</span></span>';
const cb = tr.querySelector('.image-select');
if (cb) cb.disabled = true;
});
if (note) note.textContent = data.error;
return;
}
body.innerHTML = (data.images || []).map(imageRowHtml).join('');
const stopped = !!(data.engine && !data.engine.running);
if (note) {
note.textContent = stopped
? (data.engine.name || 'the container engine') + ' is not running — image state is unknown'
: (data.runtime ? ('runtime: ' + data.runtime) : '');
}
if (stopped) {
// Rows stay visible (§13: no empty limbo) but nothing here can act on a dead engine.
body.querySelectorAll('.image-select, .image-build-btn').forEach(el => { el.disabled = true; });
}
wireImageSelectButtons(body);
wireImageBuildButtons(body);
}
function renderSetup(data) {
const cards = document.getElementById('setup-cards');
if (data.error) {
if (cards) {
cards.querySelectorAll('.setup-card').forEach(card => {
delete card.dataset.pending;
const badge = card.querySelector('.setup-card-head .session-status');
if (badge) badge.outerHTML = '<span class="chamfer session-status failed"><span>unavailable</span></span>';
});
}
const summary = document.getElementById('setup-summary');
if (summary) summary.textContent = data.error;
renderEngineBanner(data); // no `engine` field on an error payload — clears any stale banner
renderImagesTable(data);
return;
}
if (cards) {
window.__SETUP_HARNESSES = data.harnesses || [];
cards.innerHTML = (data.harnesses || []).map(setupCardHtml).join('');
wireSetupBuildButtons(cards);
wireSetupModelControls(cards);
}
renderSetupSummary(data);
renderRuntimeSelector(data);
renderEngineBanner(data);
renderImagesTable(data);
}
function refreshSetup() {
markSetupChecking();
const url = '/api/v1/setup' + (IMAGES_RUNTIME ? '?runtime=' + encodeURIComponent(IMAGES_RUNTIME) : '');
fetch(url).then(r => r.json()).then(renderSetup).catch(() => {
renderSetup({ error: 'setup unavailable (daemon error)' });
});
}
function refreshImages() {
// Advanced section refresh uses the same Setup payload (includes images[]).
refreshSetup();
}
let IMAGES_RUNTIME = ''; // '' = the host default; set by the selector in the panel
function renderRuntimeSelector(data) {
const box = document.getElementById('images-runtimes');
if (!box) return;
const available = data.available || [];
if (available.length < 2) { box.innerHTML = ''; return; }
// Apple `container` and docker/podman keep SEPARATE image stores — this segmented
// control says which world the whole tab (cards + Advanced builds) talks to.
const label = (r) => r === 'container' ? 'Apple Containers' : r === 'docker' ? 'Docker' : r === 'podman' ? 'Podman' : r;
box.innerHTML = '<span class="chamfer seg" data-tip="Each runtime keeps its own image store — cards and Build buttons apply to the selected one">' +
available.map(r =>
'<button type="button" class="seg-opt' + (r === data.runtime ? ' active' : '') +
'" data-runtime="' + esc(r) + '">' + esc(label(r)) + '</button>').join('') +
'</span>';
box.querySelectorAll('[data-runtime]').forEach(b => b.addEventListener('click', () => {
if (b.classList.contains('active')) return;
IMAGES_RUNTIME = b.dataset.runtime;
refreshSetup();
}));
}
function postImagesBuild(req) {
if (IMAGES_RUNTIME) req.runtime = IMAGES_RUNTIME;
const note = document.getElementById('images-note');
if (note) note.textContent = 'starting build…';
fetch('/api/v1/images/build', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
}).then(r => r.json()).then(resp => {
if (resp.ok && resp.session) {
window.location.href = '/job/' + resp.session;
} else if (note) {
note.textContent = resp.error || 'build request failed';
}
}).catch(() => { if (note) note.textContent = 'build request failed'; });
}
function startImagesBuild(scope) {
const harnesses = scope === 'selected' ?
Array.from(document.querySelectorAll('.image-select:checked')).map(cb => cb.value) : [];
postImagesBuild({
harnesses: harnesses,
rebuild_base: !!document.getElementById('images-rebuild-base')?.checked,
force: scope === 'all' ||
(scope === 'selected' && !!document.getElementById('images-force')?.checked),
});
}
function startImageBuildOne(name, upToDate) {
// `base` rides the same path: harnesses:["base"] builds ONLY the shared base.
postImagesBuild({ harnesses: [name], rebuild_base: false, force: upToDate });
}
// ---- subscription quota (fetched only on click — it hits every provider's endpoint) ----
function quotaTimeHtml(iso) {
if (!iso) return '<span class="dim">—</span>';
return esc(iso.replace('T', ' ').replace('Z', '').slice(0, 16));
}
function quotaPercentHtml(pct) {
const cls = pct >= 80 ? 'setup-warn' : 'setup-ok';
const shown = (pct % 1 === 0) ? String(pct) : pct.toFixed(1);
return '<span class="' + cls + '">' + esc(shown) + '%</span>';
}
function quotaRowsHtml(q) {
if (!q.windows || !q.windows.length) {
// A harness that answered nothing still gets a row: status + the actionable hint.
return '<tr><td>' + esc(q.harness) + '</td><td class="dim">—</td>' +
'<td class="setup-warn">' + esc(q.status) + '</td>' +
'<td class="dim">—</td><td class="dim">' + esc(q.hint || q.summary || '') + '</td></tr>';
}
return q.windows.map((w, i) =>
'<tr><td>' + (i === 0 ? esc(q.harness) : '') + '</td>' +
'<td>' + (i === 0 ? esc(q.plan || '—') : '') + '</td>' +
'<td>' + esc(w.label) + '</td>' +
'<td>' + quotaPercentHtml(w.used_percent) + '</td>' +
'<td>' + quotaTimeHtml(w.resets_at) + '</td></tr>').join('');
}
function renderQuota(data, sessionId) {
const body = document.getElementById('setup-quota-body');
const table = document.getElementById('setup-quota-table');
if (!body || !table) return;
if (data.error) {
quotaNote(esc(data.error), sessionId);
return;
}
body.innerHTML = (data.harnesses || []).map(quotaRowsHtml).join('');
table.hidden = false;
quotaNote(data.ok + ' of ' + data.total + ' answered — checked just now', sessionId);
}
function quotaNote(html, sessionId) {
const note = document.getElementById('setup-quota-note');
if (!note) return;
const link = sessionId
? ' <a href="/job/' + encodeURIComponent(sessionId) + '">view job</a>'
: '';
note.innerHTML = html + link;
}
// The check is a real job: one run per harness, each with its own result file and status
// line. POST starts it; this poll mirrors the runs' progress, then reads the aggregated
// per-run result files once the session ends.
function pollQuotaJob(sessionId, btn, tries) {
if (tries > 120) { // ~3 minutes — each run caps its requests at 10s, so this is generous
quotaNote('quota job did not finish in time —', sessionId);
if (btn) btn.disabled = false;
return;
}
fetch('/api/v1/session/' + encodeURIComponent(sessionId)).then(r => r.json()).then(s => {
const procs = s.procs || [];
const done = procs.filter(p => p.status !== 'waiting' && p.status !== 'running').length;
const total = procs.length || '?';
const ended = !!s.ended_at && procs.length && done === procs.length;
if (!ended) {
quotaNote('checking… ' + done + ' of ' + total + ' runs done —', sessionId);
setTimeout(() => pollQuotaJob(sessionId, btn, tries + 1), 1500);
return;
}
fetch('/api/v1/session/' + encodeURIComponent(sessionId) + '/quota').then(r => r.json())
.then(data => renderQuota(data, sessionId))
.catch(() => renderQuota({ error: 'quota results unavailable' }, sessionId))
.finally(() => { if (btn) btn.disabled = false; });
}).catch(() => {
setTimeout(() => pollQuotaJob(sessionId, btn, tries + 1), 1500);
});
}
function fetchQuota(btn) {
if (btn) btn.disabled = true;
quotaNote('starting quota job…', null);
fetch('/api/v1/setup/quota', { method: 'POST' }).then(r => r.json()).then(res => {
if (!res.ok || !res.session) {
quotaNote(esc(res.error || 'could not start the quota job'), null);
if (btn) btn.disabled = false;
return;
}
pollQuotaJob(res.session, btn, 0);
}).catch(() => {
quotaNote('quota unavailable (daemon error)', null);
if (btn) btn.disabled = false;
});
}
(function initSetupPanel() {
if (!document.getElementById('setup-cards') && !document.getElementById('images-body')) return;
refreshSetup();
document.getElementById('setup-quota-btn')?.addEventListener('click', (e) => fetchQuota(e.currentTarget));
document.getElementById('images-build-selected')?.addEventListener('click', () => startImagesBuild('selected'));
document.getElementById('images-build-stale')?.addEventListener('click', () => startImagesBuild('stale'));
document.getElementById('images-build-all')?.addEventListener('click', () => startImagesBuild('all'));
document.getElementById('images-refresh')?.addEventListener('click', (e) => { e.preventDefault(); refreshSetup(); });
document.getElementById('setup-refresh')?.addEventListener('click', (e) => { e.preventDefault(); refreshSetup(); });
document.getElementById('setup-test-all')?.addEventListener('click', (e) => {
e.preventDefault();
startSetupTests(collectPrimarySetupTests(), e.currentTarget);
});
})();
// ---- instant tooltips ----
// One floating tip for every [data-tip] element, wired by delegation so it works for
// server-rendered and live-re-rendered markup alike, with none of the native title
// tooltip's hover delay (which live table re-renders kept resetting anyway).
(function initTips() {
const tip = document.createElement('div');
tip.className = 'chamfer ui-tip';
tip.setAttribute('role', 'status');
tip.setAttribute('aria-live', 'polite');
tip.hidden = true;
document.body.appendChild(tip);
let anchor = null, timer = null;
const hide = () => {
anchor = null;
tip.hidden = true;
if (timer) { clearInterval(timer); timer = null; }
};
// Tips are multi-line (pre-line CSS). A running chip carries data-tip-running (its start
// time) instead of baking the duration into the markup, so the "running for …" line
// ticks live here while live re-renders keep comparing the row's HTML as unchanged.
const render = (el) => {
let text = el.dataset.tip;
const started = Number(el.dataset.tipRunning || 0);
if (started) text += '\nrunning for ' + formatDuration(Date.now() / 1000 - started);
tip.textContent = text;
tip.style.left = '0px';
tip.style.top = '0px';
const r = el.getBoundingClientRect();
let x = r.left + r.width / 2 - tip.offsetWidth / 2;
x = Math.max(6, Math.min(x, window.innerWidth - tip.offsetWidth - 6));
let y = r.top - tip.offsetHeight - 8;
if (y < 6) y = r.bottom + 8;
tip.style.left = x + 'px';
tip.style.top = y + 'px';
};
document.addEventListener('mouseover', (e) => {
const el = e.target.closest ? e.target.closest('[data-tip]') : null;
if (el === anchor) return;
if (timer) { clearInterval(timer); timer = null; }
if (!el) { hide(); return; }
anchor = el;
tip.hidden = false;
render(el);
if (el.dataset.tipRunning) {
timer = setInterval(() => {
if (!document.contains(el)) { hide(); return; }
render(el);
}, 1000);
}
});
document.addEventListener('scsh-tip-refresh', (e) => {
const el = e.target && e.target.closest ? e.target.closest('[data-tip]') : null;
if (!el) return;
anchor = el;
tip.hidden = false;
render(el);
});
document.addEventListener('scroll', hide, true);
})();
function copyPlainText(value) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(value).catch(() => copyPlainTextFallback(value));
}
return copyPlainTextFallback(value);
}
function copyPlainTextFallback(value) {
return new Promise((resolve, reject) => {
const input = document.createElement('textarea');
input.value = value;
input.setAttribute('readonly', '');
input.style.position = 'fixed';
input.style.opacity = '0';
document.body.appendChild(input);
input.select();
const copied = document.execCommand && document.execCommand('copy');
input.remove();
if (copied) resolve();
else reject(new Error('copy unavailable'));
});
}
function showCopiedTip(el) {
clearTimeout(el._scshCopyTimer);
el.setAttribute('data-tip', 'Copied!');
el.dispatchEvent(new CustomEvent('scsh-tip-refresh', { bubbles: true }));
el._scshCopyTimer = setTimeout(() => {
if (!document.contains(el)) return;
el.setAttribute('data-tip', el.getAttribute('data-copy-value') || '');
el._scshCopyTimer = null;
el.dispatchEvent(new CustomEvent('scsh-tip-refresh', { bubbles: true }));
}, 1400);
}
(function initCopyValues() {
document.addEventListener('click', (e) => {
const el = e.target && e.target.closest ? e.target.closest('[data-copy-value]') : null;
if (!el) return;
const value = el.getAttribute('data-copy-value') || '';
copyPlainText(value).then(() => showCopiedTip(el)).catch(() => showToast('Could not copy.'));
});
})();
// ---- repositories panel (index page only) ----
let OPEN_REPO = null;
let OPEN_REPO_RUNNABLE = false;
const OPEN_REPOS = {}; // path -> { clean }
const DEFS_BY_NAME = {}; // name -> definition
const GLOBAL_PROFILES = {}; // name -> globally installed skill profile (scsh installskills --global)
// ---- tabs ----
// Explicit tab clicks push history (/, /run, /projects, /setup); Back/Forward restore (WEB-UI §1).
// /project/… and /repo/… are filtered Projects views — keep the path, open the Projects tab.
(function initTabs() {
const tabs = document.querySelectorAll('.tab');
if (!tabs.length) return;
function pathFilter() {
const p = location.pathname || '/';
return p === '/project' || p.indexOf('/project/') === 0 || p === '/repo' || p.indexOf('/repo/') === 0;
}
function normalizeTab(id) {
if (id === 'images') return 'setup';
if (id === 'start') return 'run'; // legacy prefs / #tab=start
if (id === 'dirs') return 'projects'; // legacy prefs / #tab=dirs
return id;
}
function pathForTab(id) {
id = normalizeTab(id);
if (id === 'run') return '/run';
if (id === 'projects') return '/projects';
if (id === 'stats') return '/stats';
if (id === 'setup') return '/setup';
return '/'; // jobs
}
function tabFromLocation() {
if (pathFilter()) return 'projects';
const p = (location.pathname || '/').replace(/\/+$/, '') || '/';
if (p === '/run') return 'run';
if (p === '/projects') return 'projects';
if (p === '/stats') return 'stats';
if (p === '/setup' || p === '/images') return 'setup';
if (p === '/jobs' || p === '/') return 'jobs';
// Legacy bookmarks: /#tab=dirs → projects, etc.
const m = (location.hash || '').match(/^#tab=([a-z]+)$/);
if (m) return normalizeTab(m[1]);
return null;
}
function syncIndexCrumb(id) {
const tail = document.getElementById('index-crumb-tail');
const crumb = document.getElementById('index-crumb');
if (!tail || !crumb) return;
const visible = id !== 'jobs';
tail.hidden = !visible;
if (!visible) return;
crumb.href = pathForTab(id);
crumb.textContent = id;
}
function activate(id, mode) {
id = normalizeTab(id);
const t = document.querySelector('.tab[data-tab="' + id + '"]');
if (!t) id = 'jobs';
const active = document.querySelector('.tab[data-tab="' + id + '"]') || tabs[0];
id = active.dataset.tab;
syncIndexCrumb(id);
document.querySelectorAll('.tab').forEach(x => {
const on = x === active;
x.classList.toggle('active', on);
x.setAttribute('aria-selected', on ? 'true' : 'false');
// Roving tabindex (ARIA tabs pattern): Tab reaches only the active tab; the
// arrows walk between tabs, so inactive ones leave the page's tab order.
x.tabIndex = on ? 0 : -1;
});
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === 'tab-' + id));
if (id === 'setup' && typeof refreshSetup === 'function') refreshSetup();
if (pathFilter()) {
// Stay on /project/… or /repo/…; only rewrite when leaving the filtered view.
if (mode === 'push' && id !== 'projects') {
history.pushState({ tab: id }, '', pathForTab(id));
}
return;
}
const next = pathForTab(id);
if (mode === 'push') history.pushState({ tab: id }, '', next);
else if ((location.pathname || '/') !== next || (location.hash || '').indexOf('#tab=') === 0) {
history.replaceState({ tab: id }, '', next);
}
if (typeof SESSION_ID !== 'string' || !SESSION_ID) saveUiPrefs({ tab: id });
}
const tabList = Array.from(tabs);
tabs.forEach(t => {
t.setAttribute('role', 'tab');
t.addEventListener('click', () => activate(t.dataset.tab, 'push'));
// ArrowLeft/ArrowRight move between tabs (wrapping), and activation follows
// focus — the standard keyboard contract for an ARIA tablist.
t.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
const step = e.key === 'ArrowRight' ? 1 : tabList.length - 1;
const next = tabList[(tabList.indexOf(t) + step) % tabList.length];
activate(next.dataset.tab, 'push');
next.focus();
});
});
window.addEventListener('popstate', () => {
activate(tabFromLocation() || 'jobs', 'sync');
});
const fromLoc = tabFromLocation();
const savedRaw = loadUiPrefs().tab;
const saved = savedRaw ? normalizeTab(savedRaw) : null;
activate(fromLoc || saved || 'jobs', 'sync');
})();
function defSourceBadge(src) {
// builtin wears purple (the setup color), global cyan; repo/home keep the muted status hues.
if (src === 'builtin') return '<span class="chamfer badge badge--purple"><span>builtin</span></span>';
if (src === 'global') return '<span class="chamfer badge badge--cyan"><span>global</span></span>';
const cls = src === 'repo' ? 'completed' : 'cancelled';
return '<span class="chamfer session-status ' + cls + '"><span>' + esc(src) + '</span></span>';
}
function pickRepo() {
// The daemon is local, so it can pop the native OS folder chooser and hand back the path.
const note = document.getElementById('repo-note');
if (note) note.textContent = 'opening the folder picker…';
fetch('/api/v1/repos/pick', { method: 'POST' }).then(r => r.json()).then(resp => {
if (resp.ok && resp.path) {
const input = document.getElementById('repo-path');
if (input) input.value = resp.path;
if (note) note.textContent = '';
openRepo();
} else if (resp.cancelled) {
if (note) note.textContent = '';
} else if (note) {
note.textContent = resp.error || 'picker unavailable — type or paste the path instead';
}
}).catch(() => { if (note) note.textContent = 'picker unavailable — type or paste the path instead'; });
}
function openRepo() {
const input = document.getElementById('repo-path');
const note = document.getElementById('repo-note');
const path = (input?.value || '').trim();
if (!path) { if (note) note.textContent = 'enter a repository path'; return; }
if (note) note.textContent = 'opening…';
fetch('/api/v1/repos/open', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path }),
}).then(r => r.json()).then(resp => {
handleRepoOpened(resp, note);
}).catch(() => { if (note) note.textContent = 'could not open'; });
}
// Create a fresh project under ~/.scsh/projects/<name> — a new git repo born runnable — and
// open it in place, so a demo job can start seconds later with no terminal involved.
// Names: letters/digits/-/_ only (no dots or slashes). An existing name copies into Open + toasts.
function projectNameOk(name) {
return /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) && !/[./]/.test(name);
}
function showToast(message) {
let el = document.getElementById('scsh-toast');
if (!el) {
el = document.createElement('div');
el.id = 'scsh-toast';
el.className = 'chamfer toast';
el.setAttribute('role', 'status');
document.body.appendChild(el);
}
el.textContent = message;
el.classList.remove('show');
// Retrigger the CSS transition when the same message fires twice in a row.
void el.offsetWidth;
el.classList.add('show');
clearTimeout(showToast._timer);
showToast._timer = setTimeout(() => { el.classList.remove('show'); }, 2800);
}
function createProject() {
const input = document.getElementById('project-name');
const note = document.getElementById('repo-note');
const name = (input?.value || '').trim();
if (!name) { if (note) note.textContent = 'enter a project name'; return; }
if (!projectNameOk(name)) {
showToast('Project names: letters, digits, - or _ only (no dots or slashes).');
if (note) note.textContent = '';
return;
}
if (note) note.textContent = 'creating…';
fetch('/api/v1/projects/create', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name }),
}).then(async r => {
const resp = await r.json();
// Create-or-open: existing names return 200 with created:false (same shape as open).
handleRepoOpened(resp, note);
if (resp.ok) {
const pathInput = document.getElementById('repo-path');
if (pathInput) pathInput.value = resp.repo;
}
}).catch(() => { if (note) note.textContent = 'could not create'; });
}
// Shared tail of open/create: surface blockers, render definitions, remember the repo.
function handleRepoOpened(resp, note) {
if (!resp.ok) { if (note) note.textContent = resp.error || 'could not open'; return; }
OPEN_REPO = resp.repo;
OPEN_REPO_RUNNABLE = !!resp.runnable;
OPEN_REPOS[resp.repo] = { clean: resp.runnable };
const panel = document.getElementById('defs-panel');
if (panel) panel.hidden = false;
const label = document.getElementById('open-repo-path');
if (label) label.textContent = resp.repo;
// Show any blockers prominently; Start stays disabled until they are cleared.
const bl = document.getElementById('repo-blockers');
if (bl) {
const list = resp.blockers || [];
if (list.length) {
bl.hidden = false;
bl.innerHTML = '<strong>Not ready to run:</strong><ul>' +
list.map(b => '<li>' + esc(b) + '</li>').join('') + '</ul>';
} else {
bl.hidden = true;
bl.innerHTML = '';
}
}
if (note) {
const verb = resp.created ? 'created' : 'opened';
note.textContent = resp.runnable ? verb + ' — ready to run' : verb + ', but not ready to run (see below)';
}
renderDefs(resp.defs || [], resp.global || []);
const form = document.getElementById('def-form');
if (form) form.innerHTML = '';
renderRepoJobs(liveSessions, Date.now() / 1000);
renderInternalJobs(liveSessions, Date.now() / 1000);
// After the list is filled, scroll its first actionable area into view so Open / New
// project invites the next step without pinning the control against the viewport edge.
// #defs-list owns a blank top inset; explanatory copy above it need not stay visible.
const list = document.getElementById('defs-list');
if (list) {
requestAnimationFrame(() => {
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
list.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
});
}
}
function renderDefs(defs, globals) {
const list = document.getElementById('defs-list');
if (!list) return;
for (const k in DEFS_BY_NAME) delete DEFS_BY_NAME[k];
defs.forEach(d => { DEFS_BY_NAME[d.name] = d; });
for (const k in GLOBAL_PROFILES) delete GLOBAL_PROFILES[k];
(globals || []).forEach(g => { GLOBAL_PROFILES[g.name] = g; });
const agentBadges = (agents) => (agents || []).map(a =>
'<span class="chamfer agent-badge"><span>' + esc(a.agent) +
(a.model ? ' · ' + esc(a.model) : '') + '</span></span>').join(' ');
let html = defs.length ? defs.map(d => {
const wf = d.workflow
? ' <span class="chamfer session-status completed"><span>workflow · ' + d.steps + ' steps</span></span>'
: '';
return '<div class="chamfer def-card">' +
'<button type="button" class="chamfer btn btn--cyan btn--sm def-pick" data-def="' +
esc(d.name) + '"><span>' + esc(d.name) + '</span></button> ' +
defSourceBadge(d.source) + wf + ' <span class="dim">' + esc(d.description) + '</span>' +
'<div class="def-agents">' + agentBadges(d.agents) + '</div></div>';
}).join('') : '<p class="dim">no harness definitions found.</p>';
// Globally installed skill profiles (scsh installskills --global) run in ANY opened
// repo, so they always ride below the repo's own definitions.
const globalNames = Object.keys(GLOBAL_PROFILES);
if (globalNames.length) {
html += '<p class="section-label">Global skills</p>' +
'<p class="dim">Installed machine-wide with <code>scsh installskills --global</code> — runnable in any repository.</p>' +
globalNames.map(name => {
const g = GLOBAL_PROFILES[name];
const n = (g.agents || []).length;
return '<div class="chamfer def-card">' +
'<button type="button" class="chamfer btn btn--cyan btn--sm global-pick" data-profile="' +
esc(name) + '"><span>' + esc(name) + '</span></button> ' +
defSourceBadge('global') +
' <span class="dim">' + n + ' route' + (n === 1 ? '' : 's') + '</span>' +
'<div class="def-agents">' + agentBadges(g.agents) + '</div></div>';
}).join('');
}
list.innerHTML = html;
list.querySelectorAll('.def-pick').forEach(b =>
b.addEventListener('click', () => selectDef(b.dataset.def)));
list.querySelectorAll('.global-pick').forEach(b =>
b.addEventListener('click', () => selectGlobalProfile(b.dataset.profile)));
}
// Mirror of selectDef for a globally installed skill profile: no params to collect, so the
// form is just the Start button; the daemon spawns `scsh run <profile>` in the open repo.
function selectGlobalProfile(name) {
const form = document.getElementById('def-form');
if (!GLOBAL_PROFILES[name] || !form) return;
const disabled = OPEN_REPO_RUNNABLE ? '' : ' disabled';
const hint = OPEN_REPO_RUNNABLE ? '' : 'the repository is not ready to run (see the blockers above)';
form.innerHTML = '<h4 class="form-title">run global skill profile <code>' + esc(name) + '</code></h4>' +
'<div class="images-controls"><button type="button" class="chamfer btn btn--green btn--sm" id="def-start"' +
disabled + '><span>Start job</span></button>' +
'<span id="def-note" class="dim">' + hint + '</span></div>';
document.getElementById('def-start')?.addEventListener('click', () => startGlobalJob(name));
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
form.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
}
function startGlobalJob(name) {
const note = document.getElementById('def-note');
if (!GLOBAL_PROFILES[name] || !OPEN_REPO) return;
if (!OPEN_REPO_RUNNABLE) { if (note) note.textContent = 'the repository is not ready to run'; return; }
postJobStart({ repo: OPEN_REPO, profile: name }, note);
}
function selectDef(name) {
const def = DEFS_BY_NAME[name];
const form = document.getElementById('def-form');
if (!def || !form) return;
const fields = (def.params || []).map(p => {
const id = 'param-' + p.name;
let input;
if (p.type === 'bool') {
input = '<input type="checkbox" id="' + id + '"' + (p.default === 'true' ? ' checked' : '') + '>';
} else if (p.type === 'enum') {
input = '<select id="' + id + '">' + (p.choices || []).map(c =>
'<option' + (c === p.default ? ' selected' : '') + '>' + esc(c) + '</option>').join('') + '</select>';
} else if (p.type === 'text') {
input = '<textarea id="' + id + '" rows="12"' + (p.required ? ' required' : '') +
' placeholder="Describe the complete feature…">' + esc(p.default || '') + '</textarea>';
} else {
const t = p.type === 'int' ? 'number' : 'text';
input = '<input type="' + t + '" id="' + id + '" value="' + esc(p.default || '') + '">';
}
const rowClass = p.type === 'text' ? 'param-row param-row--text' : 'param-row';
return '<div class="' + rowClass + '"><label for="' + id + '">' + esc(p.name) +
(p.required ? ' <span class="param-req">*</span>' : '') + '</label> ' + input +
(p.description ? ' <span class="dim">' + esc(p.description) + '</span>' : '') + '</div>';
}).join('');
const disabled = OPEN_REPO_RUNNABLE ? '' : ' disabled';
const hint = OPEN_REPO_RUNNABLE ? '' : 'the repository is not ready to run (see the blockers above)';
form.innerHTML = '<h4 class="form-title">run <code>' + esc(name) + '</code></h4>' + fields +
'<div class="images-controls"><button type="button" class="chamfer btn btn--green btn--sm" id="def-start"' +
disabled + '><span>Start job</span></button>' +
'<span id="def-note" class="dim">' + hint + '</span></div>';
document.getElementById('def-start')?.addEventListener('click', () => startJob(name));
// Cmd/Ctrl+Enter anywhere in the form is the default button.
form.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') document.getElementById('def-start')?.click();
});
// The form renders below the definitions list — bring it to the user instead of making
// them hunt for what their click produced.
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
form.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
}
function collectParams(def) {
const out = {};
(def.params || []).forEach(p => {
const el = document.getElementById('param-' + p.name);
if (!el) return;
out[p.name] = p.type === 'bool' ? (el.checked ? 'true' : 'false') : el.value;
});
return out;
}
function startJob(name) {
const def = DEFS_BY_NAME[name];
const note = document.getElementById('def-note');
if (!def || !OPEN_REPO) return;
if (!OPEN_REPO_RUNNABLE) { if (note) note.textContent = 'the repository is not ready to run'; return; }
const missing = (def.params || []).find(p => {
const el = document.getElementById('param-' + p.name);
return p.type === 'text' && p.required && el && !el.value.trim();
});
if (missing) {
const el = document.getElementById('param-' + missing.name);
if (note) note.textContent = missing.name + ' is required';
if (el) { el.focus(); el.reportValidity(); }
return;
}
postJobStart({ repo: OPEN_REPO, def: name, params: collectParams(def) }, note);
}
function postJobStart(req, note) {
if (note) note.textContent = 'starting…';
fetch('/api/v1/jobs/start', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
}).then(r => r.json()).then(resp => {
if (resp.ok && resp.session) { window.location.href = '/job/' + resp.session; }
else if (note) note.textContent = resp.error || 'could not start job';
}).catch(() => { if (note) note.textContent = 'could not start job'; });
}
// Mirrors repo_jobs_rows in index.rs — keep the markup identical. The tbody arrives
// server-rendered, so a null snapshot (no full tick yet) must leave it untouched.
function collapseSlashes(s) {
return String(s || '').replace(/\/+/g, '/').replace(/(.+)\/$/, '$1') || '/';
}
function parseIndexFilter(pathname) {
const p = pathname || '/';
if (p === '/project' || p.indexOf('/project/') === 0) {
let name = collapseSlashes(decodeURIComponent(p.slice('/project'.length))).replace(/^\/+|\/+$/g, '');
if (!name || name.indexOf('/') >= 0) return null;
return { kind: 'project', name: name, repo: (PROJECTS_DIR ? PROJECTS_DIR + '/' + name : null) };
}
if (p === '/repo' || p.indexOf('/repo/') === 0) {
let rest = collapseSlashes(decodeURIComponent(p.slice('/repo'.length)));
if (!rest || rest === '/') return null;
if (rest.charAt(0) !== '/') rest = '/' + rest;
return { kind: 'repo', repo: rest };
}
return null;
}
function repoFilterHref(repo) {
const root = PROJECTS_DIR ? PROJECTS_DIR + '/' : null;
if (root && repo.indexOf(root) === 0) {
const name = repo.slice(root.length);
if (name && name.indexOf('/') < 0) return '/project/' + encodeURIComponent(name);
}
// Keep slashes; encode other unsafe bytes (mirrors encode_repo_url_path).
return '/repo' + String(repo).split('').map(ch => {
if (/[A-Za-z0-9\-._~/]/.test(ch)) return ch;
const hex = ch.charCodeAt(0).toString(16).toUpperCase();
return '%' + (hex.length === 1 ? '0' + hex : hex);
}).join('');
}
function isInternalRepo(repo) {
return repo === '(image builds)' || repo === '(internal)';
}
function renderRepoJobs(sessions, nowUnix) {
const body = document.getElementById('repos-body');
if (!body || !sessions) return;
nowUnix = nowUnix ?? (Date.now() / 1000);
const filter = parseIndexFilter(location.pathname);
const wantRepo = filter && filter.repo;
const byRepo = {};
Object.keys(OPEN_REPOS).forEach(r => {
if (wantRepo && r !== wantRepo) return;
byRepo[r] = [];
});
Object.keys(sessions).forEach(id => {
const s = sessions[id];
if (!s || s.parent_session || !s.repo || isInternalRepo(s.repo)) return;
if (wantRepo && s.repo !== wantRepo) return;
(byRepo[s.repo] = byRepo[s.repo] || []).push(Object.assign({ id: id }, s));
});
const repos = Object.keys(byRepo).sort();
if (!repos.length) {
body.innerHTML = wantRepo
? '<tr><td colspan="2" class="dim">No jobs for this project or repository.</td></tr>'
: '<tr><td colspan="2" class="dim">No jobs yet — open or create a project under Run.</td></tr>';
return;
}
const repoLabel = (repo) => {
const root = PROJECTS_DIR ? PROJECTS_DIR + '/' : null;
if (root && repo.startsWith(root)) return 'project · ' + repo.slice(root.length);
return repo;
};
// A job's "activity" moment: when it finished, or when it started if still going.
const activity = (s) => s.ended_at || s.started_at || 0;
const isRunning = (s) => sessionLifecycle(s, nowUnix).label === 'running';
body.innerHTML = repos.map(repo => {
const jobs = byRepo[repo] || [];
let cells = '<span class="dim">no jobs yet</span>';
if (jobs.length) {
const groups = {};
jobs.forEach(s => { const k = s.profile || 'default'; (groups[k] = groups[k] || []).push(s); });
const ordered = Object.keys(groups).sort().map(k => {
const g = groups[k];
g.sort((a, b) => (isRunning(b) - isRunning(a)) || (activity(b) - activity(a)));
return [k, g];
});
// Groups with something running come first, then by most recent activity.
ordered.sort((a, b) => {
const ar = a[1].some(isRunning), br = b[1].some(isRunning);
if (ar !== br) return br - ar;
return Math.max(...b[1].map(activity)) - Math.max(...a[1].map(activity));
});
cells = ordered.map(([task, g]) => {
const links = g.map(s => {
const lc = sessionLifecycle(s, nowUnix);
return '<div class="repo-job"><span class="chamfer session-status ' + lc.class +
'"><span>' + esc(lc.label) + '</span></span> <a class="job-id" href="/job/' + esc(s.id) + '">' + esc(s.id) +
'</a> <span class="dim">' + esc(formatShortAge(nowUnix - activity(s))) + '</span></div>';
}).join('');
return '<div class="repo-jobgroup"><span class="repo-jobgroup-name">' + esc(task) + '</span>' + links + '</div>';
}).join('');
}
return '<tr data-repo="' + esc(repo) + '"><td class="repo-path" title="' + esc(repo) +
'"><a class="repo-filter-link" href="' + esc(repoFilterHref(repo)) + '">' + esc(repoLabel(repo)) +
'</a></td><td>' + cells + '</td></tr>';
}).join('');
}
function renderInternalJobs(sessions, nowUnix) {
const panel = document.getElementById('tab-projects');
if (!panel || !sessions) return;
nowUnix = nowUnix ?? (Date.now() / 1000);
if (parseIndexFilter(location.pathname)) {
const existing = document.getElementById('internal-jobs-card');
if (existing) existing.remove();
return;
}
const jobs = [];
Object.keys(sessions).forEach(id => {
const s = sessions[id];
if (s && !s.parent_session && s.repo && isInternalRepo(s.repo)) jobs.push(Object.assign({ id: id }, s));
});
let card = document.getElementById('internal-jobs-card');
if (!jobs.length) {
if (card) card.remove();
return;
}
const activity = (s) => s.ended_at || s.started_at || 0;
const isRunning = (s) => sessionLifecycle(s, nowUnix).label === 'running';
const groups = {};
jobs.forEach(s => { const k = s.profile || 'default'; (groups[k] = groups[k] || []).push(s); });
const ordered = Object.keys(groups).sort().map(k => {
const g = groups[k];
g.sort((a, b) => (isRunning(b) - isRunning(a)) || (activity(b) - activity(a)));
return [k, g];
});
ordered.sort((a, b) => {
const ar = a[1].some(isRunning), br = b[1].some(isRunning);
if (ar !== br) return br - ar;
return Math.max(...b[1].map(activity)) - Math.max(...a[1].map(activity));
});
const body = ordered.map(([task, g]) => {
const links = g.map(s => {
const lc = sessionLifecycle(s, nowUnix);
return '<div class="repo-job"><span class="chamfer session-status ' + lc.class +
'"><span>' + esc(lc.label) + '</span></span> <a class="job-id" href="/job/' + esc(s.id) + '">' + esc(s.id) +
'</a> <span class="dim">' + esc(formatShortAge(nowUnix - activity(s))) + '</span></div>';
}).join('');
return '<div class="repo-jobgroup"><span class="repo-jobgroup-name">' + esc(task) + '</span>' + links + '</div>';
}).join('');
if (!card) {
card = document.createElement('div');
card.className = 'chamfer card card--accent-left-purple';
card.id = 'internal-jobs-card';
card.innerHTML = '<p class="section-label">Internal</p>' +
'<p class="dim">System jobs — image builds and annotate catch-up — not tied to a project or repository.</p>' +
'<div id="internal-body"></div>';
panel.appendChild(card);
}
const bodyEl = card.querySelector('#internal-body') || card;
if (bodyEl.id === 'internal-body') bodyEl.innerHTML = body;
else {
let inner = card.querySelector('#internal-body');
if (!inner) {
inner = document.createElement('div');
inner.id = 'internal-body';
card.appendChild(inner);
}
inner.innerHTML = body;
}
}
(function initReposPanel() {
if (!document.getElementById('repo-path')) return;
document.getElementById('repo-open')?.addEventListener('click', openRepo);
document.getElementById('project-create')?.addEventListener('click', createProject);
document.getElementById('project-name')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') createProject(); });
document.getElementById('repo-pick')?.addEventListener('click', pickRepo);
document.getElementById('repo-path')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') openRepo(); });
// Seed the opened-repo set from the daemon so repos opened before this page load keep
// their "no jobs yet" rows across live re-renders of the Projects table.
fetch('/api/v1/repos').then(r => r.json()).then(resp => {
(resp.repos || []).forEach(r => { if (r.path && !(r.path in OPEN_REPOS)) OPEN_REPOS[r.path] = { clean: r.clean }; });
renderRepoJobs(liveSessions, Date.now() / 1000);
renderInternalJobs(liveSessions, Date.now() / 1000);
}).catch(() => {});
})();
// Keep copied job URLs anchored to the section currently being read, like Packdiff's
// scroll-addressable pages. replaceState avoids turning ordinary scrolling into Back-button
// history. The candidate list is rebuilt on each frame because workflow runs add proc rows live.
(function initJobScrollAddress() {
if (!SESSION_ID) return;
// Rubber-band overscroll and subpixel scroll positions both report a hair off zero; treat
// anything within a couple of pixels as "at the top" rather than flickering the fragment.
const TOP_EPSILON = 2;
let queued = false;
function syncHashToScroll() {
queued = false;
// Back at the very top the whole job is what you are looking at, so the bare /job/<id> is
// the honest permalink — it already opens here. Without this the last fragment you scrolled
// through would stick to the URL forever, claiming you are deep in a run you have left.
if (window.scrollY <= TOP_EPSILON) {
if (location.hash) history.replaceState(history.state, '', location.pathname + location.search);
return;
}
const marker = Math.min(window.innerHeight * 0.3, 260);
const candidates = [];
const graph = document.getElementById('workflow-graph');
if (graph) candidates.push({ el: graph, hash: '#workflow-graph' });
document.querySelectorAll('details.proc[data-index]').forEach(det => {
candidates.push({ el: det, hash: procPermalinkHash(det) });
});
let current = null;
candidates.forEach(candidate => {
const rect = candidate.el.getBoundingClientRect();
if (rect.top <= marker && rect.bottom > 0) current = candidate.hash;
});
if (!current || location.hash === current) return;
history.replaceState(history.state, '', location.pathname + location.search + current);
}
window.addEventListener('scroll', () => {
if (queued) return;
queued = true;
requestAnimationFrame(syncHashToScroll);
}, { passive: true });
})();
"#
}