// BU1LDS page entry (/builds.html). The build lifecycle hub — rebuild // queue, live build log, meta inputs, and build log history on one page. // Three sub-tabs via (default = Rebuild Queue): // - R3BU1LD QU3U3: pending + running rebuilds + live build log inline // - M3T4 1NPUTS: nix flake input selector + meta-update trigger // - BUILD L0GS: all-agents build log history (lazy-loaded on tab show) // // Carved out of /core.html (rebuild queue + meta inputs) and /logs.html // (BUILD tab) so the full build lifecycle is in one place. The section // renderers here are direct copies from core.js / logs.js with only the // deep-link URL and count-pill id adjusted. import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings, } from "./common.js"; import { el } from "@hive/shared/dom.js"; import { bindAsyncForms } from "@hive/shared/forms.js"; import { themedConfirm } from "@hive/shared/modal.js"; import { h, render } from "preact"; import { fmtAgo, fmtDuration, truncate } from "./util.js"; import "@hive/shared/hive-tab-strip.js"; import { JobqGraph } from "@hive/shared/jobq-graph.js"; // ─── derived state ─────────────────────────────────────────────────────────── let metaInputsState = []; let metaUpdateRunning = false; // Kept in sync from JobqGraph's `onUpdate` callback (see mountRebuildQueue // below) — the component owns fetching GET /api/jobq/graph and renders // the tree itself; this flat array exists only for the two things it // doesn't render: the count-pill and the live-log panel. let jobqNodes = []; let jobqGraphToken = 0; function syncFromSnapshot(s) { metaInputsState = (s.meta_inputs || []).slice(); metaUpdateRunning = !!s.meta_update_running; } // ─── meta inputs ───────────────────────────────────────────────────────────── function renderMetaInputs(s) { const root = $("meta-inputs-section"); if (!root) return; // Snapshot ticked checkboxes before wiping so a concurrent // MetaInputsChanged doesn't silently clear a pending selection. const checkedInputs = new Set( Array.from( root.querySelectorAll('input[type="checkbox"][data-meta-input]:checked'), ).map((cb) => cb.dataset.metaInput), ); root.replaceChildren(); const inputs = s.meta_inputs || []; if (!inputs.length) { root.append(el("p", { class: "empty" }, "meta repo not seeded yet")); return; } if (metaUpdateRunning) { root.append( el( "p", { class: "meta-update-running" }, "⏳ meta-update running — flake lock bump + affected agents rebuilding. " + "watch the agent cards for per-rebuild progress.", ), ); } const f = el("form", { method: "POST", action: "/api/meta-update", class: "meta-inputs-form", "data-async": "", "data-no-refresh": "", "data-confirm": "update selected meta flake inputs + rebuild affected agents?", }); const bulk = el("div", { class: "meta-inputs-bulk" }); const selAll = el( "button", { type: "button", class: "meta-bulk-btn" }, "select all", ); const selNone = el( "button", { type: "button", class: "meta-bulk-btn" }, "select none", ); bulk.append("bulk: ", selAll, " ", selNone); f.append(bulk); const ul = el("ul", { class: "meta-inputs" }); for (const inp of inputs) { const depth = (inp.name.match(/\//g) || []).length; const leaf = inp.name.slice(inp.name.lastIndexOf("/") + 1); const li = el("li"); if (depth > 0) li.style.marginLeft = depth * 1.3 + "em"; const id = "meta-input-" + inp.name.replace(/[^a-z0-9-]/gi, "_"); const cb = el("input", { type: "checkbox", name: "meta_input_" + inp.name, id, value: inp.name, "data-meta-input": inp.name, }); if (checkedInputs.has(inp.name)) cb.checked = true; const label = el("label", { for: id, title: inp.name }); label.append(cb); if (depth > 0) label.append(el("span", { class: "meta-input-twig" }, "└ ")); label.append( el("span", { class: "meta-input-name" }, leaf), " ", el("code", { class: "meta-input-rev" }, inp.rev.slice(0, 12)), " ", el("span", { class: "meta-input-ts" }, fmtAgo(inp.last_modified)), ); if (inp.url) { label.append( " ", el( "span", { class: "meta-input-url", title: inp.url }, "· " + truncate(inp.url, 48), ), ); } li.append(label); ul.append(li); } f.append(ul); const hidden = el("input", { type: "hidden", name: "inputs", value: "" }); f.append(hidden); const btn = el( "button", { type: "submit", class: "btn btn-meta-update", disabled: "", }, metaUpdateRunning ? "⏳ UPD4T1NG…" : "◆ UPD4TE & R3BU1LD", ); f.append(btn); function refreshDisabled() { const any = f.querySelectorAll("input[data-meta-input]:checked").length > 0; if (any && !metaUpdateRunning) btn.removeAttribute("disabled"); else btn.setAttribute("disabled", ""); } f.addEventListener("change", refreshDisabled); function setAllChecked(val) { for (const b of f.querySelectorAll("input[data-meta-input]")) b.checked = val; refreshDisabled(); } selAll.addEventListener("click", () => setAllChecked(true)); selNone.addEventListener("click", () => setAllChecked(false)); f.addEventListener("submit", () => { const selected = Array.from( f.querySelectorAll("input[data-meta-input]:checked"), ).map((b) => b.dataset.metaInput); hidden.value = selected.join(","); }); root.append(f); } // ─── rebuild queue ──────────────────────────────────────────────────────────── // R3BU1LD QU3U3 is `JobqGraph` (Preact, rendered via plain `render(h(...))` // — no JSX pragma needed for either call, no mount wrapper: `render` is // already the re-render/diff entry point, per mara on review) directly — // no hand-rolled tree/roll-up rendering here anymore. The component owns // fetching GET /api/jobq/graph and its own refetch (bump `jobqGraphToken` // and render again); this page just reads its `onUpdate` callback to keep // `jobqNodes` (the flat array) in sync for the two things the generic // view doesn't render itself: the count-pill and the live-log panel below. // // Cancel is the one action this page *does* wire up: the `cancellable` // prop turns on the component's own per-node cancel button, which calls // `onCancel(id)` rather than posting anything — the endpoint // (`/api/rebuild-queue/{id}/cancel`) is this page's domain concept, not // the generic component's. // Split from `renderRebuildQueue` below: `replaceChildren()` only // belongs on the *first* render, to clear the static "loading…" // placeholder `#rebuild-queue-section` starts with in `builds.html` — // Preact doesn't know that placeholder is there, so a later render // call must NOT repeat `replaceChildren()` (it would wipe Preact's own // tracked children out from under its diffing rather than let it // update them minimally). function mountRebuildQueue() { const root = $("rebuild-queue-section"); if (!root) return; root.replaceChildren(); renderRebuildQueue(); } function renderRebuildQueue() { const root = $("rebuild-queue-section"); if (!root) return; render( h(JobqGraph, { endpoint: "/api/jobq/graph", cancellable: true, refreshToken: jobqGraphToken, onUpdate: (nodes) => { jobqNodes = nodes || []; renderRebuildLiveLog(); updateRebuildCount(); }, onCancel: async (id) => { const node = jobqNodes.find((n) => n.id === id); const label = node ? node.payload.label : "node " + id; if ( !(await themedConfirm({ message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`, danger: true, confirmLabel: "✕ cancel", })) ) return; try { const r = await fetch("/api/rebuild-queue/" + id + "/cancel", { method: "POST", }); if (!r.ok) throw new Error("http " + r.status); // No manual refresh: cancel flips node state, which fires // rebuild_queue_changed over SSE — the existing handler below // already bumps jobqGraphToken and re-renders on that tick. } catch (err) { console.error("cancel failed", err); } }, }), root, ); } // ─── running-rebuild live log ───────────────────────────────────────────────── // One persistent live-log panel for the first currently-building node. // Keyed to that node's id and kept in its own container (#rebuild-live-log) // so the queue's row re-render never tears down an open poll. // // Build logs are fetched by polling GET /api/build-log/ (returns // {stdout, stderr}) rather than SSE — a running node's log re-fetches on the // rebuild_queue_changed tick; a terminal node's log is static (one last fetch // on transition, then done). let liveLogId = null; // current node id being shown let liveLogDone = false; // true once the node left 'Running' let liveLogCollapsed = false; let liveLogPollTimer = null; function clearLiveLogPoll() { if (liveLogPollTimer) { clearInterval(liveLogPollTimer); liveLogPollTimer = null; } } // First running node with a log, in wire order (root-then-subtree per // root, roots in enqueue order — see hive-jobq-wire's wire_snapshot doc). // Gate on build_log_id so lock/noop/store-only nodes don't open a blank // panel. function findLiveBuild() { return ( jobqNodes.find( (n) => n.state === "Running" && n.payload.data && n.payload.data.build_log_id != null, ) || null ); } async function fetchAndRenderLiveLog(nodeId, pre) { try { const r = await fetch("/api/build-log/" + nodeId); if (!r.ok) return; const data = await r.json(); const text = [ data.stdout, data.stderr ? "--- stderr ---\n" + data.stderr : "", ] .filter(Boolean) .join("\n"); if (pre.textContent !== text) { const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40; pre.textContent = text; if (atBottom) pre.scrollTop = pre.scrollHeight; } } catch { /* network blip — ignore, next poll will retry */ } } function renderRebuildLiveLog() { const root = $("rebuild-live-log"); if (!root) return; const liveNode = findLiveBuild(); if (!liveNode) { clearLiveLogPoll(); liveLogId = null; liveLogDone = false; if (!root.hidden) { root.hidden = true; root.replaceChildren(); } return; } // Same node, already polling — just let the timer tick (or do a final // fetch if the node just went non-running and we haven't marked done yet). if (liveNode.id === liveLogId) { if (!liveLogDone && liveNode.state !== "Running") { clearLiveLogPoll(); liveLogDone = true; const pre = root.querySelector(".rebuild-live-log-output"); const badge = root.querySelector(".rebuild-live-log-badge"); if (pre) fetchAndRenderLiveLog(liveNode.id, pre); if (badge) { const ok = liveNode.state !== "Failed"; badge.className = "rebuild-live-log-badge " + (ok ? "rll-ok" : "rll-fail"); badge.textContent = liveNode.state; } } return; } // New node — tear down old poll, rebuild the panel. clearLiveLogPoll(); liveLogId = liveNode.id; liveLogDone = false; root.hidden = false; root.replaceChildren(); const pre = el("pre", { class: "rebuild-live-log-output" }, ""); pre.hidden = liveLogCollapsed; const badge = el( "span", { class: "rebuild-live-log-badge rll-running" }, "live", ); const toggle = el( "button", { type: "button", class: "rebuild-live-log-toggle", "aria-expanded": String(!liveLogCollapsed), title: liveLogCollapsed ? "expand live log" : "collapse live log", }, liveLogCollapsed ? "▸" : "▾", ); toggle.addEventListener("click", () => { liveLogCollapsed = !liveLogCollapsed; pre.hidden = liveLogCollapsed; toggle.textContent = liveLogCollapsed ? "▸" : "▾"; toggle.setAttribute("aria-expanded", String(!liveLogCollapsed)); toggle.title = liveLogCollapsed ? "expand live log" : "collapse live log"; }); const liveAgent = liveNode.payload.data && liveNode.payload.data.agent; const header = el( "div", { class: "rebuild-live-log-header" }, toggle, " ", el("span", { class: "rebuild-live-log-title" }, "live build log — "), // Label the specific node's agent, not the whole group's agent set. el("code", { class: "rqe-agent" }, liveAgent || ""), " ", el("span", { class: "rqe-kind" }, liveNode.payload.label), " ", badge, " ", el( "a", { class: "rebuild-live-log-raw", href: "/api/build-log/" + liveNode.id + "/raw", download: "build-log-" + liveNode.id + ".txt", }, "↓ raw", ), ); root.append(header, pre); // Start polling. fetchAndRenderLiveLog(liveNode.id, pre); liveLogPollTimer = setInterval( () => fetchAndRenderLiveLog(liveNode.id, pre), 2000, ); } // ─── rebuild-queue count pill ───────────────────────────────────────────────── function updateRebuildCount() { const pill = $("builds-tab-count-rebuild"); if (!pill) return; // Pending/Running/Finishing = in flight (Finishing = own work done, a // sub-node still running — still counts). Root nodes only: each is one // queue entry. const n = jobqNodes.filter( (n) => n.parent == null && (n.state === "Pending" || n.state === "Running" || n.state === "Finishing"), ).length; if (n > 0) { pill.textContent = String(n); pill.hidden = false; } else { pill.hidden = true; } } // ─── render-all (cold load + any full re-render) ────────────────────────────── function renderAll() { renderMetaInputs({ meta_inputs: metaInputsState }); // Rebuild-queue rendering + the count-pill/live-log it drives all happen // off JobqGraph's own onUpdate callback (see mountRebuildQueue) — // nothing to render here directly. } // ─── BUILD L0GS tab ─────────────────────────────────────────────────────────── // All-agents build log history with expand-to-detail. Live builds stream in // real time. Lazy-loaded on first tab activation; auto-refreshes (debounced // 2s) when rebuild_queue_changed fires while the BUILD L0GS tab is active. // Deep-link: ?id=N#buildlogs auto-expands the target row. const buildList = $("build-list"); const buildRefresh = $("build-refresh"); let buildLogsLoaded = false; let buildTabs; // set in init function fmtTs(unixSecs) { if (!unixSecs) return ""; const age = Math.floor(Date.now() / 1000) - unixSecs; return fmtAgeSecs(Math.max(0, age)) + " ago"; } async function fetchBuild() { if (!buildList) return; buildList.replaceChildren(); buildList.append(el("p", { class: "meta" }, "loading…")); try { const resp = await fetch("/api/build-logs?limit=30"); if (!resp.ok) throw new Error("http " + resp.status); const rows = await resp.json(); buildList.replaceChildren(); if (!rows || rows.length === 0) { buildList.append(el("p", { class: "meta" }, "(no build logs yet)")); return; } const ul = el("ul", { class: "build-logs-list" }); for (const h of rows) { const li = el("li", { class: "build-logs-item", "data-log-id": String(h.id), }); const live = !h.status; const ok = h.status === "ok"; const statusClass = live ? "hive-pill-sm badge-running" : ok ? "hive-pill-sm badge-ok" : "hive-pill-sm badge-fail"; const statusLabel = live ? "live" : ok ? "ok" : "fail"; const age = h.finished_at ? fmtTs(h.finished_at) : live ? "" : fmtTs(h.started_at); const runtime = h.runtime_secs != null ? el( "span", { class: "build-logs-runtime meta" }, fmtDuration(Math.max(0, h.runtime_secs)), ) : live ? el( "span", { class: "build-logs-runtime meta build-logs-live-dur" }, "…", ) : el("span", { class: "build-logs-runtime meta" }, ""); let durTimer = null; if (live && h.started_at) { const updateDur = () => { const elapsed = Math.floor(Date.now() / 1000) - h.started_at; runtime.textContent = fmtDuration(Math.max(0, elapsed)); }; updateDur(); durTimer = setInterval(updateDur, 1000); } const rowBtn = el( "button", { type: "button", class: "build-logs-row-btn", "aria-expanded": "false", }, el("span", { class: statusClass }, statusLabel), el("span", { class: "build-logs-agent" }, h.agent), runtime, el("span", { class: "build-logs-kind" }, h.kind), el("span", { class: "build-logs-age meta" }, age), el("span", { class: "build-logs-cmdline meta" }, h.cmdline), ); const detail = el("div", { class: "build-logs-detail" }); detail.hidden = true; let streamEs = null; rowBtn.addEventListener("click", async () => { const expanded = rowBtn.getAttribute("aria-expanded") === "true"; rowBtn.setAttribute("aria-expanded", String(!expanded)); detail.hidden = expanded; if (expanded) { if (streamEs) { streamEs.close(); streamEs = null; } return; } if (detail.dataset.loaded) return; detail.replaceChildren(); detail.append( el( "a", { href: "/api/build-logs/id/" + h.id + "/raw", download: "build-log-" + h.id + ".txt", class: "build-logs-dl", }, "↓ download raw", ), ); if (live) { const pre = el( "pre", { class: "build-logs-output build-logs-live" }, "", ); const badge = el( "span", { class: "build-logs-live-badge hive-pill-sm badge-running" }, "live", ); detail.append(badge, pre); streamEs = openBuildLogStream(h.id, pre, { onDone: (status) => { if (durTimer) { clearInterval(durTimer); durTimer = null; } if (h.started_at) { const elapsed = Math.floor(Date.now() / 1000) - h.started_at; runtime.textContent = fmtDuration(Math.max(0, elapsed)); } badge.className = status === "ok" ? "hive-pill-sm badge-ok" : "hive-pill-sm badge-fail"; badge.textContent = status; streamEs = null; detail.dataset.loaded = "1"; }, onError: () => { if (durTimer) { clearInterval(durTimer); durTimer = null; } badge.textContent = "stream error"; badge.className = "hive-pill-sm badge-fail"; streamEs = null; }, }); } else { const pre = el("pre", { class: "build-logs-output" }, "fetching…"); detail.append(pre); try { const r2 = await fetch("/api/build-logs/id/" + h.id); if (!r2.ok) { pre.textContent = "error " + r2.status; } else { const full = await r2.json(); const out = [full.stdout, full.stderr] .filter(Boolean) .join("\n--- stderr ---\n"); pre.textContent = out || "(empty)"; } detail.dataset.loaded = "1"; } catch (err) { pre.textContent = "fetch failed: " + err; } } }); li.append(rowBtn, detail); ul.append(li); } buildList.append(ul); // Deep-link: ?id=N auto-expands the target row. const deepId = new URLSearchParams(location.search).get("id"); if (deepId) { const target = ul.querySelector('[data-log-id="' + deepId + '"]'); if (target) { const btn = target.querySelector(".build-logs-row-btn"); if (btn) { btn.click(); target.scrollIntoView({ behavior: "smooth", block: "start" }); } } } } catch (err) { buildList.replaceChildren(); buildList.append(el("p", { class: "meta" }, "fetch failed: " + err)); } } if (buildRefresh) buildRefresh.addEventListener("click", fetchBuild); // ─── SSE handlers ───────────────────────────────────────────────────────────── let buildRefreshTimer = null; const SSE_HANDLERS = { rebuild_queue_changed() { // No `ev.queue` payload read anymore — this event still carries its own // queue snapshot on the wire (tabs.js/SW4RM still consumes it for the // swarm badges, untouched by this), this page just treats it as a // refetch trigger. `JobqGraph` owns the actual fetch. jobqGraphToken += 1; renderRebuildQueue(); // Auto-refresh build log list when the queue changes and BUILD L0GS is active. if (buildTabs && buildTabs.active() === "buildlogs") { if (buildRefreshTimer) clearTimeout(buildRefreshTimer); buildRefreshTimer = setTimeout(fetchBuild, 2000); } }, meta_inputs_changed(ev) { metaInputsState = (ev.inputs || []).slice(); renderMetaInputs({ meta_inputs: metaInputsState }); }, meta_update_running(ev) { metaUpdateRunning = !!ev.running; renderMetaInputs({ meta_inputs: metaInputsState }); }, }; // ─── boot ───────────────────────────────────────────────────────────────────── async function refreshState() { try { const resp = await fetch("/api/state"); if (resp.ok) syncFromSnapshot(await resp.json()); } catch { // best-effort } renderAll(); } async function init() { initServerWarnings(); bindAsyncForms(() => refreshState()); // Self-fetches on mount (JobqGraph's own effect) — no explicit initial // fetch needed here, unlike meta inputs below. mountRebuildQueue(); buildTabs = document.getElementById("builds-tabbar").configure({ tabs: [ { id: "rebuild", label: "R3BU1LD QU3U3", badgeId: "builds-tab-count-rebuild", }, { id: "meta", label: "M3T4 1NPUTS" }, { id: "buildlogs", label: "BUILD L0GS" }, ], defaultId: "rebuild", onShow: (id) => { if (id === "buildlogs" && !buildLogsLoaded) { buildLogsLoaded = true; fetchBuild(); } }, }); await refreshState(); // kinds= matches SSE_HANDLERS below verbatim — narrows this from all // 17 wire kinds down to the 3 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=rebuild_queue_changed,meta_inputs_changed,meta_update_running", ); 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();