// C0R3 page entry (/core.html). The host/coordinator surface, its own
// standalone page so the dashboard tab strip stays lean: kept state
// (tombstones).
//
// Rebuild queue + meta inputs are tabs of /dashboard.html, 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 { bindAsyncForms } from "@hive/shared/forms.js";
// ─── derived state (own copies; this bundle has its own runtime) ──────────
let tombstonesState = [];
function syncFromSnapshot(s) {
tombstonesState = (s.tombstones || []).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);
}
// ─── 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 });
},
};
// ─── 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());
await refreshState();
// kinds= matches SSE_HANDLERS below verbatim — narrows the stream down
// to the kinds 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");
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();