hyperhive/frontend/packages/dashboard/src/app.js
iris 7e5b496aa8 dashboard: fire-now button on schedule rows (closes #467)
Operator wants to trigger a scheduled prompt immediately
instead of waiting for the next interval. Adds a `↯ fire now`
button on every active schedule row, next to `✕ cancel all`.

Semantics (per design discussion with damocles):

- recurring schedules → out-of-band pulse, `next_fire_at_unix`
  untouched; the regular cadence keeps firing on the original
  schedule. Operator gets an extra fan-out, not a phase shift.
- one-shots → consumed after the manual fire. Operator's
  intent reads as "the scheduled time was wrong, send NOW";
  leaving the original time would be surprising.

Confirm dialog spells out the recurring-vs-one-shot
difference up front so the operator knows what they're about
to do. Button is disabled when every target is already
cancelled (nothing to fire).

Talks to `POST /api/schedules/{id}/fire-now` (damocles is
wiring the backend in parallel). Mauve styling distinguishes
it from cancel (red) and submit (amber); fits the existing
btn pattern.

docs/web-ui.md updated.
2026-05-26 17:06:14 +02:00

2890 lines
124 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// /index.html entry point: tab renderers + tab routing + refreshState
// + notification deltas. Reads /api/state on cold load and after every
// async-form submit; live updates run through `applyXxx` mutation
// handlers triggered by the dashboard event stream. (Live SSE
// subscription on /index.html is still missing — see #406 step 3 / #408.)
//
// #406 step 1: pure helpers + side panel + OS notifications + path
// linkification moved to `./common.js`.
// #406 step 2: the flow-only IIFEs (operator inbox, inbox-pill, broker
// terminal, @-mention composer) moved to `./flow.js`. /flow.html now
// loads `flow.js` as its own bundle entry; this file (still named
// `app.js` for the moment; rename to `tabs.js` is the last step
// of #406) is loaded only by /index.html.
import { marked } from 'marked';
import {
$, el, esc, form,
fmtAgeSecs,
Panel, NOTIF,
makePathLink, appendText, appendLinkified,
openStream,
} from './common.js';
// mdNode (in common.js) reads `window.marked` for the markdown side
// panel preview path. Set it here on the dashboard entry so file
// previews work; flow.js does the same for the flow-page entry.
window.marked = marked;
(() => {
// ─── constants ──────────────────────────────────────────────────────────
// Context-window badge thresholds. Preferred source is each container's
// `context_window_tokens` from /api/state (the real window for the model
// it last ran on) — thresholds are then 75% / 50% of it, matching the
// harness compaction watermarks (compact at 75%, auto-reset at 50%). The
// fixed token constants are the fallback for when that field is absent
// (agent has no turns yet, or no per-model config matched the model).
const CTX_WARN_FRACTION = 0.75; // ≥ this share of the window → red
const CTX_CAUTION_FRACTION = 0.50; // ≥ this share of the window → yellow
const CTX_WARN_TOKENS = 150_000; // fallback red threshold (≈ 75% of 200k)
const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (≈ 50% of 200k)
// Helpers ($, el, esc, form, fmtAgeSecs) moved to ./common.js (#406).
// Side panel singleton (Panel) moved to ./common.js (#406).
// Path linkification + file-preview side panel (openFilePanel,
// makePathLink, appendText, appendLinkified) moved to ./common.js (#406).
// OS notification module (NOTIF) moved to ./common.js (#406).
// Track which items we've already notified about so a re-render
// doesn't re-fire for the same row. Keyed by stable ids; reset only
// when the page reloads.
const seenApprovals = new Set();
const seenQuestions = new Set();
let seededNotify = false;
function notifyDeltas(s) {
const approvals = s.approvals || [];
const questions = s.questions || [];
if (!seededNotify) {
// First render after page load — fill the "seen" sets without
// firing notifications. We only want to notify on NEW items
// that arrived while the page is open. The inbox no longer
// needs seeding here: it's derived from the broker stream which
// does its own per-event notification on live arrival, and
// history-replayed events are silent by virtue of `fromHistory`.
for (const a of approvals) seenApprovals.add(a.id);
for (const q of questions) seenQuestions.add(q.id);
seededNotify = true;
return;
}
for (const a of approvals) {
if (seenApprovals.has(a.id)) continue;
seenApprovals.add(a.id);
const verb = a.kind === 'spawn' ? 'spawn approval'
: a.kind === 'init_config' ? 'config-init approval'
: 'config commit';
NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`,
'hyperhive:approval:' + a.id);
}
for (const q of questions) {
if (seenQuestions.has(q.id)) continue;
seenQuestions.add(q.id);
const targetLabel = q.target || 'operator';
NOTIF.show(`${q.asker}${targetLabel} asks`,
q.question.slice(0, 120),
'hyperhive:question:' + q.id);
}
}
// ─── async forms ────────────────────────────────────────────────────────
document.addEventListener('submit', async (e) => {
const f = e.target;
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
e.preventDefault();
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
if (f.dataset.prompt) {
const ans = prompt(f.dataset.prompt, '');
if (ans === null) return; // operator hit Cancel
// Drop into a hidden input named after `data-prompt-field` (or
// 'note' by default) so the value rides along on the POST.
const field = f.dataset.promptField || 'note';
let input = f.querySelector(`input[name="${field}"]`);
if (!input) {
input = document.createElement('input');
input.type = 'hidden';
input.name = field;
f.append(input);
}
input.value = ans;
}
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
const original = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
try {
const resp = await fetch(f.action, {
method: f.method || 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(new FormData(f)),
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
if (btn) { btn.disabled = false; btn.innerHTML = original; }
return;
}
// Re-enable the button — refreshState() rebuilds most lists but
// skips forms that didn't change (e.g. the spawn form), so without
// this the spinner sticks and the button can't be clicked again.
if (btn) { btn.disabled = false; btn.innerHTML = original; }
// Clear text inputs whose value was just submitted.
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
// Forms whose endpoint already emits a DashboardEvent that
// updates the derived store can opt out of the post-submit
// /api/state refetch (the event delivers the new row faster
// than the snapshot poll anyway). Container-lifecycle forms
// still rely on the refresh since `ContainerView` isn't yet
// event-derivable.
if (!f.hasAttribute('data-no-refresh')) {
refreshState();
}
} catch (err) {
alert('action failed: ' + err);
if (btn) { btn.disabled = false; btn.innerHTML = original; }
}
});
// Derived container state — cold-loaded from /api/state.containers,
// then mutated live by `container_state_changed` (upsert by name)
// and `container_removed` (drop by name). The coordinator's rescan
// helper fires these after every mutation site + on a periodic poll
// in crash_watch. Keyed by ContainerView.name so the lifecycle
// forms' POST → 200 → matching event flips the row without a
// snapshot refetch.
const containersState = new Map();
function syncContainersFromSnapshot(s) {
containersState.clear();
for (const c of s.containers || []) containersState.set(c.name, c);
}
function applyContainerStateChanged(ev) {
if (!ev.container || !ev.container.name) return;
containersState.set(ev.container.name, ev.container);
renderContainersFromState();
}
function applyContainerRemoved(ev) {
if (containersState.delete(ev.name)) renderContainersFromState();
}
// Derived tombstones + meta_inputs. Both are emitted as full
// snapshots (not diffs) — the lists are tiny and recomputing
// avoids ordering races between a same-tick destroy + purge.
let tombstonesState = [];
let metaInputsState = [];
// True while a dashboard-triggered meta-update (flake lock bump +
// agent rebuild ripple) runs in the background. Cold-loaded from
// `s.meta_update_running`, then flipped live by the
// `meta_update_running` event. Drives the META INPUTS panel's
// disabled "updating…" state (issue #259).
let metaUpdateRunning = false;
function syncTombstonesFromSnapshot(s) {
tombstonesState = (s.tombstones || []).slice();
}
function syncMetaInputsFromSnapshot(s) {
metaInputsState = (s.meta_inputs || []).slice();
metaUpdateRunning = !!s.meta_update_running;
}
function applyTombstonesChanged(ev) {
tombstonesState = (ev.tombstones || []).slice();
renderTombstonesFromState();
}
function applyMetaInputsChanged(ev) {
metaInputsState = (ev.inputs || []).slice();
renderMetaInputsFromState();
}
function applyMetaUpdateRunning(ev) {
metaUpdateRunning = !!ev.running;
renderMetaInputsFromState();
}
function renderTombstonesFromState() {
renderTombstones({ tombstones: tombstonesState });
}
function renderMetaInputsFromState() {
renderMetaInputs({ meta_inputs: metaInputsState });
}
// Derived rebuild queue state — cold-loaded from
// `/api/state.rebuild_queue`, then mutated live by the
// `rebuild_queue_changed` snapshot event. Same shape as the meta-
// inputs panel (full snapshot per change, no diff).
let rebuildQueueState = [];
function syncRebuildQueueFromSnapshot(s) {
rebuildQueueState = (s.rebuild_queue || []).slice();
}
function applyRebuildQueueChanged(ev) {
rebuildQueueState = (ev.queue || []).slice();
renderRebuildQueueFromState();
// Container cards surface in-flight rebuild / meta-update ops as
// a "building..." badge (#398) — re-render the SW4RM tab so
// newly-queued / newly-running ops light up the right card,
// and finished ops fall back to the regular state badges.
renderContainersFromState();
}
// Map from agent name → highest-priority in-flight queue entry
// (`running` beats `queued`). Used by the container row renderer
// to surface "building..." / "meta-updating..." badges on the
// SW4RM tab when an op is still in the rebuild queue but no
// operator-initiated transient is set (#398).
function inFlightOpsByAgent() {
const out = new Map();
for (const e of rebuildQueueState) {
if (e.state !== 'queued' && e.state !== 'running') continue;
// spawn ops target an agent that doesn't exist yet as a
// container — the transient store already drives the
// pending row for that case. Skip here to avoid double-
// surfacing if the spawn op happens to land in the queue
// while the row exists transiently.
if (e.kind === 'spawn') continue;
const cur = out.get(e.agent);
// Prefer running over queued; otherwise keep the first match.
if (!cur || (cur.state === 'queued' && e.state === 'running')) {
out.set(e.agent, e);
}
}
return out;
}
function renderRebuildQueueFromState() {
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
}
// Derived transient state — cold-loaded from /api/state.transients,
// then mutated live by `transient_set` / `transient_cleared`. Keyed
// by agent name so add/remove are O(1). `since_unix` is wall-clock so
// the elapsed-seconds badge ticks without polling.
const transientsState = new Map();
function syncTransientsFromSnapshot(s) {
transientsState.clear();
for (const t of s.transients || []) {
// Snapshot ships `secs` (server-computed); reconstruct an
// approximate since_unix so the live ticker keeps progressing
// without surprising jumps when the next snapshot lands.
const nowUnix = Math.floor(Date.now() / 1000);
transientsState.set(t.name, {
kind: t.kind,
since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)),
});
}
}
function applyTransientSet(ev) {
transientsState.set(ev.name, {
kind: ev.transient_kind,
since_unix: ev.since_unix,
});
renderContainersFromState();
}
function applyTransientCleared(ev) {
if (transientsState.delete(ev.name)) renderContainersFromState();
}
// Re-render using the last cached snapshot (containers come from
// /api/state, transients overlay from the derived map). The snapshot
// is stashed on window.__hyperhive_state by refreshState; on cold
// load before the first snapshot we just skip.
function renderContainersFromState() {
const s = window.__hyperhive_state;
if (s) renderContainers(s);
}
// ─── selection (#443) ───────────────────────────────────────────────
// Set of selected agent logical names. Toggled by clicking the
// container-row icon. When non-empty, the sticky #selection-bar
// becomes visible with the bulk actions. Per-card action buttons
// are gone — actions live in the bar.
const selectionState = new Set();
function toggleSelection(name) {
if (selectionState.has(name)) selectionState.delete(name);
else selectionState.add(name);
renderContainersFromState();
}
function clearSelection() {
if (selectionState.size === 0) return;
selectionState.clear();
renderContainersFromState();
}
// Esc clears the current selection (operator escape hatch — mirrors
// the side-panel close pattern). Ignored when an editable element
// has focus so typing in compose / answer / journal-search isn't
// intercepted.
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (!selectionState.size) return;
const a = document.activeElement;
if (a && (a.isContentEditable
|| a.tagName === 'INPUT'
|| a.tagName === 'TEXTAREA'
|| a.tagName === 'SELECT')) return;
e.preventDefault();
clearSelection();
});
document.addEventListener('click', (e) => {
if (e.target && e.target.closest('#selection-clear')) {
clearSelection();
}
});
// Re-derive port conflicts from the live containers map. Mirrors the
// server-side `build_port_conflicts` so the banner reacts to event
// updates instead of waiting for a /api/state refetch.
function derivePortConflicts(containers) {
const byPort = new Map();
for (const c of containers) {
if (!byPort.has(c.port)) byPort.set(c.port, []);
byPort.get(c.port).push(c.name);
}
const out = [];
for (const [port, agents] of byPort) {
if (agents.length > 1) {
agents.sort();
out.push({ port, agents });
}
}
out.sort((a, b) => a.port - b.port);
return out;
}
// ─── state rendering ────────────────────────────────────────────────────
// ─── agent topology (#363) ──────────────────────────────────────────────
// Build a forest from `ContainerView.parent` and walk depth-first to
// produce a render order with per-row depth + sibling-position info.
// Top-level (parent = null OR parent not in the container map) are
// roots. Within each level, children are sorted alphabetically by
// name; roots likewise. Cycles in the parent graph (malformed config)
// are tolerated: any container not reached via root-walk is appended
// as a root at the end, so no agent ever disappears from the list.
//
// For the visual: each row gets a textual prefix column (`├─`, `└─`,
// continuation `│ ` or padding ` ` for ancestor columns). When
// every container has `parent = null` (today's pre-#361 state), the
// tree collapses to a flat list with no glyphs and no indent — bit-
// identical to the legacy render.
function buildAgentTree(containers) {
const byName = new Map();
for (const c of containers) byName.set(c.name, c);
const children = new Map(); // parent_name → [child_name, …]
const roots = [];
for (const c of containers) {
const p = c.parent || null;
if (p == null || !byName.has(p)) {
roots.push(c.name);
} else {
const list = children.get(p) || [];
list.push(c.name);
children.set(p, list);
}
}
roots.sort();
for (const list of children.values()) list.sort();
const out = [];
const visited = new Set();
function visit(name, depth, ancestorIsLast, isLast) {
if (visited.has(name)) return;
visited.add(name);
const c = byName.get(name);
if (!c) return;
out.push({ container: c, depth, ancestorIsLast: [...ancestorIsLast], isLast });
const kids = children.get(name) || [];
kids.forEach((kid, i) =>
visit(kid, depth + 1, [...ancestorIsLast, isLast], i === kids.length - 1));
}
roots.forEach((name, i) => visit(name, 0, [], i === roots.length - 1));
// Cycle safety: anything not reached lands at root level so no
// agent silently disappears when a config is malformed.
for (const c of containers) {
if (!visited.has(c.name)) visit(c.name, 0, [], true);
}
return out;
}
// Builds the .tree-prefix DOM for a row at the given depth. Each
// lane is its own positioned child so CSS can paint full-height
// vertical bars that bridge the gap between sibling rows — text
// box-drawing glyphs only paint one text-line tall, which left
// visible breaks between rows once we grew taller-than-one-line
// container cards (#388). Ancestor lanes are either continuation
// (vertical bar top→bottom+gap) or blank; the joint at this row's
// own depth is ├ (branch — vertical continues below) or └ (last —
// vertical stops at the row's icon midline). CSS at
// `.container-row .tree-prefix` paints the bars + horizontal stub.
function treePrefixDom({ depth, ancestorIsLast, isLast }) {
if (depth === 0) return null;
const prefix = el('span', { class: 'tree-prefix', 'aria-hidden': 'true' });
// Ancestor columns (depth 1..depth-1). Skip depth 0 (root has no
// continuation column — top-level rows are separated visually as
// top-level rows already).
for (let d = 1; d < depth; d++) {
const cls = ancestorIsLast[d] ? 'tree-lane lane-blank' : 'tree-lane lane-line';
prefix.append(el('span', { class: cls }));
}
const jointCls = 'tree-lane lane-joint ' + (isLast ? 'lane-joint-last' : 'lane-joint-branch');
prefix.append(el('span', { class: jointCls }));
return prefix;
}
function renderContainers(s) {
const root = $('containers-section');
// #containers-section only exists on /index.html. The same
// bundled app.js runs on /flow.html (which has the broker
// terminal but no container list) and on any future pages —
// `container_state_changed` SSE events arrive on every page and
// route through `applyContainerStateChanged → renderContainersFromState`,
// so without this guard the renderer throws `root is null` on
// every event (#399). Matches the no-op-when-target-absent
// convention the other renderers (renderTombstones, etc.) follow.
if (!root) return;
root.innerHTML = '';
// Containers come from the derived map (event-driven) rather than
// `s.containers`; `s` still supplies hostname (for the web-ui
// link) and tombstones/meta_inputs (not event-derived yet). The
// tree builder handles the ordering — we don't pre-sort here.
const containers = Array.from(containersState.values());
const portConflicts = derivePortConflicts(containers);
const anyStale = containers.some((c) => c.needs_update);
// Port-hash collisions: rename one of the listed agents and
// rebuild. The banner sits above the agent list so it's the
// first thing the operator sees when something's wedged.
if (portConflicts.length) {
const banner = el('div', { class: 'port-conflict' },
el('strong', {}, '⚠ port collision'), ' — ');
const groups = portConflicts.map((c) =>
`:${c.port} (${c.agents.join(' + ')})`).join('; ');
banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
root.append(banner);
}
if (anyStale) {
root.append(form(
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
'rebuild every stale container?',
{}, { noRefresh: true },
));
}
if (transientsState.size) {
const ul = el('ul');
const nowUnix = Math.floor(Date.now() / 1000);
for (const [name, t] of transientsState) {
const secs = Math.max(0, nowUnix - t.since_unix);
ul.append(el('li', {},
el('span', { class: 'glyph spinner' }, '◐'), ' ',
el('span', { class: 'agent' }, name), ' ',
el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
el('span', { class: 'meta' }, `nixos-container create + start (${secs}s)`),
));
}
root.append(ul);
}
if (!containers.length && !transientsState.size) {
root.append(el('p', { class: 'empty' }, 'no managed containers'));
return;
}
// Drop stale selections (agent destroyed while selected). Defensive —
// the action bar would otherwise loop POST against a gone agent.
const liveNames = new Set(containers.map((c) => c.name));
for (const n of Array.from(selectionState)) {
if (!liveNames.has(n)) selectionState.delete(n);
}
const hostname = (s && s.hostname) || window.location.hostname;
const ul = el('ul', { class: 'containers' });
const tree = buildAgentTree(containers);
// In-flight rebuild / meta-update / destroy ops per agent name.
// Surface them as "building..." style badges on the container
// card when no operator-initiated transient already covers the
// row (#398). Mara: the SW4RM tab showed an agent as stopped
// while SYST3M showed an active rebuild; cross-reference fixes
// that.
const inFlight = inFlightOpsByAgent();
for (const node of tree) {
const c = node.container;
const url = `http://${hostname}:${c.port}/`;
// Pending state is overlaid from the transient store first
// (operator-initiated spawn/destroy/rebuild — covers the
// create+start window where the container literally isn't up
// yet), then from the rebuild_queue (#398 — covers in-flight
// ops the worker is running even if no transient was set).
// `ContainerStateChanged` doesn't carry either signal.
const transientKind = transientsState.get(c.name)?.kind || null;
const op = !transientKind ? inFlight.get(c.name) : null;
const pending = transientKind
|| (op && (op.state === 'running'
? (op.kind === 'meta_update' ? 'meta-updating'
: op.kind === 'destroy' ? 'destroying'
: 'rebuilding')
: (op.kind === 'meta_update' ? 'meta-update queued'
: op.kind === 'destroy' ? 'destroy queued'
: 'rebuild queued')));
const selected = selectionState.has(c.name);
const li = el('li', {
class: 'container-row'
+ (pending ? ' pending' : '')
+ (selected ? ' selected' : ''),
});
// Topology: depth contributes left-padding; the glyph string in
// the .tree-prefix span draws the ├─ / └─ joint + continuation
// lines (`│ `) for ancestors whose subtree extends below this
// row. Both are CSS-driven from the data attributes so the
// legacy flat layout (every container at depth 0) is bit-
// identical to today's render — no glyph, no indent.
if (node.depth > 0) li.dataset.depth = String(node.depth);
const prefix = treePrefixDom(node);
if (prefix) li.prepend(prefix);
// Full-height square agent icon, left of the card body. The
// icon is an <img> absolutely positioned inside a wrapper div:
// the div is the flex child and sizes itself via aspect-ratio +
// stretch, the <img> is out of flow so its load state — pending,
// loaded or broken — can never contribute intrinsic size or
// reflow the row. (issue #177)
//
// The icon points straight at the agent's `<url>/icon`. We don't
// guess whether the agent is reachable from the container row —
// we just let the <img> try, and if it actually fails to load
// (agent stopped, restarting, rebuilding — web server not
// answering) the error handler falls it back to the dimmed
// hyperhive mark (`/favicon.svg`, served by the dashboard
// itself, always reachable). (issues #195, #202)
const iconImg = el('img', { class: 'container-icon-img', alt: '' });
// #443: icon is the selection toggle. Click → add/remove from
// `selectionState` → re-render. role=button + tabindex makes it
// keyboard-accessible; aria-pressed reflects the toggle state.
const icon = el('div', {
class: 'container-icon',
role: 'button',
tabindex: '0',
'aria-pressed': selected ? 'true' : 'false',
title: selected
? `deselect ${c.name} (or press Esc to clear all)`
: `select ${c.name} for bulk actions`,
}, iconImg);
icon.addEventListener('click', (e) => {
e.preventDefault();
toggleSelection(c.name);
});
icon.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSelection(c.name);
}
});
if (c.running) {
iconImg.src = `${url}icon`;
iconImg.addEventListener('error', () => {
if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s
iconImg.dataset.fallback = '1';
icon.classList.add('icon-unreachable');
iconImg.src = '/favicon.svg';
});
} else {
// Container stopped (#432) — skip the doomed `${url}icon` fetch
// and go straight to the dimmed hyperhive mark. Avoids a noisy
// failed request in the console + the brief broken-image flash.
icon.classList.add('icon-unreachable');
iconImg.src = '/favicon.svg';
}
// Card body: the three stacked content lines, right of the icon.
const body = el('div', { class: 'card-body' });
// ── identity ─────────────────────────────────────────────────
const head = el('div', { class: 'head' });
head.append(
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
c.is_manager ? 'm1nd' : 'ag3nt'),
);
// Icon-only nav strip — populated async from `/api/agent/{name}/links`,
// a same-origin proxy that forwards the agent backend's own link list
// (stats / screen-if-gui / forge profile / agent-configs / extras).
// The agent backend is the single source of truth; no hardcoded link
// list here (issue #262). DOM-built — link strings come from the
// agent's process and must never reach the HTML parser.
const navStrip = el('span', { class: 'nav-strip' });
head.append(navStrip);
const forgeBase = `http://${hostname}:3000`;
const containerBase = `http://${hostname}:${c.port}`;
if (c.running) {
fetch(`/api/agent/${encodeURIComponent(c.name)}/links`)
.then((r) => (r.ok ? r.json() : []))
.then((links) => {
if (!Array.isArray(links)) return;
for (const lnk of links) {
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
: lnk.kind === 'external' ? (lnk.url || '')
: /* container */ containerBase + (lnk.url || '');
const a = el('a', {
class: 'nav-link',
href,
target: '_blank',
rel: 'noopener',
title: lnk.label || '',
});
// Plain text — agent-controlled strings stay out of innerHTML.
a.textContent = lnk.icon || lnk.label || '';
navStrip.append(a);
}
})
.catch(() => { /* graceful: agent down → no strip */ });
}
// Status / runtime badges. Pending transients always win
// (start / stop / restart / rebuild is in progress). Otherwise,
// when the container is stopped, surface a single `■ not
// running` badge; the backend has already cleared rate_limited /
// needs_login / ctx_tokens / status_text in that case (#432) so
// the rest of the chain is a no-op for stopped containers — but
// we still want SOME badge there so the row doesn't look empty.
if (pending) {
head.append(el('span', { class: 'pending-state' },
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
} else if (!c.running) {
head.append(el('span',
{ class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' },
'■ not running'));
} else if (c.rate_limited) {
head.append(el('span',
{ class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' },
'⊘ rate limited'));
} else if (c.needs_login) {
head.append(el('a',
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
'needs login →'));
}
if (c.needs_update) {
head.append(form(
'/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
'rebuild ' + c.name + '? hot-reloads the container.',
{}, { noRefresh: true },
));
}
head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`));
if (c.deployed_sha) {
head.append(el('span',
{ class: 'meta', title: 'sha currently locked in /meta/flake.lock' },
`deployed:${c.deployed_sha}`));
}
if (c.pending_reminders && c.pending_reminders > 0) {
head.append(el('span',
{
class: 'badge badge-reminder',
title: 'pending reminders queued for this agent — see the reminders section to view / cancel',
},
`${c.pending_reminders}`));
}
if (c.ctx_tokens != null) {
const k = Math.round(c.ctx_tokens / 1000);
// Thresholds track the model's real context window when the
// backend supplies it; otherwise fall back to fixed constants.
const win = c.context_window_tokens;
const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
const ctxClass = c.ctx_tokens >= warn ? 'badge-ctx-warn'
: c.ctx_tokens >= caution ? 'badge-ctx-caution'
: 'badge-ctx-ok';
const title = win != null
? `last turn context: ${c.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} `
+ `tokens (${Math.round((c.ctx_tokens / win) * 100)}% of the window)`
: `last turn context size: ${c.ctx_tokens.toLocaleString()} tokens`;
head.append(el('span',
{ class: `badge ${ctxClass}`, title },
`ctx·${k}k`));
}
body.append(head);
// ── agent status text ─────────────────────────────────────────
// Self-reported status (via set_status MCP tool) — only fresh
// while the harness is up. The backend already clears
// `status_text` on stopped containers (#432) so we can render
// unconditionally here: a stopped container simply has no
// `status_text` and skips this block naturally.
if (c.status_text) {
const nowUnix = Math.floor(Date.now() / 1000);
const ageStr = c.status_set_at != null
? ` (set ${fmtAgeSecs(nowUnix - c.status_set_at)} ago)` : '';
body.append(el('div', {
class: 'agent-status',
title: `agent self-reported status${ageStr}`,
},
el('span', { class: 'status-icon' }, '◈ '),
c.status_text,
el('span', { class: 'status-age' }, ageStr),
));
}
// Per-card action buttons used to live here (R3ST4RT / ST0P /
// ST4RT / R3BU1LD / DESTR0Y / PURG3). Per mara on #443: "dont
// show all the restart buttons etc., just show state and links.
// instead, clicking an agent icon selects that agent." Actions
// moved into the sticky #selection-bar (see renderSelectionBar)
// which appears when the operator has at least one agent
// selected via the icon click. The contextual `needs update ↻`
// chip in the head row stays — it's a state-hint, not an
// action button per se.
// ── drill-ins ────────────────────────────────────────────────
const drill = el('div', { class: 'drill-ins' });
// Per-container journald viewer. Opens the side panel and
// fetches the last N lines; refresh re-fetches; unit selector
// narrows to the harness service (or empty = full machine).
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
drill.append(buildJournalTrigger(c.container, journalUnit));
// The hardcoded config-repo trigger and the agent-declared
// extras block both moved into the unified nav strip in the
// head row above (sourced from the agent backend via
// `/api/agent/{name}/links` — issue #262). Only the journald
// trigger stays here since it opens the side panel rather
// than a link.
body.append(drill);
li.append(icon, body);
ul.append(li);
}
root.append(ul);
renderSelectionBar(containers);
}
// ─── selection bar (#443) ───────────────────────────────────────────
// Sticky-bottom strip; visible when ≥1 agent selected. mara picked
// option B: show every action button, disable the ones that don't
// apply to the full selection, hover tooltip explains why. Actions
// POST per agent in a loop (no new backend wire — endpoints already
// exist and are individually idempotent / event-covered).
function renderSelectionBar(containers) {
const bar = $('selection-bar');
if (!bar) return;
const countSpan = $('selection-count');
const namesSpan = $('selection-names');
const actions = $('selection-actions');
if (!countSpan || !namesSpan || !actions) return;
const selected = containers.filter((c) => selectionState.has(c.name));
if (!selected.length) {
bar.hidden = true;
document.body.classList.remove('has-selection');
return;
}
bar.hidden = false;
document.body.classList.add('has-selection');
countSpan.textContent = selected.length === 1
? '1 agent selected'
: selected.length + ' agents selected';
namesSpan.textContent = '· ' + selected.map((c) => c.name).join(', ');
// Recompute action availability + tooltips per render. Each
// action declares which agents it CAN'T run on; the bar disables
// the button and surfaces the offending names in the tooltip.
actions.innerHTML = '';
const allRunning = selected.every((c) => c.running);
const allStopped = selected.every((c) => !c.running);
const noManagers = selected.every((c) => !c.is_manager);
const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name);
const runningNames = selected.filter((c) => c.running).map((c) => c.name);
const managerNames = selected.filter((c) => c.is_manager).map((c) => c.name);
function why(label, blockers) {
if (!blockers.length) return null;
return `${label} not available — ${blockers.join(', ')} ${blockers.length === 1 ? 'is' : 'are'} blocking it`;
}
addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, {
action: '/restart/',
confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)),
});
// #443 also lifts the manager-stop guard: when the whole selection
// is running, ST0P applies — manager included. host-side hive-c0re
// keeps serving the dashboard either way + per-agent approvals +
// meta-input updates still work without the manager up, so we
// don't special-case the confirm prompt when the manager is in
// the selection (mara: "dont special case manager for stopping").
addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
action: '/kill/',
confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)),
});
addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, {
action: '/start/',
confirm: (names) => `start ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
disabledTitle: why('▶ ST4RT', runningNames.map((n) => `\`${n}\` is already running`)),
});
addBulkButton(actions, 'btn-rebuild', '↻ R3BU1LD', true, selected, {
action: '/rebuild/',
confirm: (names) => `rebuild ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? hot-reloads each container.`,
});
// DESTR0Y / PURG3: sub-agents only (manager has its own
// `refusing to destroy` guard at the host layer). When the
// selection includes the manager, both buttons go disabled with a
// clear reason rather than letting the operator submit and eat a
// 500.
addBulkButton(actions, 'btn-destroy', 'DESTR0Y', noManagers, selected, {
action: '/destroy/',
confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`,
disabledTitle: why('DESTR0Y', managerNames.map((n) => `\`${n}\` is the manager`)),
});
addBulkButton(actions, 'btn-destroy', 'PURG3', noManagers, selected, {
action: '/destroy/',
body: { purge: 'on' },
confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`,
disabledTitle: why('PURG3', managerNames.map((n) => `\`${n}\` is the manager`)),
});
}
function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
const names = selected.map((c) => c.name);
const btn = el('button', {
type: 'button',
class: 'btn ' + btnClass,
}, label);
if (!enabled) {
btn.disabled = true;
if (opts.disabledTitle) btn.title = opts.disabledTitle;
}
btn.addEventListener('click', async () => {
if (btn.disabled) return;
const msg = opts.confirm(names);
if (msg && !confirm(msg)) return;
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner">◐</span> ' + label;
const failures = [];
// Sequential POSTs to keep server-side serialisation predictable
// (rebuild_queue dedups but other endpoints don't); the loop is
// short — bulk selections are typically a handful of agents.
for (const name of names) {
const body = new URLSearchParams(opts.body || {});
try {
const resp = await fetch(opts.action + encodeURIComponent(name), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
failures.push(`${name}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
}
} catch (err) {
failures.push(`${name}: ${err}`);
}
}
btn.disabled = false;
btn.innerHTML = original;
if (failures.length) {
alert(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'));
}
// Container-lifecycle events (ContainerStateChanged /
// ContainerRemoved / RebuildQueueChanged) flow over the existing
// SSE channel and update the derived stores live — no manual
// refresh needed.
});
parent.append(btn);
}
// Per-container journald viewer. Returns an inline trigger; the
// click opens the side panel and fetches the last N lines. Refresh
// re-fetches; the unit toggle switches between the harness service
// and the full machine journal.
function buildJournalTrigger(containerName, defaultUnit) {
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
'↳ logs · ' + containerName);
trigger.addEventListener('click', () => {
const body = el('div', { class: 'journal-body' });
const controls = el('div', { class: 'journal-controls' });
const unitSelect = el('select', { class: 'journal-unit' });
unitSelect.append(
el('option', { value: defaultUnit }, defaultUnit),
el('option', { value: '' }, '(full machine journal)'),
);
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
'↻ refresh');
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
let fetching = false;
async function fetchLogs() {
if (fetching) return;
fetching = true;
pre.textContent = 'fetching…';
const unit = unitSelect.value;
const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit);
try {
const resp = await fetch('/api/journal/' + containerName + '?' + params);
const text = await resp.text();
if (!resp.ok) {
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
// Auto-scroll the panel to the newest lines on fresh fetch.
const sb = $('side-panel-body');
if (sb) sb.scrollTop = sb.scrollHeight;
}
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
} finally {
fetching = false;
}
}
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
unitSelect.addEventListener('change', fetchLogs);
controls.append(unitSelect, refresh);
body.append(controls, pre);
Panel.open('logs · ' + containerName, body);
fetchLogs();
});
return trigger;
}
function renderTombstones(s) {
const root = $('tombstones-section');
// #tombstones-section only lives on /index.html (SYST3M tab);
// no-op on /flow.html and any other page that loads the shared
// bundle without the dashboard's tab panes (#399).
if (!root) return;
root.innerHTML = '';
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 fmtAge = (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';
};
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),
el('span', { class: 'badge badge-muted' }, 'destroyed'),
);
if (t.has_creds) {
head.append(el('span', { class: 'badge badge-muted' }, 'creds kept'));
}
head.append(el('span', { class: 'meta' },
`${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`));
li.append(head);
const actions = el('div', { class: 'actions' });
// Reuse the existing spawn form pattern via /request-spawn — operator
// can queue an approval that recreates the agent with the same name
// and reuses the kept state.
const respawn = el('form', {
method: 'POST', action: '/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(
'/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);
}
// Derived question state — cold-loaded from /api/state, then mutated
// live by `question_added` / `question_resolved` dashboard events.
const QUESTION_HISTORY_LIMIT = 20;
const questionsState = { pending: [], history: [] };
function syncQuestionsFromSnapshot(s) {
questionsState.pending = (s.questions || []).slice();
questionsState.history = (s.question_history || []).slice();
}
function applyQuestionAdded(ev) {
if (questionsState.pending.some((q) => q.id === ev.id)) return;
questionsState.pending.push({
id: ev.id,
asker: ev.asker,
question: ev.question,
options: ev.options || [],
multi: !!ev.multi,
asked_at: ev.asked_at,
deadline_at: ev.deadline_at ?? null,
target: ev.target || null,
question_refs: ev.question_refs || [],
});
renderQuestions();
}
function applyQuestionResolved(ev) {
const idx = questionsState.pending.findIndex((q) => q.id === ev.id);
const existing = idx >= 0 ? questionsState.pending[idx] : null;
if (idx >= 0) questionsState.pending.splice(idx, 1);
// Idempotent: a snapshot re-sync (issue #163) can carry this same
// answered row in `question_history` while a live event also
// delivers it — guard the unshift so history can't double a row.
if (!questionsState.history.some((h) => h.id === ev.id)) {
questionsState.history.unshift({
id: ev.id,
asker: existing?.asker || '?',
question: existing?.question || '',
options: existing?.options || [],
multi: existing?.multi || false,
asked_at: existing?.asked_at || ev.answered_at,
answered_at: ev.answered_at,
answer: ev.answer,
answerer: ev.answerer,
target: existing?.target ?? ev.target ?? null,
question_refs: existing?.question_refs || [],
answer_refs: ev.answer_refs || [],
});
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
questionsState.history.length = QUESTION_HISTORY_LIMIT;
}
}
renderQuestions();
}
// Filter selection for the questions section. Persisted so the
// operator's preferred view (all / operator-targeted / peer)
// survives a reload.
const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter';
function getQuestionsFilter() {
return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all';
}
function setQuestionsFilter(v) {
localStorage.setItem(QUESTIONS_FILTER_KEY, v);
renderQuestions();
}
function questionMatchesFilter(q, filter) {
if (filter === 'all') return true;
if (filter === 'operator') return !q.target;
if (filter === 'peer') return !!q.target;
// `agent:<name>` matches when the agent appears as asker OR target.
if (filter.startsWith('agent:')) {
const name = filter.slice('agent:'.length);
return q.asker === name || q.target === name;
}
return true;
}
function renderQuestions() {
const root = $('questions-section');
// #questions-section only lives on /index.html (Y3R C4LL tab);
// no-op on /flow.html etc. — the bundled app.js runs everywhere
// and `question_added` / `question_resolved` SSE events route
// through here (#399).
if (!root) return;
root.innerHTML = '';
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const allPending = questionsState.pending;
const activeFilter = getQuestionsFilter();
const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter));
// Filter chips. Always include `all` / `operator` / `peer`; add
// per-agent chips for any agent that appears as asker or target
// in the pending list so the operator can isolate a single
// thread without typing.
const participants = new Set();
for (const q of allPending) {
participants.add(q.asker);
if (q.target) participants.add(q.target);
}
const filterRow = el('div', { class: 'questions-filters' });
const mkChip = (value, label) => {
const b = el('button', {
type: 'button',
class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''),
}, label);
b.addEventListener('click', () => setQuestionsFilter(value));
return b;
};
filterRow.append(
mkChip('all', `all · ${allPending.length}`),
mkChip('operator', '@operator'),
mkChip('peer', '@peer'),
);
for (const name of Array.from(participants).sort()) {
filterRow.append(mkChip('agent:' + name, '@' + name));
}
root.append(filterRow);
if (!pending.length) {
root.append(el('p', { class: 'empty' },
activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter'));
}
const ul = el('ul', { class: 'questions' });
for (const q of pending) {
const targetLabel = q.target || 'operator';
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
const head = el('div', { class: 'q-head' },
el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
el('span', { class: 'msg-from' }, q.asker), ' ',
el('span', { class: 'msg-sep' }, '→'), ' ',
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
el('span', { class: 'msg-sep' }, 'asks:'),
);
if (q.deadline_at) {
// Tag the chip with its deadline so the global 1s ticker
// (set up just below this function) can refresh the text
// without re-rendering the whole questions section
// (issue #335).
const ttlEl = el('span', {
class: 'q-ttl', 'data-deadline': String(q.deadline_at),
});
ttlEl.textContent = formatTtl(
q.deadline_at - Math.floor(Date.now() / 1000),
);
head.append(' ', ttlEl);
}
const qBody = el('div', { class: 'q-body' });
appendLinkified(qBody, q.question, q.question_refs);
li.append(head, qBody);
const f = el('form', {
method: 'POST', action: '/answer-question/' + q.id,
class: 'qform', 'data-async': '', 'data-no-refresh': '',
});
const hasOptions = q.options && q.options.length;
const isMulti = !!q.multi && hasOptions;
const freeText = el('textarea', {
name: 'answer-free', rows: '2', autocomplete: 'off',
placeholder: (hasOptions ? 'or type your own…' : 'your answer')
+ ' (shift+enter for newline)',
});
// Enter submits; shift+enter inserts a newline (textarea default).
freeText.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
f.requestSubmit();
}
});
const optionGroup = el('div', { class: 'q-options' });
if (hasOptions) {
for (const opt of q.options) {
const inputType = isMulti ? 'checkbox' : 'radio';
const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8);
const input = el('input', { type: inputType, name: 'choice', value: opt, id });
const label = el('label', { for: id }, ' ' + opt);
optionGroup.append(el('div', { class: 'q-option' }, input, label));
}
}
// On submit, build the final `answer` field from selected
// options + free-text, joined by ', '. This lets the operator
// pick options AND add free text in the same form.
f.addEventListener('submit', (ev) => {
const parts = [];
for (const cb of f.querySelectorAll('input[name="choice"]:checked')) {
parts.push(cb.value);
}
const ft = (freeText.value || '').trim();
if (ft) parts.push(ft);
const merged = parts.join(', ');
// Replace the existing hidden `answer` (if any) with the merged value.
const existing = f.querySelector('input[name="answer"]');
if (existing) existing.remove();
f.append(el('input', { type: 'hidden', name: 'answer', value: merged }));
if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
}, true);
if (hasOptions) f.append(optionGroup);
const buttons = el('div', { class: 'q-buttons' });
// On peer threads the operator's answer is an override —
// mark the button so it's clear what the click does (the
// backend permits it via OperatorQuestions::answer's
// answerer-auth rule).
const answerLabel = q.target
? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3')
: (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R');
buttons.append(
el('button', {
type: 'submit',
class: 'btn btn-approve' + (q.target ? ' btn-override' : ''),
title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '',
}, answerLabel),
);
f.append(
el('div', { class: 'q-free' }, freeText),
buttons,
);
li.append(f);
// Separate form so the cancel button doesn't get the answer
// merge-on-submit handler attached to the main form.
const cancelTargetLabel = q.target ? q.target : 'asker';
const cancelForm = el('form', {
method: 'POST', action: '/cancel-question/' + q.id,
class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '',
'data-confirm': `cancel this question? ${cancelTargetLabel} will see `
+ '"[cancelled]" as the answer.',
});
cancelForm.append(
el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'),
);
li.append(cancelForm);
ul.append(li);
}
if (pending.length) root.append(ul);
// Answered question history
const hist = questionsState.history;
if (hist.length) {
const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' });
details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
const hul = el('ul', { class: 'questions questions-answered' });
for (const q of hist) {
const targetLabel = q.target || 'operator';
const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') });
const head = el('div', { class: 'q-head' },
el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ',
el('span', { class: 'msg-from' }, q.asker), ' ',
el('span', { class: 'msg-sep' }, '→'), ' ',
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
el('span', { class: 'msg-sep' }, 'asked:'),
);
const histBody = el('div', { class: 'q-body' });
appendLinkified(histBody, q.question, q.question_refs);
const ansText = el('span', { class: 'q-answer-text' });
appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
const ansLine = el('div', { class: 'q-answer' },
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
ansText,
);
li.append(head, histBody, ansLine);
hul.append(li);
}
details.append(hul);
root.append(details);
}
}
// Format a remaining-seconds count as the `⏳ …` TTL chip text on a
// question card. Bucketed at minutes / hours so a long deadline stays
// readable; "expiring…" once the deadline has passed (the host-side
// ttl-watchdog will fire shortly).
function formatTtl(remaining) {
if (remaining <= 0) return 'expiring…';
if (remaining < 60) return '⏳ ' + remaining + 's';
if (remaining < 3600) {
return '⏳ ' + Math.floor(remaining / 60) + 'm '
+ (remaining % 60) + 's';
}
return '⏳ ' + Math.floor(remaining / 3600) + 'h '
+ Math.floor((remaining % 3600) / 60) + 'm';
}
// Single page-wide ticker that refreshes every TTL chip in place
// each second (issue #335). Renderers stamp `data-deadline` on the
// chip; this just updates `textContent`, no re-render of the
// questions section. No-op when no chips are on screen, so the
// cost is negligible.
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => {
const deadline = Number(node.getAttribute('data-deadline'));
if (!Number.isFinite(deadline)) return;
node.textContent = formatTtl(deadline - now);
});
}, 1000);
// Operator-inbox derived store moved to ./flow.js (#406 step 2).
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: [] };
function syncApprovalsFromSnapshot(s) {
approvalsState.pending = (s.approvals || []).slice();
approvalsState.history = (s.approval_history || []).slice();
}
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,
diff: ev.diff || 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. (#272)
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();
}
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 (issue #163) 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();
}
// Classify each unified-diff line by its leading char so
// `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` /
// `.diff-ctx` colour the output. Built as text-only spans (no
// innerHTML) so there's no HTML-escape surface.
function buildDiffPre(text) {
const pre = el('pre', { class: 'diff' });
for (const raw of String(text).split('\n')) {
let cls = 'diff-ctx';
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
else if (raw.startsWith('@')) cls = 'diff-hunk';
else if (raw.startsWith('+')) cls = 'diff-add';
else if (raw.startsWith('-')) cls = 'diff-del';
const span = document.createElement('span');
span.className = cls;
span.textContent = raw + '\n';
pre.appendChild(span);
}
return pre;
}
// Open an approval's diff in the side panel with a 3-way base
// toggle: vs applied (running tree), vs last-approved, vs previous
// proposal. `applied` uses the diff already shipped on the approval
// for instant paint; the other two fetch /api/approval-diff.
function openDiffPanel(a) {
const bases = [
['applied', 'vs applied'],
['approved', 'vs last-approved'],
['previous', 'vs previous proposal'],
];
const tabs = el('div', { class: 'diff-base-tabs' });
const host = el('div', { class: 'diff-host' });
async function selectBase(base) {
for (const btn of tabs.children) {
btn.classList.toggle('active', btn.dataset.base === base);
}
if (base === 'applied' && a.diff != null) {
host.replaceChildren(buildDiffPre(a.diff));
return;
}
host.replaceChildren(el('div', { class: 'meta' }, 'loading…'));
try {
const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base);
const text = await resp.text();
host.replaceChildren(resp.ok
? buildDiffPre(text)
: el('div', { class: 'meta' }, 'error: ' + text));
} catch (e) {
host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e));
}
}
for (const [base, label] of bases) {
const btn = el('button',
{ type: 'button', class: 'diff-base-tab', 'data-base': base }, label);
btn.addEventListener('click', () => selectBase(base));
tabs.append(btn);
}
const wrap = el('div', { class: 'diff-panel' }, tabs, host);
Panel.open('diff · ' + a.agent + ' #' + a.id, wrap);
selectBase('applied');
}
function renderApprovals() {
const root = $('approvals-section');
// #approvals-section only lives on /index.html (Y3R C4LL tab);
// no-op elsewhere — `approval_added` / `approval_resolved` SSE
// events route through here on every page that loads the bundle
// (#399).
if (!root) return;
root.innerHTML = '';
// Spawn request form: submitting it queues a Spawn approval that
// lands in this same list, so the form belongs here rather than on
// the containers list (the agent doesn't exist yet).
const spawn = el('form', {
method: 'POST', action: '/request-spawn',
class: 'spawnform', 'data-async': '', 'data-no-refresh': '',
});
spawn.append(
el('input', {
name: 'name',
placeholder: 'new agent name (≤9 chars)',
maxlength: '9', required: '', autocomplete: 'off',
}),
el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'),
);
root.append(spawn);
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;
const hostname = (fs && fs.hostname) || window.location.hostname;
const forgeBase = (fs && fs.forge_present) ? `http://${hostname}:3000` : null;
const ul = el('ul', { class: 'approvals' });
for (const a of pending) {
const isApply = a.kind === 'apply_commit';
const isInit = a.kind === 'init_config';
const li = el('li', { class: 'approval-card' });
// ── identity header ──────────────────────────────────────────
const head = el('div', { class: 'approval-head' },
el('span', { class: 'glyph' }, isApply ? '→' : '⊕'),
el('span', { class: 'id' }, '#' + a.id),
el('span', { class: 'agent' }, a.agent),
el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') },
isApply ? 'apply' : isInit ? 'init' : 'spawn'),
);
if (isApply && 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. (issue #272)
if (a.requested_at != null) {
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - a.requested_at));
head.append(el('span', {
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(),
}, '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 (isApply) {
const drill = el('div', { class: 'drill-ins' });
const diffBtn = el('button', { type: 'button', class: 'panel-trigger' },
'↳ view diff');
diffBtn.addEventListener('click', () => openDiffPanel(a));
drill.append(diffBtn);
if (forgeBase && a.sha_short) {
drill.append(el('a', {
class: 'panel-trigger', target: '_blank', rel: 'noopener',
href: `${forgeBase}/agent-configs/${a.agent}/commit/${a.sha_short}`,
title: 'this proposal commit on the hive forge',
}, '↳ commit on forge ↗'));
}
body.append(drill);
} else {
body.append(el('span', { class: 'meta' },
isInit
? 'scaffold proposed config repo — manager 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 manager via
// HelperEvent::ApprovalResolved { note }.
const denyForm = el('form', {
method: 'POST', action: '/deny/' + a.id,
class: 'inline', 'data-async': '', 'data-no-refresh': '',
'data-prompt': 'reason for denying (optional, sent to manager):',
});
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
li.append(el('div', { class: 'approval-actions' },
form('/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' ? '✗'
: '⚠';
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 === 'apply_commit' ? 'apply' : '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);
}
// Relative time, anchored to now. resolved_at is unix seconds (server-
// authored), so we don't have to worry about client/server clock skew
// for sub-minute precision.
function fmtAgo(unixSecs) {
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs));
if (ageSec < 60) return ageSec + 's ago';
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
return Math.floor(ageSec / 86400) + 'd ago';
}
function renderMetaInputs(s) {
const root = $('meta-inputs-section');
if (!root) return;
root.innerHTML = '';
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 form = el('form', {
method: 'POST',
action: '/meta-update',
class: 'meta-inputs-form',
'data-async': '',
// run_meta_update emits MetaInputsChanged once the lock
// bump finishes; per-agent rebuilds fire their own
// ContainerStateChanged. No /api/state refetch needed.
'data-no-refresh': '',
'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
});
// Bulk select — the full input tree gets long; ticking each box
// one by one is tedious (issue #275).
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);
form.append(bulk);
const ul = el('ul', { class: 'meta-inputs' });
for (const inp of inputs) {
// `name` is a slash-path from the meta root. Indent depth = its
// segment count; the row label shows just the leaf segment, the
// full path stays as the checkbox value + the label title.
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,
});
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);
}
form.append(ul);
// Hidden input the POST handler reads — populated at submit
// time from the checkbox states. axum's Form extractor doesn't
// natively decode repeated keys, so we join into one CSV.
const hidden = el('input', { type: 'hidden', name: 'inputs', value: '' });
form.append(hidden);
const btn = el('button', {
type: 'submit',
class: 'btn btn-meta-update',
disabled: '',
}, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD');
form.append(btn);
function refreshDisabled() {
const any = form.querySelectorAll('input[data-meta-input]:checked').length > 0;
// Stay disabled while an update is already in flight — no
// stacking a second run on top of the rebuild ripple.
if (any && !metaUpdateRunning) btn.removeAttribute('disabled');
else btn.setAttribute('disabled', '');
}
form.addEventListener('change', refreshDisabled);
function setAllChecked(val) {
for (const b of form.querySelectorAll('input[data-meta-input]')) {
b.checked = val;
}
refreshDisabled();
}
selAll.addEventListener('click', () => setAllChecked(true));
selNone.addEventListener('click', () => setAllChecked(false));
form.addEventListener('submit', () => {
const selected = Array.from(form.querySelectorAll('input[data-meta-input]:checked'))
.map((b) => b.dataset.metaInput);
hidden.value = selected.join(',');
});
root.append(form);
}
function truncate(s, n) {
return s.length <= n ? s : s.slice(0, n - 1) + '…';
}
// ─── rebuild queue ──────────────────────────────────────────────────────
// Glyph + verb per QueueKind. Mirrors the labels used in
// hive-c0re::rebuild_queue::QueueKind::as_str.
const QUEUE_KIND_GLYPH = {
rebuild: '↻',
meta_update: '◆',
spawn: '✨',
destroy: '🗑',
};
const QUEUE_STATE_GLYPH = {
queued: '⏸',
running: '▶',
done: '✔',
failed: '✖',
cancelled: '⊘',
};
function renderRebuildQueue(s) {
const root = $('rebuild-queue-section');
if (!root) return;
root.innerHTML = '';
const queue = s.rebuild_queue || [];
if (!queue.length) {
root.append(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.'));
return;
}
// Index by id for parent lookup.
const byId = new Map(queue.map((e) => [e.id, e]));
// Top-level entries first; children render nested under their parent.
const tops = queue.filter((e) => e.parent_id == null);
const childrenOf = new Map();
for (const e of queue) {
if (e.parent_id != null) {
if (!childrenOf.has(e.parent_id)) childrenOf.set(e.parent_id, []);
childrenOf.get(e.parent_id).push(e);
}
}
const ul = el('ul', { class: 'rebuild-queue' });
for (const top of tops) {
ul.append(renderQueueEntry(top, byId));
for (const child of childrenOf.get(top.id) || []) {
ul.append(renderQueueEntry(child, byId, true));
}
}
// Children whose parent isn't in the snapshot (history-evicted) still render flat.
const orphans = queue.filter(
(e) => e.parent_id != null && !byId.has(e.parent_id),
);
for (const o of orphans) {
ul.append(renderQueueEntry(o, byId, true));
}
root.append(ul);
}
function renderQueueEntry(entry, _byId, isChild) {
const li = el('li', {
class: 'rebuild-queue-entry rqe-' + entry.state,
'data-id': String(entry.id),
});
if (isChild) li.classList.add('rqe-child');
// State glyph + kind + agent.
li.append(
el('span', { class: 'rqe-state', title: entry.state }, QUEUE_STATE_GLYPH[entry.state] || '?'),
' ',
el('span', { class: 'rqe-kind', title: entry.kind },
(QUEUE_KIND_GLYPH[entry.kind] || '?') + ' ' + entry.kind),
' ',
el('code', { class: 'rqe-agent' }, entry.agent),
);
// Source chip (manual / meta_update / auto_update / crash_recover).
li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source));
// Timing: queued Xs ago when pending, elapsed when running,
// finished Xs ago for terminal.
if (entry.state === 'queued') {
li.append(' ', el('span', { class: 'rqe-when' }, '· queued ' + fmtAgo(entry.enqueued_at)));
} else if (entry.state === 'running' && entry.started_at) {
const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - entry.started_at));
li.append(' ', el('span', {
class: 'rqe-when',
'data-rqe-elapsed': String(entry.started_at),
}, '· ' + fmtElapsed(elapsed)));
} else if (entry.finished_at) {
li.append(' ', el('span', { class: 'rqe-when' }, '· ' + entry.state + ' ' + fmtAgo(entry.finished_at)));
}
// Reason (truncated; full text on hover).
if (entry.reason) {
const r = entry.reason.split('\n')[0];
li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60)));
}
// Error block, when failed.
if (entry.error) {
li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200)));
}
return li;
}
function fmtElapsed(secs) {
if (secs < 60) return secs + 's running';
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's running';
return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm running';
}
// Tick once per second to refresh "running Xs" badges in place
// (mirrors the question-TTL ticker pattern from #335).
setInterval(() => {
for (const span of document.querySelectorAll('.rqe-when[data-rqe-elapsed]')) {
const started = parseInt(span.dataset.rqeElapsed, 10);
if (!started) continue;
const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - started));
span.textContent = '· ' + fmtElapsed(elapsed);
}
}, 1000);
// ─── reminders ──────────────────────────────────────────────────────────
// Reminders aren't part of /api/state (separate sqlite table, separate
// mutation cadence). Refresh fires alongside refreshState() so a
// cancel POST or a cold load both reflect within the same tick. A
// periodic poll isn't necessary — new reminders are queued by the
// agents themselves and the operator already sees them next time
// they interact with the page.
async function refreshReminders() {
const root = $('reminders-section');
if (!root) return;
try {
const resp = await fetch('/api/reminders');
if (!resp.ok) {
root.innerHTML = '';
root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status));
return;
}
const rows = await resp.json();
renderReminders(rows);
} catch (err) {
root.innerHTML = '';
root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err));
}
}
function renderReminders(rows) {
const root = $('reminders-section');
if (!root) return;
root.innerHTML = '';
if (!rows.length) {
root.append(el('p', { class: 'empty' }, 'no queued reminders'));
return;
}
const ul = el('ul', { class: 'reminders' });
for (const r of rows) {
const failed = (r.attempt_count || 0) > 0;
const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') });
const dueIn = r.due_at - Math.floor(Date.now() / 1000);
const dueLabel = dueIn <= 0
? `overdue ${fmtAgo(r.due_at)}`
: `in ${fmtDuration(dueIn)}`;
const head = el('div', { class: 'reminder-head' },
el('span', { class: 'agent' }, r.agent), ' ',
el('span', { class: 'meta', title: new Date(r.due_at * 1000).toISOString() }, dueLabel),
' ',
el('span', { class: 'meta' }, `· id ${r.id}`),
);
if (r.file_path) {
head.append(' ', el('span', { class: 'meta' }, '· payload → '));
appendLinkified(head, r.file_path);
}
if (failed) {
head.append(' ', el('span',
{
class: 'badge badge-warn',
title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)',
},
`${r.attempt_count} failed`));
}
const body = el('div', { class: 'reminder-body' });
appendLinkified(body, r.message);
li.append(head, body);
if (r.last_error) {
li.append(el('div', { class: 'reminder-error' },
el('span', { class: 'msg-sep' }, 'error: '),
r.last_error,
));
}
const actions = el('div', { class: 'reminder-actions' });
if (failed) {
// Retry resets the failure counters so the scheduler picks
// the row up again on its next 5s tick. No data-no-refresh
// — the resulting refreshState re-fires refreshReminders.
const retryForm = el('form', {
method: 'POST', action: '/retry-reminder/' + r.id,
class: 'inline', 'data-async': '',
});
retryForm.append(el('button',
{ type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY'));
actions.append(retryForm);
}
const cancelForm = el('form', {
method: 'POST', action: '/cancel-reminder/' + r.id,
class: 'inline', 'data-async': '',
'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`,
});
cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L'));
actions.append(cancelForm);
li.append(actions);
ul.append(li);
}
root.append(ul);
}
function fmtDuration(secs) {
if (secs < 60) return secs + 's';
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
}
// ─── scheduled prompts (#459) ──────────────────────────────────────────
// Backend (#444) exposes `/api/schedules` (snapshot), `/api/schedules`
// (POST, operator-direct submit), `/api/schedules/{id}/cancel`
// (whole or per-target). No SSE channel for schedule mutations yet,
// so we refresh on tab activation + after every submit/cancel POST.
// Local cache lets `refreshTabCounts` show the active count without
// re-fetching every second.
let schedulesState = [];
// #474 — schedule ids whose inline edit form is currently open.
// The set survives across refreshSchedules() calls so a state
// poll doesn't yank the form out from under the operator. Per-id
// mid-edit carry sits in `scheduleEditCarry` so unsaved typing
// also rides the refresh.
const editingSchedules = new Set();
const scheduleEditCarry = new Map();
async function refreshSchedules() {
const listRoot = $('schedules-section');
if (!listRoot) return;
try {
const resp = await fetch('/api/schedules');
if (!resp.ok) {
listRoot.innerHTML = '';
listRoot.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status));
return;
}
schedulesState = await resp.json();
} catch (err) {
listRoot.innerHTML = '';
listRoot.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err));
return;
}
renderScheduleNewForm();
renderSchedulesList();
}
// Active = at least one target still alive (no `cancelled_at_unix`)
// AND the whole schedule isn't cancelled. Drives the tab pill count.
function activeScheduleCount() {
let n = 0;
for (const s of schedulesState) {
if (s.cancelled_at_unix) continue;
if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++;
}
return n;
}
// Interval composer (#466) — shared between the new-schedule form
// and the edit-schedule form (#474). Builds the preset chip row +
// d/h/m/s sub-fields + live preview, and returns helpers to read
// and write the value.
//
// The composer carries no opinion on what "0 / blank" means at the
// semantic layer: it just reports the integer total. Callers decide
// whether to treat 0 as `null` (one-shot) for their own submit path.
function buildIntervalComposer({
label = 'interval (blank / all-zero = one-shot)',
namePrefix = 'interval_',
initialSeconds = 0,
} = {}) {
const wrapper = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, label));
const presets = [
['1m', 60], ['5m', 300], ['15m', 900], ['30m', 1800],
['1h', 3600], ['6h', 21600], ['12h', 43200],
['1d', 86400], ['7d', 604800],
];
const presetsRow = el('div', { class: 'schedule-interval-presets' });
for (const [presetLabel, secs] of presets) {
presetsRow.append(el('button', {
type: 'button',
class: 'btn btn-interval-preset',
'data-secs': String(secs),
}, presetLabel));
}
presetsRow.append(el('button', {
type: 'button',
class: 'btn btn-interval-preset btn-interval-oneshot',
'data-secs': '0',
}, 'one-shot'));
wrapper.append(presetsRow);
const partsRow = el('div', { class: 'schedule-interval-parts' });
const mkPart = (suffix, unit) => {
const inp = el('input', {
type: 'number', name: namePrefix + suffix, min: '0', step: '1', placeholder: '0',
class: 'schedule-interval-num',
});
partsRow.append(inp, el('span', { class: 'schedule-interval-unit' }, unit));
return inp;
};
const dInp = mkPart('d', 'd');
const hInp = mkPart('h', 'h');
const mInp = mkPart('m', 'm');
const sInp = mkPart('s', 's');
wrapper.append(partsRow);
const preview = el('div', { class: 'schedule-interval-preview', 'aria-live': 'polite' });
wrapper.append(preview);
function getSeconds() {
const n = (v) => {
const x = parseInt(v, 10);
return Number.isFinite(x) && x > 0 ? x : 0;
};
return n(dInp.value) * 86400 + n(hInp.value) * 3600
+ n(mInp.value) * 60 + n(sInp.value);
}
function fillFromSeconds(total) {
const t = Math.max(0, Math.floor(total));
const d = Math.floor(t / 86400);
const h = Math.floor((t % 86400) / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
dInp.value = d > 0 ? String(d) : '';
hInp.value = h > 0 ? String(h) : '';
mInp.value = m > 0 ? String(m) : '';
sInp.value = s > 0 ? String(s) : '';
}
function setParts({ d, h, m, s }) {
if (d !== undefined) dInp.value = d;
if (h !== undefined) hInp.value = h;
if (m !== undefined) mInp.value = m;
if (s !== undefined) sInp.value = s;
}
function updatePreview() {
const total = getSeconds();
preview.textContent = total > 0
? '↻ every ' + fmtDuration(total)
: 'one-shot (fires once at first-fire time)';
preview.classList.toggle('schedule-interval-preview-oneshot', total === 0);
}
for (const inp of [dInp, hInp, mInp, sInp]) {
inp.addEventListener('input', updatePreview);
}
for (const b of presetsRow.querySelectorAll('button[data-secs]')) {
b.addEventListener('click', () => {
fillFromSeconds(parseInt(b.getAttribute('data-secs'), 10) || 0);
updatePreview();
});
}
if (initialSeconds > 0) fillFromSeconds(initialSeconds);
updatePreview();
return { wrapper, getSeconds, fillFromSeconds, setParts, updatePreview };
}
function renderScheduleNewForm() {
const root = $('schedule-new-section');
if (!root) return;
// Preserve any field the operator was mid-typing in by reading
// current values before we re-render. Only `body` + `description`
// are big enough to feel — the others are toggle/datetime/number.
const carry = readScheduleFormCarry(root);
root.innerHTML = '';
// Targets multi-select pulls from the live containers list. The
// backend accepts any string (forward-compat for richer recipients),
// so we also surface the special `operator` and `manager` names
// as fixed options on top.
const candidateTargets = ['operator', 'manager'];
const containerNames = Array.from(containersState.values())
.map((c) => c.name)
.filter((n) => n !== 'manager' && n !== 'operator')
.sort();
for (const n of containerNames) candidateTargets.push(n);
const form_ = el('form', { class: 'schedule-new-form' });
form_.addEventListener('submit', (e) => {
e.preventDefault();
submitNewSchedule(form_);
});
const targetsLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'targets'));
const targetsBox = el('div', { class: 'schedule-targets' });
const restoredTargets = new Set(carry.targets);
for (const name of candidateTargets) {
const id_ = 'st-' + name;
const cb = el('input', {
type: 'checkbox', name: 'targets', value: name, id: id_,
});
if (restoredTargets.has(name)) cb.checked = true;
targetsBox.append(el('label', { class: 'schedule-target-chip', for: id_ },
cb, el('span', {}, name)));
}
targetsLabel.append(targetsBox);
form_.append(targetsLabel);
const bodyLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'prompt body'));
const bodyInput = el('textarea', {
name: 'body', rows: '4', required: 'required', placeholder: 'message delivered to each target at fire time',
});
bodyInput.value = carry.body;
bodyLabel.append(bodyInput);
form_.append(bodyLabel);
const firstFireLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'first fire'));
const firstFireInput = el('input', {
type: 'datetime-local', name: 'first_fire', required: 'required',
});
// Default to "5 minutes from now" so the operator has a sensible
// pre-filled value (also future-positive so worker doesn't fire
// immediately on a stale-clock accident).
firstFireInput.value = carry.first_fire
|| isoForDatetimeLocal(new Date(Date.now() + 5 * 60 * 1000));
firstFireLabel.append(firstFireInput);
form_.append(firstFireLabel);
// Interval composer (#466). See `buildIntervalComposer` below —
// shared between the new-schedule form and the edit-schedule form
// (#474) so the same chip/d/h/m/s widget works for both flows.
const intervalCx = buildIntervalComposer({
label: 'interval (blank / all-zero = one-shot)',
namePrefix: 'interval_',
initialSeconds: 0,
});
form_.append(intervalCx.wrapper);
// Restore from carry. Prefer the new split fields; fall back to
// legacy single seconds value if the page was reloaded mid-edit
// against an older bundle.
if (carry.interval_d || carry.interval_h
|| carry.interval_m || carry.interval_s) {
intervalCx.setParts({
d: carry.interval_d, h: carry.interval_h,
m: carry.interval_m, s: carry.interval_s,
});
} else if (carry.interval) {
const legacy = parseInt(carry.interval, 10);
if (Number.isFinite(legacy) && legacy > 0) intervalCx.fillFromSeconds(legacy);
}
intervalCx.updatePreview();
const descLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'description (optional)'));
const descInput = el('input', {
type: 'text', name: 'description', placeholder: 'shown on the schedule card',
});
descInput.value = carry.description;
descLabel.append(descInput);
form_.append(descLabel);
const actions = el('div', { class: 'schedule-actions' });
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, ' qu3ue prompt');
actions.append(submit);
form_.append(actions);
root.append(form_);
}
function readScheduleFormCarry(root) {
return {
targets: Array.from(root.querySelectorAll('input[name="targets"]:checked')).map((i) => i.value),
body: root.querySelector('textarea[name="body"]')?.value || '',
first_fire: root.querySelector('input[name="first_fire"]')?.value || '',
// #466: interval is now split into d/h/m/s sub-fields. Keep the
// legacy `interval` key in case an older bundle is in the page.
interval: root.querySelector('input[name="interval"]')?.value || '',
interval_d: root.querySelector('input[name="interval_d"]')?.value || '',
interval_h: root.querySelector('input[name="interval_h"]')?.value || '',
interval_m: root.querySelector('input[name="interval_m"]')?.value || '',
interval_s: root.querySelector('input[name="interval_s"]')?.value || '',
description: root.querySelector('input[name="description"]')?.value || '',
};
}
function isoForDatetimeLocal(date) {
// `<input type="datetime-local">` expects `YYYY-MM-DDTHH:MM`
// in the user's local timezone (with no trailing Z). Build it
// by hand rather than slicing toISOString which is always UTC.
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
async function submitNewSchedule(form_) {
const fd = new FormData(form_);
const targets = fd.getAll('targets').map(String);
const body = String(fd.get('body') || '').trim();
const firstFireStr = String(fd.get('first_fire') || '');
const description = String(fd.get('description') || '').trim();
if (!targets.length) { alert('schedule must have at least one target'); return; }
if (!body) { alert('prompt body must be non-empty'); return; }
if (!firstFireStr) { alert('first fire timestamp is required'); return; }
const firstFireDate = new Date(firstFireStr);
if (Number.isNaN(firstFireDate.getTime())) { alert('first fire is not a valid datetime'); return; }
const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000);
// Interval composer (#466): combine d/h/m/s into total seconds.
// Negative values are rejected; total == 0 means one-shot, same
// semantic the backend expects when interval_seconds is null.
const partSecs = (name, mult) => {
const raw = String(fd.get(name) || '').trim();
if (!raw) return 0;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) return NaN;
return n * mult;
};
const partD = partSecs('interval_d', 86400);
const partH = partSecs('interval_h', 3600);
const partM = partSecs('interval_m', 60);
const partS = partSecs('interval_s', 1);
if ([partD, partH, partM, partS].some((v) => Number.isNaN(v))) {
alert('interval fields must be non-negative integers (or blank for one-shot)');
return;
}
const intervalTotal = partD + partH + partM + partS;
const interval_seconds = intervalTotal > 0 ? intervalTotal : null;
const payload = { targets, body, first_fire_at_unix };
if (interval_seconds != null) payload.interval_seconds = interval_seconds;
if (description) payload.description = description;
const submitBtn = form_.querySelector('button[type="submit"]');
const originalLabel = submitBtn ? submitBtn.innerHTML : '';
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<span class="spinner">◐</span> queueing…'; }
try {
const resp = await fetch('/api/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
return;
}
// Clear the form (form_ is about to be re-rendered fresh below).
form_.reset();
await refreshSchedules();
} catch (err) {
alert('schedule submit failed: ' + err);
} finally {
if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = originalLabel; }
}
}
function renderSchedulesList() {
const root = $('schedules-section');
if (!root) return;
root.innerHTML = '';
if (!schedulesState.length) {
root.append(el('p', { class: 'empty' }, 'no schedules queued'));
return;
}
const ul = el('ul', { class: 'schedules' });
// Active schedules first (still firing), then cancelled tail.
const sorted = schedulesState.slice().sort((a, b) => {
const aDone = a.cancelled_at_unix ? 1 : 0;
const bDone = b.cancelled_at_unix ? 1 : 0;
if (aDone !== bDone) return aDone - bDone;
return a.next_fire_at_unix - b.next_fire_at_unix;
});
for (const s of sorted) ul.append(renderScheduleCard(s));
root.append(ul);
}
function renderScheduleCard(s) {
const cancelled = !!s.cancelled_at_unix;
const li = el('li', { class: 'schedule-row' + (cancelled ? ' schedule-cancelled' : '') });
const head = el('div', { class: 'schedule-head' });
head.append(
el('span', { class: 'meta' }, '#' + s.id), ' ',
el('span', { class: 'rqe-source rqe-source-' + (s.source && s.source.kind === 'approval' ? 'approval' : 'manual') },
s.source && s.source.kind === 'approval' ? 'approval' : 'operator'),
);
if (cancelled) {
head.append(' ', el('span', { class: 'badge badge-muted', title: 'cancelled ' + new Date(s.cancelled_at_unix * 1000).toISOString() }, 'cancelled'));
} else {
const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000);
const dueLabel = dueIn <= 0
? `overdue ${fmtAgo(s.next_fire_at_unix)}`
: `next fire in ${fmtDuration(dueIn)}`;
head.append(' ', el('span', {
class: 'meta',
title: new Date(s.next_fire_at_unix * 1000).toISOString(),
}, dueLabel));
}
if (s.interval_seconds) {
head.append(' ', el('span',
{ class: 'badge badge-muted', title: 'recurring' },
`↻ every ${fmtDuration(s.interval_seconds)}`));
} else {
head.append(' ', el('span', { class: 'badge badge-muted', title: 'one-shot' }, 'one-shot'));
}
head.append(' ', el('span', { class: 'meta' }, '· owner ' + s.owner));
li.append(head);
if (s.description) {
li.append(el('div', { class: 'schedule-description' }, s.description));
}
const body = el('div', { class: 'schedule-body' });
appendLinkified(body, s.body);
li.append(body);
// Targets table — one row per recipient with cancel button +
// last-fire metadata.
const targets = s.targets || [];
if (targets.length) {
const table = el('table', { class: 'schedule-targets-table' });
const thead = el('thead', {}, el('tr', {},
el('th', {}, 'target'),
el('th', {}, 'last fire'),
el('th', {}, 'last result'),
el('th', {}, ''),
));
table.append(thead);
const tbody = el('tbody', {});
for (const t of targets) {
const tCancelled = !!t.cancelled_at_unix;
const tr = el('tr', { class: tCancelled ? 'schedule-target-cancelled' : '' });
tr.append(el('td', {}, t.target));
tr.append(el('td', { class: 'meta' },
t.last_fired_at_unix
? fmtAgo(t.last_fired_at_unix) + ' ago'
: '—'));
tr.append(el('td', { class: 'meta' }, t.last_result || '—'));
const actionTd = el('td', {});
if (!cancelled && !tCancelled) {
const btn = el('button', { type: 'button', class: 'btn btn-deny btn-inline-small' }, '✕');
btn.title = 'cancel just this target';
btn.addEventListener('click', () => cancelScheduleTargets(s.id, [t.target]));
actionTd.append(btn);
} else if (tCancelled) {
actionTd.append(el('span', { class: 'meta' }, '✕ cancelled'));
}
tr.append(actionTd);
tbody.append(tr);
}
table.append(tbody);
li.append(table);
}
if (!cancelled) {
const actions = el('div', { class: 'schedule-actions' });
// Fire-now (#467): out-of-band manual pulse. Recurring schedules
// get an extra fan-out without disturbing `next_fire_at`; one-shots
// are consumed (cancelled after fan-out), per damocles's design.
// Disabled when every target is cancelled — there's nothing to
// fire to. Backend: POST /api/schedules/{id}/fire-now.
const activeTargets = (s.targets || []).filter((t) => !t.cancelled_at_unix);
const fireBtn = el('button', { type: 'button', class: 'btn btn-fire-now' }, '↯ fire now');
const isOneShot = !s.interval_seconds;
fireBtn.title = isOneShot
? 'fire this schedule once, immediately (one-shot — schedule is consumed after the manual fire)'
: 'fire this schedule once, immediately (recurring — next regularly-scheduled fire is unaffected)';
if (!activeTargets.length) {
fireBtn.disabled = true;
fireBtn.title = 'every target is cancelled — nothing to fire';
}
fireBtn.addEventListener('click', () => fireScheduleNow(s.id, isOneShot, activeTargets.map((t) => t.target)));
actions.append(fireBtn);
const editBtn = el('button', { type: 'button', class: 'btn btn-edit-schedule' },
editingSchedules.has(s.id) ? '✎ close edit' : '✎ edit');
editBtn.title = 'edit body / description / interval / next-fire (targets stay immutable)';
editBtn.addEventListener('click', () => {
if (editingSchedules.has(s.id)) {
editingSchedules.delete(s.id);
scheduleEditCarry.delete(s.id);
} else {
editingSchedules.add(s.id);
}
renderSchedulesList();
});
actions.append(editBtn);
const cancelAll = el('button', { type: 'button', class: 'btn btn-deny' }, '✕ cancel all');
cancelAll.title = 'cancel the whole schedule';
cancelAll.addEventListener('click', () => cancelScheduleAll(s.id));
actions.append(cancelAll);
li.append(actions);
if (editingSchedules.has(s.id)) {
li.append(renderScheduleEditForm(s));
}
}
return li;
}
// #474 — inline edit form. Renders inside the schedule row when the
// row's `✎ edit` button is toggled on. Pre-filled with current
// values; submit PATCHes /api/schedules/{id}. Targets stay
// immutable (per damocles's backend; the workaround for retargeting
// is cancel + new schedule). Mid-edit field values survive a
// state-poll refresh via `scheduleEditCarry`.
function renderScheduleEditForm(s) {
const wrapper = el('div', { class: 'schedule-edit-form-wrapper' });
const form_ = el('form', { class: 'schedule-edit-form' });
form_.addEventListener('submit', (e) => {
e.preventDefault();
submitEditSchedule(s, form_);
});
const carry = scheduleEditCarry.get(s.id) || {};
const bodyLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'body'));
const bodyInput = el('textarea', { name: 'body', rows: '4', required: 'required' });
bodyInput.value = carry.body !== undefined ? carry.body : s.body;
bodyLabel.append(bodyInput);
form_.append(bodyLabel);
const descLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'description (blank to clear)'));
const descInput = el('input', { type: 'text', name: 'description' });
descInput.value = carry.description !== undefined
? carry.description
: (s.description || '');
descLabel.append(descInput);
form_.append(descLabel);
const firstFireLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'next fire'));
const firstFireInput = el('input', {
type: 'datetime-local', name: 'next_fire', required: 'required',
});
firstFireInput.value = carry.next_fire !== undefined
? carry.next_fire
: isoForDatetimeLocal(new Date(s.next_fire_at_unix * 1000));
firstFireLabel.append(firstFireInput);
form_.append(firstFireLabel);
const intervalCx = buildIntervalComposer({
label: 'interval (blank / all-zero = flip to one-shot)',
namePrefix: 'edit_interval_',
initialSeconds: 0,
});
form_.append(intervalCx.wrapper);
if (carry.interval_d !== undefined || carry.interval_h !== undefined
|| carry.interval_m !== undefined || carry.interval_s !== undefined) {
intervalCx.setParts({
d: carry.interval_d || '',
h: carry.interval_h || '',
m: carry.interval_m || '',
s: carry.interval_s || '',
});
} else if (s.interval_seconds) {
intervalCx.fillFromSeconds(s.interval_seconds);
}
intervalCx.updatePreview();
// Persist carry on every input so a refresh repaint preserves it.
form_.addEventListener('input', () => {
const fd = new FormData(form_);
scheduleEditCarry.set(s.id, {
body: String(fd.get('body') || ''),
description: String(fd.get('description') || ''),
next_fire: String(fd.get('next_fire') || ''),
interval_d: String(fd.get('edit_interval_d') || ''),
interval_h: String(fd.get('edit_interval_h') || ''),
interval_m: String(fd.get('edit_interval_m') || ''),
interval_s: String(fd.get('edit_interval_s') || ''),
});
});
// Targets read-only callout. Per #474 design: targets stay
// immutable, cancel+new is the retarget workaround.
const targetList = (s.targets || [])
.filter((t) => !t.cancelled_at_unix)
.map((t) => t.target)
.join(', ');
form_.append(el('p', { class: 'meta schedule-edit-targets-note' },
'targets (' + (targetList || 'none active') + ') are immutable — '
+ 'cancel + new schedule if you need a different recipient set.'));
const actions = el('div', { class: 'schedule-actions' });
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '✓ save changes');
const cancelEdit = el('button', { type: 'button', class: 'btn' }, 'cancel');
cancelEdit.addEventListener('click', () => {
editingSchedules.delete(s.id);
scheduleEditCarry.delete(s.id);
renderSchedulesList();
});
actions.append(submit, cancelEdit);
form_.append(actions);
wrapper.append(form_);
return wrapper;
}
async function submitEditSchedule(originalSchedule, form_) {
const s = originalSchedule;
const fd = new FormData(form_);
const newBody = String(fd.get('body') || '').trim();
const newDescription = String(fd.get('description') || '').trim();
const newNextFireStr = String(fd.get('next_fire') || '');
if (!newBody) { alert('body must be non-empty'); return; }
if (!newNextFireStr) { alert('next-fire timestamp is required'); return; }
const newNextFireDate = new Date(newNextFireStr);
if (Number.isNaN(newNextFireDate.getTime())) {
alert('next-fire is not a valid datetime'); return;
}
const newNextFireUnix = Math.floor(newNextFireDate.getTime() / 1000);
// Compute interval total from the d/h/m/s fields (composer uses
// `edit_interval_*` names in this form).
const partSecs = (name, mult) => {
const raw = String(fd.get(name) || '').trim();
if (!raw) return 0;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) return NaN;
return n * mult;
};
const partD = partSecs('edit_interval_d', 86400);
const partH = partSecs('edit_interval_h', 3600);
const partM = partSecs('edit_interval_m', 60);
const partS = partSecs('edit_interval_s', 1);
if ([partD, partH, partM, partS].some((v) => Number.isNaN(v))) {
alert('interval fields must be non-negative integers');
return;
}
const intervalTotal = partD + partH + partM + partS;
const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null;
// Build the PATCH body. Only include keys for fields that
// actually changed; explicit `null` clears description / flips
// recurring→one-shot.
const patch = {};
if (newBody !== s.body) patch.body = newBody;
if (newDescription !== (s.description || '')) {
patch.description = newDescription || null;
}
if (newNextFireUnix !== s.next_fire_at_unix) {
patch.next_fire_at_unix = newNextFireUnix;
}
if (newIntervalSeconds !== (s.interval_seconds || null)) {
patch.interval_seconds = newIntervalSeconds;
}
if (!Object.keys(patch).length) {
// No-op submit. Treat as "close edit form".
editingSchedules.delete(s.id);
scheduleEditCarry.delete(s.id);
renderSchedulesList();
return;
}
const submitBtn = form_.querySelector('button[type="submit"]');
const originalLabel = submitBtn ? submitBtn.textContent : '';
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'saving…'; }
try {
const resp = await fetch('/api/schedules/' + encodeURIComponent(s.id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('edit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
return;
}
editingSchedules.delete(s.id);
scheduleEditCarry.delete(s.id);
await refreshSchedules();
} catch (err) {
alert('edit failed: ' + err);
} finally {
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; }
}
}
async function fireScheduleNow(id, isOneShot, targets) {
const targetList = targets.length ? targets.join(', ') : '(no active targets)';
const prompt = isOneShot
? `fire schedule #${id} now to ${targetList}?\n\n`
+ 'this is a ONE-SHOT — firing now consumes the schedule. '
+ 'the scheduled fire time will no longer trigger.'
: `fire schedule #${id} now to ${targetList}?\n\n`
+ 'this is RECURRING — sends an extra pulse out-of-band. '
+ 'the regular cadence keeps firing on schedule.';
if (!confirm(prompt)) return;
try {
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/fire-now', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : ''));
return;
}
await refreshSchedules();
} catch (err) {
alert('fire-now failed: ' + err);
}
}
async function cancelScheduleAll(id) {
if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return;
await postScheduleCancel(id, null);
}
async function cancelScheduleTargets(id, targets) {
if (!confirm(`cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`)) return;
await postScheduleCancel(id, targets);
}
async function postScheduleCancel(id, targets) {
try {
const opts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
};
if (targets) opts.body = JSON.stringify({ targets });
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/cancel', opts);
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('cancel failed: http ' + resp.status + (text ? '\n\n' + text : ''));
return;
}
await refreshSchedules();
} catch (err) {
alert('cancel failed: ' + err);
}
}
// ─── state polling ──────────────────────────────────────────────────────
let pollTimer = null;
// Sections whose innerHTML gets blown away on each refresh. If the
// operator is typing in one of them, skip the refresh — the next
// tick (or a manual action) will pick it up after they blur.
const MANAGED_SECTION_IDS = [
'containers-section',
'tombstones-section',
'questions-section',
'inbox-section',
'approvals-section',
'meta-inputs-section',
'rebuild-queue-section',
'reminders-section',
'schedule-new-section',
'schedules-section',
];
// <details> sections that should survive a refresh need a stable
// `data-restore-key` attribute. snapshotOpenDetails walks managed
// sections and records which keys are currently open; restoreOpenDetails
// re-applies after the render. (Long-content drill-ins — file
// previews, diffs, logs, config — open in the side panel instead,
// which lives outside the managed sections and survives re-render
// on its own.)
function snapshotOpenDetails() {
const open = new Set();
for (const id of MANAGED_SECTION_IDS) {
const sect = document.getElementById(id);
if (!sect) continue;
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
if (d.open) open.add(d.dataset.restoreKey);
}
}
return open;
}
function restoreOpenDetails(open) {
if (!open.size) return;
for (const id of MANAGED_SECTION_IDS) {
const sect = document.getElementById(id);
if (!sect) continue;
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
if (open.has(d.dataset.restoreKey)) d.open = true;
}
}
}
function operatorIsTyping() {
const el_ = document.activeElement;
if (!el_ || el_ === document.body) return false;
const tag = el_.tagName;
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false;
return MANAGED_SECTION_IDS.some((id) => {
const sect = document.getElementById(id);
return sect && sect.contains(el_);
});
}
async function refreshState() {
// Don't yank the form out from under the operator. Try again
// shortly on the next tick; eventually they'll blur and the
// refresh lands.
if (operatorIsTyping()) {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = setTimeout(refreshState, 2000);
return;
}
try {
const resp = await fetch('/api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
// Stash the latest snapshot for any sub-widget that wants a
// synchronous read (e.g. the compose autocomplete pulls agent
// names from here instead of refetching on every keystroke).
window.__hyperhive_state = s;
const openDetails = snapshotOpenDetails();
// Sync transients + containers first so renderContainers below
// sees the current derived maps (it reads from
// `transientsState` + `containersState`, not from `s.*`).
syncTransientsFromSnapshot(s);
syncContainersFromSnapshot(s);
syncTombstonesFromSnapshot(s);
syncMetaInputsFromSnapshot(s);
syncRebuildQueueFromSnapshot(s);
renderContainers(s);
renderTombstones(s);
// Sync the derived approvals + questions stores from the
// snapshot, then render. Live `*_added` / `*_resolved` events
// mutate the stores directly and re-render without a snapshot
// refetch.
syncQuestionsFromSnapshot(s);
renderQuestions();
// (renderInbox now lives in ./flow.js — dashboard has no
// #inbox-section element to render into.)
syncApprovalsFromSnapshot(s);
renderApprovals();
renderMetaInputs(s);
renderRebuildQueue(s);
refreshReminders();
refreshSchedules();
restoreOpenDetails(openDetails);
notifyDeltas(s);
// No periodic refresh timer. Phase 6 covers every container
// mutation with `ContainerStateChanged` / `ContainerRemoved`
// (lifecycle ops, destroy, rebuild, crash_watch's 10s poll);
// approvals + questions + transients have their own events;
// broker traffic flows through the SSE channel. The only
// /api/state fetches are the initial cold load and the
// post-submit refetch on forms without `data-no-refresh`
// (tombstones, meta-input updates).
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
} catch (err) {
console.error('refreshState failed', err);
// Schedule a single retry on transient errors so the page
// recovers from a brief network blip without making the
// operator reload.
pollTimer = setTimeout(refreshState, 5000);
}
}
refreshState();
NOTIF.bind();
Panel.bind();
// ─── live updates: dashboard event stream (#406 step 3) ────────────────
// The dashboard subscribes to /dashboard/stream for live mutation
// events so the SW4RM / Y3R C4LL / SYST3M panes update without an
// operator action triggering a refreshState. Pre-step-2 this wiring
// lived inside the broker-terminal IIFE which only fired on /flow.html
// — meaning /index.html only updated on cold load + after async-form
// submits.
//
// Bare `EventSource` (no terminal infrastructure needed — the
// dashboard doesn't render broker rows). Each event's `kind` is
// looked up against `MUTATION_HANDLERS`; unknown kinds (broker
// `sent` / `delivered`, anything new the backend adds) silently
// no-op. On (re)connect we kick a refreshState() to recover events
// lost during the disconnect window (same pattern as flow.js's
// onStreamOpen).
//
// `#408` will give /index.html its own stream that omits the
// broker traffic the dashboard never uses; for now both pages
// subscribe to `/dashboard/stream` and filter client-side.
const MUTATION_HANDLERS = {
approval_added: applyApprovalAdded,
approval_resolved: applyApprovalResolved,
question_added: applyQuestionAdded,
question_resolved: applyQuestionResolved,
transient_set: applyTransientSet,
transient_cleared: applyTransientCleared,
container_state_changed: applyContainerStateChanged,
container_removed: applyContainerRemoved,
tombstones_changed: applyTombstonesChanged,
meta_inputs_changed: applyMetaInputsChanged,
meta_update_running: applyMetaUpdateRunning,
rebuild_queue_changed: applyRebuildQueueChanged,
};
(function bindDashboardStream() {
// #448: route the EventSource through a SharedWorker so all open
// hyperhive tabs share ONE backend SSE connection. Survives
// Firefox's per-tab connection throttling under many-open-tabs
// pressure (the actual mara symptom). `openStream` returns an
// EventSource-shaped facade so the rest of this IIFE is unchanged;
// graceful fallback to direct `new EventSource` when SharedWorker
// isn't supported.
const es = openStream('/dashboard/stream');
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
const h = MUTATION_HANDLERS[ev.kind];
if (!h) return; // broker rows + future kinds — dashboard doesn't care
try { h(ev); }
catch (err) { console.error('dashboard SSE handler', ev.kind, err); }
};
es.onopen = () => {
// Re-sync to recover events that fired during a disconnect
// window (issue #163). Initial connect also fires onopen — the
// first refreshState() above and this one race, but refreshState
// is idempotent so the second call just overwrites with the
// freshest snapshot. Cheap on a quiescent server, fine to repeat.
refreshState();
};
es.onerror = () => {
// EventSource auto-reconnects; nothing to do beyond logging.
console.debug('dashboard SSE error, will retry');
};
})();
// ─── tab routing (#369) ────────────────────────────────────────────────
// Hash-based: `#swarm` / `#call` / `#system` activate the matching
// pane on the dashboard. Empty hash defaults to SW4RM. FL0W is NOT
// a tab — it's a separate page (`/flow.html`) reached via the
// tab-strip link. Tab routing only applies when the tab DOM is
// present (e.g. not on the flow page itself, where these elements
// don't exist and the loop no-ops).
const TABS = ['swarm', 'call', 'system', 'schedules'];
function activateTab(name) {
const target = TABS.includes(name) ? name : TABS[0];
for (const t of TABS) {
const tab = $('tab-' + t);
const pane = $('tab-pane-' + t);
if (tab) tab.classList.toggle('active', t === target);
if (pane) pane.classList.toggle('tab-pane-active', t === target);
}
// #459: schedules pane has no SSE channel yet (PR C follow-up), so
// re-fetch on activation so the operator never lands on stale data.
if (target === 'schedules') refreshSchedules();
}
function syncTabFromHash() {
const h = (window.location.hash || '#swarm').replace(/^#/, '');
activateTab(h);
}
window.addEventListener('hashchange', syncTabFromHash);
syncTabFromHash();
// Tab count pills — pure derived data from the existing state
// stores so SSE-driven updates flow through without extra plumbing.
// Set `hidden` when the count is zero so the pill doesn't draw
// attention to an empty room.
function setTabCount(tab, n) {
const el_ = $('tab-count-' + tab);
if (!el_) return;
el_.textContent = String(n);
el_.hidden = n <= 0;
}
/** Recompute every tab's count from the current state. Called on
* every renderXxx that's tab-relevant. */
function refreshTabCounts() {
// SW4RM — flag any container that's stale (needs_update). Empty
// when everyone's current. Container-row pulse signals state
// transitions; the pill catches "deploy-pending" specifically.
let swarm = 0;
for (const c of containersState.values()) {
if (c.needs_update) swarm++;
}
setTabCount('swarm', swarm);
// Y3R C4LL — pending approvals + operator-targeted questions.
const callCount =
(approvalsState?.pending?.length ?? 0) +
(questionsState?.pending?.length ?? 0);
setTabCount('call', callCount);
// SYST3M — queued + running rebuild_queue entries (terminal
// entries are kept for history but aren't 'attention').
let sysCount = 0;
if (rebuildQueueState) {
for (const e of rebuildQueueState) {
if (e.state === 'Queued' || e.state === 'Running') sysCount++;
}
}
setTabCount('system', sysCount);
// SCH3DUL3S — count of schedules with at least one still-active
// target (whole-schedule cancellation or all-targets-cancelled
// means "not waiting on the worker"; those don't pull attention).
setTabCount('schedules', activeScheduleCount());
// FL0W pill count: lives in ./flow.js now (it has the inbox
// derived store). Dashboard tab strip's `#tab-count-flow` slot
// stays hidden by default; future plumbing could broadcast the
// count via localStorage / BroadcastChannel if both pages are
// open.
}
// Poll the state stores on a 1s tick to keep the pill counts in
// sync. The state stores are mutated synchronously by every SSE
// event + refreshState call, so polling them is correct and cheap
// — no per-renderer hookup needed.
refreshTabCounts();
setInterval(refreshTabCounts, 1000);
// Flow-specific IIFEs moved to ./flow.js (#406 step 2): inbox-pill
// wiring, the broker terminal init, and the @-mention composer.
// /index.html no longer loads them — only /flow.html does.
})();