// /index.html entry point: tab renderers + tab routing + refreshState // + notification deltas. Reads /api/state on cold load and after every // async-form submit; live updates run through `applyXxx` mutation // handlers triggered by the dashboard event stream. (Live SSE // subscription on /index.html is still missing — see #406 step 3 / #408.) // // #406 step 1: pure helpers + side panel + OS notifications + path // linkification moved to `./common.js`. // #406 step 2: the flow-only IIFEs (operator inbox, inbox-pill, broker // terminal, @-mention composer) moved to `./flow.js`. /flow.html now // loads `flow.js` as its own bundle entry; this file (still named // `app.js` for the moment; rename to `tabs.js` is the last step // of #406) is loaded only by /index.html. import { marked } from 'marked'; import { $, el, esc, form, fmtAgeSecs, Panel, NOTIF, makePathLink, appendText, appendLinkified, openStream, } from './common.js'; // mdNode (in common.js) reads `window.marked` for the markdown side // panel preview path. Set it here on the dashboard entry so file // previews work; flow.js does the same for the flow-page entry. window.marked = marked; (() => { // ─── constants ────────────────────────────────────────────────────────── // Context-window badge thresholds. Preferred source is each container's // `context_window_tokens` from /api/state (the real window for the model // it last ran on) — thresholds are then 75% / 50% of it, matching the // harness compaction watermarks (compact at 75%, auto-reset at 50%). The // fixed token constants are the fallback for when that field is absent // (agent has no turns yet, or no per-model config matched the model). const CTX_WARN_FRACTION = 0.75; // ≥ this share of the window → red const CTX_CAUTION_FRACTION = 0.50; // ≥ this share of the window → yellow const CTX_WARN_TOKENS = 150_000; // fallback red threshold (≈ 75% of 200k) const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (≈ 50% of 200k) // Helpers ($, el, esc, form, fmtAgeSecs) moved to ./common.js (#406). // Side panel singleton (Panel) moved to ./common.js (#406). // Path linkification + file-preview side panel (openFilePanel, // makePathLink, appendText, appendLinkified) moved to ./common.js (#406). // OS notification module (NOTIF) moved to ./common.js (#406). // Track which items we've already notified about so a re-render // doesn't re-fire for the same row. Keyed by stable ids; reset only // when the page reloads. const seenApprovals = new Set(); const seenQuestions = new Set(); let seededNotify = false; function notifyDeltas(s) { const approvals = s.approvals || []; const questions = s.questions || []; if (!seededNotify) { // First render after page load — fill the "seen" sets without // firing notifications. We only want to notify on NEW items // that arrived while the page is open. The inbox no longer // needs seeding here: it's derived from the broker stream which // does its own per-event notification on live arrival, and // history-replayed events are silent by virtue of `fromHistory`. for (const a of approvals) seenApprovals.add(a.id); for (const q of questions) seenQuestions.add(q.id); seededNotify = true; return; } for (const a of approvals) { if (seenApprovals.has(a.id)) continue; seenApprovals.add(a.id); const verb = a.kind === 'spawn' ? 'spawn approval' : a.kind === 'init_config' ? 'config-init approval' : 'config commit'; NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`, 'hyperhive:approval:' + a.id); } for (const q of questions) { if (seenQuestions.has(q.id)) continue; seenQuestions.add(q.id); const targetLabel = q.target || 'operator'; NOTIF.show(`◆ ${q.asker} → ${targetLabel} asks`, q.question.slice(0, 120), 'hyperhive:question:' + q.id); } } // ─── async forms ──────────────────────────────────────────────────────── document.addEventListener('submit', async (e) => { const f = e.target; if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return; e.preventDefault(); if (f.dataset.confirm && !confirm(f.dataset.confirm)) return; if (f.dataset.prompt) { const ans = prompt(f.dataset.prompt, ''); if (ans === null) return; // operator hit Cancel // Drop into a hidden input named after `data-prompt-field` (or // 'note' by default) so the value rides along on the POST. const field = f.dataset.promptField || 'note'; let input = f.querySelector(`input[name="${field}"]`); if (!input) { input = document.createElement('input'); input.type = 'hidden'; input.name = field; f.append(input); } input.value = ans; } const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline'); const original = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = ''; } try { const resp = await fetch(f.action, { method: f.method || 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(new FormData(f)), redirect: 'manual', }); const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); if (!ok) { const text = await resp.text().catch(() => ''); alert('action failed: ' + resp.status + (text ? '\n\n' + text : '')); if (btn) { btn.disabled = false; btn.innerHTML = original; } return; } // Re-enable the button — refreshState() rebuilds most lists but // skips forms that didn't change (e.g. the spawn form), so without // this the spinner sticks and the button can't be clicked again. if (btn) { btn.disabled = false; btn.innerHTML = original; } // Clear text inputs whose value was just submitted. f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; }); // Forms whose endpoint already emits a DashboardEvent that // updates the derived store can opt out of the post-submit // /api/state refetch (the event delivers the new row faster // than the snapshot poll anyway). Container-lifecycle forms // still rely on the refresh since `ContainerView` isn't yet // event-derivable. if (!f.hasAttribute('data-no-refresh')) { refreshState(); } } catch (err) { alert('action failed: ' + err); if (btn) { btn.disabled = false; btn.innerHTML = original; } } }); // Derived container state — cold-loaded from /api/state.containers, // then mutated live by `container_state_changed` (upsert by name) // and `container_removed` (drop by name). The coordinator's rescan // helper fires these after every mutation site + on a periodic poll // in crash_watch. Keyed by ContainerView.name so the lifecycle // forms' POST → 200 → matching event flips the row without a // snapshot refetch. const containersState = new Map(); function syncContainersFromSnapshot(s) { containersState.clear(); for (const c of s.containers || []) containersState.set(c.name, c); } function applyContainerStateChanged(ev) { if (!ev.container || !ev.container.name) return; containersState.set(ev.container.name, ev.container); renderContainersFromState(); } function applyContainerRemoved(ev) { if (containersState.delete(ev.name)) renderContainersFromState(); } // Derived tombstones + meta_inputs. Both are emitted as full // snapshots (not diffs) — the lists are tiny and recomputing // avoids ordering races between a same-tick destroy + purge. let tombstonesState = []; let metaInputsState = []; // True while a dashboard-triggered meta-update (flake lock bump + // agent rebuild ripple) runs in the background. Cold-loaded from // `s.meta_update_running`, then flipped live by the // `meta_update_running` event. Drives the META INPUTS panel's // disabled "updating…" state (issue #259). let metaUpdateRunning = false; function syncTombstonesFromSnapshot(s) { tombstonesState = (s.tombstones || []).slice(); } function syncMetaInputsFromSnapshot(s) { metaInputsState = (s.meta_inputs || []).slice(); metaUpdateRunning = !!s.meta_update_running; } function applyTombstonesChanged(ev) { tombstonesState = (ev.tombstones || []).slice(); renderTombstonesFromState(); } function applyMetaInputsChanged(ev) { metaInputsState = (ev.inputs || []).slice(); renderMetaInputsFromState(); } function applyMetaUpdateRunning(ev) { metaUpdateRunning = !!ev.running; renderMetaInputsFromState(); } function renderTombstonesFromState() { renderTombstones({ tombstones: tombstonesState }); } function renderMetaInputsFromState() { renderMetaInputs({ meta_inputs: metaInputsState }); } // Derived rebuild queue state — cold-loaded from // `/api/state.rebuild_queue`, then mutated live by the // `rebuild_queue_changed` snapshot event. Same shape as the meta- // inputs panel (full snapshot per change, no diff). let rebuildQueueState = []; function syncRebuildQueueFromSnapshot(s) { rebuildQueueState = (s.rebuild_queue || []).slice(); } function applyRebuildQueueChanged(ev) { rebuildQueueState = (ev.queue || []).slice(); renderRebuildQueueFromState(); // Container cards surface in-flight rebuild / meta-update ops as // a "building..." badge (#398) — re-render the SW4RM tab so // newly-queued / newly-running ops light up the right card, // and finished ops fall back to the regular state badges. renderContainersFromState(); } // Map from agent name → highest-priority in-flight queue entry // (`running` beats `queued`). Used by the container row renderer // to surface "building..." / "meta-updating..." badges on the // SW4RM tab when an op is still in the rebuild queue but no // operator-initiated transient is set (#398). function inFlightOpsByAgent() { const out = new Map(); for (const e of rebuildQueueState) { if (e.state !== 'queued' && e.state !== 'running') continue; // spawn ops target an agent that doesn't exist yet as a // container — the transient store already drives the // pending row for that case. Skip here to avoid double- // surfacing if the spawn op happens to land in the queue // while the row exists transiently. if (e.kind === 'spawn') continue; const cur = out.get(e.agent); // Prefer running over queued; otherwise keep the first match. if (!cur || (cur.state === 'queued' && e.state === 'running')) { out.set(e.agent, e); } } return out; } function renderRebuildQueueFromState() { renderRebuildQueue({ rebuild_queue: rebuildQueueState }); } // Derived transient state — cold-loaded from /api/state.transients, // then mutated live by `transient_set` / `transient_cleared`. Keyed // by agent name so add/remove are O(1). `since_unix` is wall-clock so // the elapsed-seconds badge ticks without polling. const transientsState = new Map(); function syncTransientsFromSnapshot(s) { transientsState.clear(); for (const t of s.transients || []) { // Snapshot ships `secs` (server-computed); reconstruct an // approximate since_unix so the live ticker keeps progressing // without surprising jumps when the next snapshot lands. const nowUnix = Math.floor(Date.now() / 1000); transientsState.set(t.name, { kind: t.kind, since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)), }); } } function applyTransientSet(ev) { transientsState.set(ev.name, { kind: ev.transient_kind, since_unix: ev.since_unix, }); renderContainersFromState(); } function applyTransientCleared(ev) { if (transientsState.delete(ev.name)) renderContainersFromState(); } // Re-render using the last cached snapshot (containers come from // /api/state, transients overlay from the derived map). The snapshot // is stashed on window.__hyperhive_state by refreshState; on cold // load before the first snapshot we just skip. function renderContainersFromState() { const s = window.__hyperhive_state; if (s) renderContainers(s); } // ─── selection (#443) ─────────────────────────────────────────────── // Set of selected agent logical names. Toggled by clicking the // container-row icon. When non-empty, the sticky #selection-bar // becomes visible with the bulk actions. Per-card action buttons // are gone — actions live in the bar. const selectionState = new Set(); function toggleSelection(name) { if (selectionState.has(name)) selectionState.delete(name); else selectionState.add(name); renderContainersFromState(); } function clearSelection() { if (selectionState.size === 0) return; selectionState.clear(); renderContainersFromState(); } // Esc clears the current selection (operator escape hatch — mirrors // the side-panel close pattern). Ignored when an editable element // has focus so typing in compose / answer / journal-search isn't // intercepted. document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; if (!selectionState.size) return; const a = document.activeElement; if (a && (a.isContentEditable || a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.tagName === 'SELECT')) return; e.preventDefault(); clearSelection(); }); document.addEventListener('click', (e) => { if (e.target && e.target.closest('#selection-clear')) { clearSelection(); } }); // Re-derive port conflicts from the live containers map. Mirrors the // server-side `build_port_conflicts` so the banner reacts to event // updates instead of waiting for a /api/state refetch. function derivePortConflicts(containers) { const byPort = new Map(); for (const c of containers) { if (!byPort.has(c.port)) byPort.set(c.port, []); byPort.get(c.port).push(c.name); } const out = []; for (const [port, agents] of byPort) { if (agents.length > 1) { agents.sort(); out.push({ port, agents }); } } out.sort((a, b) => a.port - b.port); return out; } // ─── state rendering ──────────────────────────────────────────────────── // ─── agent topology (#363) ────────────────────────────────────────────── // Build a forest from `ContainerView.parent` and walk depth-first to // produce a render order with per-row depth + sibling-position info. // Top-level (parent = null OR parent not in the container map) are // roots. Within each level, children are sorted alphabetically by // name; roots likewise. Cycles in the parent graph (malformed config) // are tolerated: any container not reached via root-walk is appended // as a root at the end, so no agent ever disappears from the list. // // For the visual: each row gets a textual prefix column (`├─`, `└─`, // continuation `│ ` or padding ` ` for ancestor columns). When // every container has `parent = null` (today's pre-#361 state), the // tree collapses to a flat list with no glyphs and no indent — bit- // identical to the legacy render. function buildAgentTree(containers) { const byName = new Map(); for (const c of containers) byName.set(c.name, c); const children = new Map(); // parent_name → [child_name, …] const roots = []; for (const c of containers) { const p = c.parent || null; if (p == null || !byName.has(p)) { roots.push(c.name); } else { const list = children.get(p) || []; list.push(c.name); children.set(p, list); } } roots.sort(); for (const list of children.values()) list.sort(); const out = []; const visited = new Set(); function visit(name, depth, ancestorIsLast, isLast) { if (visited.has(name)) return; visited.add(name); const c = byName.get(name); if (!c) return; out.push({ container: c, depth, ancestorIsLast: [...ancestorIsLast], isLast }); const kids = children.get(name) || []; kids.forEach((kid, i) => visit(kid, depth + 1, [...ancestorIsLast, isLast], i === kids.length - 1)); } roots.forEach((name, i) => visit(name, 0, [], i === roots.length - 1)); // Cycle safety: anything not reached lands at root level so no // agent silently disappears when a config is malformed. for (const c of containers) { if (!visited.has(c.name)) visit(c.name, 0, [], true); } return out; } // Builds the .tree-prefix DOM for a row at the given depth. Each // lane is its own positioned child so CSS can paint full-height // vertical bars that bridge the gap between sibling rows — text // box-drawing glyphs only paint one text-line tall, which left // visible breaks between rows once we grew taller-than-one-line // container cards (#388). Ancestor lanes are either continuation // (vertical bar top→bottom+gap) or blank; the joint at this row's // own depth is ├ (branch — vertical continues below) or └ (last — // vertical stops at the row's icon midline). CSS at // `.container-row .tree-prefix` paints the bars + horizontal stub. function treePrefixDom({ depth, ancestorIsLast, isLast }) { if (depth === 0) return null; const prefix = el('span', { class: 'tree-prefix', 'aria-hidden': 'true' }); // Ancestor columns (depth 1..depth-1). Skip depth 0 (root has no // continuation column — top-level rows are separated visually as // top-level rows already). for (let d = 1; d < depth; d++) { const cls = ancestorIsLast[d] ? 'tree-lane lane-blank' : 'tree-lane lane-line'; prefix.append(el('span', { class: cls })); } const jointCls = 'tree-lane lane-joint ' + (isLast ? 'lane-joint-last' : 'lane-joint-branch'); prefix.append(el('span', { class: jointCls })); return prefix; } function renderContainers(s) { const root = $('containers-section'); // #containers-section only exists on /index.html. The same // bundled app.js runs on /flow.html (which has the broker // terminal but no container list) and on any future pages — // `container_state_changed` SSE events arrive on every page and // route through `applyContainerStateChanged → renderContainersFromState`, // so without this guard the renderer throws `root is null` on // every event (#399). Matches the no-op-when-target-absent // convention the other renderers (renderTombstones, etc.) follow. if (!root) return; root.innerHTML = ''; // Containers come from the derived map (event-driven) rather than // `s.containers`; `s` still supplies hostname (for the web-ui // link) and tombstones/meta_inputs (not event-derived yet). The // tree builder handles the ordering — we don't pre-sort here. const containers = Array.from(containersState.values()); const portConflicts = derivePortConflicts(containers); const anyStale = containers.some((c) => c.needs_update); // Port-hash collisions: rename one of the listed agents and // rebuild. The banner sits above the agent list so it's the // first thing the operator sees when something's wedged. if (portConflicts.length) { const banner = el('div', { class: 'port-conflict' }, el('strong', {}, '⚠ port collision'), ' — '); const groups = portConflicts.map((c) => `:${c.port} (${c.agents.join(' + ')})`).join('; '); banner.append(groups + '. rename one of each and ↻ R3BU1LD.'); root.append(banner); } if (anyStale) { root.append(form( '/update-all', 'btn-rebuild', '↻ UPD4TE 4LL', 'rebuild every stale container?', {}, { noRefresh: true }, )); } if (transientsState.size) { const ul = el('ul'); const nowUnix = Math.floor(Date.now() / 1000); for (const [name, t] of transientsState) { const secs = Math.max(0, nowUnix - t.since_unix); ul.append(el('li', {}, el('span', { class: 'glyph spinner' }, '◐'), ' ', el('span', { class: 'agent' }, name), ' ', el('span', { class: 'role role-pending' }, t.kind + '…'), ' ', el('span', { class: 'meta' }, `nixos-container create + start (${secs}s)`), )); } root.append(ul); } if (!containers.length && !transientsState.size) { root.append(el('p', { class: 'empty' }, 'no managed containers')); return; } // Drop stale selections (agent destroyed while selected). Defensive — // the action bar would otherwise loop POST against a gone agent. const liveNames = new Set(containers.map((c) => c.name)); for (const n of Array.from(selectionState)) { if (!liveNames.has(n)) selectionState.delete(n); } const hostname = (s && s.hostname) || window.location.hostname; const ul = el('ul', { class: 'containers' }); const tree = buildAgentTree(containers); // In-flight rebuild / meta-update / destroy ops per agent name. // Surface them as "building..." style badges on the container // card when no operator-initiated transient already covers the // row (#398). Mara: the SW4RM tab showed an agent as stopped // while SYST3M showed an active rebuild; cross-reference fixes // that. const inFlight = inFlightOpsByAgent(); for (const node of tree) { const c = node.container; const url = `http://${hostname}:${c.port}/`; // Pending state is overlaid from the transient store first // (operator-initiated spawn/destroy/rebuild — covers the // create+start window where the container literally isn't up // yet), then from the rebuild_queue (#398 — covers in-flight // ops the worker is running even if no transient was set). // `ContainerStateChanged` doesn't carry either signal. const transientKind = transientsState.get(c.name)?.kind || null; const op = !transientKind ? inFlight.get(c.name) : null; const pending = transientKind || (op && (op.state === 'running' ? (op.kind === 'meta_update' ? 'meta-updating' : op.kind === 'destroy' ? 'destroying' : 'rebuilding') : (op.kind === 'meta_update' ? 'meta-update queued' : op.kind === 'destroy' ? 'destroy queued' : 'rebuild queued'))); const selected = selectionState.has(c.name); const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') + (selected ? ' selected' : ''), }); // Topology: depth contributes left-padding; the glyph string in // the .tree-prefix span draws the ├─ / └─ joint + continuation // lines (`│ `) for ancestors whose subtree extends below this // row. Both are CSS-driven from the data attributes so the // legacy flat layout (every container at depth 0) is bit- // identical to today's render — no glyph, no indent. if (node.depth > 0) li.dataset.depth = String(node.depth); const prefix = treePrefixDom(node); if (prefix) li.prepend(prefix); // Full-height square agent icon, left of the card body. The // icon is an absolutely positioned inside a wrapper div: // the div is the flex child and sizes itself via aspect-ratio + // stretch, the is out of flow so its load state — pending, // loaded or broken — can never contribute intrinsic size or // reflow the row. (issue #177) // // The icon points straight at the agent's `/icon`. We don't // guess whether the agent is reachable from the container row — // we just let the try, and if it actually fails to load // (agent stopped, restarting, rebuilding — web server not // answering) the error handler falls it back to the dimmed // hyperhive mark (`/favicon.svg`, served by the dashboard // itself, always reachable). (issues #195, #202) const iconImg = el('img', { class: 'container-icon-img', alt: '' }); // #443: icon is the selection toggle. Click → add/remove from // `selectionState` → re-render. role=button + tabindex makes it // keyboard-accessible; aria-pressed reflects the toggle state. const icon = el('div', { class: 'container-icon', role: 'button', tabindex: '0', 'aria-pressed': selected ? 'true' : 'false', title: selected ? `deselect ${c.name} (or press Esc to clear all)` : `select ${c.name} for bulk actions`, }, iconImg); icon.addEventListener('click', (e) => { e.preventDefault(); toggleSelection(c.name); }); icon.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSelection(c.name); } }); if (c.running) { iconImg.src = `${url}icon`; iconImg.addEventListener('error', () => { if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s iconImg.dataset.fallback = '1'; icon.classList.add('icon-unreachable'); iconImg.src = '/favicon.svg'; }); } else { // Container stopped (#432) — skip the doomed `${url}icon` fetch // and go straight to the dimmed hyperhive mark. Avoids a noisy // failed request in the console + the brief broken-image flash. icon.classList.add('icon-unreachable'); iconImg.src = '/favicon.svg'; } // Card body: the three stacked content lines, right of the icon. const body = el('div', { class: 'card-body' }); // ── identity ───────────────────────────────────────────────── const head = el('div', { class: 'head' }); head.append( el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name), el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, c.is_manager ? 'm1nd' : 'ag3nt'), ); // Icon-only nav strip — populated async from `/api/agent/{name}/links`, // a same-origin proxy that forwards the agent backend's own link list // (stats / screen-if-gui / forge profile / agent-configs / extras). // The agent backend is the single source of truth; no hardcoded link // list here (issue #262). DOM-built — link strings come from the // agent's process and must never reach the HTML parser. const navStrip = el('span', { class: 'nav-strip' }); head.append(navStrip); const forgeBase = `http://${hostname}:3000`; const containerBase = `http://${hostname}:${c.port}`; if (c.running) { fetch(`/api/agent/${encodeURIComponent(c.name)}/links`) .then((r) => (r.ok ? r.json() : [])) .then((links) => { if (!Array.isArray(links)) return; for (const lnk of links) { const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') : lnk.kind === 'external' ? (lnk.url || '') : /* container */ containerBase + (lnk.url || ''); const a = el('a', { class: 'nav-link', href, target: '_blank', rel: 'noopener', title: lnk.label || '', }); // Plain text — agent-controlled strings stay out of innerHTML. a.textContent = lnk.icon || lnk.label || ''; navStrip.append(a); } }) .catch(() => { /* graceful: agent down → no strip */ }); } // Status / runtime badges. Pending transients always win // (start / stop / restart / rebuild is in progress). Otherwise, // when the container is stopped, surface a single `■ not // running` badge; the backend has already cleared rate_limited / // needs_login / ctx_tokens / status_text in that case (#432) so // the rest of the chain is a no-op for stopped containers — but // we still want SOME badge there so the row doesn't look empty. if (pending) { head.append(el('span', { class: 'pending-state' }, el('span', { class: 'spinner' }, '◐'), ' ', pending + '…')); } else if (!c.running) { head.append(el('span', { class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' }, '■ not running')); } else if (c.rate_limited) { head.append(el('span', { class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' }, '⊘ rate limited')); } else if (c.needs_login) { head.append(el('a', { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' }, 'needs login →')); } if (c.needs_update) { head.append(form( '/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻', 'rebuild ' + c.name + '? hot-reloads the container.', {}, { noRefresh: true }, )); } head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`)); if (c.deployed_sha) { head.append(el('span', { class: 'meta', title: 'sha currently locked in /meta/flake.lock' }, `deployed:${c.deployed_sha}`)); } if (c.pending_reminders && c.pending_reminders > 0) { head.append(el('span', { class: 'badge badge-reminder', title: 'pending reminders queued for this agent — see the reminders section to view / cancel', }, `⏰ ${c.pending_reminders}`)); } if (c.ctx_tokens != null) { const k = Math.round(c.ctx_tokens / 1000); // Thresholds track the model's real context window when the // backend supplies it; otherwise fall back to fixed constants. const win = c.context_window_tokens; const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS; const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS; const ctxClass = c.ctx_tokens >= warn ? 'badge-ctx-warn' : c.ctx_tokens >= caution ? 'badge-ctx-caution' : 'badge-ctx-ok'; const title = win != null ? `last turn context: ${c.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} ` + `tokens (${Math.round((c.ctx_tokens / win) * 100)}% of the window)` : `last turn context size: ${c.ctx_tokens.toLocaleString()} tokens`; head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`)); } body.append(head); // ── agent status text ───────────────────────────────────────── // Self-reported status (via set_status MCP tool) — only fresh // while the harness is up. The backend already clears // `status_text` on stopped containers (#432) so we can render // unconditionally here: a stopped container simply has no // `status_text` and skips this block naturally. if (c.status_text) { const nowUnix = Math.floor(Date.now() / 1000); const ageStr = c.status_set_at != null ? ` (set ${fmtAgeSecs(nowUnix - c.status_set_at)} ago)` : ''; body.append(el('div', { class: 'agent-status', title: `agent self-reported status${ageStr}`, }, el('span', { class: 'status-icon' }, '◈ '), c.status_text, el('span', { class: 'status-age' }, ageStr), )); } // Per-card action buttons used to live here (R3ST4RT / ST0P / // ST4RT / R3BU1LD / DESTR0Y / PURG3). Per mara on #443: "dont // show all the restart buttons etc., just show state and links. // instead, clicking an agent icon selects that agent." Actions // moved into the sticky #selection-bar (see renderSelectionBar) // which appears when the operator has at least one agent // selected via the icon click. The contextual `needs update ↻` // chip in the head row stays — it's a state-hint, not an // action button per se. // ── drill-ins ──────────────────────────────────────────────── const drill = el('div', { class: 'drill-ins' }); // Per-container journald viewer. Opens the side panel and // fetches the last N lines; refresh re-fetches; unit selector // narrows to the harness service (or empty = full machine). const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service'; drill.append(buildJournalTrigger(c.container, journalUnit)); // The hardcoded config-repo trigger and the agent-declared // extras block both moved into the unified nav strip in the // head row above (sourced from the agent backend via // `/api/agent/{name}/links` — issue #262). Only the journald // trigger stays here since it opens the side panel rather // than a link. body.append(drill); li.append(icon, body); ul.append(li); } root.append(ul); renderSelectionBar(containers); } // ─── selection bar (#443) ─────────────────────────────────────────── // Sticky-bottom strip; visible when ≥1 agent selected. mara picked // option B: show every action button, disable the ones that don't // apply to the full selection, hover tooltip explains why. Actions // POST per agent in a loop (no new backend wire — endpoints already // exist and are individually idempotent / event-covered). function renderSelectionBar(containers) { const bar = $('selection-bar'); if (!bar) return; const countSpan = $('selection-count'); const namesSpan = $('selection-names'); const actions = $('selection-actions'); if (!countSpan || !namesSpan || !actions) return; const selected = containers.filter((c) => selectionState.has(c.name)); if (!selected.length) { bar.hidden = true; document.body.classList.remove('has-selection'); return; } bar.hidden = false; document.body.classList.add('has-selection'); countSpan.textContent = selected.length === 1 ? '1 agent selected' : selected.length + ' agents selected'; namesSpan.textContent = '· ' + selected.map((c) => c.name).join(', '); // Recompute action availability + tooltips per render. Each // action declares which agents it CAN'T run on; the bar disables // the button and surfaces the offending names in the tooltip. actions.innerHTML = ''; const allRunning = selected.every((c) => c.running); const allStopped = selected.every((c) => !c.running); const noManagers = selected.every((c) => !c.is_manager); const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name); const runningNames = selected.filter((c) => c.running).map((c) => c.name); const managerNames = selected.filter((c) => c.is_manager).map((c) => c.name); function why(label, blockers) { if (!blockers.length) return null; return `${label} not available — ${blockers.join(', ')} ${blockers.length === 1 ? 'is' : 'are'} blocking it`; } addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, { action: '/restart/', confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)), }); // #443 also lifts the manager-stop guard: when the whole selection // is running, ST0P applies — manager included. host-side hive-c0re // keeps serving the dashboard either way + per-agent approvals + // meta-input updates still work without the manager up, so we // don't special-case the confirm prompt when the manager is in // the selection (mara: "dont special case manager for stopping"). addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, { action: '/kill/', confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)), }); addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, { action: '/start/', confirm: (names) => `start ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, disabledTitle: why('▶ ST4RT', runningNames.map((n) => `\`${n}\` is already running`)), }); addBulkButton(actions, 'btn-rebuild', '↻ R3BU1LD', true, selected, { action: '/rebuild/', confirm: (names) => `rebuild ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? hot-reloads each container.`, }); // DESTR0Y / PURG3: sub-agents only (manager has its own // `refusing to destroy` guard at the host layer). When the // selection includes the manager, both buttons go disabled with a // clear reason rather than letting the operator submit and eat a // 500. addBulkButton(actions, 'btn-destroy', 'DESTR0Y', noManagers, selected, { action: '/destroy/', confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`, disabledTitle: why('DESTR0Y', managerNames.map((n) => `\`${n}\` is the manager`)), }); addBulkButton(actions, 'btn-destroy', 'PURG3', noManagers, selected, { action: '/destroy/', body: { purge: 'on' }, confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`, disabledTitle: why('PURG3', managerNames.map((n) => `\`${n}\` is the manager`)), }); } function addBulkButton(parent, btnClass, label, enabled, selected, opts) { const names = selected.map((c) => c.name); const btn = el('button', { type: 'button', class: 'btn ' + btnClass, }, label); if (!enabled) { btn.disabled = true; if (opts.disabledTitle) btn.title = opts.disabledTitle; } btn.addEventListener('click', async () => { if (btn.disabled) return; const msg = opts.confirm(names); if (msg && !confirm(msg)) return; btn.disabled = true; const original = btn.innerHTML; btn.innerHTML = ' ' + label; const failures = []; // Sequential POSTs to keep server-side serialisation predictable // (rebuild_queue dedups but other endpoints don't); the loop is // short — bulk selections are typically a handful of agents. for (const name of names) { const body = new URLSearchParams(opts.body || {}); try { const resp = await fetch(opts.action + encodeURIComponent(name), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, redirect: 'manual', }); const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); if (!ok) { const text = await resp.text().catch(() => ''); failures.push(`${name}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`); } } catch (err) { failures.push(`${name}: ${err}`); } } btn.disabled = false; btn.innerHTML = original; if (failures.length) { alert(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n')); } // Container-lifecycle events (ContainerStateChanged / // ContainerRemoved / RebuildQueueChanged) flow over the existing // SSE channel and update the derived stores live — no manual // refresh needed. }); parent.append(btn); } // Per-container journald viewer. Returns an inline trigger; the // click opens the side panel and fetches the last N lines. Refresh // re-fetches; the unit toggle switches between the harness service // and the full machine journal. function buildJournalTrigger(containerName, defaultUnit) { const trigger = el('button', { type: 'button', class: 'panel-trigger' }, '↳ logs · ' + containerName); trigger.addEventListener('click', () => { const body = el('div', { class: 'journal-body' }); const controls = el('div', { class: 'journal-controls' }); const unitSelect = el('select', { class: 'journal-unit' }); unitSelect.append( el('option', { value: defaultUnit }, defaultUnit), el('option', { value: '' }, '(full machine journal)'), ); const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' }, '↻ refresh'); const pre = el('pre', { class: 'journal-output' }, 'fetching…'); let fetching = false; async function fetchLogs() { if (fetching) return; fetching = true; pre.textContent = 'fetching…'; const unit = unitSelect.value; const params = new URLSearchParams({ lines: '500' }); if (unit) params.set('unit', unit); try { const resp = await fetch('/api/journal/' + containerName + '?' + params); const text = await resp.text(); if (!resp.ok) { pre.textContent = 'error: ' + resp.status + '\n' + text; } else { pre.textContent = text || '(empty)'; // Auto-scroll the panel to the newest lines on fresh fetch. const sb = $('side-panel-body'); if (sb) sb.scrollTop = sb.scrollHeight; } } catch (err) { pre.textContent = 'fetch failed: ' + err; } finally { fetching = false; } } refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); }); unitSelect.addEventListener('change', fetchLogs); controls.append(unitSelect, refresh); body.append(controls, pre); Panel.open('logs · ' + containerName, body); fetchLogs(); }); return trigger; } function renderTombstones(s) { const root = $('tombstones-section'); // #tombstones-section only lives on /index.html (SYST3M tab); // no-op on /flow.html and any other page that loads the shared // bundle without the dashboard's tab panes (#399). if (!root) return; root.innerHTML = ''; if (!s.tombstones || !s.tombstones.length) { root.append(el('p', { class: 'empty' }, 'no kept state — clean')); return; } const fmtBytes = (n) => { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB'; return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; }; const fmtAge = (ts) => { if (!ts) return '?'; const d = Math.floor((Date.now() / 1000 - ts) / 86400); if (d <= 0) return 'today'; if (d === 1) return '1 day ago'; return d + ' days ago'; }; const ul = el('ul', { class: 'containers' }); for (const t of s.tombstones) { const li = el('li', { class: 'container-row tombstone' }); const head = el('div', { class: 'head' }); head.append( el('span', { class: 'name' }, t.name), el('span', { class: 'badge badge-muted' }, 'destroyed'), ); if (t.has_creds) { head.append(el('span', { class: 'badge badge-muted' }, 'creds kept')); } head.append(el('span', { class: 'meta' }, `${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`)); li.append(head); const actions = el('div', { class: 'actions' }); // Reuse the existing spawn form pattern via /request-spawn — operator // can queue an approval that recreates the agent with the same name // and reuses the kept state. const respawn = el('form', { method: 'POST', action: '/request-spawn', class: 'inline', 'data-async': '', 'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.', }); respawn.append( el('input', { type: 'hidden', name: 'name', value: t.name }), el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'), ); actions.append(respawn); actions.append(form( '/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3', 'PURGE ' + t.name + '? config history, claude creds, ' + 'and notes are all WIPED. no undo.', {}, { noRefresh: true }, )); li.append(actions); ul.append(li); } root.append(ul); } // Derived question state — cold-loaded from /api/state, then mutated // live by `question_added` / `question_resolved` dashboard events. const QUESTION_HISTORY_LIMIT = 20; const questionsState = { pending: [], history: [] }; function syncQuestionsFromSnapshot(s) { questionsState.pending = (s.questions || []).slice(); questionsState.history = (s.question_history || []).slice(); } function applyQuestionAdded(ev) { if (questionsState.pending.some((q) => q.id === ev.id)) return; questionsState.pending.push({ id: ev.id, asker: ev.asker, question: ev.question, options: ev.options || [], multi: !!ev.multi, asked_at: ev.asked_at, deadline_at: ev.deadline_at ?? null, target: ev.target || null, question_refs: ev.question_refs || [], }); renderQuestions(); } function applyQuestionResolved(ev) { const idx = questionsState.pending.findIndex((q) => q.id === ev.id); const existing = idx >= 0 ? questionsState.pending[idx] : null; if (idx >= 0) questionsState.pending.splice(idx, 1); // Idempotent: a snapshot re-sync (issue #163) can carry this same // answered row in `question_history` while a live event also // delivers it — guard the unshift so history can't double a row. if (!questionsState.history.some((h) => h.id === ev.id)) { questionsState.history.unshift({ id: ev.id, asker: existing?.asker || '?', question: existing?.question || '', options: existing?.options || [], multi: existing?.multi || false, asked_at: existing?.asked_at || ev.answered_at, answered_at: ev.answered_at, answer: ev.answer, answerer: ev.answerer, target: existing?.target ?? ev.target ?? null, question_refs: existing?.question_refs || [], answer_refs: ev.answer_refs || [], }); if (questionsState.history.length > QUESTION_HISTORY_LIMIT) { questionsState.history.length = QUESTION_HISTORY_LIMIT; } } renderQuestions(); } // Filter selection for the questions section. Persisted so the // operator's preferred view (all / operator-targeted / peer) // survives a reload. const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter'; function getQuestionsFilter() { return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all'; } function setQuestionsFilter(v) { localStorage.setItem(QUESTIONS_FILTER_KEY, v); renderQuestions(); } function questionMatchesFilter(q, filter) { if (filter === 'all') return true; if (filter === 'operator') return !q.target; if (filter === 'peer') return !!q.target; // `agent:` matches when the agent appears as asker OR target. if (filter.startsWith('agent:')) { const name = filter.slice('agent:'.length); return q.asker === name || q.target === name; } return true; } function renderQuestions() { const root = $('questions-section'); // #questions-section only lives on /index.html (Y3R C4LL tab); // no-op on /flow.html etc. — the bundled app.js runs everywhere // and `question_added` / `question_resolved` SSE events route // through here (#399). if (!root) return; root.innerHTML = ''; const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); const allPending = questionsState.pending; const activeFilter = getQuestionsFilter(); const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter)); // Filter chips. Always include `all` / `operator` / `peer`; add // per-agent chips for any agent that appears as asker or target // in the pending list so the operator can isolate a single // thread without typing. const participants = new Set(); for (const q of allPending) { participants.add(q.asker); if (q.target) participants.add(q.target); } const filterRow = el('div', { class: 'questions-filters' }); const mkChip = (value, label) => { const b = el('button', { type: 'button', class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''), }, label); b.addEventListener('click', () => setQuestionsFilter(value)); return b; }; filterRow.append( mkChip('all', `all · ${allPending.length}`), mkChip('operator', '@operator'), mkChip('peer', '@peer'), ); for (const name of Array.from(participants).sort()) { filterRow.append(mkChip('agent:' + name, '@' + name)); } root.append(filterRow); if (!pending.length) { root.append(el('p', { class: 'empty' }, activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter')); } const ul = el('ul', { class: 'questions' }); for (const q of pending) { const targetLabel = q.target || 'operator'; const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') }); const head = el('div', { class: 'q-head' }, el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', el('span', { class: 'msg-from' }, q.asker), ' ', el('span', { class: 'msg-sep' }, '→'), ' ', el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', el('span', { class: 'msg-sep' }, 'asks:'), ); if (q.deadline_at) { // Tag the chip with its deadline so the global 1s ticker // (set up just below this function) can refresh the text // without re-rendering the whole questions section // (issue #335). const ttlEl = el('span', { class: 'q-ttl', 'data-deadline': String(q.deadline_at), }); ttlEl.textContent = formatTtl( q.deadline_at - Math.floor(Date.now() / 1000), ); head.append(' ', ttlEl); } const qBody = el('div', { class: 'q-body' }); appendLinkified(qBody, q.question, q.question_refs); li.append(head, qBody); const f = el('form', { method: 'POST', action: '/answer-question/' + q.id, class: 'qform', 'data-async': '', 'data-no-refresh': '', }); const hasOptions = q.options && q.options.length; const isMulti = !!q.multi && hasOptions; const freeText = el('textarea', { name: 'answer-free', rows: '2', autocomplete: 'off', placeholder: (hasOptions ? 'or type your own…' : 'your answer') + ' (shift+enter for newline)', }); // Enter submits; shift+enter inserts a newline (textarea default). freeText.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); f.requestSubmit(); } }); const optionGroup = el('div', { class: 'q-options' }); if (hasOptions) { for (const opt of q.options) { const inputType = isMulti ? 'checkbox' : 'radio'; const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8); const input = el('input', { type: inputType, name: 'choice', value: opt, id }); const label = el('label', { for: id }, ' ' + opt); optionGroup.append(el('div', { class: 'q-option' }, input, label)); } } // On submit, build the final `answer` field from selected // options + free-text, joined by ', '. This lets the operator // pick options AND add free text in the same form. f.addEventListener('submit', (ev) => { const parts = []; for (const cb of f.querySelectorAll('input[name="choice"]:checked')) { parts.push(cb.value); } const ft = (freeText.value || '').trim(); if (ft) parts.push(ft); const merged = parts.join(', '); // Replace the existing hidden `answer` (if any) with the merged value. const existing = f.querySelector('input[name="answer"]'); if (existing) existing.remove(); f.append(el('input', { type: 'hidden', name: 'answer', value: merged })); if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); } }, true); if (hasOptions) f.append(optionGroup); const buttons = el('div', { class: 'q-buttons' }); // On peer threads the operator's answer is an override — // mark the button so it's clear what the click does (the // backend permits it via OperatorQuestions::answer's // answerer-auth rule). const answerLabel = q.target ? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3') : (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R'); buttons.append( el('button', { type: 'submit', class: 'btn btn-approve' + (q.target ? ' btn-override' : ''), title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '', }, answerLabel), ); f.append( el('div', { class: 'q-free' }, freeText), buttons, ); li.append(f); // Separate form so the cancel button doesn't get the answer // merge-on-submit handler attached to the main form. const cancelTargetLabel = q.target ? q.target : 'asker'; const cancelForm = el('form', { method: 'POST', action: '/cancel-question/' + q.id, class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '', 'data-confirm': `cancel this question? ${cancelTargetLabel} will see ` + '"[cancelled]" as the answer.', }); cancelForm.append( el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'), ); li.append(cancelForm); ul.append(li); } if (pending.length) root.append(ul); // Answered question history const hist = questionsState.history; if (hist.length) { const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' }); details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')')); const hul = el('ul', { class: 'questions questions-answered' }); for (const q of hist) { const targetLabel = q.target || 'operator'; const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') }); const head = el('div', { class: 'q-head' }, el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ', el('span', { class: 'msg-from' }, q.asker), ' ', el('span', { class: 'msg-sep' }, '→'), ' ', el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', el('span', { class: 'msg-sep' }, 'asked:'), ); const histBody = el('div', { class: 'q-body' }); appendLinkified(histBody, q.question, q.question_refs); const ansText = el('span', { class: 'q-answer-text' }); appendLinkified(ansText, q.answer || '(none)', q.answer_refs); const ansLine = el('div', { class: 'q-answer' }, el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), ansText, ); li.append(head, histBody, ansLine); hul.append(li); } details.append(hul); root.append(details); } } // Format a remaining-seconds count as the `⏳ …` TTL chip text on a // question card. Bucketed at minutes / hours so a long deadline stays // readable; "expiring…" once the deadline has passed (the host-side // ttl-watchdog will fire shortly). function formatTtl(remaining) { if (remaining <= 0) return 'expiring…'; if (remaining < 60) return '⏳ ' + remaining + 's'; if (remaining < 3600) { return '⏳ ' + Math.floor(remaining / 60) + 'm ' + (remaining % 60) + 's'; } return '⏳ ' + Math.floor(remaining / 3600) + 'h ' + Math.floor((remaining % 3600) / 60) + 'm'; } // Single page-wide ticker that refreshes every TTL chip in place // each second (issue #335). Renderers stamp `data-deadline` on the // chip; this just updates `textContent`, no re-render of the // questions section. No-op when no chips are on screen, so the // cost is negligible. setInterval(() => { const now = Math.floor(Date.now() / 1000); document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => { const deadline = Number(node.getAttribute('data-deadline')); if (!Number.isFinite(deadline)) return; node.textContent = formatTtl(deadline - now); }); }, 1000); // Operator-inbox derived store moved to ./flow.js (#406 step 2). const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab'; // Derived approval state — cold-loaded from /api/state, then mutated // live by `approval_added` / `approval_resolved` dashboard events. // `pending` is the open queue (newest-first); `history` is the last // 30 resolved rows. const APPROVAL_HISTORY_LIMIT = 30; const approvalsState = { pending: [], history: [] }; function syncApprovalsFromSnapshot(s) { approvalsState.pending = (s.approvals || []).slice(); approvalsState.history = (s.approval_history || []).slice(); } function applyApprovalAdded(ev) { // Upsert by id so a snapshot that already included the row (cold // load + event lands at the same tick) doesn't double it. const existing = approvalsState.pending.findIndex((a) => a.id === ev.id); const row = { id: ev.id, agent: ev.agent, kind: ev.approval_kind, sha_short: ev.sha_short || null, diff: ev.diff || null, description: ev.description || null, // The ApprovalAdded event carries no requested_at; a live-added // approval was queued just now, so client-now is accurate — and // consistent with how fmtAgo compares everything to client-now. // A later /api/state cold-load swaps in the server value. (#272) requested_at: ev.requested_at != null ? ev.requested_at : Math.floor(Date.now() / 1000), }; if (existing >= 0) approvalsState.pending[existing] = row; else approvalsState.pending.push(row); renderApprovals(); } function applyApprovalResolved(ev) { // Drop from pending; prepend to history (newest-first), cap at 30. approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id); // Idempotent: a snapshot re-sync (issue #163) can carry this same // resolved row in `approval_history` while a live event also // delivers it — guard the unshift so history can't double a row. if (!approvalsState.history.some((h) => h.id === ev.id)) { approvalsState.history.unshift({ id: ev.id, agent: ev.agent, kind: ev.approval_kind, sha_short: ev.sha_short || null, status: ev.status, resolved_at: ev.resolved_at, note: ev.note || null, description: ev.description || null, }); if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) { approvalsState.history.length = APPROVAL_HISTORY_LIMIT; } } renderApprovals(); } // Classify each unified-diff line by its leading char so // `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` / // `.diff-ctx` colour the output. Built as text-only spans (no // innerHTML) so there's no HTML-escape surface. function buildDiffPre(text) { const pre = el('pre', { class: 'diff' }); for (const raw of String(text).split('\n')) { let cls = 'diff-ctx'; if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file'; else if (raw.startsWith('@')) cls = 'diff-hunk'; else if (raw.startsWith('+')) cls = 'diff-add'; else if (raw.startsWith('-')) cls = 'diff-del'; const span = document.createElement('span'); span.className = cls; span.textContent = raw + '\n'; pre.appendChild(span); } return pre; } // Open an approval's diff in the side panel with a 3-way base // toggle: vs applied (running tree), vs last-approved, vs previous // proposal. `applied` uses the diff already shipped on the approval // for instant paint; the other two fetch /api/approval-diff. function openDiffPanel(a) { const bases = [ ['applied', 'vs applied'], ['approved', 'vs last-approved'], ['previous', 'vs previous proposal'], ]; const tabs = el('div', { class: 'diff-base-tabs' }); const host = el('div', { class: 'diff-host' }); async function selectBase(base) { for (const btn of tabs.children) { btn.classList.toggle('active', btn.dataset.base === base); } if (base === 'applied' && a.diff != null) { host.replaceChildren(buildDiffPre(a.diff)); return; } host.replaceChildren(el('div', { class: 'meta' }, 'loading…')); try { const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base); const text = await resp.text(); host.replaceChildren(resp.ok ? buildDiffPre(text) : el('div', { class: 'meta' }, 'error: ' + text)); } catch (e) { host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e)); } } for (const [base, label] of bases) { const btn = el('button', { type: 'button', class: 'diff-base-tab', 'data-base': base }, label); btn.addEventListener('click', () => selectBase(base)); tabs.append(btn); } const wrap = el('div', { class: 'diff-panel' }, tabs, host); Panel.open('diff · ' + a.agent + ' #' + a.id, wrap); selectBase('applied'); } function renderApprovals() { const root = $('approvals-section'); // #approvals-section only lives on /index.html (Y3R C4LL tab); // no-op elsewhere — `approval_added` / `approval_resolved` SSE // events route through here on every page that loads the bundle // (#399). if (!root) return; root.innerHTML = ''; // Spawn request form: submitting it queues a Spawn approval that // lands in this same list, so the form belongs here rather than on // the containers list (the agent doesn't exist yet). const spawn = el('form', { method: 'POST', action: '/request-spawn', class: 'spawnform', 'data-async': '', 'data-no-refresh': '', }); spawn.append( el('input', { name: 'name', placeholder: 'new agent name (≤9 chars)', maxlength: '9', required: '', autocomplete: 'off', }), el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'), ); root.append(spawn); const pending = approvalsState.pending; const history = approvalsState.history; const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending'; const tabs = el('div', { class: 'approval-tabs' }); const pendingTab = el( 'button', { type: 'button', class: 'approval-tab' + (active === 'pending' ? ' active' : ''), }, `pending · ${pending.length}`, ); const historyTab = el( 'button', { type: 'button', class: 'approval-tab' + (active === 'history' ? ' active' : ''), }, `history · ${history.length}`, ); pendingTab.addEventListener('click', () => { localStorage.setItem(APPROVAL_TAB_KEY, 'pending'); renderApprovals(); }); historyTab.addEventListener('click', () => { localStorage.setItem(APPROVAL_TAB_KEY, 'history'); renderApprovals(); }); tabs.append(pendingTab, historyTab); root.append(tabs); if (active === 'history') { renderApprovalHistory(root, history); return; } if (!pending.length) { root.append(el('p', { class: 'empty' }, 'queue empty')); return; } // forge link base — only when the hive-forge container is up. const fs = window.__hyperhive_state; const hostname = (fs && fs.hostname) || window.location.hostname; const forgeBase = (fs && fs.forge_present) ? `http://${hostname}:3000` : null; const ul = el('ul', { class: 'approvals' }); for (const a of pending) { const isApply = a.kind === 'apply_commit'; const isInit = a.kind === 'init_config'; const li = el('li', { class: 'approval-card' }); // ── identity header ────────────────────────────────────────── const head = el('div', { class: 'approval-head' }, el('span', { class: 'glyph' }, isApply ? '→' : '⊕'), el('span', { class: 'id' }, '#' + a.id), el('span', { class: 'agent' }, a.agent), el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') }, isApply ? 'apply' : isInit ? 'init' : 'spawn'), ); if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short)); // When the approval was requested — relative time, right-aligned. // Goes amber once it's been pending an hour so a stale request is // obvious at a glance. (issue #272) if (a.requested_at != null) { const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - a.requested_at)); head.append(el('span', { class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''), title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(), }, 'requested ' + fmtAgo(a.requested_at))); } li.append(head); // ── what-changed body ──────────────────────────────────────── const body = el('div', { class: 'approval-body' }); if (a.description) { body.append(el('div', { class: 'approval-description' }, a.description)); } if (isApply) { const drill = el('div', { class: 'drill-ins' }); const diffBtn = el('button', { type: 'button', class: 'panel-trigger' }, '↳ view diff'); diffBtn.addEventListener('click', () => openDiffPanel(a)); drill.append(diffBtn); if (forgeBase && a.sha_short) { drill.append(el('a', { class: 'panel-trigger', target: '_blank', rel: 'noopener', href: `${forgeBase}/agent-configs/${a.agent}/commit/${a.sha_short}`, title: 'this proposal commit on the hive forge', }, '↳ commit on forge ↗')); } body.append(drill); } else { body.append(el('span', { class: 'meta' }, isInit ? 'scaffold proposed config repo — manager customises agent.nix before spawn' : 'new sub-agent — container will be created on approve')); } li.append(body); // ── decision actions ───────────────────────────────────────── // Deny prompts the operator for an optional reason; the submit // handler stashes it into a hidden `note` input that rides along // on the POST and is surfaced to the manager via // HelperEvent::ApprovalResolved { note }. const denyForm = el('form', { method: 'POST', action: '/deny/' + a.id, class: 'inline', 'data-async': '', 'data-no-refresh': '', 'data-prompt': 'reason for denying (optional, sent to manager):', }); denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY')); li.append(el('div', { class: 'approval-actions' }, form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }), denyForm, )); ul.append(li); } root.append(ul); } function renderApprovalHistory(root, history) { if (!history.length) { root.append(el('p', { class: 'empty' }, 'no resolved approvals yet')); return; } const ul = el('ul', { class: 'approvals approvals-history' }); for (const a of history) { const li = el('li'); const row = el('div', { class: 'row' }); const glyph = a.status === 'approved' ? '✓' : a.status === 'denied' ? '✗' : '⚠'; row.append( el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ', el('span', { class: 'id' }, '#' + a.id), ' ', el('span', { class: 'agent' }, a.agent), ' ', el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : 'spawn'), ' ', ); if (a.sha_short) row.append(el('code', {}, a.sha_short), ' '); row.append( el('span', { class: 'status status-' + a.status }, a.status), ' ', el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)), ); li.append(row); if (a.note) { li.append(el('div', { class: 'history-note' }, a.note)); } ul.append(li); } root.append(ul); } // Relative time, anchored to now. resolved_at is unix seconds (server- // authored), so we don't have to worry about client/server clock skew // for sub-minute precision. function fmtAgo(unixSecs) { const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs)); if (ageSec < 60) return ageSec + 's ago'; if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago'; if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago'; return Math.floor(ageSec / 86400) + 'd ago'; } function renderMetaInputs(s) { const root = $('meta-inputs-section'); if (!root) return; root.innerHTML = ''; const inputs = s.meta_inputs || []; if (!inputs.length) { root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet')); return; } if (metaUpdateRunning) { root.append(el('p', { class: 'meta-update-running' }, '⏳ meta-update running — flake lock bump + affected agents rebuilding. ' + 'watch the agent cards for per-rebuild progress.')); } const form = el('form', { method: 'POST', action: '/meta-update', class: 'meta-inputs-form', 'data-async': '', // run_meta_update emits MetaInputsChanged once the lock // bump finishes; per-agent rebuilds fire their own // ContainerStateChanged. No /api/state refetch needed. 'data-no-refresh': '', 'data-confirm': 'update selected meta flake inputs + rebuild affected agents?', }); // Bulk select — the full input tree gets long; ticking each box // one by one is tedious (issue #275). const bulk = el('div', { class: 'meta-inputs-bulk' }); const selAll = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select all'); const selNone = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select none'); bulk.append('bulk: ', selAll, ' ', selNone); form.append(bulk); const ul = el('ul', { class: 'meta-inputs' }); for (const inp of inputs) { // `name` is a slash-path from the meta root. Indent depth = its // segment count; the row label shows just the leaf segment, the // full path stays as the checkbox value + the label title. const depth = (inp.name.match(/\//g) || []).length; const leaf = inp.name.slice(inp.name.lastIndexOf('/') + 1); const li = el('li'); if (depth > 0) li.style.marginLeft = (depth * 1.3) + 'em'; const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_'); const cb = el('input', { type: 'checkbox', name: 'meta_input_' + inp.name, id, value: inp.name, 'data-meta-input': inp.name, }); const label = el('label', { for: id, title: inp.name }); label.append(cb); if (depth > 0) label.append(el('span', { class: 'meta-input-twig' }, '└ ')); label.append( el('span', { class: 'meta-input-name' }, leaf), ' ', el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ', el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)), ); if (inp.url) { label.append(' ', el('span', { class: 'meta-input-url', title: inp.url }, '· ' + truncate(inp.url, 48))); } li.append(label); ul.append(li); } form.append(ul); // Hidden input the POST handler reads — populated at submit // time from the checkbox states. axum's Form extractor doesn't // natively decode repeated keys, so we join into one CSV. const hidden = el('input', { type: 'hidden', name: 'inputs', value: '' }); form.append(hidden); const btn = el('button', { type: 'submit', class: 'btn btn-meta-update', disabled: '', }, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD'); form.append(btn); function refreshDisabled() { const any = form.querySelectorAll('input[data-meta-input]:checked').length > 0; // Stay disabled while an update is already in flight — no // stacking a second run on top of the rebuild ripple. if (any && !metaUpdateRunning) btn.removeAttribute('disabled'); else btn.setAttribute('disabled', ''); } form.addEventListener('change', refreshDisabled); function setAllChecked(val) { for (const b of form.querySelectorAll('input[data-meta-input]')) { b.checked = val; } refreshDisabled(); } selAll.addEventListener('click', () => setAllChecked(true)); selNone.addEventListener('click', () => setAllChecked(false)); form.addEventListener('submit', () => { const selected = Array.from(form.querySelectorAll('input[data-meta-input]:checked')) .map((b) => b.dataset.metaInput); hidden.value = selected.join(','); }); root.append(form); } function truncate(s, n) { return s.length <= n ? s : s.slice(0, n - 1) + '…'; } // ─── rebuild queue ────────────────────────────────────────────────────── // Glyph + verb per QueueKind. Mirrors the labels used in // hive-c0re::rebuild_queue::QueueKind::as_str. const QUEUE_KIND_GLYPH = { rebuild: '↻', meta_update: '◆', spawn: '✨', destroy: '🗑', }; const QUEUE_STATE_GLYPH = { queued: '⏸', running: '▶', done: '✔', failed: '✖', cancelled: '⊘', }; function renderRebuildQueue(s) { const root = $('rebuild-queue-section'); if (!root) return; root.innerHTML = ''; const queue = s.rebuild_queue || []; if (!queue.length) { root.append(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.')); return; } // Index by id for parent lookup. const byId = new Map(queue.map((e) => [e.id, e])); // Top-level entries first; children render nested under their parent. const tops = queue.filter((e) => e.parent_id == null); const childrenOf = new Map(); for (const e of queue) { if (e.parent_id != null) { if (!childrenOf.has(e.parent_id)) childrenOf.set(e.parent_id, []); childrenOf.get(e.parent_id).push(e); } } const ul = el('ul', { class: 'rebuild-queue' }); for (const top of tops) { ul.append(renderQueueEntry(top, byId)); for (const child of childrenOf.get(top.id) || []) { ul.append(renderQueueEntry(child, byId, true)); } } // Children whose parent isn't in the snapshot (history-evicted) still render flat. const orphans = queue.filter( (e) => e.parent_id != null && !byId.has(e.parent_id), ); for (const o of orphans) { ul.append(renderQueueEntry(o, byId, true)); } root.append(ul); } function renderQueueEntry(entry, _byId, isChild) { const li = el('li', { class: 'rebuild-queue-entry rqe-' + entry.state, 'data-id': String(entry.id), }); if (isChild) li.classList.add('rqe-child'); // State glyph + kind + agent. li.append( el('span', { class: 'rqe-state', title: entry.state }, QUEUE_STATE_GLYPH[entry.state] || '?'), ' ', el('span', { class: 'rqe-kind', title: entry.kind }, (QUEUE_KIND_GLYPH[entry.kind] || '?') + ' ' + entry.kind), ' ', el('code', { class: 'rqe-agent' }, entry.agent), ); // Source chip (manual / meta_update / auto_update / crash_recover). li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source)); // Timing: queued Xs ago when pending, elapsed when running, // finished Xs ago for terminal. if (entry.state === 'queued') { li.append(' ', el('span', { class: 'rqe-when' }, '· queued ' + fmtAgo(entry.enqueued_at))); } else if (entry.state === 'running' && entry.started_at) { const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - entry.started_at)); li.append(' ', el('span', { class: 'rqe-when', 'data-rqe-elapsed': String(entry.started_at), }, '· ' + fmtElapsed(elapsed))); } else if (entry.finished_at) { li.append(' ', el('span', { class: 'rqe-when' }, '· ' + entry.state + ' ' + fmtAgo(entry.finished_at))); } // Reason (truncated; full text on hover). if (entry.reason) { const r = entry.reason.split('\n')[0]; li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60))); } // Error block, when failed. if (entry.error) { li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200))); } return li; } function fmtElapsed(secs) { if (secs < 60) return secs + 's running'; if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's running'; return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm running'; } // Tick once per second to refresh "running Xs" badges in place // (mirrors the question-TTL ticker pattern from #335). setInterval(() => { for (const span of document.querySelectorAll('.rqe-when[data-rqe-elapsed]')) { const started = parseInt(span.dataset.rqeElapsed, 10); if (!started) continue; const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - started)); span.textContent = '· ' + fmtElapsed(elapsed); } }, 1000); // ─── reminders ────────────────────────────────────────────────────────── // Reminders aren't part of /api/state (separate sqlite table, separate // mutation cadence). Refresh fires alongside refreshState() so a // cancel POST or a cold load both reflect within the same tick. A // periodic poll isn't necessary — new reminders are queued by the // agents themselves and the operator already sees them next time // they interact with the page. async function refreshReminders() { const root = $('reminders-section'); if (!root) return; try { const resp = await fetch('/api/reminders'); if (!resp.ok) { root.innerHTML = ''; root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status)); return; } const rows = await resp.json(); renderReminders(rows); } catch (err) { root.innerHTML = ''; root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err)); } } function renderReminders(rows) { const root = $('reminders-section'); if (!root) return; root.innerHTML = ''; if (!rows.length) { root.append(el('p', { class: 'empty' }, 'no queued reminders')); return; } const ul = el('ul', { class: 'reminders' }); for (const r of rows) { const failed = (r.attempt_count || 0) > 0; const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') }); const dueIn = r.due_at - Math.floor(Date.now() / 1000); const dueLabel = dueIn <= 0 ? `overdue ${fmtAgo(r.due_at)}` : `in ${fmtDuration(dueIn)}`; const head = el('div', { class: 'reminder-head' }, el('span', { class: 'agent' }, r.agent), ' ', el('span', { class: 'meta', title: new Date(r.due_at * 1000).toISOString() }, dueLabel), ' ', el('span', { class: 'meta' }, `· id ${r.id}`), ); if (r.file_path) { head.append(' ', el('span', { class: 'meta' }, '· payload → ')); appendLinkified(head, r.file_path); } if (failed) { head.append(' ', el('span', { class: 'badge badge-warn', title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)', }, `⚠ ${r.attempt_count} failed`)); } const body = el('div', { class: 'reminder-body' }); appendLinkified(body, r.message); li.append(head, body); if (r.last_error) { li.append(el('div', { class: 'reminder-error' }, el('span', { class: 'msg-sep' }, 'error: '), r.last_error, )); } const actions = el('div', { class: 'reminder-actions' }); if (failed) { // Retry resets the failure counters so the scheduler picks // the row up again on its next 5s tick. No data-no-refresh // — the resulting refreshState re-fires refreshReminders. const retryForm = el('form', { method: 'POST', action: '/retry-reminder/' + r.id, class: 'inline', 'data-async': '', }); retryForm.append(el('button', { type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY')); actions.append(retryForm); } const cancelForm = el('form', { method: 'POST', action: '/cancel-reminder/' + r.id, class: 'inline', 'data-async': '', 'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`, }); cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L')); actions.append(cancelForm); li.append(actions); ul.append(li); } root.append(ul); } function fmtDuration(secs) { if (secs < 60) return secs + 's'; if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's'; if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm'; return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h'; } // ─── scheduled prompts (#459) ────────────────────────────────────────── // Backend (#444) exposes `/api/schedules` (snapshot), `/api/schedules` // (POST, operator-direct submit), `/api/schedules/{id}/cancel` // (whole or per-target). No SSE channel for schedule mutations yet, // so we refresh on tab activation + after every submit/cancel POST. // Local cache lets `refreshTabCounts` show the active count without // re-fetching every second. let schedulesState = []; // #474 — schedule ids whose inline edit form is currently open. // The set survives across refreshSchedules() calls so a state // poll doesn't yank the form out from under the operator. Per-id // mid-edit carry sits in `scheduleEditCarry` so unsaved typing // also rides the refresh. const editingSchedules = new Set(); const scheduleEditCarry = new Map(); async function refreshSchedules() { const listRoot = $('schedules-section'); if (!listRoot) return; try { const resp = await fetch('/api/schedules'); if (!resp.ok) { listRoot.innerHTML = ''; listRoot.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status)); return; } schedulesState = await resp.json(); } catch (err) { listRoot.innerHTML = ''; listRoot.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err)); return; } renderScheduleNewForm(); renderSchedulesList(); } // Active = at least one target still alive (no `cancelled_at_unix`) // AND the whole schedule isn't cancelled. Drives the tab pill count. function activeScheduleCount() { let n = 0; for (const s of schedulesState) { if (s.cancelled_at_unix) continue; if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++; } return n; } // Interval composer (#466) — shared between the new-schedule form // and the edit-schedule form (#474). Builds the preset chip row + // d/h/m/s sub-fields + live preview, and returns helpers to read // and write the value. // // The composer carries no opinion on what "0 / blank" means at the // semantic layer: it just reports the integer total. Callers decide // whether to treat 0 as `null` (one-shot) for their own submit path. function buildIntervalComposer({ label = 'interval (blank / all-zero = one-shot)', namePrefix = 'interval_', initialSeconds = 0, } = {}) { const wrapper = el('label', { class: 'schedule-field' }, el('span', { class: 'schedule-field-label' }, label)); const presets = [ ['1m', 60], ['5m', 300], ['15m', 900], ['30m', 1800], ['1h', 3600], ['6h', 21600], ['12h', 43200], ['1d', 86400], ['7d', 604800], ]; const presetsRow = el('div', { class: 'schedule-interval-presets' }); for (const [presetLabel, secs] of presets) { presetsRow.append(el('button', { type: 'button', class: 'btn btn-interval-preset', 'data-secs': String(secs), }, presetLabel)); } presetsRow.append(el('button', { type: 'button', class: 'btn btn-interval-preset btn-interval-oneshot', 'data-secs': '0', }, 'one-shot')); wrapper.append(presetsRow); const partsRow = el('div', { class: 'schedule-interval-parts' }); const mkPart = (suffix, unit) => { const inp = el('input', { type: 'number', name: namePrefix + suffix, min: '0', step: '1', placeholder: '0', class: 'schedule-interval-num', }); partsRow.append(inp, el('span', { class: 'schedule-interval-unit' }, unit)); return inp; }; const dInp = mkPart('d', 'd'); const hInp = mkPart('h', 'h'); const mInp = mkPart('m', 'm'); const sInp = mkPart('s', 's'); wrapper.append(partsRow); const preview = el('div', { class: 'schedule-interval-preview', 'aria-live': 'polite' }); wrapper.append(preview); function getSeconds() { const n = (v) => { const x = parseInt(v, 10); return Number.isFinite(x) && x > 0 ? x : 0; }; return n(dInp.value) * 86400 + n(hInp.value) * 3600 + n(mInp.value) * 60 + n(sInp.value); } function fillFromSeconds(total) { const t = Math.max(0, Math.floor(total)); const d = Math.floor(t / 86400); const h = Math.floor((t % 86400) / 3600); const m = Math.floor((t % 3600) / 60); const s = t % 60; dInp.value = d > 0 ? String(d) : ''; hInp.value = h > 0 ? String(h) : ''; mInp.value = m > 0 ? String(m) : ''; sInp.value = s > 0 ? String(s) : ''; } function setParts({ d, h, m, s }) { if (d !== undefined) dInp.value = d; if (h !== undefined) hInp.value = h; if (m !== undefined) mInp.value = m; if (s !== undefined) sInp.value = s; } function updatePreview() { const total = getSeconds(); preview.textContent = total > 0 ? '↻ every ' + fmtDuration(total) : 'one-shot (fires once at first-fire time)'; preview.classList.toggle('schedule-interval-preview-oneshot', total === 0); } for (const inp of [dInp, hInp, mInp, sInp]) { inp.addEventListener('input', updatePreview); } for (const b of presetsRow.querySelectorAll('button[data-secs]')) { b.addEventListener('click', () => { fillFromSeconds(parseInt(b.getAttribute('data-secs'), 10) || 0); updatePreview(); }); } if (initialSeconds > 0) fillFromSeconds(initialSeconds); updatePreview(); return { wrapper, getSeconds, fillFromSeconds, setParts, updatePreview }; } function renderScheduleNewForm() { const root = $('schedule-new-section'); if (!root) return; // Preserve any field the operator was mid-typing in by reading // current values before we re-render. Only `body` + `description` // are big enough to feel — the others are toggle/datetime/number. const carry = readScheduleFormCarry(root); root.innerHTML = ''; // Targets multi-select pulls from the live containers list. The // backend accepts any string (forward-compat for richer recipients), // so we also surface the special `operator` and `manager` names // as fixed options on top. const candidateTargets = ['operator', 'manager']; const containerNames = Array.from(containersState.values()) .map((c) => c.name) .filter((n) => n !== 'manager' && n !== 'operator') .sort(); for (const n of containerNames) candidateTargets.push(n); const form_ = el('form', { class: 'schedule-new-form' }); form_.addEventListener('submit', (e) => { e.preventDefault(); submitNewSchedule(form_); }); const targetsLabel = el('label', { class: 'schedule-field' }, el('span', { class: 'schedule-field-label' }, 'targets')); const targetsBox = el('div', { class: 'schedule-targets' }); const restoredTargets = new Set(carry.targets); for (const name of candidateTargets) { const id_ = 'st-' + name; const cb = el('input', { type: 'checkbox', name: 'targets', value: name, id: id_, }); if (restoredTargets.has(name)) cb.checked = true; targetsBox.append(el('label', { class: 'schedule-target-chip', for: id_ }, cb, el('span', {}, name))); } targetsLabel.append(targetsBox); form_.append(targetsLabel); const bodyLabel = el('label', { class: 'schedule-field' }, el('span', { class: 'schedule-field-label' }, 'prompt body')); const bodyInput = el('textarea', { name: 'body', rows: '4', required: 'required', placeholder: 'message delivered to each target at fire time', }); bodyInput.value = carry.body; bodyLabel.append(bodyInput); form_.append(bodyLabel); const firstFireLabel = el('label', { class: 'schedule-field' }, el('span', { class: 'schedule-field-label' }, 'first fire')); const firstFireInput = el('input', { type: 'datetime-local', name: 'first_fire', required: 'required', }); // Default to "5 minutes from now" so the operator has a sensible // pre-filled value (also future-positive so worker doesn't fire // immediately on a stale-clock accident). firstFireInput.value = carry.first_fire || isoForDatetimeLocal(new Date(Date.now() + 5 * 60 * 1000)); firstFireLabel.append(firstFireInput); form_.append(firstFireLabel); // Interval composer (#466). See `buildIntervalComposer` below — // shared between the new-schedule form and the edit-schedule form // (#474) so the same chip/d/h/m/s widget works for both flows. const intervalCx = buildIntervalComposer({ label: 'interval (blank / all-zero = one-shot)', namePrefix: 'interval_', initialSeconds: 0, }); form_.append(intervalCx.wrapper); // Restore from carry. Prefer the new split fields; fall back to // legacy single seconds value if the page was reloaded mid-edit // against an older bundle. if (carry.interval_d || carry.interval_h || carry.interval_m || carry.interval_s) { intervalCx.setParts({ d: carry.interval_d, h: carry.interval_h, m: carry.interval_m, s: carry.interval_s, }); } else if (carry.interval) { const legacy = parseInt(carry.interval, 10); if (Number.isFinite(legacy) && legacy > 0) intervalCx.fillFromSeconds(legacy); } intervalCx.updatePreview(); const descLabel = el('label', { class: 'schedule-field' }, el('span', { class: 'schedule-field-label' }, 'description (optional)')); const descInput = el('input', { type: 'text', name: 'description', placeholder: 'shown on the schedule card', }); descInput.value = carry.description; descLabel.append(descInput); form_.append(descLabel); const actions = el('div', { class: 'schedule-actions' }); const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '+ qu3ue prompt'); actions.append(submit); form_.append(actions); root.append(form_); } function readScheduleFormCarry(root) { return { targets: Array.from(root.querySelectorAll('input[name="targets"]:checked')).map((i) => i.value), body: root.querySelector('textarea[name="body"]')?.value || '', first_fire: root.querySelector('input[name="first_fire"]')?.value || '', // #466: interval is now split into d/h/m/s sub-fields. Keep the // legacy `interval` key in case an older bundle is in the page. interval: root.querySelector('input[name="interval"]')?.value || '', interval_d: root.querySelector('input[name="interval_d"]')?.value || '', interval_h: root.querySelector('input[name="interval_h"]')?.value || '', interval_m: root.querySelector('input[name="interval_m"]')?.value || '', interval_s: root.querySelector('input[name="interval_s"]')?.value || '', description: root.querySelector('input[name="description"]')?.value || '', }; } function isoForDatetimeLocal(date) { // `` expects `YYYY-MM-DDTHH:MM` // in the user's local timezone (with no trailing Z). Build it // by hand rather than slicing toISOString which is always UTC. const pad = (n) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + `T${pad(date.getHours())}:${pad(date.getMinutes())}`; } async function submitNewSchedule(form_) { const fd = new FormData(form_); const targets = fd.getAll('targets').map(String); const body = String(fd.get('body') || '').trim(); const firstFireStr = String(fd.get('first_fire') || ''); const description = String(fd.get('description') || '').trim(); if (!targets.length) { alert('schedule must have at least one target'); return; } if (!body) { alert('prompt body must be non-empty'); return; } if (!firstFireStr) { alert('first fire timestamp is required'); return; } const firstFireDate = new Date(firstFireStr); if (Number.isNaN(firstFireDate.getTime())) { alert('first fire is not a valid datetime'); return; } const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000); // Interval composer (#466): combine d/h/m/s into total seconds. // Negative values are rejected; total == 0 means one-shot, same // semantic the backend expects when interval_seconds is null. const partSecs = (name, mult) => { const raw = String(fd.get(name) || '').trim(); if (!raw) return 0; const n = parseInt(raw, 10); if (!Number.isFinite(n) || n < 0) return NaN; return n * mult; }; const partD = partSecs('interval_d', 86400); const partH = partSecs('interval_h', 3600); const partM = partSecs('interval_m', 60); const partS = partSecs('interval_s', 1); if ([partD, partH, partM, partS].some((v) => Number.isNaN(v))) { alert('interval fields must be non-negative integers (or blank for one-shot)'); return; } const intervalTotal = partD + partH + partM + partS; const interval_seconds = intervalTotal > 0 ? intervalTotal : null; const payload = { targets, body, first_fire_at_unix }; if (interval_seconds != null) payload.interval_seconds = interval_seconds; if (description) payload.description = description; const submitBtn = form_.querySelector('button[type="submit"]'); const originalLabel = submitBtn ? submitBtn.innerHTML : ''; if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = ' queueing…'; } try { const resp = await fetch('/api/schedules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) { const text = await resp.text().catch(() => ''); alert('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : '')); return; } // Clear the form (form_ is about to be re-rendered fresh below). form_.reset(); await refreshSchedules(); } catch (err) { alert('schedule submit failed: ' + err); } finally { if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = originalLabel; } } } function renderSchedulesList() { const root = $('schedules-section'); if (!root) return; root.innerHTML = ''; if (!schedulesState.length) { root.append(el('p', { class: 'empty' }, 'no schedules queued')); return; } const ul = el('ul', { class: 'schedules' }); // Active schedules first (still firing), then cancelled tail. const sorted = schedulesState.slice().sort((a, b) => { const aDone = a.cancelled_at_unix ? 1 : 0; const bDone = b.cancelled_at_unix ? 1 : 0; if (aDone !== bDone) return aDone - bDone; return a.next_fire_at_unix - b.next_fire_at_unix; }); for (const s of sorted) ul.append(renderScheduleCard(s)); root.append(ul); } function renderScheduleCard(s) { const cancelled = !!s.cancelled_at_unix; const li = el('li', { class: 'schedule-row' + (cancelled ? ' schedule-cancelled' : '') }); const head = el('div', { class: 'schedule-head' }); head.append( el('span', { class: 'meta' }, '#' + s.id), ' ', el('span', { class: 'rqe-source rqe-source-' + (s.source && s.source.kind === 'approval' ? 'approval' : 'manual') }, s.source && s.source.kind === 'approval' ? 'approval' : 'operator'), ); if (cancelled) { head.append(' ', el('span', { class: 'badge badge-muted', title: 'cancelled ' + new Date(s.cancelled_at_unix * 1000).toISOString() }, 'cancelled')); } else { const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000); const dueLabel = dueIn <= 0 ? `overdue ${fmtAgo(s.next_fire_at_unix)}` : `next fire in ${fmtDuration(dueIn)}`; head.append(' ', el('span', { class: 'meta', title: new Date(s.next_fire_at_unix * 1000).toISOString(), }, dueLabel)); } if (s.interval_seconds) { head.append(' ', el('span', { class: 'badge badge-muted', title: 'recurring' }, `↻ every ${fmtDuration(s.interval_seconds)}`)); } else { head.append(' ', el('span', { class: 'badge badge-muted', title: 'one-shot' }, 'one-shot')); } head.append(' ', el('span', { class: 'meta' }, '· owner ' + s.owner)); li.append(head); if (s.description) { li.append(el('div', { class: 'schedule-description' }, s.description)); } const body = el('div', { class: 'schedule-body' }); appendLinkified(body, s.body); li.append(body); // Targets table — one row per recipient with cancel button + // last-fire metadata. const targets = s.targets || []; if (targets.length) { const table = el('table', { class: 'schedule-targets-table' }); const thead = el('thead', {}, el('tr', {}, el('th', {}, 'target'), el('th', {}, 'last fire'), el('th', {}, 'last result'), el('th', {}, ''), )); table.append(thead); const tbody = el('tbody', {}); for (const t of targets) { const tCancelled = !!t.cancelled_at_unix; const tr = el('tr', { class: tCancelled ? 'schedule-target-cancelled' : '' }); tr.append(el('td', {}, t.target)); tr.append(el('td', { class: 'meta' }, t.last_fired_at_unix ? fmtAgo(t.last_fired_at_unix) + ' ago' : '—')); tr.append(el('td', { class: 'meta' }, t.last_result || '—')); const actionTd = el('td', {}); if (!cancelled && !tCancelled) { const btn = el('button', { type: 'button', class: 'btn btn-deny btn-inline-small' }, '✕'); btn.title = 'cancel just this target'; btn.addEventListener('click', () => cancelScheduleTargets(s.id, [t.target])); actionTd.append(btn); } else if (tCancelled) { actionTd.append(el('span', { class: 'meta' }, '✕ cancelled')); } tr.append(actionTd); tbody.append(tr); } table.append(tbody); li.append(table); } if (!cancelled) { const actions = el('div', { class: 'schedule-actions' }); // Fire-now (#467): out-of-band manual pulse. Recurring schedules // get an extra fan-out without disturbing `next_fire_at`; one-shots // are consumed (cancelled after fan-out), per damocles's design. // Disabled when every target is cancelled — there's nothing to // fire to. Backend: POST /api/schedules/{id}/fire-now. const activeTargets = (s.targets || []).filter((t) => !t.cancelled_at_unix); const fireBtn = el('button', { type: 'button', class: 'btn btn-fire-now' }, '↯ fire now'); const isOneShot = !s.interval_seconds; fireBtn.title = isOneShot ? 'fire this schedule once, immediately (one-shot — schedule is consumed after the manual fire)' : 'fire this schedule once, immediately (recurring — next regularly-scheduled fire is unaffected)'; if (!activeTargets.length) { fireBtn.disabled = true; fireBtn.title = 'every target is cancelled — nothing to fire'; } fireBtn.addEventListener('click', () => fireScheduleNow(s.id, isOneShot, activeTargets.map((t) => t.target))); actions.append(fireBtn); const editBtn = el('button', { type: 'button', class: 'btn btn-edit-schedule' }, editingSchedules.has(s.id) ? '✎ close edit' : '✎ edit'); editBtn.title = 'edit body / description / interval / next-fire (targets stay immutable)'; editBtn.addEventListener('click', () => { if (editingSchedules.has(s.id)) { editingSchedules.delete(s.id); scheduleEditCarry.delete(s.id); } else { editingSchedules.add(s.id); } renderSchedulesList(); }); actions.append(editBtn); const cancelAll = el('button', { type: 'button', class: 'btn btn-deny' }, '✕ cancel all'); cancelAll.title = 'cancel the whole schedule'; cancelAll.addEventListener('click', () => cancelScheduleAll(s.id)); actions.append(cancelAll); li.append(actions); if (editingSchedules.has(s.id)) { li.append(renderScheduleEditForm(s)); } } return li; } // #474 — inline edit form. Renders inside the schedule row when the // row's `✎ edit` button is toggled on. Pre-filled with current // values; submit PATCHes /api/schedules/{id}. Targets stay // immutable (per damocles's backend; the workaround for retargeting // is cancel + new schedule). Mid-edit field values survive a // state-poll refresh via `scheduleEditCarry`. function renderScheduleEditForm(s) { const wrapper = el('div', { class: 'schedule-edit-form-wrapper' }); const form_ = el('form', { class: 'schedule-edit-form' }); form_.addEventListener('submit', (e) => { e.preventDefault(); submitEditSchedule(s, form_); }); const carry = scheduleEditCarry.get(s.id) || {}; const bodyLabel = el('label', { class: 'schedule-field' }, el('span', { class: 'schedule-field-label' }, 'body')); const bodyInput = el('textarea', { name: 'body', rows: '4', required: 'required' }); bodyInput.value = carry.body !== undefined ? carry.body : s.body; bodyLabel.append(bodyInput); form_.append(bodyLabel); const descLabel = el('label', { class: 'schedule-field' }, el('span', { class: 'schedule-field-label' }, 'description (blank to clear)')); const descInput = el('input', { type: 'text', name: 'description' }); descInput.value = carry.description !== undefined ? carry.description : (s.description || ''); descLabel.append(descInput); form_.append(descLabel); const firstFireLabel = el('label', { class: 'schedule-field' }, el('span', { class: 'schedule-field-label' }, 'next fire')); const firstFireInput = el('input', { type: 'datetime-local', name: 'next_fire', required: 'required', }); firstFireInput.value = carry.next_fire !== undefined ? carry.next_fire : isoForDatetimeLocal(new Date(s.next_fire_at_unix * 1000)); firstFireLabel.append(firstFireInput); form_.append(firstFireLabel); const intervalCx = buildIntervalComposer({ label: 'interval (blank / all-zero = flip to one-shot)', namePrefix: 'edit_interval_', initialSeconds: 0, }); form_.append(intervalCx.wrapper); if (carry.interval_d !== undefined || carry.interval_h !== undefined || carry.interval_m !== undefined || carry.interval_s !== undefined) { intervalCx.setParts({ d: carry.interval_d || '', h: carry.interval_h || '', m: carry.interval_m || '', s: carry.interval_s || '', }); } else if (s.interval_seconds) { intervalCx.fillFromSeconds(s.interval_seconds); } intervalCx.updatePreview(); // Persist carry on every input so a refresh repaint preserves it. form_.addEventListener('input', () => { const fd = new FormData(form_); scheduleEditCarry.set(s.id, { body: String(fd.get('body') || ''), description: String(fd.get('description') || ''), next_fire: String(fd.get('next_fire') || ''), interval_d: String(fd.get('edit_interval_d') || ''), interval_h: String(fd.get('edit_interval_h') || ''), interval_m: String(fd.get('edit_interval_m') || ''), interval_s: String(fd.get('edit_interval_s') || ''), }); }); // Targets read-only callout. Per #474 design: targets stay // immutable, cancel+new is the retarget workaround. const targetList = (s.targets || []) .filter((t) => !t.cancelled_at_unix) .map((t) => t.target) .join(', '); form_.append(el('p', { class: 'meta schedule-edit-targets-note' }, 'targets (' + (targetList || 'none active') + ') are immutable — ' + 'cancel + new schedule if you need a different recipient set.')); const actions = el('div', { class: 'schedule-actions' }); const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '✓ save changes'); const cancelEdit = el('button', { type: 'button', class: 'btn' }, 'cancel'); cancelEdit.addEventListener('click', () => { editingSchedules.delete(s.id); scheduleEditCarry.delete(s.id); renderSchedulesList(); }); actions.append(submit, cancelEdit); form_.append(actions); wrapper.append(form_); return wrapper; } async function submitEditSchedule(originalSchedule, form_) { const s = originalSchedule; const fd = new FormData(form_); const newBody = String(fd.get('body') || '').trim(); const newDescription = String(fd.get('description') || '').trim(); const newNextFireStr = String(fd.get('next_fire') || ''); if (!newBody) { alert('body must be non-empty'); return; } if (!newNextFireStr) { alert('next-fire timestamp is required'); return; } const newNextFireDate = new Date(newNextFireStr); if (Number.isNaN(newNextFireDate.getTime())) { alert('next-fire is not a valid datetime'); return; } const newNextFireUnix = Math.floor(newNextFireDate.getTime() / 1000); // Compute interval total from the d/h/m/s fields (composer uses // `edit_interval_*` names in this form). const partSecs = (name, mult) => { const raw = String(fd.get(name) || '').trim(); if (!raw) return 0; const n = parseInt(raw, 10); if (!Number.isFinite(n) || n < 0) return NaN; return n * mult; }; const partD = partSecs('edit_interval_d', 86400); const partH = partSecs('edit_interval_h', 3600); const partM = partSecs('edit_interval_m', 60); const partS = partSecs('edit_interval_s', 1); if ([partD, partH, partM, partS].some((v) => Number.isNaN(v))) { alert('interval fields must be non-negative integers'); return; } const intervalTotal = partD + partH + partM + partS; const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null; // Build the PATCH body. Only include keys for fields that // actually changed; explicit `null` clears description / flips // recurring→one-shot. const patch = {}; if (newBody !== s.body) patch.body = newBody; if (newDescription !== (s.description || '')) { patch.description = newDescription || null; } if (newNextFireUnix !== s.next_fire_at_unix) { patch.next_fire_at_unix = newNextFireUnix; } if (newIntervalSeconds !== (s.interval_seconds || null)) { patch.interval_seconds = newIntervalSeconds; } if (!Object.keys(patch).length) { // No-op submit. Treat as "close edit form". editingSchedules.delete(s.id); scheduleEditCarry.delete(s.id); renderSchedulesList(); return; } const submitBtn = form_.querySelector('button[type="submit"]'); const originalLabel = submitBtn ? submitBtn.textContent : ''; if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'saving…'; } try { const resp = await fetch('/api/schedules/' + encodeURIComponent(s.id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); if (!resp.ok) { const text = await resp.text().catch(() => ''); alert('edit failed: http ' + resp.status + (text ? '\n\n' + text : '')); return; } editingSchedules.delete(s.id); scheduleEditCarry.delete(s.id); await refreshSchedules(); } catch (err) { alert('edit failed: ' + err); } finally { if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; } } } async function fireScheduleNow(id, isOneShot, targets) { const targetList = targets.length ? targets.join(', ') : '(no active targets)'; const prompt = isOneShot ? `fire schedule #${id} now to ${targetList}?\n\n` + 'this is a ONE-SHOT — firing now consumes the schedule. ' + 'the scheduled fire time will no longer trigger.' : `fire schedule #${id} now to ${targetList}?\n\n` + 'this is RECURRING — sends an extra pulse out-of-band. ' + 'the regular cadence keeps firing on schedule.'; if (!confirm(prompt)) return; try { const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/fire-now', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); if (!resp.ok) { const text = await resp.text().catch(() => ''); alert('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : '')); return; } await refreshSchedules(); } catch (err) { alert('fire-now failed: ' + err); } } async function cancelScheduleAll(id) { if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return; await postScheduleCancel(id, null); } async function cancelScheduleTargets(id, targets) { if (!confirm(`cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`)) return; await postScheduleCancel(id, targets); } async function postScheduleCancel(id, targets) { try { const opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, }; if (targets) opts.body = JSON.stringify({ targets }); const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/cancel', opts); if (!resp.ok) { const text = await resp.text().catch(() => ''); alert('cancel failed: http ' + resp.status + (text ? '\n\n' + text : '')); return; } await refreshSchedules(); } catch (err) { alert('cancel failed: ' + err); } } // ─── state polling ────────────────────────────────────────────────────── let pollTimer = null; // Sections whose innerHTML gets blown away on each refresh. If the // operator is typing in one of them, skip the refresh — the next // tick (or a manual action) will pick it up after they blur. const MANAGED_SECTION_IDS = [ 'containers-section', 'tombstones-section', 'questions-section', 'inbox-section', 'approvals-section', 'meta-inputs-section', 'rebuild-queue-section', 'reminders-section', 'schedule-new-section', 'schedules-section', ]; //
sections that should survive a refresh need a stable // `data-restore-key` attribute. snapshotOpenDetails walks managed // sections and records which keys are currently open; restoreOpenDetails // re-applies after the render. (Long-content drill-ins — file // previews, diffs, logs, config — open in the side panel instead, // which lives outside the managed sections and survives re-render // on its own.) function snapshotOpenDetails() { const open = new Set(); for (const id of MANAGED_SECTION_IDS) { const sect = document.getElementById(id); if (!sect) continue; for (const d of sect.querySelectorAll('details[data-restore-key]')) { if (d.open) open.add(d.dataset.restoreKey); } } return open; } function restoreOpenDetails(open) { if (!open.size) return; for (const id of MANAGED_SECTION_IDS) { const sect = document.getElementById(id); if (!sect) continue; for (const d of sect.querySelectorAll('details[data-restore-key]')) { if (open.has(d.dataset.restoreKey)) d.open = true; } } } function operatorIsTyping() { const el_ = document.activeElement; if (!el_ || el_ === document.body) return false; const tag = el_.tagName; if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false; return MANAGED_SECTION_IDS.some((id) => { const sect = document.getElementById(id); return sect && sect.contains(el_); }); } async function refreshState() { // Don't yank the form out from under the operator. Try again // shortly on the next tick; eventually they'll blur and the // refresh lands. if (operatorIsTyping()) { if (pollTimer) clearTimeout(pollTimer); pollTimer = setTimeout(refreshState, 2000); return; } try { const resp = await fetch('/api/state'); if (!resp.ok) throw new Error('http ' + resp.status); const s = await resp.json(); // Stash the latest snapshot for any sub-widget that wants a // synchronous read (e.g. the compose autocomplete pulls agent // names from here instead of refetching on every keystroke). window.__hyperhive_state = s; const openDetails = snapshotOpenDetails(); // Sync transients + containers first so renderContainers below // sees the current derived maps (it reads from // `transientsState` + `containersState`, not from `s.*`). syncTransientsFromSnapshot(s); syncContainersFromSnapshot(s); syncTombstonesFromSnapshot(s); syncMetaInputsFromSnapshot(s); syncRebuildQueueFromSnapshot(s); renderContainers(s); renderTombstones(s); // Sync the derived approvals + questions stores from the // snapshot, then render. Live `*_added` / `*_resolved` events // mutate the stores directly and re-render without a snapshot // refetch. syncQuestionsFromSnapshot(s); renderQuestions(); // (renderInbox now lives in ./flow.js — dashboard has no // #inbox-section element to render into.) syncApprovalsFromSnapshot(s); renderApprovals(); renderMetaInputs(s); renderRebuildQueue(s); refreshReminders(); refreshSchedules(); restoreOpenDetails(openDetails); notifyDeltas(s); // No periodic refresh timer. Phase 6 covers every container // mutation with `ContainerStateChanged` / `ContainerRemoved` // (lifecycle ops, destroy, rebuild, crash_watch's 10s poll); // approvals + questions + transients have their own events; // broker traffic flows through the SSE channel. The only // /api/state fetches are the initial cold load and the // post-submit refetch on forms without `data-no-refresh` // (tombstones, meta-input updates). if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } } catch (err) { console.error('refreshState failed', err); // Schedule a single retry on transient errors so the page // recovers from a brief network blip without making the // operator reload. pollTimer = setTimeout(refreshState, 5000); } } refreshState(); NOTIF.bind(); Panel.bind(); // ─── live updates: dashboard event stream (#406 step 3) ──────────────── // The dashboard subscribes to /dashboard/stream for live mutation // events so the SW4RM / Y3R C4LL / SYST3M panes update without an // operator action triggering a refreshState. Pre-step-2 this wiring // lived inside the broker-terminal IIFE which only fired on /flow.html // — meaning /index.html only updated on cold load + after async-form // submits. // // Bare `EventSource` (no terminal infrastructure needed — the // dashboard doesn't render broker rows). Each event's `kind` is // looked up against `MUTATION_HANDLERS`; unknown kinds (broker // `sent` / `delivered`, anything new the backend adds) silently // no-op. On (re)connect we kick a refreshState() to recover events // lost during the disconnect window (same pattern as flow.js's // onStreamOpen). // // `#408` will give /index.html its own stream that omits the // broker traffic the dashboard never uses; for now both pages // subscribe to `/dashboard/stream` and filter client-side. const MUTATION_HANDLERS = { approval_added: applyApprovalAdded, approval_resolved: applyApprovalResolved, question_added: applyQuestionAdded, question_resolved: applyQuestionResolved, transient_set: applyTransientSet, transient_cleared: applyTransientCleared, container_state_changed: applyContainerStateChanged, container_removed: applyContainerRemoved, tombstones_changed: applyTombstonesChanged, meta_inputs_changed: applyMetaInputsChanged, meta_update_running: applyMetaUpdateRunning, rebuild_queue_changed: applyRebuildQueueChanged, }; (function bindDashboardStream() { // #448: route the EventSource through a SharedWorker so all open // hyperhive tabs share ONE backend SSE connection. Survives // Firefox's per-tab connection throttling under many-open-tabs // pressure (the actual mara symptom). `openStream` returns an // EventSource-shaped facade so the rest of this IIFE is unchanged; // graceful fallback to direct `new EventSource` when SharedWorker // isn't supported. const es = openStream('/dashboard/stream'); es.onmessage = (e) => { let ev; try { ev = JSON.parse(e.data); } catch { return; } const h = MUTATION_HANDLERS[ev.kind]; if (!h) return; // broker rows + future kinds — dashboard doesn't care try { h(ev); } catch (err) { console.error('dashboard SSE handler', ev.kind, err); } }; es.onopen = () => { // Re-sync to recover events that fired during a disconnect // window (issue #163). Initial connect also fires onopen — the // first refreshState() above and this one race, but refreshState // is idempotent so the second call just overwrites with the // freshest snapshot. Cheap on a quiescent server, fine to repeat. refreshState(); }; es.onerror = () => { // EventSource auto-reconnects; nothing to do beyond logging. console.debug('dashboard SSE error, will retry'); }; })(); // ─── tab routing (#369) ──────────────────────────────────────────────── // Hash-based: `#swarm` / `#call` / `#system` activate the matching // pane on the dashboard. Empty hash defaults to SW4RM. FL0W is NOT // a tab — it's a separate page (`/flow.html`) reached via the // tab-strip link. Tab routing only applies when the tab DOM is // present (e.g. not on the flow page itself, where these elements // don't exist and the loop no-ops). const TABS = ['swarm', 'call', 'system', 'schedules']; function activateTab(name) { const target = TABS.includes(name) ? name : TABS[0]; for (const t of TABS) { const tab = $('tab-' + t); const pane = $('tab-pane-' + t); if (tab) tab.classList.toggle('active', t === target); if (pane) pane.classList.toggle('tab-pane-active', t === target); } // #459: schedules pane has no SSE channel yet (PR C follow-up), so // re-fetch on activation so the operator never lands on stale data. if (target === 'schedules') refreshSchedules(); } function syncTabFromHash() { const h = (window.location.hash || '#swarm').replace(/^#/, ''); activateTab(h); } window.addEventListener('hashchange', syncTabFromHash); syncTabFromHash(); // Tab count pills — pure derived data from the existing state // stores so SSE-driven updates flow through without extra plumbing. // Set `hidden` when the count is zero so the pill doesn't draw // attention to an empty room. function setTabCount(tab, n) { const el_ = $('tab-count-' + tab); if (!el_) return; el_.textContent = String(n); el_.hidden = n <= 0; } /** Recompute every tab's count from the current state. Called on * every renderXxx that's tab-relevant. */ function refreshTabCounts() { // SW4RM — flag any container that's stale (needs_update). Empty // when everyone's current. Container-row pulse signals state // transitions; the pill catches "deploy-pending" specifically. let swarm = 0; for (const c of containersState.values()) { if (c.needs_update) swarm++; } setTabCount('swarm', swarm); // Y3R C4LL — pending approvals + operator-targeted questions. const callCount = (approvalsState?.pending?.length ?? 0) + (questionsState?.pending?.length ?? 0); setTabCount('call', callCount); // SYST3M — queued + running rebuild_queue entries (terminal // entries are kept for history but aren't 'attention'). let sysCount = 0; if (rebuildQueueState) { for (const e of rebuildQueueState) { if (e.state === 'Queued' || e.state === 'Running') sysCount++; } } setTabCount('system', sysCount); // SCH3DUL3S — count of schedules with at least one still-active // target (whole-schedule cancellation or all-targets-cancelled // means "not waiting on the worker"; those don't pull attention). setTabCount('schedules', activeScheduleCount()); // FL0W pill count: lives in ./flow.js now (it has the inbox // derived store). Dashboard tab strip's `#tab-count-flow` slot // stays hidden by default; future plumbing could broadcast the // count via localStorage / BroadcastChannel if both pages are // open. } // Poll the state stores on a 1s tick to keep the pill counts in // sync. The state stores are mutated synchronously by every SSE // event + refreshState call, so polling them is correct and cheap // — no per-renderer hookup needed. refreshTabCounts(); setInterval(refreshTabCounts, 1000); // Flow-specific IIFEs moved to ./flow.js (#406 step 2): inbox-pill // wiring, the broker terminal init, and the @-mention composer. // /index.html no longer loads them — only /flow.html does. })();