// C0R3 page entry (/core.html). The host/coordinator surface, 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 live at /builds.html (the build lifecycle // hub), not here. 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" }); 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; } } // ─── 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" }, ], defaultId: "kept", onShow: (id) => { if (id === "load") startContainerLoadPolling(); else stopContainerLoadPolling(); // 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();