From 49f4e9cc89f6387991c57bd71c0bfcf5b9c528ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Wed, 20 May 2026 11:22:28 +0200 Subject: [PATCH] dashboard: forge-linked config + approval card + 3-way diff base - forge nix option moves to hyperhive.forge.enable, defaults true; hive-c0re imports the forge module so it's on by default. - drop the agent.nix container-row viewer + /api/agent-config; link to the agent-configs forge repo instead. - restructure pending approvals into a card (identity header / what-changed body / decision actions) with a link to the proposal commit on the forge. - diff opens in the side panel with a 3-way base toggle: vs applied (running) / vs last-approved / vs previous proposal, served by the new /api/approval-diff/{id}?base= endpoint. --- hive-c0re/assets/app.js | 223 ++++++++++++++++++--------------- hive-c0re/assets/dashboard.css | 64 +++++++++- hive-c0re/src/dashboard.rs | 131 ++++++++++++++----- nix/modules/hive-c0re.nix | 5 + nix/modules/hive-forge.nix | 16 ++- 5 files changed, 305 insertions(+), 134 deletions(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 35cd865..a186030 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -593,14 +593,23 @@ } li.append(actions); + // ── line 3: 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'; - li.append(buildJournalTrigger(c.container, journalUnit)); - // Per-container applied config viewer. Shows the agent.nix - // the container is actually built against. - li.append(buildConfigTrigger(c.name)); + drill.append(buildJournalTrigger(c.container, journalUnit)); + // Applied config now lives on the forge — link to the + // agent-configs mirror repo instead of a one-file viewer. + if (s && s.forge_present) { + drill.append(el('a', { + class: 'panel-trigger', target: '_blank', rel: 'noopener', + href: `http://${hostname}:3000/agent-configs/${c.name}`, + title: 'applied config repo on the hive forge', + }, '↳ config repo ↗')); + } + li.append(drill); ul.append(li); } @@ -660,46 +669,6 @@ return trigger; } - // Per-container applied-config viewer. Returns an inline trigger; - // the click opens the side panel and fetches agent.nix. Read-only — - // the file is hive-c0re's applied repo, mutated only via approvals. - function buildConfigTrigger(agentName) { - const trigger = el('button', { type: 'button', class: 'panel-trigger' }, - '↳ agent.nix · ' + agentName); - trigger.addEventListener('click', () => { - const body = el('div', { class: 'journal-body' }); - const controls = el('div', { class: 'journal-controls' }); - 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 fetchConfig() { - if (fetching) return; - fetching = true; - pre.textContent = 'fetching…'; - try { - const resp = await fetch('/api/agent-config/' + agentName); - const text = await resp.text(); - if (!resp.ok) { - pre.textContent = 'error: ' + resp.status + '\n' + text; - } else { - pre.textContent = text || '(empty)'; - } - } catch (err) { - pre.textContent = 'fetch failed: ' + err; - } finally { - fetching = false; - } - } - refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); }); - controls.append(refresh); - body.append(controls, pre); - Panel.open('agent.nix · ' + agentName, body); - fetchConfig(); - }); - return trigger; - } - function renderTombstones(s) { const root = $('tombstones-section'); root.innerHTML = ''; @@ -1097,6 +1066,68 @@ } 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'); root.innerHTML = ''; @@ -1158,34 +1189,56 @@ 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 li = el('li'); - const row = el('div', { class: 'row' }); - if (a.kind === 'apply_commit') { - row.append( - el('span', { class: 'glyph' }, '→'), ' ', - el('span', { class: 'id' }, '#' + a.id), ' ', - el('span', { class: 'agent' }, a.agent), ' ', - el('span', { class: 'kind' }, 'apply'), ' ', - el('code', {}, a.sha_short || ''), - ); - } else { - row.append( - el('span', { class: 'glyph' }, '⊕'), ' ', - el('span', { class: 'id' }, '#' + a.id), ' ', - el('span', { class: 'agent' }, a.agent), ' ', - el('span', { class: 'kind kind-spawn' }, 'spawn'), ' ', - el('span', { class: 'meta' }, - 'new sub-agent — container will be created on approve'), - ); - } + const isApply = a.kind === 'apply_commit'; + 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' : 'spawn'), + ); + if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short)); + li.append(head); + + // ── what-changed body ──────────────────────────────────────── + const body = el('div', { class: 'approval-body' }); if (a.description) { - li.append(el('div', { class: 'approval-description' }, a.description)); + body.append(el('div', { class: 'approval-description' }, a.description)); } - // 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 + 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' }, + '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, @@ -1193,39 +1246,11 @@ 'data-prompt': 'reason for denying (optional, sent to manager):', }); denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY')); - row.append( - ' ', + li.append(el('div', { class: 'approval-actions' }, form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }), - ' ', denyForm, - ); - li.append(row); - if (a.diff) { - const trigger = el('button', { type: 'button', class: 'panel-trigger' }, - 'diff vs applied'); - trigger.addEventListener('click', () => { - // Server ships the raw unified diff; classify each line by - // its leading char so `.diff-add` / `.diff-del` / - // `.diff-hunk` / `.diff-file` / `.diff-ctx` colour the - // output. Building spans here (instead of innerHTML-ing - // pre-rendered markup) keeps the snapshot wire format - // text-only and one less HTML-escape surface server-side. - const pre = el('pre', { class: 'diff' }); - for (const raw of a.diff.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); - } - Panel.open('diff · ' + a.agent + ' #' + a.id, pre); - }); - li.append(trigger); - } + )); + ul.append(li); } root.append(ul); diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 5990b1b..7eaf67a 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -226,9 +226,66 @@ code { border-radius: 2px; font-size: 0.9em; } -.approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; } -.approvals form.inline { display: inline; margin-left: 0.4em; } -.approval-description { font-size: 0.85em; color: var(--fg-dim, #888); margin: 0.2em 0 0.4em 1.2em; } +/* Pending approval: a card with three stacked sections — identity + header, what-changed body, decision actions. */ +.approvals { list-style: none; padding: 0; margin: 0.4em 0 0; } +.approval-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-left: 3px solid var(--purple); + border-radius: 4px; + padding: 0.6em 0.8em; + margin-bottom: 0.6em; +} +.approval-head { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.3em; +} +.approval-body { + margin: 0.45em 0; + padding-left: 1.3em; +} +.approval-description { + font-size: 0.9em; + color: var(--fg); + white-space: pre-wrap; + margin-bottom: 0.35em; +} +.approval-actions { + display: flex; + gap: 0.5em; + padding-top: 0.45em; + border-top: 1px solid var(--border); +} +.approval-actions form.inline { display: inline; } +/* Inline drill-in triggers (logs / config repo / view diff). */ +.drill-ins { + display: flex; + flex-wrap: wrap; + gap: 0.15em 1.1em; + margin-top: 0.4em; +} +.drill-ins .panel-trigger { margin-top: 0; } +/* Diff side-panel: base-toggle tabs above the diff host. */ +.diff-panel { display: flex; flex-direction: column; gap: 0.6em; } +.diff-base-tabs { display: flex; flex-wrap: wrap; gap: 0.4em; } +.diff-base-tab { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); + font: inherit; + font-size: 0.85em; + padding: 0.2em 0.7em; + cursor: pointer; +} +.diff-base-tab:hover { color: var(--fg); } +.diff-base-tab.active { + color: var(--purple); + border-color: var(--purple); + background: rgba(203, 166, 247, 0.08); +} .approval-tabs { display: flex; gap: 0.4em; @@ -738,6 +795,7 @@ footer a { color: var(--purple); } margin-top: 0.5em; display: inline-block; text-align: left; + text-decoration: none; } .panel-trigger:hover { color: var(--cyan); } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 5dae339..28ffd5a 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -54,11 +54,11 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/cancel-question/{id}", post(post_cancel_question)) .route("/purge-tombstone/{name}", post(post_purge_tombstone)) .route("/api/journal/{name}", get(get_journal)) + .route("/api/approval-diff/{id}", get(get_approval_diff)) .route("/api/state-file", get(get_state_file)) .route("/api/reminders", get(api_reminders)) .route("/cancel-reminder/{id}", post(post_cancel_reminder)) .route("/retry-reminder/{id}", post(post_retry_reminder)) - .route("/api/agent-config/{name}", get(get_agent_config)) .route("/request-spawn", post(post_request_spawn)) .route("/op-send", post(post_op_send)) .route("/meta-update", post(post_meta_update)) @@ -195,6 +195,10 @@ struct StateSnapshot { /// Inputs in `meta/flake.lock` the operator can selectively /// `nix flake update`. Hyperhive first, then `agent-` rows. meta_inputs: Vec, + /// Whether the hive-forge container is up. When true the dashboard + /// links each container's config + each approval's commit into the + /// forge's `agent-configs` repos. + forge_present: bool, } /// `OpQuestion` + computed `question_refs` / `answer_refs`. Built @@ -381,6 +385,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J question_history, tombstones, port_conflicts, + forge_present: crate::forge::is_present().await, }) } @@ -932,30 +937,6 @@ async fn get_journal( } } -/// Show the current `agent.nix` from the applied repo — the file -/// the container actually builds against. Read-only; the manager -/// can't influence what this returns (that path goes through the -/// approval queue). -async fn get_agent_config(AxumPath(name): AxumPath) -> Response { - let logical = strip_container_prefix(&name); - // Constrain to managed containers — same shape as the journal - // endpoint, prevents arbitrary filesystem reads. - let live = lifecycle::list().await.unwrap_or_default(); - let prefixed = if logical == lifecycle::MANAGER_NAME { - logical.clone() - } else { - format!("{}{logical}", lifecycle::AGENT_PREFIX) - }; - if !live.iter().any(|c| c == &prefixed) { - return error_response(&format!("agent-config: no managed container {prefixed:?}")); - } - let path = Coordinator::agent_applied_dir(&logical).join("agent.nix"); - match std::fs::read_to_string(&path) { - Ok(body) => ([("content-type", "text/plain; charset=utf-8")], body).into_response(), - Err(e) => error_response(&format!("read {}: {e}", path.display())), - } -} - #[derive(Deserialize)] struct StateFileQuery { path: String, @@ -1801,26 +1782,118 @@ pub(crate) async fn approval_diff(agent: &str, approval_id: i64) -> String { return format!("(no applied git repo at {})", applied.display()); } let proposal_ref = format!("refs/tags/proposal/{approval_id}"); - match git_diff_main_to(&applied, &proposal_ref).await { + match git_diff_refs(&applied, "refs/heads/main", &proposal_ref).await { Ok(s) if s.is_empty() => "(proposal matches currently-deployed tree)".to_owned(), Ok(s) => s, Err(e) => format!("(error: {e:#})"), } } -async fn git_diff_main_to(applied_dir: &Path, target_ref: &str) -> Result { +async fn git_diff_refs(applied_dir: &Path, base_ref: &str, target_ref: &str) -> Result { let out = lifecycle::git_command() .current_dir(applied_dir) - .args(["diff", &format!("refs/heads/main..{target_ref}")]) + .args(["diff", &format!("{base_ref}..{target_ref}")]) .output() .await .with_context(|| format!("spawn `git diff` in {}", applied_dir.display()))?; if !out.status.success() { anyhow::bail!( - "git diff main..{target_ref} failed: {}", + "git diff {base_ref}..{target_ref} failed: {}", String::from_utf8_lossy(&out.stderr).trim() ); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } +/// Numeric ids of `/` tags in the applied repo (e.g. +/// `proposal/3` → `3`). Unparseable suffixes are skipped. Used to +/// resolve the `approved` / `previous` diff bases for an approval. +async fn tag_ids(applied_dir: &Path, prefix: &str) -> Vec { + let Ok(out) = lifecycle::git_command() + .current_dir(applied_dir) + .args(["tag", "-l", &format!("{prefix}/*")]) + .output() + .await + else { + return Vec::new(); + }; + if !out.status.success() { + return Vec::new(); + } + let strip = format!("{prefix}/"); + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| l.trim().strip_prefix(&strip)) + .filter_map(|s| s.parse::().ok()) + .collect() +} + +#[derive(Deserialize)] +struct DiffBaseQuery { + /// `applied` (running tree — default), `approved` (most recent + /// earlier approved proposal), or `previous` (the prior queued + /// proposal for this agent). + base: Option, +} + +/// On-demand unified diff for one `ApplyCommit` approval against a +/// chosen base. `applied` = `applied/main` (what's running); +/// `approved` = the most recent earlier `approved/` tag (the last +/// proposal the operator OK'd, even if its build then failed); +/// `previous` = the prior queued `proposal/` (the incremental +/// delta when the manager chains proposals). Returns the raw diff +/// text — the dashboard classifies lines client-side. +async fn get_approval_diff( + State(state): State, + AxumPath(id): AxumPath, + axum::extract::Query(q): axum::extract::Query, +) -> Response { + let base = q.base.as_deref().unwrap_or("applied"); + let approval = match state.coord.approvals.get(id) { + Ok(Some(a)) => a, + Ok(None) => return error_response(&format!("approval {id} not found")), + Err(e) => return error_response(&format!("approval {id}: {e:#}")), + }; + if !matches!(approval.kind, hive_sh4re::ApprovalKind::ApplyCommit) { + return error_response("spawn approvals carry no commit to diff"); + } + let applied = Coordinator::agent_applied_dir(&approval.agent); + if !applied.join(".git").exists() { + return plain_text(format!("(no applied git repo at {})", applied.display())); + } + let target = format!("refs/tags/proposal/{id}"); + let base_ref = match base { + "applied" => Some("refs/heads/main".to_owned()), + "approved" => { + let ids = tag_ids(&applied, "approved").await; + ids.into_iter() + .filter(|&n| n != id) + .max() + .map(|n| format!("refs/tags/approved/{n}")) + } + "previous" => { + let ids = tag_ids(&applied, "proposal").await; + ids.into_iter() + .filter(|&n| n < id) + .max() + .map(|n| format!("refs/tags/proposal/{n}")) + } + other => return error_response(&format!("unknown diff base {other:?}")), + }; + let Some(base_ref) = base_ref else { + return plain_text(match base { + "approved" => "(no earlier approved proposal to diff against)".to_owned(), + _ => "(no previous proposal to diff against)".to_owned(), + }); + }; + match git_diff_refs(&applied, &base_ref, &target).await { + Ok(s) if s.is_empty() => plain_text("(identical — no changes vs this base)".to_owned()), + Ok(s) => plain_text(s), + Err(e) => error_response(&format!("git diff: {e:#}")), + } +} + +fn plain_text(body: String) -> Response { + (StatusCode::OK, body).into_response() +} + diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index b6bdbcb..158eefe 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -12,6 +12,11 @@ let cfg = config.services.hive-c0re; in { + # The forge is part of the standard install — hive-c0re mirrors + # every agent's applied config repo into it. On by default; opt out + # with `hyperhive.forge.enable = false`. + imports = [ ./hive-forge.nix ]; + options.services.hive-c0re = { enable = lib.mkEnableOption "hive-c0re — hyperhive coordinator daemon"; package = lib.mkOption { diff --git a/nix/modules/hive-forge.nix b/nix/modules/hive-forge.nix index b5ded52..6edfcd6 100644 --- a/nix/modules/hive-forge.nix +++ b/nix/modules/hive-forge.nix @@ -5,7 +5,7 @@ ... }: let - cfg = config.services.hive-forge; + cfg = config.hyperhive.forge; in { # Private Forgejo for hyperhive agents, wrapped in a nixos-container @@ -24,8 +24,18 @@ in # and survives container restart / host reboot. To wipe, destroy the # container. - options.services.hive-forge = { - enable = lib.mkEnableOption "hive-forge — private Forgejo (in a nixos-container) for hyperhive agents"; + options.hyperhive.forge = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Run hive-forge — a private Forgejo (in a nixos-container) for + hyperhive agents. On by default: hive-c0re mirrors every + agent's applied config repo into the forge's `agent-configs` + org, so the forge is part of the standard install. Set + `hyperhive.forge.enable = false` to opt out. + ''; + }; httpPort = lib.mkOption { type = lib.types.port;