// C0R3 page entry (/core.html). The host/coordinator surface carved out // of the dashboard's old SYST3M tab into its own standalone page so the // dashboard tab strip stays lean. Two sub-tabs via // (default = K3PT ST4T3): kept state (tombstones) and container load. // // Rebuild queue + meta inputs have moved to /builds.html (the build // lifecycle hub). This page is its own esbuild bundle with its own // runtime: it cold-loads /api/state and subscribes to /dashboard/stream // (the same broker event channel the dashboard uses), maintaining its // own copy of the tombstones state. import { $, form, openStream, initServerWarnings } from './common.js'; import { el } from '@hive/shared/dom.js'; import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js'; import '@hive/shared/hive-tab-strip.js'; // ─── derived state (own copies; this bundle has its own runtime) ────────── let tombstonesState = []; // ContainerView list from the last /api/state snapshot — used by renderContainerLoad // to splice in the configured cpu_quota/memory_max limits alongside live cgroup data. let containersState = []; function syncFromSnapshot(s) { tombstonesState = (s.tombstones || []).slice(); containersState = (s.containers || []).slice(); } // ─── kept state (tombstones) ────────────────────────────────────────────── function renderTombstones(s) { const root = $('tombstones-section'); if (!root) return; root.replaceChildren(); 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 fmtAgeDays = (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'; }; // Only shown alongside actual rows — a caveat over an empty list is noise. // Wording is deliberately about what the list *is* rather than what it // isn't: nothing records a destroy, so "container absent" is the only thing // the backend can actually tell. const warn = el('hive-warn', { level: 'warning' }); warn.append( el('strong', {}, 'shows every agent whose container is absent'), ' — not only destroyed ones. An agent part-way through being spawned ' + 'looks identical here, because nothing records a destroy. Check it is ' + 'really gone before you PURG3.', ); root.append(warn); 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), // Was `destroyed`, which the backend cannot actually know — see the // caveat above. `offline` is what the absence of a container proves. el('span', { class: 'hive-pill-sm badge-muted' }, 'offline'), ); if (t.has_creds) head.append(el('span', { class: 'hive-pill-sm badge-muted' }, 'creds kept')); head.append(el('span', { class: 'meta' }, `${fmtBytes(t.state_bytes)} · ${fmtAgeDays(t.last_seen)}`)); li.append(head); const actions = el('div', { class: 'actions' }); const respawn = el('form', { method: 'POST', action: '/api/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( '/api/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); } // ─── stale permission entries (K3PT ST4T3 pane sub-section) ────────────── // Agents with explicit capability / tool-group JSON entries but no live // container AND no kept-state tombstone (e.g. renamed agents like the old // "root" manager name). Ghost detection is server-side via // GET /api/permissions/stale so the client doesn't need to maintain a // container-roster cache or perform set arithmetic. Lazy-loaded on first // "kept" tab activation; refreshed when perm data changes via SSE. let stalePermsLoaded = false; function renderStalePerms(root, ghosts) { root.replaceChildren(); if (!ghosts.length) return; root.append(el('p', { class: 'tombstones-stale-heading' }, 'stale permission entries')); root.append(el('p', { class: 'meta' }, 'agents with explicit capability or tool-group entries but no live container ' + 'or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).')); const errP = el('p', { class: 'tombstones-stale-err', hidden: true }); const ul = el('ul', { class: 'tombstones-stale-list' }); for (const name of ghosts) { const li = el('li', { class: 'tombstones-stale-row' }); li.append(el('span', { class: 'tombstones-stale-name' }, name)); li.append(el('span', { class: 'hive-pill-sm badge-muted' }, 'stale perms')); const btn = el('button', { type: 'button', class: 'btn btn-destroy', title: 'remove explicit capability and tool-group entries for ' + name, }, '✕ clear perms'); btn.addEventListener('click', () => asyncBtn(btn, async () => { errP.hidden = true; try { const resp = await fetch('/api/permissions/' + encodeURIComponent(name), { method: 'DELETE' }); if (!resp.ok) { const msg = await resp.text().catch(() => String(resp.status)); errP.textContent = 'failed to clear perms for ' + name + ': ' + msg; errP.hidden = false; return; } await fetchAndRenderStalePerms(); } catch (err) { errP.textContent = 'failed to clear perms for ' + name + ': ' + err; errP.hidden = false; } })); li.append(btn); ul.append(li); } root.append(ul, errP); } // Ghost detection is entirely server-side: GET /api/permissions/stale // returns the computed list of agent names that have explicit JSON entries // but are absent from both the live roster and the kept-state tombstones. // One call, no client-side roster cache, always authoritative. async function fetchAndRenderStalePerms() { const root = $('tombstones-stale-perms'); if (!root) return; try { const resp = await fetch('/api/permissions/stale'); if (!resp.ok) throw new Error('http ' + resp.status); const data = await resp.json(); renderStalePerms(root, data.stale || []); } catch (err) { root.replaceChildren(); root.append(el('p', { class: 'meta' }, 'failed to load stale perm data: ' + err)); } stalePermsLoaded = true; } // ─── container load (live cgroup poll while the LOAD sub-tab is open) ────── let containerLoadTimer = null; function cloadFmtBytes(n) { if (!Number.isFinite(n) || n <= 0) return '0'; const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; let i = 0; let v = n; while (v >= 1024 && i < u.length - 1) { v /= 1024; i += 1; } return v.toFixed(v < 10 && i > 0 ? 1 : 0) + ' ' + u[i]; } function cloadMeter(pct) { const p = Math.max(0, Math.min(100, pct)); const cls = p >= 90 ? 'hot' : (p >= 70 ? 'warn' : ''); const m = document.createElement('span'); m.className = 'cload-meter'; m.title = p.toFixed(0) + '%'; const fill = document.createElement('span'); fill.className = 'fill' + (cls ? ' ' + cls : ''); fill.style.width = p + '%'; m.append(fill); return m; } // Stash the last load rows so re-renders triggered by SSE state updates can // call renderContainerLoad(lastLoadRows) without waiting for the next poll. let lastLoadRows = []; function renderContainerLoad(rows) { lastLoadRows = rows; const root = $('container-load-section'); if (!root) return; if (!Array.isArray(rows) || rows.length === 0) { root.replaceChildren(); const p = document.createElement('p'); p.className = 'meta'; p.textContent = 'no running agent containers'; root.append(p); return; } // Build a name→ContainerView lookup so we can splice in the configured // cpu_quota / memory_max limits (set in meta/resource-limits.json and // resolved by the server) next to the live cgroup readings. const cvByName = new Map(containersState.map((c) => [c.name, c])); const table = document.createElement('table'); table.className = 'hive-stats-table'; // "cpu cap" / "mem cap" show the configured ceilings from ContainerView // (effective drop-in values, take effect on next start/restart). // "limit" remains the live cgroup memory ceiling from /api/container-resources. table.innerHTML = 'agentcpumemory' + 'peaklimitdisk' + 'cpu cap' + 'mem cap' + ''; const tb = document.createElement('tbody'); for (const r of rows) { const cv = cvByName.get(r.name); const tr = document.createElement('tr'); const name = document.createElement('td'); name.textContent = r.name; tr.append(name); const cpu = document.createElement('td'); cpu.className = 'num'; cpu.append((Number(r.cpu_pct) || 0).toFixed(1) + '%', cloadMeter(Number(r.cpu_pct) || 0)); tr.append(cpu); const mem = document.createElement('td'); mem.className = 'num'; const memCur = Number(r.mem_current_bytes) || 0; if (r.mem_max_bytes) { mem.append(cloadFmtBytes(memCur), cloadMeter(100 * memCur / r.mem_max_bytes)); } else { mem.textContent = cloadFmtBytes(memCur); } tr.append(mem); const peak = document.createElement('td'); peak.className = 'num'; peak.textContent = r.mem_peak_bytes ? cloadFmtBytes(Number(r.mem_peak_bytes)) : '—'; tr.append(peak); const lim = document.createElement('td'); lim.className = 'num'; lim.textContent = r.mem_max_bytes ? cloadFmtBytes(Number(r.mem_max_bytes)) : '∞'; tr.append(lim); // Disk: on-disk footprint (state dir + container writable rootfs, shared // nix store excluded). Sampled out-of-band every few minutes server-side, // so it's null until the first sample lands — show an em-dash then. const disk = document.createElement('td'); disk.className = 'num'; disk.title = 'state dir + container writable rootfs (shared nix store excluded); sampled every few minutes'; disk.textContent = (r.disk_bytes != null) ? cloadFmtBytes(Number(r.disk_bytes)) : '—'; tr.append(disk); // Configured CPU / memory caps from ContainerView (resolved effective // values: per-agent override when set, hive-wide default otherwise). const cpuCap = document.createElement('td'); cpuCap.className = 'num cload-cap'; cpuCap.title = 'configured ceiling — takes effect on next start'; cpuCap.textContent = cv?.cpu_quota || '—'; tr.append(cpuCap); const memCap = document.createElement('td'); memCap.className = 'num cload-cap'; memCap.title = 'configured ceiling — takes effect on next start'; memCap.textContent = cv?.memory_max || '—'; tr.append(memCap); // S3T button toggles the inline edit row for this agent. const actTd = document.createElement('td'); const setBtn = document.createElement('button'); setBtn.type = 'button'; setBtn.className = 'btn btn-sm cload-set-btn'; setBtn.textContent = 'S3T'; setBtn.title = 'set CPU / memory cap for ' + r.name; tr.append(actTd); actTd.append(setBtn); tb.append(tr); // Inline edit row (hidden by default, toggled by the S3T button). const editRow = document.createElement('tr'); editRow.className = 'cload-edit-row'; editRow.hidden = true; const editTd = document.createElement('td'); editTd.colSpan = 9; editTd.className = 'cload-edit-cell'; const editForm = document.createElement('form'); editForm.className = 'cload-edit-form'; editForm.addEventListener('submit', async (e) => { e.preventDefault(); const cpuInput = editForm.querySelector('.cload-cpu-input'); const memInput = editForm.querySelector('.cload-mem-input'); const errSpan = editForm.querySelector('.cload-edit-err'); const submitBtn = editForm.querySelector('[type="submit"]'); errSpan.hidden = true; submitBtn.disabled = true; try { const body = new URLSearchParams({ cpu_quota: cpuInput.value.trim(), memory_max: memInput.value.trim(), }); const resp = await fetch( '/api/resource-limits/' + encodeURIComponent(r.name), { method: 'POST', body }, ); if (!resp.ok) { errSpan.textContent = await resp.text().catch(() => 'error ' + resp.status); errSpan.hidden = false; return; } // Success: collapse the edit row. The SSE rescan will push updated // ContainerView data (cpu_quota/memory_max) to containersState, // triggering a re-render of the cap columns via refreshContainerLoad. editRow.hidden = true; setBtn.textContent = 'S3T'; } catch (err) { errSpan.textContent = String(err); errSpan.hidden = false; } finally { submitBtn.disabled = false; } }); const cpuLabel = document.createElement('label'); cpuLabel.className = 'cload-edit-label'; cpuLabel.textContent = 'cpu quota'; const cpuInput = document.createElement('input'); cpuInput.type = 'text'; cpuInput.className = 'cload-cpu-input'; cpuInput.placeholder = cv ? cv.cpu_quota : 'e.g. 200%'; cpuInput.title = 'systemd CPUQuota= value (e.g. "400%"). empty = use hive default'; cpuLabel.append(cpuInput); const memLabel = document.createElement('label'); memLabel.className = 'cload-edit-label'; memLabel.textContent = 'mem max'; const memInput = document.createElement('input'); memInput.type = 'text'; memInput.className = 'cload-mem-input'; memInput.placeholder = cv ? cv.memory_max : 'e.g. 8G'; memInput.title = 'systemd MemoryMax= value (e.g. "8G", "50%", "infinity"). empty = use hive default'; memLabel.append(memInput); const submitBtn = document.createElement('button'); submitBtn.type = 'submit'; submitBtn.className = 'btn btn-restart cload-save-btn'; submitBtn.textContent = 'S4V3'; const hintSpan = document.createElement('span'); hintSpan.className = 'meta cload-edit-hint'; hintSpan.textContent = '↺ restart to apply to a running container'; const errSpan = document.createElement('span'); errSpan.className = 'cload-edit-err'; errSpan.hidden = true; editForm.append(cpuLabel, memLabel, submitBtn, hintSpan, errSpan); editTd.append(editForm); editRow.append(editTd); tb.append(editRow); setBtn.addEventListener('click', () => { const open = !editRow.hidden; editRow.hidden = open; setBtn.textContent = open ? 'S3T' : '✕'; }); } table.append(tb); root.replaceChildren(table); } async function refreshContainerLoad() { try { const resp = await fetch('/api/container-resources'); if (!resp.ok) throw new Error('http ' + resp.status); renderContainerLoad(await resp.json()); } catch (e) { const root = $('container-load-section'); if (root) { root.replaceChildren(); const p = document.createElement('p'); p.className = 'meta'; p.textContent = 'container load fetch failed: ' + e; root.append(p); } } } function startContainerLoadPolling() { refreshContainerLoad(); if (containerLoadTimer) return; containerLoadTimer = setInterval(refreshContainerLoad, 5000); } function stopContainerLoadPolling() { if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; } } // ─── infra containers (start/stop/restart the 4 hive infra containers) ──── let infraTimer = null; function renderInfraContainers(rows) { const root = $('infra-containers-section'); if (!root) return; root.replaceChildren(); if (!Array.isArray(rows) || !rows.length) { root.append(el('p', { class: 'meta' }, 'no infra container data')); return; } const ul = el('ul', { class: 'containers' }); for (const c of rows) { const li = el('li', { class: 'container-row' }); const head = el('div', { class: 'head' }); head.append( el('span', { class: 'name' }, c.name), el('span', { class: 'hive-pill-sm ' + (c.running ? 'badge-ok' : 'badge-fail') }, c.running ? 'running' : 'stopped'), ); li.append(head); const actions = el('div', { class: 'actions' }); const base = '/api/infra-container/' + encodeURIComponent(c.name) + '/'; if (c.running) { actions.append(form(base + 'restart', 'btn-restart', '↺ R3ST4RT', 'restart ' + c.name + '?')); actions.append(form(base + 'stop', 'btn-stop', '■ ST0P', 'stop ' + c.name + '?')); } else { actions.append(form(base + 'start', 'btn-start', '▶ ST4RT', 'start ' + c.name + '?')); } li.append(actions); ul.append(li); } root.append(ul); } async function refreshInfraContainers() { try { const resp = await fetch('/api/state'); if (!resp.ok) throw new Error('http ' + resp.status); const s = await resp.json(); renderInfraContainers(s.infra_containers || []); } catch (e) { const root = $('infra-containers-section'); if (root) { root.replaceChildren(); root.append(el('p', { class: 'meta' }, 'infra container fetch failed: ' + e)); } } } function startInfraPolling() { refreshInfraContainers(); if (infraTimer) return; infraTimer = setInterval(refreshInfraContainers, 5000); } function stopInfraPolling() { if (infraTimer) { clearInterval(infraTimer); infraTimer = null; } } // ─── render-all (cold load + any full re-render) ────────────────────────── function renderAll() { renderTombstones({ tombstones: tombstonesState }); } // ─── live SSE handlers ─────────────────────────────────────────────────── const SSE_HANDLERS = { tombstones_changed(ev) { tombstonesState = (ev.tombstones || []).slice(); renderTombstones({ tombstones: tombstonesState }); }, // When a container's state changes (e.g. after resource-limits update // triggers rescan_containers_and_emit), update containersState in place // so the cap columns in the LOAD table reflect the new configured values. container_state_changed(ev) { if (!ev.container) return; const idx = containersState.findIndex((c) => c.name === ev.container.name); if (idx >= 0) { containersState[idx] = ev.container; } else { containersState.push(ev.container); } if (lastLoadRows.length) renderContainerLoad(lastLoadRows); }, // Refresh the stale-perms sub-section when perm data changes (a ghost // was cleared, or perms were saved for an agent whose name collides). capabilities_changed(_ev) { if (stalePermsLoaded) fetchAndRenderStalePerms(); }, tool_groups_changed(_ev) { if (stalePermsLoaded) fetchAndRenderStalePerms(); }, }; // ─── boot ───────────────────────────────────────────────────────────────── // Re-fetch /api/state, re-sync the derived state, re-render. Used for the // cold load and as the post-submit refresh for `data-async` forms whose // mutation doesn't arrive via an SSE event (e.g. the meta-update / spawn / // purge actions that opt out of `data-no-refresh`). async function refreshState() { try { const resp = await fetch('/api/state'); if (resp.ok) syncFromSnapshot(await resp.json()); } catch { // best-effort: the page keeps its last-rendered state } renderAll(); } async function init() { initServerWarnings(); // `data-async` form submit interceptor — without this the meta-update / // cancel / respawn / purge buttons POST natively and the browser navigates // to the bare `ok` response page. bindAsyncForms(() => refreshState()); // Hash-routed sub-tab strip; default K3PT ST4T3. Container-load // polling runs only while the LOAD sub-tab is open (cpu is a short // two-sample read each refresh on the server). document.getElementById('core-tabbar').configure({ tabs: [ { id: 'kept', label: 'K3PT ST4T3' }, { id: 'load', label: 'C0NT41N3R L04D' }, { id: 'infra', label: '1NFR4' }, ], defaultId: 'kept', onShow: (id) => { if (id === 'load') startContainerLoadPolling(); else stopContainerLoadPolling(); if (id === 'infra') startInfraPolling(); else stopInfraPolling(); // Lazy-load stale-perms on first K3PT ST4T3 activation; always // re-fetch on subsequent visits in case perms changed. if (id === 'kept') fetchAndRenderStalePerms(); }, }); await refreshState(); // kinds= matches SSE_HANDLERS below verbatim — narrows this from all // 17 wire kinds down to the 4 this page acts on. This page was one // of 4 unfiltered `/api/dashboard/stream` subscribers before this // (subscription discipline, part 1 of the dashboard-event-stream- // split issue). const es = openStream( '/api/dashboard/stream?kinds=tombstones_changed,container_state_changed,' + 'capabilities_changed,tool_groups_changed', ); if (es) { es.onmessage = (e) => { let ev; try { ev = JSON.parse(e.data); } catch { return; } const h = SSE_HANDLERS[ev.kind]; if (h) h(ev); }; } } init();