// Y3R C4LL domain — the dashboard pane for things blocked on an operator // decision: approvals and the operator inbox (messages agents sent to // `to="operator"`). Lifted out of the dashboard entry (tabs.js) into its // own module so the entry stays a thin coordinator; mirrors the // permissions.js / schedules.js extractions. // // Tab-count wiring: like schedules.js this module does not call the // coordinator's `refreshTabCounts` directly (that would be a circular // import). Instead it exposes count getters the coordinator pulls, and the // live-mutation paths call an injected `onCountsChanged` callback the entry // registers once via `initCall`. import { $, form, appendLinkified } from "./common.js"; import { el } from "@hive/shared/dom.js"; import { epochSec, fmtAgo, fmtDuration } from "./util.js"; // Registered by the dashboard entry at boot; defaults to a no-op so the // module is safe to call before wiring. let onCountsChanged = () => {}; export function initCall(opts = {}) { if (typeof opts.onCountsChanged === "function") onCountsChanged = opts.onCountsChanged; } // ─── operator inbox — unread agent→operator messages ──────────── // The Y3R C4LL tab surfaces messages agents `send(to: "operator")` so // the operator stops missing them. Unread = broker rows to "operator" // with `acked_at IS NULL`; cold-loaded from `/api/operator-inbox`, // appended live from the broker `sent` stream, and cleared via the // existing per-recipient ack (`POST /api/agent/operator/mark-all-read`). // Count folds into the Y3R C4LL pill + browser-title prefix. let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first export function operatorInboxCount() { return operatorInbox.length; } export async function refreshOperatorInbox() { try { const r = await fetch("/api/operator-inbox"); if (r.ok) { const data = await r.json(); operatorInbox = Array.isArray(data.messages) ? data.messages : []; } } catch { /* keep prior list on transient failure */ } renderOperatorInbox(); onCountsChanged(); } function renderOperatorInbox() { const root = $("operator-inbox-section"); if (!root) return; root.replaceChildren(); if (!operatorInbox.length) { root.append(el("p", { class: "meta" }, "no unread messages")); return; } const mark = el( "button", { type: "button", class: "btn", id: "op-inbox-mark-read" }, `✓ mark all read (${operatorInbox.length})`, ); mark.addEventListener("click", markOperatorInboxRead); root.append(el("div", { class: "inbox-toolbar" }, mark)); const fmt = (ts) => new Date(ts).toISOString().replace("T", " ").slice(0, 19); const ul = el("ul", { class: "inbox" }); for (const m of operatorInbox) { const body = el("span", { class: "msg-body" }); appendLinkified(body, m.body, m.file_refs); ul.append( el( "li", {}, el("span", { class: "msg-ts" }, fmt(m.at)), " ", el("span", { class: "msg-from" }, m.from), " ", el("span", { class: "msg-sep" }, "→ "), body, ), ); } root.append(ul); } async function markOperatorInboxRead() { try { await fetch("/api/agent/operator/mark-all-read", { method: "POST" }); } catch { /* best-effort; the next refresh reconciles */ } operatorInbox = []; renderOperatorInbox(); onCountsChanged(); } // Live append from the broker stream — a `sent` frame addressed to // "operator". De-dupes on broker row id so a history/live overlap or // a refresh racing the stream doesn't double-list. export function operatorInboxAppendFromEvent(ev) { if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return; operatorInbox.unshift({ id: ev.id, from: ev.from, body: ev.body, at: ev.at, file_refs: ev.file_refs || [], }); if (operatorInbox.length > 100) operatorInbox.length = 100; renderOperatorInbox(); onCountsChanged(); } // ─── approvals — the operator config-change / spawn approval queue ──────── const APPROVAL_TAB_KEY = "hyperhive:approvals:tab"; // Derived approval state — cold-loaded from /api/state, then mutated // live by `approval_added` / `approval_resolved` dashboard events. // `pending` is the open queue (newest-first); `history` is the last // 30 resolved rows. const APPROVAL_HISTORY_LIMIT = 30; const approvalsState = { pending: [], history: [] }; export function activeApprovalCount() { return approvalsState.pending.length; } export function syncApprovalsFromSnapshot(s) { approvalsState.pending = (s.approvals || []).slice(); approvalsState.history = (s.approval_history || []).slice(); } export function applyApprovalAdded(ev) { // Upsert by id so a snapshot that already included the row (cold // load + event lands at the same tick) doesn't double it. const existing = approvalsState.pending.findIndex((a) => a.id === ev.id); const row = { id: ev.id, agent: ev.agent, kind: ev.approval_kind, sha_short: ev.sha_short || null, pr_number: ev.pr_number ?? null, description: ev.description || null, // The ApprovalAdded event carries no requested_at; a live-added // approval was queued just now, so client-now is accurate — and // consistent with how fmtAgo compares everything to client-now. // A later /api/state cold-load swaps in the server value. requested_at: ev.requested_at != null ? ev.requested_at : Math.floor(Date.now() / 1000), }; if (existing >= 0) approvalsState.pending[existing] = row; else approvalsState.pending.push(row); renderApprovals(); } export function applyApprovalResolved(ev) { // Drop from pending; prepend to history (newest-first), cap at 30. approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id); // Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can // carry this same resolved row in `approval_history` while a live // event also delivers it — guard the unshift so history can't // double a row. if (!approvalsState.history.some((h) => h.id === ev.id)) { approvalsState.history.unshift({ id: ev.id, agent: ev.agent, kind: ev.approval_kind, sha_short: ev.sha_short || null, status: ev.status, resolved_at: ev.resolved_at, note: ev.note || null, description: ev.description || null, }); if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) { approvalsState.history.length = APPROVAL_HISTORY_LIMIT; } } renderApprovals(); } export function renderApprovals() { const root = $("approvals-section"); // #approvals-section only lives on /dashboard.html (Y3R C4LL tab); // no-op elsewhere — `approval_added` / `approval_resolved` SSE // events route through here on every page that loads the bundle. if (!root) return; root.replaceChildren(); const pending = approvalsState.pending; const history = approvalsState.history; const active = localStorage.getItem(APPROVAL_TAB_KEY) || "pending"; const tabs = el("div", { class: "approval-tabs" }); const pendingTab = el( "button", { type: "button", class: "approval-tab" + (active === "pending" ? " active" : ""), }, `pending · ${pending.length}`, ); const historyTab = el( "button", { type: "button", class: "approval-tab" + (active === "history" ? " active" : ""), }, `history · ${history.length}`, ); pendingTab.addEventListener("click", () => { localStorage.setItem(APPROVAL_TAB_KEY, "pending"); renderApprovals(); }); historyTab.addEventListener("click", () => { localStorage.setItem(APPROVAL_TAB_KEY, "history"); renderApprovals(); }); tabs.append(pendingTab, historyTab); root.append(tabs); if (active === "history") { renderApprovalHistory(root, history); return; } if (!pending.length) { root.append(el("p", { class: "empty" }, "queue empty")); return; } // forge link base — only when the hive-forge container is up. const fs = window.__hyperhive_state; // state.forge_public_url (set from services.hyperhive.forge.publicUrl) // or null — never guessed from ":3000". The PR-link builder // below already gates on forgeBase being truthy. const forgeBase = (fs && fs.forge_present && fs.forge_public_url) || null; const ul = el("ul", { class: "approvals" }); for (const a of pending) { const isInit = a.kind === "init_config"; const isMergePr = a.kind === "merge_config_pr"; const isUpdateMeta = a.kind === "update_meta_inputs"; const isSchedule = a.kind === "schedule_prompt"; const li = el("li", { class: "approval-card" }); // ── identity header ────────────────────────────────────────── const head = el( "div", { class: "approval-head" }, el( "span", { class: "glyph" }, isMergePr ? "⇒" : isUpdateMeta ? "↻" : isSchedule ? "⏱" : "⊕", ), el("span", { class: "id" }, "#" + a.id), el("span", { class: "agent" }, a.agent), el( "span", { class: "kind" + (isMergePr || isUpdateMeta || isSchedule ? "" : " kind-spawn"), }, isMergePr ? "merge-pr" : isUpdateMeta ? "meta-update" : isSchedule ? "schedule" : isInit ? "init" : "spawn", ), ); if (isMergePr && a.sha_short) head.append(el("code", {}, a.sha_short)); // When the approval was requested — relative time, right-aligned. // Goes amber once it's been pending an hour so a stale request is // obvious at a glance (see docs/web-ui.md::Approval card). if (a.requested_at != null) { const requestedSec = epochSec(a.requested_at); const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - requestedSec)); head.append( el( "span", { class: "approval-ts" + (ageSec >= 3600 ? " stale" : ""), title: "requested " + new Date(a.requested_at).toLocaleString(), "data-requested-at": String(requestedSec), }, "requested " + fmtAgo(a.requested_at), ), ); } li.append(head); // ── what-changed body ──────────────────────────────────────── const body = el("div", { class: "approval-body" }); if (a.description) { body.append(el("div", { class: "approval-description" }, a.description)); } if (isMergePr) { // PR-based config deploy: link to the reviewed PR on the forge. // The config diff lives on the forge PR itself. const drill = el("div", { class: "drill-ins" }); if (forgeBase && a.pr_number != null) { drill.append( el( "a", { class: "panel-trigger", target: "_blank", rel: "noopener", href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`, title: "review this config PR on the hive forge", }, "↳ review PR on forge ↗", ), ); } body.append(drill); } else if (isUpdateMeta) { let inputs; try { inputs = JSON.parse(a.commit_ref || "[]"); } catch (_) { inputs = []; } body.append( el( "span", { class: "meta" }, inputs.length ? "bump flake inputs: " + inputs.join(", ") : "bump all flake inputs", ), ); } else if (isSchedule) { let payload; try { payload = JSON.parse(a.commit_ref || "{}"); } catch (_) { payload = {}; } const targets = (payload.targets || []).join(", "); const firstFire = payload.first_fire_at_unix ? new Date(payload.first_fire_at_unix * 1000).toLocaleString() : "?"; const cadence = payload.interval_seconds ? " · ↻ every " + fmtDuration(payload.interval_seconds) : " · one-shot"; body.append( el( "div", { class: "meta" }, "→ " + targets + " · first: " + firstFire + cadence, ), ); if (payload.body) { const excerpt = payload.body.length > 80 ? payload.body.slice(0, 80) + "…" : payload.body; body.append(el("div", { class: "approval-description" }, excerpt)); } } else { body.append( el( "span", { class: "meta" }, isInit ? "scaffold proposed config repo — submitting agent customises agent.nix before spawn" : "new sub-agent — container will be created on approve", ), ); } li.append(body); // ── decision actions ───────────────────────────────────────── // Deny prompts the operator for an optional reason; the submit // handler stashes it into a hidden `note` input that rides along // on the POST and is surfaced to the submitting agent via // HelperEvent::ApprovalResolved { note }. const denyForm = el("form", { method: "POST", action: "/api/deny/" + a.id, class: "inline", "data-async": "", "data-no-refresh": "", "data-prompt": "reason for denying (optional, sent to submitter):", }); denyForm.append( el("button", { type: "submit", class: "btn btn-deny" }, "DENY"), ); li.append( el( "div", { class: "approval-actions" }, form( "/api/approve/" + a.id, "btn-approve", "◆ APPR0VE", null, {}, { noRefresh: true }, ), denyForm, ), ); ul.append(li); } root.append(ul); } function renderApprovalHistory(root, history) { if (!history.length) { root.append(el("p", { class: "empty" }, "no resolved approvals yet")); return; } const ul = el("ul", { class: "approvals approvals-history" }); for (const a of history) { const li = el("li"); const row = el("div", { class: "row" }); const glyph = a.status === "approved" ? "✓" : a.status === "denied" ? "✗" : a.status === "cancelled" ? "⊘" : "⚠"; row.append( el("span", { class: "glyph glyph-" + a.status }, glyph), " ", el("span", { class: "id" }, "#" + a.id), " ", el("span", { class: "agent" }, a.agent), " ", el( "span", { class: "kind" }, a.kind === "merge_config_pr" ? "merge-pr" : a.kind === "update_meta_inputs" ? "meta-update" : a.kind === "schedule_prompt" ? "schedule" : a.kind === "init_config" ? "init" : "spawn", ), " ", ); if (a.sha_short) row.append(el("code", {}, a.sha_short), " "); row.append( el("span", { class: "status status-" + a.status }, a.status), " ", el("span", { class: "msg-ts" }, fmtAgo(a.resolved_at)), ); li.append(row); if (a.note) { li.append(el("div", { class: "history-note" }, a.note)); } ul.append(li); } root.append(ul); }