// Y3R C4LL domain — the dashboard pane for things blocked on an operator // decision: approvals, the operator's question queue, and the operator // inbox (messages agents sent to `to="operator"`). Lifted out of the // dashboard entry (tabs.js) into its own module so the entry stays a thin // coordinator; mirrors the permissions.js / schedules.js extractions. // // State the tab owns lives here (module-local); the one genuinely // cross-domain store, `questionsState`, lives in state.js because the // SW4RM container rows read it too. // // Tab-count wiring: like schedules.js this module does not call the // coordinator's `refreshTabCounts` directly (that would be a circular // import). Instead it exposes count getters the coordinator pulls, and the // live-mutation paths call an injected `onCountsChanged` callback the entry // registers once via `initCall`. import { $, el, form, Panel, appendLinkified } from './common.js'; import { fmtAgo } from './util.js'; import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js'; // Registered by the dashboard entry at boot; defaults to a no-op so the // module is safe to call before wiring. let onCountsChanged = () => {}; export function initCall(opts = {}) { if (typeof opts.onCountsChanged === 'function') onCountsChanged = opts.onCountsChanged; } // ─── operator inbox — unread agent→operator messages ──────────── // The Y3R C4LL tab surfaces messages agents `send(to: "operator")` so // the operator stops missing them. Unread = broker rows to "operator" // with `acked_at IS NULL`; cold-loaded from `/api/operator-inbox`, // appended live from the broker `sent` stream, and cleared via the // existing per-recipient ack (`POST /api/agent/operator/mark-all-read`). // Count folds into the Y3R C4LL pill + browser-title prefix. let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first export function operatorInboxCount() { return operatorInbox.length; } export async function refreshOperatorInbox() { try { const r = await fetch('/api/operator-inbox'); if (r.ok) { const data = await r.json(); operatorInbox = Array.isArray(data.messages) ? data.messages : []; } } catch { /* keep prior list on transient failure */ } renderOperatorInbox(); onCountsChanged(); } function renderOperatorInbox() { const root = $('operator-inbox-section'); if (!root) return; root.replaceChildren(); if (!operatorInbox.length) { root.append(el('p', { class: 'meta' }, 'no unread messages')); return; } const mark = el('button', { type: 'button', class: 'btn', id: 'op-inbox-mark-read' }, `✓ mark all read (${operatorInbox.length})`); mark.addEventListener('click', markOperatorInboxRead); root.append(el('div', { class: 'inbox-toolbar' }, mark)); const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); const ul = el('ul', { class: 'inbox' }); for (const m of operatorInbox) { const body = el('span', { class: 'msg-body' }); appendLinkified(body, m.body, m.file_refs); ul.append(el('li', {}, el('span', { class: 'msg-ts' }, fmt(m.at)), ' ', el('span', { class: 'msg-from' }, m.from), ' ', el('span', { class: 'msg-sep' }, '→ '), body, )); } root.append(ul); } async function markOperatorInboxRead() { try { await fetch('/api/agent/operator/mark-all-read', { method: 'POST' }); } catch { /* best-effort; the next refresh reconciles */ } operatorInbox = []; renderOperatorInbox(); onCountsChanged(); } // Live append from the broker stream — a `sent` frame addressed to // "operator". De-dupes on broker row id so a history/live overlap or // a refresh racing the stream doesn't double-list. export function operatorInboxAppendFromEvent(ev) { if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return; operatorInbox.unshift({ id: ev.id, from: ev.from, body: ev.body, at: ev.at, file_refs: ev.file_refs || [], }); if (operatorInbox.length > 100) operatorInbox.length = 100; renderOperatorInbox(); onCountsChanged(); } // ─── approvals — the operator config-change / spawn approval queue ──────── 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: [] }; export function activeApprovalCount() { return approvalsState.pending.length; } export function syncApprovalsFromSnapshot(s) { approvalsState.pending = (s.approvals || []).slice(); approvalsState.history = (s.approval_history || []).slice(); } export 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. 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(); } export 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 (post-disconnect SSE catchup) 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'); } export function renderApprovals() { const root = $('approvals-section'); // #approvals-section only lives on /dashboard.html (Y3R C4LL tab); // no-op elsewhere — `approval_added` / `approval_resolved` SSE // events route through here on every page that loads the bundle. if (!root) return; // Save spawn form input + focus state before the DOM wipe so a live // approval_added/resolved event doesn't erase a partially-typed name // or steal focus from the operator. const savedSpawnName = root.querySelector('.spawnform input[name="name"]')?.value ?? ''; const spawnHadFocus = document.activeElement === root.querySelector('.spawnform input[name="name"]'); root.replaceChildren(); // 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 spawnNameInput = el('input', { name: 'name', placeholder: 'new agent name (≤9 chars)', maxlength: '9', required: '', autocomplete: 'off', }); if (savedSpawnName) spawnNameInput.value = savedSpawnName; if (spawnHadFocus) spawnNameInput.focus(); const spawn = el('form', { method: 'POST', action: '/request-spawn', class: 'spawnform', 'data-async': '', 'data-no-refresh': '', }); spawn.append( spawnNameInput, 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; // Prefer state.forge_public_url (set when forge.behindGateway=true, // e.g. "https://forge.pr1ma.darkest.space") over the direct :3000 port. const forgeBase = (fs && fs.forge_present) ? (fs.forge_public_url || `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 (see docs/web-ui.md::Approval card). 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(), 'data-requested-at': String(a.requested_at), }, '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' ? '✗' : a.status === 'cancelled' ? '⊘' : '⚠'; 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' : a.kind === 'init_config' ? 'init' : '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); } // ─── questions — the operator question queue (Y3R C4LL) ─────────────────── // questionsState + QUESTION_HISTORY_LIMIT are imported from state.js (shared // with the SW4RM per-agent question-count badges). // Keyed row cache: question id → {el, fingerprint}. Allows renderQuestions // to reuse