diff --git a/CLAUDE.md b/CLAUDE.md index ab826887..0b2bf157 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,26 +193,6 @@ Prune freely. domain tooling — the agent flake's `inputs` block pulls the external flake, `agent.nix` references it via `flakeInputs..packages.${pkgs.system}.default`. -- **Just landed:** Phase 6 container events. New - `DashboardEvent::ContainerStateChanged { container }` + - `ContainerRemoved { name }` close the last refetch loop on the - dashboard side. `Coordinator::rescan_containers_and_emit` builds a - fresh `container_view::build_all` snapshot, diffs it against a - cached `last_containers` map, and fires per-row events for the - delta. Called from every mutation site: `actions::approve` - (post-spawn), `actions::destroy`, the `lifecycle_action` wrapper - in `dashboard.rs` (start/stop/restart/rebuild), `auto_update:: - rebuild_agent`, and the existing 10s `crash_watch` poll loop. - `ContainerView` extracted to its own module so coordinator + - dashboard can both build it. Dashboard endpoints (`/restart`, - `/destroy`, `/kill`, `/rebuild`, `/start`, `/update-all`, - `/meta-update`, `/purge-tombstone`) now return 200; matching - forms carry `data-no-refresh` where the event coverage is - complete (purge + meta-update keep the refetch since tombstones - + meta_inputs aren't event-derived yet). Client drops the 5s - periodic `/api/state` poll entirely — initial cold load + SSE - for everything afterwards; pending overlay reads from - `transientsState` since the new event payload doesn't carry it. - **Just landed:** dashboard event refactor. New `hive-fr0nt` workspace crate hosts shared frontend assets (palette + terminal CSS + `window.HiveTerminal.create` JS) so both the dashboard and diff --git a/TODO.md b/TODO.md index b0554c51..fcbd796c 100644 --- a/TODO.md +++ b/TODO.md @@ -21,23 +21,13 @@ ## Dashboard - - +- **UI for agent-to-agent questions** (follow-up to the `ask` rename): now that agents can `ask(to: )` each other, surface those threads in the per-agent dashboard view. Replace the existing read/unread tabs with THREE filters: `unread`, `from: `, `to: `. The `to:` filter makes agent-targeted questions visible so the operator can see at a glance "alice has 3 questions outstanding from bob" and intervene if a thread is stuck. Same UI is useful for general inbox filtering too. Data lives in the existing `operator_questions` table (with the new `target` column) + the broker inbox; no new schema needed. Also expose a "respond" affordance so the operator can override-answer a peer question when an agent is offline / stuck (the answerer-auth check in `OperatorQuestions::answer` already permits the operator on any target). +- **Clickable file paths in message bodies**: agents drop pointer strings like `/agents//state/foo.md` constantly (it's the whole 1 KiB-cap escape hatch). Right now they're plain text — operator has to copy-paste into a terminal to peek. Detect path-shaped tokens (start with `/agents/`, `/shared/`, `/state/`, or absolute `/var/lib/hyperhive/...`) in rendered message bodies + question text + answer text + helper-event payloads, render as clickable links that hit a new `/api/state-file?path=…` dashboard endpoint. Endpoint serves the file as text (with a strict allow-list — only paths under `/var/lib/hyperhive/agents/*/state/`, `/var/lib/hyperhive/shared/`, never anything else), syntax-highlighting where it makes sense, falling back to download for binaries. Reuses the existing `
` collapse pattern so inline preview doesn't blow up the message-flow stream. - **UI for pending reminders**: show pending/queued reminders in dashboard, allow operator to view/debug/cancel - Per-agent reminder status (pending, delivered) - Reminder query interface for debugging - Display reminder delivery errors (failed sends, mark failures) -- **Phase 6 follow-ups** — dashboard side is fully event-driven (Phase 6 leftovers landed); the per-agent web UI's lifecycle endpoints (`/api/{cancel,compact,model,new-session}`, `/login/*`) still 303-redirect-and-poll. Convert them to 200 + `data-no-refresh` so the per-agent page stops refetching `/api/state` on every operator click — `LiveEvent::Note` already covers cancel/compact/model/new-session, login state needs its own `NeedsLogin` / `LoggedIn` events on the per-agent bus. -- **Tombstones + meta_inputs events**: not yet event-derived. PURG3 + meta-update still trigger a post-submit `/api/state` refetch on the dashboard. Add `TombstoneAdded`/`TombstoneRemoved` + `MetaInputsChanged` so those forms can drop their refetch too and the cold-load is the only `/api/state` fetch in normal operation. +- **Phase 6 leftovers** — event-covered endpoints (`/approve`, `/deny`, `/answer-question`, `/cancel-question`, `/request-spawn`) now return 200 (f559441); the matching forms carry `data-no-refresh` so the post-submit `/api/state` refetch is skipped (the SSE event delivers the update). Container-lifecycle endpoints (`/restart`, `/destroy`, `/kill`, `/rebuild`, `/start`, `/api/{cancel,compact,model,new-session}`, `/meta-update`, `/purge-tombstone`) still need a `ContainerListChanged` event before their redirects can drop — `ContainerView` is currently sourced from external `nixos-container list`, so the 5s poll continues to drive that section. ## Bugs diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index b2f370c7..4d247a73 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -38,78 +38,6 @@ return f; }; - // ─── path linkification ───────────────────────────────────────────────── - // Agents constantly drop pointer strings into messages + question - // bodies (it's the 1 KiB-cap escape hatch). Anything matching the - // PATH_RE patterns becomes a clickable anchor; clicking expands an - // inline
with the file's contents, fetched lazily from - // /api/state-file. The legacy in-container `/state/...` prefix is - // deliberately not matched — it's ambiguous from the host's - // perspective (we'd need to know which agent the message is about - // to translate it). Prefer `/agents//state/...` in agent - // outputs and the link will resolve. - const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/[\w./-]+|\/var\/lib\/hyperhive\/shared\/[\w./-]+|\/agents\/[\w.-]+\/state\/[\w./-]+|\/shared\/[\w./-]+)/g; - async function fetchStateFile(path) { - const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path)); - const text = await resp.text(); - if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status)); - return text; - } - function makePathPreview(path) { - // Inline anchor + a sibling
that lazy-loads the file - // on first open. Caller appends both: the anchor inline with the - // surrounding text, the details as a block sibling after the - // line so the layout doesn't get awkward. - const anchor = el('a', { - href: '#', class: 'path-link', title: 'click to preview ' + path, - }, path); - const details = el('details', { class: 'path-preview' }); - const summary = el('summary', {}, '↳ ' + path); - const pre = el('pre', { class: 'path-preview-body' }, '(fetching…)'); - details.append(summary, pre); - let fetched = false; - async function doFetch() { - if (fetched) return; - fetched = true; - try { - pre.textContent = await fetchStateFile(path); - } catch (e) { - pre.textContent = 'error: ' + (e.message || e); - fetched = false; // allow retry on next open - } - } - details.addEventListener('toggle', () => { if (details.open) doFetch(); }); - anchor.addEventListener('click', (e) => { - e.preventDefault(); - details.open = !details.open; - }); - return { anchor, details }; - } - // Append `text` to `parent` as a mix of text nodes + path anchors. - // Returns the array of generated `
` previews so the - // caller can append them as block siblings under the row. - function appendLinkified(parent, text) { - const previews = []; - if (text == null) return previews; - const str = String(text); - let lastIdx = 0; - PATH_RE.lastIndex = 0; - let m; - while ((m = PATH_RE.exec(str)) !== null) { - if (m.index > lastIdx) { - parent.appendChild(document.createTextNode(str.slice(lastIdx, m.index))); - } - const { anchor, details } = makePathPreview(m[0]); - parent.appendChild(anchor); - previews.push(details); - lastIdx = m.index + m[0].length; - } - if (lastIdx < str.length) { - parent.appendChild(document.createTextNode(str.slice(lastIdx))); - } - return previews; - } - // ─── browser notifications ────────────────────────────────────────────── // Fires OS notifications on three operator-bound signals: // - new approval landed in the queue @@ -221,9 +149,7 @@ 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), + NOTIF.show('◆ manager asks', q.question.slice(0, 120), 'hyperhive:question:' + q.id); } } @@ -288,27 +214,6 @@ } }); - // 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 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 @@ -346,56 +251,27 @@ if (s) renderContainers(s); } - // 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 ──────────────────────────────────────────────────── function renderContainers(s) { const root = $('containers-section'); 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). - const containers = Array.from(containersState.values()) - .sort((a, b) => a.name.localeCompare(b.name)); - 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) { + if (s.port_conflicts && s.port_conflicts.length) { const banner = el('div', { class: 'port-conflict' }, el('strong', {}, '⚠ port collision'), ' — '); - const groups = portConflicts.map((c) => + const groups = s.port_conflicts.map((c) => `:${c.port} (${c.agents.join(' + ')})`).join('; '); banner.append(groups + '. rename one of each and ↻ R3BU1LD.'); root.append(banner); } - if (anyStale) { + if (s.any_stale) { root.append(form( '/update-all', 'btn-rebuild', '↻ UPD4TE 4LL', 'rebuild every stale container?', - {}, { noRefresh: true }, )); } @@ -414,20 +290,15 @@ root.append(ul); } - if (!containers.length && !transientsState.size) { + if (!s.containers.length && !transientsState.size) { root.append(el('p', { class: 'empty' }, 'no managed containers')); return; } - const hostname = (s && s.hostname) || window.location.hostname; const ul = el('ul', { class: 'containers' }); - for (const c of containers) { - const url = `http://${hostname}:${c.port}/`; - // Pending state is overlaid from the transient store, not from - // the container row — `ContainerStateChanged` doesn't carry it, - // `TransientSet` / `TransientCleared` do. - const pending = transientsState.get(c.name)?.kind || null; - const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') }); + for (const c of s.containers) { + const url = `http://${s.hostname}:${c.port}/`; + const li = el('li', { class: 'container-row' + (c.pending ? ' pending' : '') }); // ── line 1: identity ───────────────────────────────────────── const head = el('div', { class: 'head' }); @@ -436,9 +307,9 @@ el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, c.is_manager ? 'm1nd' : 'ag3nt'), ); - if (pending) { + if (c.pending) { head.append(el('span', { class: 'pending-state' }, - el('span', { class: 'spinner' }, '◐'), ' ', pending + '…')); + el('span', { class: 'spinner' }, '◐'), ' ', c.pending + '…')); } else if (c.needs_login) { head.append(el('a', { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' }, @@ -448,7 +319,6 @@ 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}`)); @@ -463,37 +333,29 @@ const actions = el('div', { class: 'actions' }); if (c.running) { actions.append( - form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT', - 'restart ' + c.name + '?', {}, { noRefresh: true }), + form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT', 'restart ' + c.name + '?'), ); if (!c.is_manager) { actions.append( - form('/kill/' + c.name, 'btn-stop', '■ ST0P', - 'stop ' + c.name + '?', {}, { noRefresh: true }), + form('/kill/' + c.name, 'btn-stop', '■ ST0P', 'stop ' + c.name + '?'), ); } } else { actions.append( - form('/start/' + c.name, 'btn-start', '▶ ST4RT', - 'start ' + c.name + '?', {}, { noRefresh: true }), + form('/start/' + c.name, 'btn-start', '▶ ST4RT', 'start ' + c.name + '?'), ); } actions.append( form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD', - 'rebuild ' + c.name + '? hot-reloads the container.', - {}, { noRefresh: true }), + 'rebuild ' + c.name + '? hot-reloads the container.'), ); if (!c.is_manager) { - // DESTR0Y is event-covered (ContainerRemoved); PURG3 also - // wipes tombstone state which isn't event-derived yet, so it - // keeps the post-submit refetch. actions.append( form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y', - 'destroy ' + c.name + '? container is removed; state + creds kept.', - {}, { noRefresh: true }), + 'destroy ' + c.name + '? container is removed; state + creds kept.'), form('/destroy/' + c.name, 'btn-destroy', 'PURG3', 'PURGE ' + c.name + '? container, config history, claude creds, ' - + 'and notes are all WIPED. no undo.', { purge: 'on' }), + + 'and /state/ notes are all WIPED. no undo.', { purge: 'on' }), ); } li.append(actions); @@ -684,7 +546,6 @@ multi: !!ev.multi, asked_at: ev.asked_at, deadline_at: ev.deadline_at ?? null, - target: ev.target || null, }); renderQuestions(); } @@ -702,84 +563,26 @@ answered_at: ev.answered_at, answer: ev.answer, answerer: ev.answerer, - target: existing?.target ?? ev.target ?? null, }); 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'); 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); - + const pending = questionsState.pending; if (!pending.length) { - root.append(el('p', { class: 'empty' }, - activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter')); + root.append(el('p', { class: 'empty' }, 'no pending questions')); } 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 li = el('li', { class: 'question' }); 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) { @@ -793,10 +596,7 @@ + Math.floor((remaining % 3600) / 60) + 'm'; head.append(' ', el('span', { class: 'q-ttl' }, txt)); } - const qBody = el('div', { class: 'q-body' }); - const qPreviews = appendLinkified(qBody, q.question); - li.append(head, qBody); - for (const d of qPreviews) li.appendChild(d); + li.append(head, el('div', { class: 'q-body' }, q.question)); const f = el('form', { method: 'POST', action: '/answer-question/' + q.id, class: 'qform', 'data-async': '', 'data-no-refresh': '', @@ -837,19 +637,9 @@ }, 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), + el('button', { type: 'submit', class: 'btn btn-approve' }, + isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'), ); f.append( el('div', { class: 'q-free' }, freeText), @@ -858,11 +648,10 @@ 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 ` + 'data-confirm': 'cancel this question? manager will see ' + '"[cancelled]" as the answer.', }); cancelForm.append( @@ -880,27 +669,20 @@ 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 li = el('li', { class: 'question question-answered' }); 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' }); - const histBodyPreviews = appendLinkified(histBody, q.question); - const ansText = el('span', { class: 'q-answer-text' }); - const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)'); - const ansLine = el('div', { class: 'q-answer' }, - el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), - ansText, + li.append( + head, + el('div', { class: 'q-body' }, q.question), + el('div', { class: 'q-answer' }, + el('span', { class: 'msg-sep' }, 'answer: '), + el('span', { class: 'q-answer-text' }, q.answer || '(none)'), + ), ); - li.append(head, histBody); - for (const d of histBodyPreviews) li.appendChild(d); - li.append(ansLine); - for (const d of histAnsPreviews) li.appendChild(d); hul.append(li); } details.append(hul); @@ -933,15 +715,12 @@ const ul = el('ul', { class: 'inbox' }); for (const m of operatorInbox) { const li = el('li'); - const body = el('span', { class: 'msg-body' }); - const previews = appendLinkified(body, m.body); li.append( el('span', { class: 'msg-ts' }, fmt(m.at)), ' ', el('span', { class: 'msg-from' }, m.from), ' ', el('span', { class: 'msg-sep' }, '→ '), - body, + el('span', { class: 'msg-body' }, m.body), ); - for (const d of previews) li.appendChild(d); ul.append(li); } root.append(ul); @@ -1309,11 +1088,10 @@ // 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.*`). + // Sync transients first so renderContainers below sees the + // current derived map (it reads from `transientsState`, not + // from `s.transients`). syncTransientsFromSnapshot(s); - syncContainersFromSnapshot(s); renderContainers(s); renderTombstones(s); // Sync the derived approvals + questions stores from the @@ -1328,20 +1106,18 @@ renderMetaInputs(s); 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). + // Auto-refresh: fast (2s) while a spawn or a per-container + // action is in flight, otherwise heartbeat (5s) so newly-queued + // approvals from the manager show up without the operator + // having to reload the page. Broker SSE already triggers a + // refresh on operator-bound messages; this catches the rest + // (approvals, tombstones, questions). + const anyPending = s.containers.some((c) => c.pending); + const next = (transientsState.size || anyPending) ? 2000 : 5000; if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } + if (next) pollTimer = setTimeout(refreshState, next); } 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); } } @@ -1370,24 +1146,14 @@ bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000); } function renderMsg(ev, api, glyph) { - const row = api.row('msgrow ' + ev.kind, ''); - // Build via DOM so path anchors stay live + escape rules are - // automatic (text nodes don't need esc()). - const ts = document.createElement('span'); - ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at); - const arrow = document.createElement('span'); - arrow.className = 'msg-arrow'; arrow.textContent = glyph; - const from = document.createElement('span'); - from.className = 'msg-from'; from.textContent = ev.from; - const sep = document.createElement('span'); - sep.className = 'msg-sep'; sep.textContent = '→'; - const to = document.createElement('span'); - to.className = 'msg-to'; to.textContent = ev.to; - const body = document.createElement('span'); - body.className = 'msg-body'; - const previews = appendLinkified(body, ev.body); - row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body); - for (const d of previews) row.appendChild(d); + const el = api.row('msgrow ' + ev.kind, ''); + el.innerHTML = + '' + tsFmt(ev.at) + '' + + '' + glyph + '' + + '' + esc(ev.from) + '' + + '' + + '' + esc(ev.to) + '' + + '' + esc(ev.body) + ''; } HiveTerminal.create({ logEl: flow, @@ -1405,8 +1171,6 @@ question_resolved: (ev) => { applyQuestionResolved(ev); }, transient_set: (ev) => { applyTransientSet(ev); }, transient_cleared: (ev) => { applyTransientCleared(ev); }, - container_state_changed: (ev) => { applyContainerStateChanged(ev); }, - container_removed: (ev) => { applyContainerRemoved(ev); }, }, // Both history backfill and live frames flow through here, so the // inbox section ends up populated correctly on first paint and @@ -1444,14 +1208,15 @@ prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>'; } function knownAgents() { - // Read live from the derived containers map so newly-spawned - // agents become addressable without an /api/state refetch. - // Broker uses the literal recipient `manager` for the manager's - // inbox, not the container name `hm1nd`. - const names = Array.from(containersState.values()) - .map((c) => (c.is_manager ? 'manager' : c.name)); - // `*` fans out to every registered agent (server-side - // broadcast_send). + const s = window.__hyperhive_state; + if (!s || !Array.isArray(s.containers)) return []; + // The broker uses the literal recipient `manager` for the + // manager's inbox, not the container name `hm1nd`. Swap on + // suggestion so `@manager` Just Works. + const names = s.containers.map((c) => (c.is_manager ? 'manager' : c.name)); + // `*` fans out the message to every registered agent (server-side + // broadcast_send). Surface it as a suggestion so operators can + // type `@*` from the dashboard the same way the manager does. names.unshift('*'); return names; } diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index d6648cc1..beba286f 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -450,75 +450,6 @@ summary:hover { color: var(--purple); } 0%, 100% { box-shadow: 0 0 12px -4px rgba(250, 179, 135, 0.55); } 50% { box-shadow: 0 0 22px -2px rgba(250, 179, 135, 0.95); } } -/* Path linkification — agents drop pointer strings into messages - constantly; clicking the anchor expands a sibling
that - lazy-loads from /api/state-file. */ -.path-link { - color: var(--blue, #89b4fa); - text-decoration: underline dotted; - cursor: pointer; -} -.path-link:hover { color: var(--amber); } -.path-preview { - margin: 0.2em 0 0.4em 1.5em; - border-left: 2px solid var(--border); - padding-left: 0.6em; -} -.path-preview > summary { - cursor: pointer; - color: var(--muted); - font-size: 0.85em; - list-style: none; - user-select: none; -} -.path-preview > summary::marker { content: ''; } -.path-preview-body { - background: var(--bg); - border: 1px solid var(--border); - padding: 0.5em 0.7em; - margin: 0.3em 0 0; - max-height: 30em; - overflow: auto; - white-space: pre-wrap; - word-break: break-word; - font-size: 0.85em; - color: var(--fg); -} - -/* Filter chip row above the questions list. The active chip lights - up amber to match the rest of the dashboard's selection accents. */ -.questions-filters { - display: flex; - flex-wrap: wrap; - gap: 0.3em; - margin-bottom: 0.5em; -} -.q-filter-chip { - background: var(--bg); - color: var(--muted); - border: 1px solid var(--border); - border-radius: 999px; - padding: 0.15em 0.7em; - font: inherit; - font-size: 0.85em; - cursor: pointer; -} -.q-filter-chip:hover { color: var(--fg); } -.q-filter-chip.active { - color: var(--amber); - border-color: var(--amber); -} -/* Peer (agent-to-agent) question rows get a left rule + dim - target-name styling so they read distinctly from operator-bound - threads at a glance. */ -.questions li.question-peer { - border-left: 2px solid var(--mauve, #cba6f7); - padding-left: 0.6em; -} -.questions .msg-to-peer { color: var(--mauve, #cba6f7); } -/* The override button on peer threads picks up a non-default colour - so the operator notices they're answering on someone's behalf. */ -.btn-override { background: var(--mauve, #cba6f7) !important; color: var(--bg) !important; } .questions li.question { padding: 0.4em 0; border-bottom: 1px solid var(--border); diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 32ce5af1..8dbbfdf7 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -85,10 +85,6 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { if let Err(e) = finish_approval(&coord_bg, &approval_bg, result, None) { tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed"); } - // New container row appeared (or didn't, on failure - // before nixos-container create completed) — rescan so - // dashboards reflect the post-spawn state. - coord_bg.rescan_containers_and_emit().await; }); Ok(()) } @@ -359,9 +355,6 @@ pub async fn destroy(coord: &Arc, name: &str, purge: bool) -> Resul coord.notify_manager(&HelperEvent::Destroyed { agent: name.to_owned(), }); - // Container row disappeared — rescan so the dashboard fires - // `ContainerRemoved` for the gone row. - coord.rescan_containers_and_emit().await; Ok(()) } diff --git a/hive-c0re/src/auto_update.rs b/hive-c0re/src/auto_update.rs index 4c714d34..2b2eb6ec 100644 --- a/hive-c0re/src/auto_update.rs +++ b/hive-c0re/src/auto_update.rs @@ -95,10 +95,6 @@ pub async fn rebuild_agent(coord: &Arc, name: &str, current_rev: &s // dashboard's meta-input update path — all of which // route through rebuild_agent. coord.kick_agent(name, "container rebuilt"); - // Container state (needs_update, deployed_sha) may have - // shifted — rescan so dashboards drop the "needs update" - // chip without waiting for the next /api/state poll. - coord.rescan_containers_and_emit().await; } Err(e) => { coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt { @@ -108,7 +104,6 @@ pub async fn rebuild_agent(coord: &Arc, name: &str, current_rev: &s sha: None, tag: None, }); - coord.rescan_containers_and_emit().await; } } result diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs deleted file mode 100644 index 869eea87..00000000 --- a/hive-c0re/src/container_view.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! `ContainerView` + the snapshot builder that turns -//! `nixos-container list` (plus per-agent state on disk) into the row -//! shape the dashboard renders. Extracted from `dashboard.rs` so the -//! coordinator's rescan-and-emit helper can build the same view and -//! diff against the last snapshot to fire -//! `ContainerStateChanged` / `ContainerRemoved` events. - -use std::collections::HashMap; -use std::path::Path; - -use serde::Serialize; - -use crate::coordinator::Coordinator; -use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME}; - -#[derive(Serialize, Clone, PartialEq, Eq, Debug)] -#[allow(clippy::struct_excessive_bools)] -pub struct ContainerView { - /// Logical agent name (no `h-` prefix). Used in action URLs. - pub name: String, - /// Container name as nixos-container sees it (`h-foo`, `hm1nd`). - pub container: String, - pub is_manager: bool, - pub port: u16, - pub running: bool, - pub needs_update: bool, - pub needs_login: bool, - /// First 12 chars of the sha the meta flake currently has locked - /// for this agent's input. - #[serde(skip_serializing_if = "Option::is_none")] - pub deployed_sha: Option, -} - -/// Build the full container list. Wraps `lifecycle::list()` and -/// resolves every per-agent attribute the dashboard surfaces. -pub async fn build_all(coord: &Coordinator) -> Vec { - let raw = lifecycle::list().await.unwrap_or_default(); - let current_rev = crate::auto_update::current_flake_rev(&coord.hyperhive_flake); - let locked = read_meta_locked_revs(); - let mut out = Vec::new(); - for c in &raw { - let (logical, is_manager) = if c == MANAGER_NAME { - (MANAGER_NAME.to_owned(), true) - } else if let Some(n) = c.strip_prefix(AGENT_PREFIX) { - (n.to_owned(), false) - } else { - continue; - }; - let needs_update = - current_rev.as_deref().is_some_and(|rev| crate::auto_update::agent_needs_update(&logical, rev)); - let needs_login = - !is_manager && !claude_has_session(&Coordinator::agent_claude_dir(&logical)); - let deployed_sha = locked - .get(&format!("agent-{logical}")) - .map(|s| s[..s.len().min(12)].to_owned()); - out.push(ContainerView { - port: lifecycle::agent_web_port(&logical), - running: lifecycle::is_running(&logical).await, - container: c.clone(), - name: logical, - is_manager, - needs_update, - needs_login, - deployed_sha, - }); - } - out -} - -/// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true -/// if the agent's bound `~/.claude/` dir on disk contains any regular -/// file. Reads each `build_all()` so a login driven from the agent's -/// own web UI reflects on the next snapshot. -pub fn claude_has_session(dir: &Path) -> bool { - let Ok(entries) = std::fs::read_dir(dir) else { - return false; - }; - entries - .flatten() - .any(|e| e.file_type().is_ok_and(|t| t.is_file())) -} - -/// Map of `agent-` → locked sha from meta's flake.lock. Used to -/// render the `deployed:` chip per container row. -fn read_meta_locked_revs() -> HashMap { - let mut out = HashMap::new(); - let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else { - return out; - }; - let Ok(json) = serde_json::from_str::(&raw) else { - return out; - }; - let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else { - return out; - }; - let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else { - return out; - }; - let Some(root_inputs) = nodes - .get(root_name) - .and_then(|n| n.get("inputs")) - .and_then(|v| v.as_object()) - else { - return out; - }; - for alias in root_inputs.keys() { - let target_name = match root_inputs.get(alias) { - Some(serde_json::Value::String(s)) => s.clone(), - _ => continue, - }; - if let Some(rev) = nodes - .get(&target_name) - .and_then(|n| n.get("locked")) - .and_then(|v| v.get("rev")) - .and_then(|v| v.as_str()) - { - out.insert(alias.clone(), rev.to_owned()); - } - } - out -} diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 98e9c778..35c486f3 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -13,7 +13,6 @@ use tokio::sync::broadcast; use crate::agent_server::{self, AgentSocket}; use crate::approvals::Approvals; use crate::broker::Broker; -use crate::container_view::{self, ContainerView}; use crate::dashboard_events::DashboardEvent; use crate::operator_questions::OperatorQuestions; @@ -65,14 +64,6 @@ pub struct Coordinator { /// snapshot. dashboard_events: broadcast::Sender, event_seq: AtomicU64, - /// Last container snapshot seen by `rescan_containers_and_emit`, - /// keyed by `ContainerView.name`. The rescan diffs a fresh - /// `container_view::build_all` against this map and emits one - /// `ContainerStateChanged` per added/changed row and one - /// `ContainerRemoved` per disappeared row. Async — guarded by a - /// tokio mutex so the rescan can `await` `lifecycle::list` / - /// `is_running` without blocking other coordinator paths. - last_containers: tokio::sync::Mutex>, } /// Per-agent in-progress state that the dashboard surfaces between approve @@ -151,7 +142,6 @@ impl Coordinator { transient: Mutex::new(HashMap::new()), dashboard_events, event_seq: AtomicU64::new(0), - last_containers: tokio::sync::Mutex::new(HashMap::new()), }) } @@ -243,10 +233,11 @@ impl Coordinator { }); } - /// Emit `QuestionAdded` after a question is inserted. Fires for - /// both operator-targeted (`target = None`) and peer-to-peer - /// (`target = Some(agent)`) threads — the dashboard surfaces - /// both, distinguishing visually + offering operator override. + /// Emit `QuestionAdded` after an operator-targeted question is + /// inserted. Peer-to-peer questions (those with a non-null + /// `target` agent) never fire this — they don't surface on the + /// dashboard at all. Caller is responsible for the + /// `target.is_none()` guard. pub fn emit_question_added( &self, id: i64, @@ -255,7 +246,6 @@ impl Coordinator { options: &[String], multi: bool, deadline_at: Option, - target: Option<&str>, ) { let asked_at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -271,22 +261,20 @@ impl Coordinator { multi, asked_at, deadline_at, - target: target.map(str::to_owned), }); } - /// Emit `QuestionResolved` when a question transitions to - /// answered (operator answer, peer answer, operator override on - /// a peer thread, operator cancel, or ttl watchdog). Both - /// operator-targeted and peer threads fire so the dashboard's - /// derived store can move the row from pending to history. + /// Emit `QuestionResolved` when an operator-targeted question + /// transitions to answered (operator answer, peer override, + /// cancel, or ttl watchdog). Caller filters on the original + /// question's `target.is_none()` — peer questions are dashboard- + /// invisible. pub fn emit_question_resolved( &self, id: i64, answer: &str, answerer: &str, cancelled: bool, - target: Option<&str>, ) { let answered_at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -300,72 +288,9 @@ impl Coordinator { answerer: answerer.to_owned(), answered_at, cancelled, - target: target.map(str::to_owned), }); } - /// Rebuild the per-container snapshot, diff it against the last - /// one cached on `self`, and emit one - /// `DashboardEvent::ContainerStateChanged` per added/changed row - /// and one `DashboardEvent::ContainerRemoved` per disappeared row. - /// Call after any mutation that could affect what - /// `nixos-container list` returns or what a row's - /// `running` / `needs_update` / `needs_login` / `deployed_sha` - /// resolves to — lifecycle ops, destroy, approve (post-spawn), - /// rebuild, meta-update, and the crash-watcher's periodic poll. - /// Cheap when nothing changed (one `nixos-container list` + a - /// HashMap diff + zero emits). - pub async fn rescan_containers_and_emit(self: &Arc) { - let fresh = container_view::build_all(self).await; - let mut last = self.last_containers.lock().await; - let mut changed_or_new = Vec::new(); - let mut removed = Vec::new(); - // Diff into change vs. add. - for view in &fresh { - match last.get(&view.name) { - Some(prev) if prev == view => {} // unchanged - _ => changed_or_new.push(view.clone()), - } - } - // Anything in `last` but not in `fresh` is gone. - let fresh_names: std::collections::HashSet<&str> = - fresh.iter().map(|c| c.name.as_str()).collect(); - for name in last.keys() { - if !fresh_names.contains(name.as_str()) { - removed.push(name.clone()); - } - } - // Rebuild the cache from the fresh snapshot. - last.clear(); - for c in fresh { - last.insert(c.name.clone(), c); - } - drop(last); - for c in changed_or_new { - self.emit_dashboard_event(DashboardEvent::ContainerStateChanged { - seq: self.next_seq(), - container: c, - }); - } - for name in removed { - self.emit_dashboard_event(DashboardEvent::ContainerRemoved { - seq: self.next_seq(), - name, - }); - } - } - - /// Read-only snapshot of the last cached container view. Used by - /// `/api/state` to cold-load page-open clients without re-running - /// `nixos-container list` themselves; the - /// `rescan_containers_and_emit` calls keep this fresh. - pub async fn containers_snapshot(&self) -> Vec { - let last = self.last_containers.lock().await; - let mut out: Vec = last.values().cloned().collect(); - out.sort_by(|a, b| a.name.cmp(&b.name)); - out - } - pub fn register_agent(self: &Arc, name: &str) -> Result { // Idempotent: drop any existing listener so re-registration (e.g. on rebuild, // or after a hive-c0re restart cleared /run/hyperhive) gets a fresh socket. diff --git a/hive-c0re/src/crash_watch.rs b/hive-c0re/src/crash_watch.rs index 724bd8ef..66c3600d 100644 --- a/hive-c0re/src/crash_watch.rs +++ b/hive-c0re/src/crash_watch.rs @@ -16,10 +16,10 @@ //! but polling is simpler and a 10s detection delay is fine. use std::collections::HashSet; +use std::path::Path; use std::sync::Arc; use std::time::Duration; -use crate::container_view::claude_has_session; use crate::coordinator::{Coordinator, TransientKind}; use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME}; @@ -69,12 +69,6 @@ pub fn spawn(coord: Arc) { emit_login_transitions(&coord, &prev_logged_in, ¤t_logged_in, &sub_agents); emit_update_transitions(&coord, &prev_updated, ¤t_updated, &sub_agents); } - // Periodic container rescan — catches state flips that - // happen outside our mutation surface (operator runs - // `nixos-container stop` over ssh, agent logs in via its - // own web UI, etc.) so the dashboard converges within one - // POLL_INTERVAL. Idempotent + cheap when nothing changed. - coord.rescan_containers_and_emit().await; prev_running = current_running; prev_logged_in = current_logged_in; prev_updated = current_updated; @@ -169,3 +163,14 @@ fn emit_update_transitions( }); } } + +/// Mirrors `dashboard::claude_has_session`. Lives here too so the +/// watcher doesn't depend on dashboard internals. +fn claude_has_session(dir: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + entries + .flatten() + .any(|e| e.file_type().is_ok_and(|t| t.is_file())) +} diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index c513e516..8054fff6 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -14,7 +14,7 @@ use axum::{ extract::{Path as AxumPath, State}, http::{HeaderMap, StatusCode}, response::{ - Html, IntoResponse, Response, + Html, IntoResponse, Redirect, Response, sse::{Event, KeepAlive, Sse}, }, routing::{get, post}, @@ -25,9 +25,8 @@ use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt}; use crate::actions; -use crate::container_view::{ContainerView, claude_has_session}; use crate::coordinator::Coordinator; -use crate::lifecycle::{self, MANAGER_NAME}; +use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME}; const MANAGER_PORT: u16 = 8000; @@ -54,7 +53,6 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/cancel-question/{id}", post(post_cancel_question)) .route("/purge-tombstone/{name}", post(post_purge_tombstone)) .route("/api/journal/{name}", get(get_journal)) - .route("/api/state-file", get(get_state_file)) .route("/api/agent-config/{name}", get(get_agent_config)) .route("/request-spawn", post(post_request_spawn)) .route("/op-send", post(post_op_send)) @@ -202,6 +200,31 @@ struct TombstoneView { has_creds: bool, } +#[derive(Serialize)] +#[allow(clippy::struct_excessive_bools)] +struct ContainerView { + /// Logical agent name (no `h-` prefix). Used in action URLs. + name: String, + /// Container name as nixos-container sees it (`h-foo`, `hm1nd`). + container: String, + is_manager: bool, + port: u16, + running: bool, + needs_update: bool, + needs_login: bool, + /// When a lifecycle action is in flight on this container, the kind + /// (`starting`, `stopping`, etc.) so the JS can render a spinner + + /// disable other buttons. + #[serde(skip_serializing_if = "Option::is_none")] + pending: Option<&'static str>, + /// First 12 chars of the sha the meta flake currently has locked + /// for this agent's input. Reflects what's actually deployed; can + /// differ from `applied//main` only between + /// `meta::prepare_deploy` and `finalize_deploy` (≤ build duration). + #[serde(skip_serializing_if = "Option::is_none")] + deployed_sha: Option, +} + #[derive(Serialize)] struct TransientView { name: String, @@ -280,20 +303,17 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J // to make idempotent, not ours to avoid here. let seq = state.coord.current_seq(); - // Refresh the coordinator's cached container snapshot before - // reading. Cold-load clients then see whatever the latest rescan - // produced; live clients converge via the matching - // `ContainerStateChanged` / `ContainerRemoved` events the rescan - // emits. - state.coord.rescan_containers_and_emit().await; - let containers = state.coord.containers_snapshot().await; - let any_stale = containers.iter().any(|c| c.needs_update); + let raw_containers = log_default("nixos-container list", lifecycle::list().await); + let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake); let transient_snapshot = state.coord.transient_snapshot(); let pending_approvals = gc_orphans( &state.coord, log_default("approvals.pending", state.coord.approvals.pending()), ); - let transients = build_transient_views(&containers, &transient_snapshot); + + let (containers, any_stale) = + build_container_views(&raw_containers, current_rev.as_deref(), &transient_snapshot).await; + let transients = build_transient_views(&raw_containers, &transient_snapshot); let approvals = build_approval_views(pending_approvals).await; let approval_history = log_default( "approvals.recent_resolved", @@ -308,13 +328,9 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J // operator_inbox used to be served here as a 50-row array; the // dashboard now derives it client-side from the message stream // (terminal backfill + live SSE), so the snapshot stops shipping it. - // Both operator-targeted and peer threads now surface on the - // dashboard. Client filters by target client-side. - let questions = log_default("questions.pending_all", state.coord.questions.pending_all()); - let question_history = log_default( - "questions.recent_answered_all", - state.coord.questions.recent_answered_all(20), - ); + let questions = log_default("questions.pending", state.coord.questions.pending()); + let question_history = + log_default("questions.recent_answered", state.coord.questions.recent_answered(20)); axum::Json(StateSnapshot { seq, @@ -354,6 +370,96 @@ fn build_port_conflicts(containers: &[ContainerView]) -> Vec { .collect() } +/// Build `ContainerView`s for every live nixos-container. Returns the +/// list and whether any container is stale (drives the "↻ UPD4TE 4LL" +/// banner). +async fn build_container_views( + raw_containers: &[String], + current_rev: Option<&str>, + transient_snapshot: &std::collections::HashMap, +) -> (Vec, bool) { + let mut out = Vec::new(); + let mut any_stale = false; + let locked = read_meta_locked_revs(); + for c in raw_containers { + let (logical, is_manager) = if c == MANAGER_NAME { + (MANAGER_NAME.to_owned(), true) + } else if let Some(n) = c.strip_prefix(AGENT_PREFIX) { + (n.to_owned(), false) + } else { + continue; + }; + let needs_update = + current_rev.is_some_and(|rev| crate::auto_update::agent_needs_update(&logical, rev)); + if needs_update { + any_stale = true; + } + let needs_login = + !is_manager && !claude_has_session(&Coordinator::agent_claude_dir(&logical)); + let pending = transient_snapshot + .get(&logical) + .map(|st| transient_label(st.kind)); + let deployed_sha = locked + .get(&format!("agent-{logical}")) + .map(|s| s[..s.len().min(12)].to_owned()); + out.push(ContainerView { + port: lifecycle::agent_web_port(&logical), + running: lifecycle::is_running(&logical).await, + container: c.clone(), + name: logical, + is_manager, + needs_update, + needs_login, + pending, + deployed_sha, + }); + } + (out, any_stale) +} + +/// Map of node name → locked sha for nodes the **root** of meta +/// directly depends on (`hyperhive`, `agent-`). Used by the +/// container row to render its `deployed:` chip per agent. +/// Distinct from `read_meta_inputs()` which walks deeper for the +/// flake-input update form. +fn read_meta_locked_revs() -> std::collections::HashMap { + let mut out = std::collections::HashMap::new(); + let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else { + return out; + }; + let Ok(json) = serde_json::from_str::(&raw) else { + return out; + }; + let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else { + return out; + }; + let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else { + return out; + }; + let Some(root_inputs) = nodes + .get(root_name) + .and_then(|n| n.get("inputs")) + .and_then(|v| v.as_object()) + else { + return out; + }; + for alias in root_inputs.keys() { + let target_name = match root_inputs.get(alias) { + Some(serde_json::Value::String(s)) => s.clone(), + _ => continue, + }; + if let Some(rev) = nodes + .get(&target_name) + .and_then(|n| n.get("locked")) + .and_then(|v| v.get("rev")) + .and_then(|v| v.as_str()) + { + out.insert(alias.clone(), rev.to_owned()); + } + } + out +} + #[derive(Serialize, Clone)] struct MetaInputView { /// Input key in meta's `flake.nix` — `hyperhive`, `agent-`, etc. @@ -471,12 +577,16 @@ fn walk_meta_inputs( /// (`Spawning`). Lifecycle ops on existing containers surface as /// `ContainerView.pending` inline; this list only catches pre-creation. fn build_transient_views( - containers: &[ContainerView], + raw_containers: &[String], transient_snapshot: &std::collections::HashMap, ) -> Vec { transient_snapshot .iter() - .filter(|(name, _)| !containers.iter().any(|c| &c.name == *name)) + .filter(|(name, _)| { + !raw_containers + .iter() + .any(|c| c == &format!("{AGENT_PREFIX}{name}") || c == *name) + }) .map(|(name, st)| TransientView { name: name.clone(), kind: transient_label(st.kind), @@ -739,13 +849,14 @@ async fn post_answer_question( answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), }, ); - state.coord.emit_question_resolved( - id, - answer, - hive_sh4re::OPERATOR_RECIPIENT, - false, - target.as_deref(), - ); + if target.is_none() { + state.coord.emit_question_resolved( + id, + answer, + hive_sh4re::OPERATOR_RECIPIENT, + false, + ); + } (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("answer {id} failed: {e:#}")), @@ -770,13 +881,14 @@ async fn post_cancel_question( { Ok((question, asker, target)) => { tracing::info!(%id, %asker, "operator cancelled question"); - state.coord.emit_question_resolved( - id, - SENTINEL, - hive_sh4re::OPERATOR_RECIPIENT, - true, - target.as_deref(), - ); + if target.is_none() { + state.coord.emit_question_resolved( + id, + SENTINEL, + hive_sh4re::OPERATOR_RECIPIENT, + true, + ); + } state.coord.notify_agent( &asker, &hive_sh4re::HelperEvent::QuestionAnswered { @@ -886,103 +998,6 @@ async fn get_agent_config(AxumPath(name): AxumPath) -> Response { } } -#[derive(Deserialize)] -struct StateFileQuery { - path: String, -} - -/// Bounded-size read of a file under one of two allow-listed -/// roots: `/var/lib/hyperhive/agents//state/` (per-agent durable -/// notes — the only writable path agents have outside their -/// container) and `/var/lib/hyperhive/shared/` (shared docs). Both -/// path forms are accepted: -/// - canonical host: `/var/lib/hyperhive/agents/alice/state/foo.md` -/// - container view: `/agents/alice/state/foo.md` -/// - shared: `/shared/foo.md` -/// -/// `/state/...` on its own is *not* accepted — the in-container -/// mount is ambiguous from the host's perspective (we don't know -/// which agent's `/state` it refers to) and using it would silently -/// resolve to the wrong file. -/// -/// Path is canonicalised before the allow-list check so `..` -/// traversal and symlink games can't escape the roots. Files larger -/// than `MAX_BYTES` are truncated with a banner so a runaway log -/// can't OOM the browser. -async fn get_state_file( - axum::extract::Query(q): axum::extract::Query, -) -> Response { - const MAX_BYTES: usize = 1 << 20; // 1 MiB - const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents"; - const SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; - let raw = q.path.trim(); - // Translate the container-view forms to host paths so the - // allow-list check has a single canonical shape to match. - let mapped: std::path::PathBuf = if let Some(rest) = raw.strip_prefix("/agents/") { - std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")) - } else if let Some(rest) = raw.strip_prefix("/shared/") { - std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")) - } else if raw.starts_with(AGENTS_ROOT) || raw.starts_with(SHARED_ROOT) { - std::path::PathBuf::from(raw) - } else { - return error_response(&format!("state-file: path not in allow-list: {raw}")); - }; - // Canonicalise so `..` / symlinks resolve before the prefix - // check. A failure here means the path doesn't exist on disk - // (or we can't reach it) — surface the underlying error. - let canonical = match std::fs::canonicalize(&mapped) { - Ok(p) => p, - Err(e) => return error_response(&format!("state-file: {}: {e}", mapped.display())), - }; - let allowed = canonical.starts_with(AGENTS_ROOT) || canonical.starts_with(SHARED_ROOT); - if !allowed { - return error_response(&format!( - "state-file: resolved path escapes allow-list: {}", - canonical.display() - )); - } - // For per-agent paths, also require the second-from-root - // component to be `state` (not `claude` or `config`). Claude - // creds shouldn't leak through this endpoint; config is the - // applied repo (already exposed via /api/agent-config). Reading - // `/var/lib/hyperhive/agents//state/...` is the intended use. - if let Ok(rel) = canonical.strip_prefix(AGENTS_ROOT) { - let mut components = rel.components(); - let _agent = components.next(); - let dir = components.next().and_then(|c| c.as_os_str().to_str()); - if dir != Some("state") { - return error_response(&format!( - "state-file: only per-agent state/ is readable here ({} dir not allowed)", - dir.unwrap_or("(root)") - )); - } - } - let meta = match std::fs::metadata(&canonical) { - Ok(m) => m, - Err(e) => return error_response(&format!("state-file: stat {}: {e}", canonical.display())), - }; - if !meta.is_file() { - return error_response(&format!( - "state-file: {} is not a regular file", - canonical.display() - )); - } - let size = meta.len(); - let bytes = match std::fs::read(&canonical) { - Ok(b) => b, - Err(e) => return error_response(&format!("state-file: read {}: {e}", canonical.display())), - }; - let truncated = bytes.len() > MAX_BYTES; - let body_bytes = if truncated { &bytes[..MAX_BYTES] } else { &bytes[..] }; - let mut body = String::from_utf8_lossy(body_bytes).into_owned(); - if truncated { - body.push_str(&format!( - "\n\n--- truncated at {MAX_BYTES} of {size} bytes ---\n" - )); - } - ([("content-type", "text/plain; charset=utf-8")], body).into_response() -} - async fn post_purge_tombstone( State(state): State, AxumPath(name): AxumPath, @@ -1019,10 +1034,7 @@ async fn post_purge_tombstone( .fail_pending_for_agent(&name, "agent state purged"); if errors.is_empty() { tracing::info!(%name, "tombstone purged"); - // Tombstones aren't event-derived yet, so the client still - // refetches /api/state to see this one disappear (matching - // form omits `data-no-refresh`). - (StatusCode::OK, "ok").into_response() + Redirect::to("/").into_response() } else { error_response(&format!("purge {name} partial: {}", errors.join(", "))) } @@ -1074,10 +1086,7 @@ async fn post_meta_update( tokio::spawn(async move { run_meta_update(&coord, &inputs_clone).await; }); - // Background task — each per-agent rebuild emits its own - // `ContainerStateChanged`; the meta inputs panel still relies on - // /api/state freshness (matching form omits `data-no-refresh`). - (StatusCode::OK, "ok").into_response() + Redirect::to("/").into_response() } /// Background task: run `nix flake update ` in meta + commit, @@ -1251,13 +1260,7 @@ where match result { Ok(()) => { extra(state, &logical); - // Rescan so the running/needs_login/needs_update flip on - // the affected row lands on every dashboard's SSE channel - // without waiting for a snapshot poll. 200 + matching - // `data-no-refresh` on the form skip the post-submit - // /api/state refetch. - state.coord.rescan_containers_and_emit().await; - (StatusCode::OK, "ok").into_response() + Redirect::to("/").into_response() } Err(e) => error_response(&format!("{verb} {logical} failed: {e:#}")), } @@ -1333,8 +1336,7 @@ async fn post_update_all(State(state): State) -> Response { } } if errors.is_empty() { - // Each rebuild_agent rescanned; no extra refetch needed. - (StatusCode::OK, "ok").into_response() + Redirect::to("/").into_response() } else { error_response(&format!( "update-all partial failure:\n{}", @@ -1378,11 +1380,8 @@ async fn post_destroy( ) -> Response { // Checkbox semantics: any non-empty value (axum sends "on") = purge. let purge = form.purge.as_deref().is_some_and(|v| !v.is_empty()); - // `actions::destroy` rescans the container list on success, so the - // `ContainerRemoved` event lands before we return 200. The matching - // form carries `data-no-refresh`. match actions::destroy(&state.coord, &name, purge).await { - Ok(()) => (StatusCode::OK, "ok").into_response(), + Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("destroy {name} failed: {e:#}")), } } @@ -1430,6 +1429,18 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { .collect() } +/// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true if the +/// agent's bound `~/.claude/` dir on disk contains any regular file. The +/// dashboard reads this each render so logins driven from the agent web UI +/// (Phase 8 step 4) reflect within one auto-refresh cycle. +fn claude_has_session(dir: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + entries + .flatten() + .any(|e| e.file_type().is_ok_and(|t| t.is_file())) +} /// Multi-file unified diff between the currently-deployed tree and /// the proposal for this approval. Runs against the applied repo diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index e17a23e0..44261082 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -25,8 +25,6 @@ use serde::Serialize; -use crate::container_view::ContainerView; - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum DashboardEvent { @@ -79,11 +77,11 @@ pub enum DashboardEvent { note: Option, description: Option, }, - /// A question landed in the queue. `target = None` means - /// operator-targeted (`Ask { to: None | Some("operator") }`); - /// `target = Some()` means a peer-to-peer question. Both - /// are surfaced on the dashboard so the operator can monitor / - /// override-answer stuck threads. + /// An operator-targeted question landed in the queue + /// (`Ask { to: None | Some("operator") }`). Peer-to-peer + /// questions (target = Some()) never fire this event — + /// the dashboard only ever shows operator-bound questions, so + /// the emit site filters on `target.is_none()`. QuestionAdded { seq: u64, id: i64, @@ -93,13 +91,12 @@ pub enum DashboardEvent { multi: bool, asked_at: i64, deadline_at: Option, - target: Option, }, - /// A question was answered (operator answer, peer answer, - /// operator override on a peer thread, or ttl watchdog - /// `[expired]`). Clients move the row from pending to history. - /// `cancelled = true` when the operator dismissed via the cancel - /// button. + /// An operator-targeted question was answered (operator answer, + /// peer override, or ttl watchdog `[expired]`). Clients move the + /// row from pending to history. `cancelled = true` when the + /// operator dismissed via the cancel button — same code path on + /// the server but useful to surface differently in the UI. QuestionResolved { seq: u64, id: i64, @@ -107,7 +104,6 @@ pub enum DashboardEvent { answerer: String, answered_at: i64, cancelled: bool, - target: Option, }, /// A lifecycle action started for an agent (spawn / start / stop /// / restart / rebuild / destroy). Clients render a spinner next @@ -125,24 +121,4 @@ pub enum DashboardEvent { /// The matching lifecycle action resolved (success or failure). /// Clients drop the spinner row. TransientCleared { seq: u64, name: String }, - /// One container row changed — new container appeared (post-spawn - /// finalise), an existing one flipped running/needs_update/sha, - /// etc. Clients upsert by `container.name`. Payload carries the - /// full row so cold-loaded clients and event-driven clients - /// converge on the same render. - /// - /// Fired by `Coordinator::rescan_containers_and_emit`, which diffs - /// a fresh `nixos-container list`–derived snapshot against the - /// last one cached on the coordinator. Mutation sites (lifecycle - /// endpoints, actions::destroy / approve, crash_watch's poll loop) - /// call the rescan after their work lands. - ContainerStateChanged { - seq: u64, - container: ContainerView, - }, - /// A container that was in the previous snapshot is gone. Clients - /// drop the row by name. Fired alongside any - /// `nixos-container destroy` (operator-driven or otherwise) on the - /// next rescan. - ContainerRemoved { seq: u64, name: String }, } diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index a8ed2e89..6c10a6e4 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -11,7 +11,6 @@ mod approvals; mod auto_update; mod broker; mod client; -mod container_view; mod coordinator; mod crash_watch; mod dashboard; diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 203f2fc8..5fafc398 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -454,7 +454,9 @@ pub fn spawn_question_watchdog(coord: &Arc, id: i64, ttl_secs: u64) answerer: TTL_ANSWERER.to_owned(), }, ); - coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false, target.as_deref()); + if target.is_none() { + coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false); + } } }); } diff --git a/hive-c0re/src/operator_questions.rs b/hive-c0re/src/operator_questions.rs index 5ef5b199..6c24dc32 100644 --- a/hive-c0re/src/operator_questions.rs +++ b/hive-c0re/src/operator_questions.rs @@ -209,15 +209,15 @@ impl OperatorQuestions { .map_err(Into::into) } - /// Every pending question, operator-targeted or peer-to-peer. - /// Drives the dashboard's questions pane now that peer threads - /// are surfaced for visibility + operator override-answer. - pub fn pending_all(&self) -> Result> { + /// Pending operator-targeted questions only (`target IS NULL`). + /// Drives the dashboard's pending-question pane — agent-to-agent + /// questions never appear here so the operator's queue stays clean. + pub fn pending(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target FROM operator_questions - WHERE answered_at IS NULL + WHERE answered_at IS NULL AND target IS NULL ORDER BY id ASC", )?; let rows = stmt.query_map([], row_to_question)?; @@ -225,14 +225,15 @@ impl OperatorQuestions { .map_err(Into::into) } - /// Last `limit` answered questions across both target kinds, - /// newest-first. Companion to `pending_all`. - pub fn recent_answered_all(&self, limit: u64) -> Result> { + /// Last `limit` answered operator-targeted questions, newest-first. + /// Same `target IS NULL` filter as `pending()` so the dashboard's + /// history view only shows operator-relevant rows. + pub fn recent_answered(&self, limit: u64) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target FROM operator_questions - WHERE answered_at IS NOT NULL + WHERE answered_at IS NOT NULL AND target IS NULL ORDER BY answered_at DESC LIMIT ?1", )?; @@ -240,7 +241,6 @@ impl OperatorQuestions { rows.collect::>>() .map_err(Into::into) } - } fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result { diff --git a/hive-c0re/src/questions.rs b/hive-c0re/src/questions.rs index d622d1d8..d94ad8fe 100644 --- a/hive-c0re/src/questions.rs +++ b/hive-c0re/src/questions.rs @@ -86,10 +86,9 @@ pub fn handle_ask( multi, }, ); + } else { + coord.emit_question_added(id, asker, question, options, multi, deadline_at); } - // Always fire on the dashboard channel — both operator-targeted - // and peer threads now surface in the dashboard's questions pane. - coord.emit_question_added(id, asker, question, options, multi, deadline_at, target); if let Some(t) = ttl { spawn_question_watchdog(coord, id, t); } @@ -121,11 +120,13 @@ pub fn handle_answer( answerer: answerer.to_owned(), }, ); - // Dashboard surfaces both operator-targeted and peer threads; - // emit unconditionally so the derived store moves the row. - // `cancelled = false` because this path is a real answer (the - // operator-cancel button goes through `post_cancel_question`). - coord.emit_question_resolved(id, answer, answerer, false, target.as_deref()); + // Only operator-targeted questions surface on the dashboard; + // peer-to-peer answers are invisible to it. `cancelled = false` + // because this path is a real answer (operator cancel goes + // through `post_cancel_question` directly). + if target.is_none() { + coord.emit_question_resolved(id, answer, answerer, false); + } Ok(()) }