From fdf05c1673cf9c719e004960381681f199270cd7 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 8 Jun 2026 23:44:58 +0200 Subject: [PATCH 01/14] =?UTF-8?q?refactor(gateway):=20make=20the=20gateway?= =?UTF-8?q?=20unconditional=20=E2=80=94=20remove=20gateway.enable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway container starts alongside every hyperhive deployment, so gating it behind a separate enable flag was a footgun: an operator who set it false lost the only thing exposed to the outside while the agent containers kept running. Re-gate the gateway config on the top-level services.hyperhive.enable instead. - hive-gateway.nix: drop the gateway.enable mkOption; gate the config block on config.services.hyperhive.enable. - hive-forge.nix: behindGateway now defaults to services.hyperhive.enable; remove the behindGateway-requires-gateway assertion (now vacuous). - hive-network.nix: remove both gateway.enable assertions (vacuous). - hive-c0re.nix: drop the firewall.allowedTCPPortRanges 8100-8999 fallback that opened agent ports when the gateway was off (the gateway is now the sole entry point); HIVE_GATEWAY_ENABLED is always set since the gateway always runs. - nix/docs/default.nix: remove the gateway.enable = mkForce false stub (would be an eval error against the removed option; the gateway is already re-gated on hyperhive.enable, which docs force false). - hive-matrix.nix, dashboard.rs: comment/prose updates only. BREAKING: operators relying on services.hyperhive.gateway.enable = false to suppress the gateway must instead point their own reverse proxy at the gateway's port. NixOS errors clearly on the now-unknown option. --- hive-c0re/src/dashboard.rs | 20 ++++++++++---------- nix/docs/default.nix | 1 - nix/modules/hive-c0re.nix | 20 ++++++++------------ nix/modules/hive-forge.nix | 25 +++++-------------------- nix/modules/hive-gateway.nix | 23 ++++++----------------- nix/modules/hive-matrix.nix | 10 +++++----- nix/modules/hive-network.nix | 21 --------------------- 7 files changed, 34 insertions(+), 86 deletions(-) diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index f03897de..0a243645 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -229,15 +229,15 @@ struct StateSnapshot { /// chrome so the `M4TR1X →` tab doesn't flash when the GUI is off. matrix_gui_enabled: bool, /// Whether `hive-gateway` is in front of this dashboard. Sourced - /// from `HIVE_GATEWAY_ENABLED` env var (set by the c0re NixOS - /// module when `services.hyperhive.gateway.enable` is on). When - /// true the dashboard frontend builds same-origin - /// `/agent//` links to the per-agent web UI (the gateway - /// routes them via the runtime-generated `agents.conf` include - /// file — see `gateway_nginx.rs`); when false it falls back to - /// direct `http://:/` TCP links so gateway-off / - /// local-dev deploys keep working. See `docs/gateway.md::Vhost - /// map`. + /// from the `HIVE_GATEWAY_ENABLED` env var, which the c0re NixOS + /// module now always sets (the gateway runs unconditionally + /// alongside hyperhive), so this is effectively always true: the + /// dashboard frontend builds same-origin `/agent//` links to + /// the per-agent web UI (the gateway routes them via the + /// runtime-generated `agents.conf` include file — see + /// `gateway_nginx.rs`). The `false` branch (direct + /// `http://:/` TCP links) is retained as a defensive + /// fallback for the env being unset. See `docs/gateway.md::Vhost map`. gateway_enabled: bool, /// Public URL of the forge vhost served by hive-gateway (e.g. /// `"https://forge.pr1ma.darkest.space"`). Sourced from the @@ -480,7 +480,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J gateway_enabled: std::env::var_os("HIVE_GATEWAY_ENABLED").is_some_and(|v| { // Same truthy-string parse as `matrix_gui_enabled`; the // env var is set by the c0re NixOS module to the literal - // "1" when `services.hyperhive.gateway.enable` is on. + // "1" — the gateway always runs alongside hyperhive. let s = v.to_string_lossy().to_ascii_lowercase(); matches!(s.as_str(), "1" | "true" | "yes") }), diff --git a/nix/docs/default.nix b/nix/docs/default.nix index 0595e603..90edb099 100644 --- a/nix/docs/default.nix +++ b/nix/docs/default.nix @@ -31,7 +31,6 @@ let services.hyperhive.enable = lib.mkForce false; services.hyperhive.forge.enable = lib.mkForce false; services.hyperhive.matrix.enable = lib.mkForce false; - services.hyperhive.gateway.enable = lib.mkForce false; } ) ]; diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 7a1fc782..b5d3356a 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -634,15 +634,9 @@ in }; users.groups.hive-core = { }; - # Open the per-agent web-port range when the gateway is *off* — - # otherwise the gateway nginx is the sole external entry point. - # See `docs/gateway.md::Firewall posture (host-level)`. - networking.firewall.allowedTCPPortRanges = lib.mkIf (!config.services.hyperhive.gateway.enable) [ - { - from = 8100; - to = 8999; - } - ]; + # The gateway nginx is always the sole external entry point (it runs + # alongside hyperhive), so the per-agent web-port range stays closed on + # the host firewall. See `docs/gateway.md::Firewall posture (host-level)`. # WireGuard inter-hive mesh. Enabled when # `services.hyperhive.swarm.wireguard.enable = true`. Brings up a @@ -777,9 +771,11 @@ in # docs/gateway.md::Vhost map. HIVE_MATRIX_GUI_ENABLED = "1"; } - // lib.optionalAttrs config.services.hyperhive.gateway.enable { - # When true the dashboard builds same-origin `/agent//` - # links; when false it falls back to direct `:` TCP. + // { + # The gateway always runs, so the dashboard always builds + # same-origin `/agent//` links (never the direct + # `:` TCP fallback). Kept as an env flag so the + # dashboard doesn't need to learn the gateway is unconditional. HIVE_GATEWAY_ENABLED = "1"; } // diff --git a/nix/modules/hive-forge.nix b/nix/modules/hive-forge.nix index da52a894..ce420e58 100644 --- a/nix/modules/hive-forge.nix +++ b/nix/modules/hive-forge.nix @@ -112,8 +112,8 @@ in behindGateway = lib.mkOption { type = lib.types.bool; - default = gatewayCfg.enable or false; - defaultText = lib.literalExpression "config.services.hyperhive.gateway.enable"; + default = config.services.hyperhive.enable; + defaultText = lib.literalExpression "config.services.hyperhive.enable"; description = '' Serve forgejo through the hive-gateway nginx as a sub-domain vhost (`server_name = cfg.domain`) instead of directly on @@ -127,9 +127,9 @@ in - `gateway.localHostsEntry = true` extends `/etc/hosts` to include `cfg.domain → 127.0.0.1` for local dev. - Defaults to `services.hyperhive.gateway.enable` — flipping - the gateway on/off auto-routes forge through it. Set `false` - explicitly to keep forge on the direct port even when the + Defaults to `services.hyperhive.enable` (the gateway always runs + alongside hyperhive, so forge auto-routes through it). Set `false` + explicitly to keep forge on the direct port even though the gateway is running (e.g. an external git client that doesn't traverse the gateway). @@ -211,21 +211,6 @@ in or "git.internal". ''; } - { - # behindGateway requires the gateway module to actually be on. - # Otherwise the configured `ROOT_URL` flips to a sub-domain - # shape that has no nginx vhost backing it → broken on the - # rebuild. - assertion = !cfg.behindGateway || (gatewayCfg.enable or false); - message = '' - services.hyperhive.forge.behindGateway = true requires - services.hyperhive.gateway.enable = true (the gateway vhost - serving forge needs the gateway container to actually be - running). Either turn the gateway on, or set - services.hyperhive.forge.behindGateway = false to keep forge - on its direct port. - ''; - } ]; containers.hive-forge = { diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index 219785e7..efa3e776 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -69,22 +69,11 @@ in # `docs/gateway.md`. options.services.hyperhive.gateway = { - enable = lib.mkOption { - type = lib.types.bool; - default = true; - description = '' - Run hive-gateway — a single nginx in front of every hyperhive - surface. On by default: the gateway hosts the matrix GUI static - dist (when `services.hyperhive.matrix.gui.enable` is true) and - proxies everything else to hive-c0re's dashboard upstream. Set - `services.hyperhive.gateway.enable = false` to bypass nginx - entirely and reach hive-c0re directly on its dashboard port - (7000 by default). - - v0 is HTTP-only; TLS / public-domain shape is tracked - separately. - ''; - }; + # The gateway is always run alongside hyperhive (it's the single nginx + # in front of every surface and the only thing exposed to the outside); + # there is no enable flag. An operator who wants their own reverse proxy + # in front points it at the gateway's `port`. The gateway config below + # is gated on the top-level `services.hyperhive.enable`. port = lib.mkOption { type = lib.types.port; @@ -375,7 +364,7 @@ in }; - config = lib.mkIf cfg.enable { + config = lib.mkIf config.services.hyperhive.enable { assertions = [ { assertion = !cfg.localHostsEntry || hyperhiveDomain != null; diff --git a/nix/modules/hive-matrix.nix b/nix/modules/hive-matrix.nix index e510de50..5c8b5ba7 100644 --- a/nix/modules/hive-matrix.nix +++ b/nix/modules/hive-matrix.nix @@ -248,8 +248,8 @@ in defaultText = lib.literalExpression "config.services.hyperhive.matrix.enable"; description = '' Serve a matrix web client at `matrix.''${services.hyperhive.domain}/`. - Requires `gateway.enable` + `matrix.gatewayHost != null` - (default true / `matrix.` when hive-domain set). When + Requires `matrix.gatewayHost != null` (default `matrix.` + when hive-domain set); the gateway itself always runs. When off, the dashboard's `M4TR1X →` tab is hidden. See `docs/gateway.md` for the discovery flow that lets clients auto-find the sub-domain. @@ -435,9 +435,9 @@ in # boot failure this module fixes was an *empty* resolv.conf, a parse # error, not a connectivity one) — so this is robustness, not a boot # requirement. Soft `after` ordering (not `requires`) keeps the matrix - # container's lifecycle decoupled from the gateway's. `network.enable` - # asserts `gateway.enable`, so the gateway container unit always exists - # here. (Declarative `containers.` → `container@.service` — the + # container's lifecycle decoupled from the gateway's. The gateway + # always runs alongside hyperhive, so the gateway container unit always + # exists here. (Declarative `containers.` → `container@.service` — the # nspawn template NixOS generates, confirmed from the live # `container@hive-matrix.service` host unit.) systemd.services."container@hive-matrix".after = lib.mkIf networkCfg.enable [ diff --git a/nix/modules/hive-network.nix b/nix/modules/hive-network.nix index 9d86f770..9c495b43 100644 --- a/nix/modules/hive-network.nix +++ b/nix/modules/hive-network.nix @@ -154,16 +154,6 @@ in `services.hyperhive.network.enable = false` explicitly. ''; } - { - assertion = config.services.hyperhive.gateway.enable; - message = '' - services.hyperhive.network.enable = true requires - services.hyperhive.gateway.enable = true — the dnsmasq - resolver runs inside the hive-gateway container (single - front-door for both DNS and HTTP). Enable the gateway or - set `services.hyperhive.network.enable = false` explicitly. - ''; - } ]; # Virtual bridge — veth pairs attach when isolateContainers flips on. @@ -196,17 +186,6 @@ in resolver must be running before isolation is flipped on). ''; } - { - assertion = !config.services.hyperhive.forge.enable || config.services.hyperhive.gateway.enable; - message = '' - services.hyperhive.network.isolateContainers = true with - services.hyperhive.forge.enable = true requires - services.hyperhive.gateway.enable = true — isolated agents - reach the forge via `http://forge.` which nginx (in - the gateway container) proxies to forgejo. Without the gateway - there is nothing listening on port 80 to serve that hostname. - ''; - } ]; }) From 09cb705738358764960b2b1ce6240caec87ed8cf Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 8 Jun 2026 23:37:53 +0200 Subject: [PATCH 02/14] refactor(frontend): move ST4TS to its own /stats.html page (#1464 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the hive-wide turn-stats rollup out of the dashboard tab strip into a standalone /stats.html page, reached from the H0M3 hub — same minimal-chrome pattern as /flow.html and /logs.html. The dashboard tab strip is now purely operational. - new stats.{html,css,js}; stats.js holds the moved render JS and fetches /api/stats-hive on load + window change. - migrate the window selector (#hive-stats-windows) from a bespoke data-w/.active toggle to the shared createTabStrip — now hash-routed (#1h / #24h / …) and deep-linkable, matching the per-agent /stats page. - drop the ST4TS tab + pane from dashboard.html and the hive-stats render block + the stats->refreshHiveStats lazy-load from tabs.js. - move the shared .hive-stats-table to common.css (the dashboard SYST3M > C0NT41N3R L04D table still uses it); the ST4TS-only window/chip/bar styles go to stats.css. - add a Stats tile to the H0M3 hub (and drop the now-stale "stats" from the Dashboard tile desc); wire build.mjs + the nix/frontend.nix manifest. Second slice of #1464 step 2; follows the /settings.html extraction. --- frontend/packages/dashboard/build.mjs | 6 +- frontend/packages/dashboard/src/common.css | 28 +++ frontend/packages/dashboard/src/dashboard.css | 95 +--------- .../packages/dashboard/src/dashboard.html | 45 +---- frontend/packages/dashboard/src/index.html | 7 +- frontend/packages/dashboard/src/stats.css | 84 +++++++++ frontend/packages/dashboard/src/stats.html | 53 ++++++ frontend/packages/dashboard/src/stats.js | 167 ++++++++++++++++ frontend/packages/dashboard/src/tabs.js | 178 +----------------- nix/frontend.nix | 6 +- 10 files changed, 367 insertions(+), 302 deletions(-) create mode 100644 frontend/packages/dashboard/src/stats.css create mode 100644 frontend/packages/dashboard/src/stats.html create mode 100644 frontend/packages/dashboard/src/stats.js diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index 63c6f6b5..b0d79747 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -52,7 +52,7 @@ mkdirSync(staticDir(''), { recursive: true }); // follow-up once asset sizes warrant it). esbuild writes each entry // to `static/.js` based on the entryPoint basename. await build({ - entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js')], + entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js')], outdir: staticDir(''), bundle: true, format: 'esm', @@ -91,7 +91,7 @@ await build({ // so a swap replaces only it) + theme.css (the semantic derivation // layer) + common.css (shared typography, badges, buttons, inbox, side // panel) plus its own page-specific bundle. -for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css']) { +for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css']) { await build({ entryPoints: [src(entry)], outfile: staticDir(entry), @@ -101,7 +101,7 @@ for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', ' }); } -for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html']) { +for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html']) { copyFileSync(src(html), dist(html)); } diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css index 22c6fb37..b377eb58 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -558,3 +558,31 @@ body.home-shell #server-warnings { background: color-mix(in srgb, var(--red) 18%, transparent); border-bottom: 1px solid var(--red); } + +/* ─── stats table ───────────────────────────────────────────────── + Shared right-aligned numeric table. Used by /stats.html (busiest + agents) and the dashboard SYST3M › C0NT41N3R L04D table — moved here + from dashboard.css when ST4TS became its own page. */ +.hive-stats-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin-bottom: 8px; +} +.hive-stats-table th, +.hive-stats-table td { + border-bottom: 1px solid var(--border); + padding: 5px 8px; + text-align: right; +} +.hive-stats-table th:first-child, +.hive-stats-table td:first-child { + text-align: left; +} +.hive-stats-table th { + color: var(--muted); + font-weight: normal; +} +.hive-stats-table td.num { + font-variant-numeric: tabular-nums; +} diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 3b1f0229..5cc492ea 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -1392,97 +1392,14 @@ body.dashboard-shell.has-selection { padding-bottom: 4.5em; } cursor: default; } -/* ─── ST4TS tab: hive-wide turn-stats rollup ────────────────────────────── - Plain tables + CSS bars (no chart lib in the dashboard bundle). */ -.hive-stats-windows { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin: 12px 0; -} -.hive-stats-windows .btn.active { - border-color: var(--amber); - color: var(--amber); -} -.hive-stats-chips { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 16px; -} -.hive-stats-chip { - display: inline-flex; - flex-direction: column; - padding: 6px 10px; - background: var(--bg-elev); - border: 1px solid var(--border); - border-radius: 4px; - min-width: 7em; -} -.hive-stats-chip .k { - font-size: 0.72rem; - color: var(--muted); - text-transform: lowercase; -} -.hive-stats-chip .v { - font-size: 1.05rem; - color: var(--fg); -} -.hive-stats-chip.est .v { - color: var(--amber); -} -.hive-stats-table { - width: 100%; - border-collapse: collapse; - font-size: 0.85rem; - margin-bottom: 8px; -} -.hive-stats-table th, -.hive-stats-table td { - border-bottom: 1px solid var(--border); - padding: 5px 8px; - text-align: right; -} -.hive-stats-table th:first-child, -.hive-stats-table td:first-child { - text-align: left; -} -.hive-stats-table th { - color: var(--muted); - font-weight: normal; -} -.hive-stats-table td.num { - font-variant-numeric: tabular-nums; -} -.hive-stats-bar { - display: grid; - grid-template-columns: 12em 1fr 4em; - align-items: center; - gap: 8px; - margin: 3px 0; - font-size: 0.85rem; -} -.hive-stats-bar .track { - background: var(--bg-elev); - border: 1px solid var(--border); - border-radius: 3px; - height: 14px; - overflow: hidden; -} -.hive-stats-bar .fill { - display: block; - height: 100%; - background: var(--purple); -} -.hive-stats-bar .cnt { - text-align: right; - color: var(--muted); - font-variant-numeric: tabular-nums; -} +/* ST4TS moved to its own page (`/stats.html`): the + window selector / summary chips / bars live in stats.css, and the + shared `.hive-stats-table` moved to common.css (the SYST3M › + C0NT41N3R L04D table below still uses it). */ /* ─── SYST3M › C0NT41N3R L04D: live cgroup cpu/mem ───────────────────────────── - Reuses the `.hive-stats-table` styling from the ST4TS tab; only the - inline meter bar is new. */ + Reuses the shared `.hive-stats-table` styling (now in common.css); + only the inline meter bar is new. */ .cload-meter { display: inline-block; width: 6em; diff --git a/frontend/packages/dashboard/src/dashboard.html b/frontend/packages/dashboard/src/dashboard.html index e1f20160..07ef0a9b 100644 --- a/frontend/packages/dashboard/src/dashboard.html +++ b/frontend/packages/dashboard/src/dashboard.html @@ -60,14 +60,9 @@ - - - ◆ ST4TS ◆ - + @@ -241,35 +236,11 @@ - - + +
+ ← home + ST4TS +
+ +
+

hive-wide turn statistics, aggregated across every agent over the selected window. cost is a rough estimate from approximate per-model list prices — it drifts and is a ballpark, not a bill.

+ + + + +
+

◇ busiest agents

+

loading…

+

◇ model mix (turns across the swarm)

+
+ + + +
+ + + + diff --git a/frontend/packages/dashboard/src/stats.js b/frontend/packages/dashboard/src/stats.js new file mode 100644 index 00000000..cf8eb18b --- /dev/null +++ b/frontend/packages/dashboard/src/stats.js @@ -0,0 +1,167 @@ +// /stats.html — hive-wide turn-stats rollup. +// +// Extracted from the dashboard ST4TS tab. Pull-only (no SSE): fetched +// from /api/stats-hive on load and on window change. Plain tables/bars — +// this bundle has no chart lib; per-agent trend charts live on each +// agent's own /stats page. The window selector is a hash-routed +// createTabStrip (#1h / #24h / …), matching the per-agent stats page. +import { $, initServerWarnings } from './common.js'; +import { createTabStrip } from '@hive/shared/tabs.js'; + +let hiveStatsWindow = '24h'; + +function hsFmtInt(n) { + return Number.isFinite(n) ? new Intl.NumberFormat().format(Math.round(n)) : '0'; +} +function hsFmtTokens(n) { + if (!Number.isFinite(n)) return '0'; + if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'; + if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; + return String(Math.round(n)); +} +function hsFmtUsd(n) { + if (!Number.isFinite(n)) return '$0'; + if (n >= 100) return '$' + n.toFixed(0); + if (n >= 1) return '$' + n.toFixed(2); + return '$' + n.toFixed(3); +} +function hsChip(parent, label, value, est) { + const c = document.createElement('span'); + c.className = 'hive-stats-chip' + (est ? ' est' : ''); + const k = document.createElement('span'); k.className = 'k'; k.textContent = label; + const v = document.createElement('span'); v.className = 'v'; v.textContent = value; + c.append(k, v); + parent.append(c); +} +function hsMeta(parent, text) { + parent.replaceChildren(); + const p = document.createElement('p'); + p.className = 'meta'; + p.textContent = text; + parent.append(p); +} + +function renderHiveStats(s) { + const sum = $('hive-stats-summary'); + if (sum) { + sum.replaceChildren(); + hsChip(sum, 'window', s.window); + hsChip(sum, 'active agents', hsFmtInt(s.active_agents)); + hsChip(sum, 'turns', hsFmtInt(s.total_turns)); + const totalTok = (s.total_input_tokens || 0) + (s.total_output_tokens || 0) + + (s.total_cache_read_tokens || 0) + (s.total_cache_creation_tokens || 0); + hsChip(sum, 'tokens', hsFmtTokens(totalTok)); + hsChip(sum, 'input', hsFmtTokens(s.total_input_tokens)); + hsChip(sum, 'output', hsFmtTokens(s.total_output_tokens)); + hsChip(sum, 'cache read', hsFmtTokens(s.total_cache_read_tokens)); + hsChip(sum, 'est cost', hsFmtUsd(s.est_cost_usd), true); + } + + const at = $('hive-stats-agents'); + if (at) { + const agents = s.agents || []; + if (!agents.length) { + hsMeta(at, 'no turns in window'); + } else { + at.replaceChildren(); + const table = document.createElement('table'); + table.className = 'hive-stats-table'; + table.innerHTML = 'agentturnsinput' + + 'outputcache readest cost'; + const tb = document.createElement('tbody'); + for (const a of agents) { + const tr = document.createElement('tr'); + const cells = [ + a.name, hsFmtInt(a.turns), hsFmtTokens(a.input_tokens), + hsFmtTokens(a.output_tokens), hsFmtTokens(a.cache_read_tokens), + hsFmtUsd(a.est_cost_usd), + ]; + cells.forEach((txt, i) => { + const td = document.createElement('td'); + if (i > 0) td.className = 'num'; + td.textContent = txt; + tr.append(td); + }); + tb.append(tr); + } + table.append(tb); + at.append(table); + } + } + + const mm = $('hive-stats-models'); + if (mm) { + const mix = s.model_mix || []; + if (!mix.length) { + hsMeta(mm, 'no turns in window'); + } else { + mm.replaceChildren(); + const max = mix[0].count || 1; + for (const kc of mix) { + const row = document.createElement('div'); row.className = 'hive-stats-bar'; + const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key; + const track = document.createElement('span'); track.className = 'track'; + const fill = document.createElement('span'); fill.className = 'fill'; + fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%'; + track.append(fill); + const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count); + row.append(lbl, track, cnt); + mm.append(row); + } + } + } + + // "favorite tools": most-run bash commands across the swarm. Hidden + // (header + list) until the capture has recorded data, so the + // section never shows an empty block while capture is pre-data. + const bh = $('hive-stats-bash'); + const bhH = $('hive-stats-bash-h'); + if (bh) { + const bmix = s.bash_mix || []; + if (!bmix.length) { + bh.replaceChildren(); + bh.hidden = true; + if (bhH) bhH.hidden = true; + } else { + bh.hidden = false; + if (bhH) bhH.hidden = false; + bh.replaceChildren(); + const max = bmix[0].count || 1; + for (const kc of bmix) { + const row = document.createElement('div'); row.className = 'hive-stats-bar'; + const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key; + const track = document.createElement('span'); track.className = 'track'; + const fill = document.createElement('span'); fill.className = 'fill'; + fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%'; + track.append(fill); + const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count); + row.append(lbl, track, cnt); + bh.append(row); + } + } + } +} + +async function refreshHiveStats() { + try { + const resp = await fetch('/api/stats-hive?window=' + encodeURIComponent(hiveStatsWindow)); + if (!resp.ok) throw new Error('http ' + resp.status); + renderHiveStats(await resp.json()); + } catch (e) { + const at = $('hive-stats-agents'); + if (at) hsMeta(at, 'stats fetch failed: ' + e); + } +} + +initServerWarnings(); + +// Window selector → hash-routed createTabStrip (#1h / #24h / …). No panes +// here (the selector swaps data, not DOM); paneFor() returns null for each +// tab and the strip's `if (pane)` guard handles it. The initial show() +// fires onShow once → sets the window + does the first fetch, so no +// separate refreshHiveStats() call is needed. +createTabStrip(document.getElementById('hive-stats-windows'), { + defaultId: hiveStatsWindow, + onShow: (w) => { hiveStatsWindow = w; refreshHiveStats(); }, +}); diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 333268cf..73f5a9e1 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -4055,11 +4055,11 @@ window.marked = marked; })(); // ─── tab routing ─────────────────────────────────────────────────────── - // Hash-based: `#swarm` / `#call` / `#system` / `#schedules` / - // `#permissions` / `#stats` activate the matching pane on the - // dashboard. Empty hash defaults to SW4RM. FL0W and S3TT1NGS are NOT - // tabs — they're separate pages (`/flow.html`, `/settings.html`) - // reached from the H0M3 hub. Tab routing only + // Hash-based: `#swarm` / `#call` / `#system` / `#permissions` / + // `#schedules` activate the matching pane on the dashboard. Empty + // hash defaults to SW4RM. FL0W, S3TT1NGS, and ST4TS are NOT tabs — + // they're separate pages (`/flow.html`, `/settings.html`, + // `/stats.html`) reached from the H0M3 hub. 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). // The shared hash-routed tab strip (@hive/shared/tabs.js) owns the @@ -4085,176 +4085,16 @@ window.marked = marked; fetchAndRenderCapabilities(); fetchAndRenderToolGroups(); } - // ST4TS: hive-wide rollup is a pull (no SSE) — fetch on activation. - if (target === 'stats') { refreshHiveStats(); } if (target === 'call') { refreshOperatorInbox(); } // SYST3M › C0NT41N3R L04D: live cgroup poll only while the tab is // open (cpu needs a short two-sample read each refresh). if (target === 'system') { startContainerLoadPolling(); } else { stopContainerLoadPolling(); } } - // ─── ST4TS: hive-wide turn-stats rollup ────────────────────────────────── - // Pull-only (no SSE): fetched from /api/stats-hive on tab activation and - // on window change. Plain tables/bars — the dashboard bundle has no chart - // lib, and per-agent trend charts live on each agent's own /stats page. - let hiveStatsWindow = '24h'; - - function hsFmtInt(n) { - return Number.isFinite(n) ? new Intl.NumberFormat().format(Math.round(n)) : '0'; - } - function hsFmtTokens(n) { - if (!Number.isFinite(n)) return '0'; - if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'; - if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; - if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; - return String(Math.round(n)); - } - function hsFmtUsd(n) { - if (!Number.isFinite(n)) return '$0'; - if (n >= 100) return '$' + n.toFixed(0); - if (n >= 1) return '$' + n.toFixed(2); - return '$' + n.toFixed(3); - } - function hsChip(parent, label, value, est) { - const c = document.createElement('span'); - c.className = 'hive-stats-chip' + (est ? ' est' : ''); - const k = document.createElement('span'); k.className = 'k'; k.textContent = label; - const v = document.createElement('span'); v.className = 'v'; v.textContent = value; - c.append(k, v); - parent.append(c); - } - function hsMeta(parent, text) { - parent.replaceChildren(); - const p = document.createElement('p'); - p.className = 'meta'; - p.textContent = text; - parent.append(p); - } - - function renderHiveStats(s) { - const sum = $('hive-stats-summary'); - if (sum) { - sum.replaceChildren(); - hsChip(sum, 'window', s.window); - hsChip(sum, 'active agents', hsFmtInt(s.active_agents)); - hsChip(sum, 'turns', hsFmtInt(s.total_turns)); - const totalTok = (s.total_input_tokens || 0) + (s.total_output_tokens || 0) - + (s.total_cache_read_tokens || 0) + (s.total_cache_creation_tokens || 0); - hsChip(sum, 'tokens', hsFmtTokens(totalTok)); - hsChip(sum, 'input', hsFmtTokens(s.total_input_tokens)); - hsChip(sum, 'output', hsFmtTokens(s.total_output_tokens)); - hsChip(sum, 'cache read', hsFmtTokens(s.total_cache_read_tokens)); - hsChip(sum, 'est cost', hsFmtUsd(s.est_cost_usd), true); - } - - const at = $('hive-stats-agents'); - if (at) { - const agents = s.agents || []; - if (!agents.length) { - hsMeta(at, 'no turns in window'); - } else { - at.replaceChildren(); - const table = document.createElement('table'); - table.className = 'hive-stats-table'; - table.innerHTML = 'agentturnsinput' - + 'outputcache readest cost'; - const tb = document.createElement('tbody'); - for (const a of agents) { - const tr = document.createElement('tr'); - const cells = [ - a.name, hsFmtInt(a.turns), hsFmtTokens(a.input_tokens), - hsFmtTokens(a.output_tokens), hsFmtTokens(a.cache_read_tokens), - hsFmtUsd(a.est_cost_usd), - ]; - cells.forEach((txt, i) => { - const td = document.createElement('td'); - if (i > 0) td.className = 'num'; - td.textContent = txt; - tr.append(td); - }); - tb.append(tr); - } - table.append(tb); - at.append(table); - } - } - - const mm = $('hive-stats-models'); - if (mm) { - const mix = s.model_mix || []; - if (!mix.length) { - hsMeta(mm, 'no turns in window'); - } else { - mm.replaceChildren(); - const max = mix[0].count || 1; - for (const kc of mix) { - const row = document.createElement('div'); row.className = 'hive-stats-bar'; - const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key; - const track = document.createElement('span'); track.className = 'track'; - const fill = document.createElement('span'); fill.className = 'fill'; - fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%'; - track.append(fill); - const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count); - row.append(lbl, track, cnt); - mm.append(row); - } - } - } - - // "favorite tools": most-run bash commands across the swarm. Hidden - // (header + list) until the capture has recorded data, so the - // section never shows an empty block while capture is pre-data. - const bh = $('hive-stats-bash'); - const bhH = $('hive-stats-bash-h'); - if (bh) { - const bmix = s.bash_mix || []; - if (!bmix.length) { - bh.replaceChildren(); - bh.hidden = true; - if (bhH) bhH.hidden = true; - } else { - bh.hidden = false; - if (bhH) bhH.hidden = false; - bh.replaceChildren(); - const max = bmix[0].count || 1; - for (const kc of bmix) { - const row = document.createElement('div'); row.className = 'hive-stats-bar'; - const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key; - const track = document.createElement('span'); track.className = 'track'; - const fill = document.createElement('span'); fill.className = 'fill'; - fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%'; - track.append(fill); - const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count); - row.append(lbl, track, cnt); - bh.append(row); - } - } - } - } - - async function refreshHiveStats() { - try { - const resp = await fetch('/api/stats-hive?window=' + encodeURIComponent(hiveStatsWindow)); - if (!resp.ok) throw new Error('http ' + resp.status); - renderHiveStats(await resp.json()); - } catch (e) { - const at = $('hive-stats-agents'); - if (at) hsMeta(at, 'stats fetch failed: ' + e); - } - } - - function bindHiveStatsWindows() { - const tabs = $('hive-stats-windows'); - if (!tabs) return; - tabs.addEventListener('click', (ev) => { - const btn = ev.target.closest('button[data-w]'); - if (!btn) return; - hiveStatsWindow = btn.dataset.w; - for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn); - refreshHiveStats(); - }); - } - bindHiveStatsWindows(); + // ST4TS (hive-wide turn-stats rollup) moved to its own page, + // `/stats.html` — the render JS + the window selector live in + // stats.js now. The dashboard no longer fetches + // /api/stats-hive. // ─── SYST3M › C0NT41N3R L04D: live per-container cgroup cpu/mem ──────────── // Pull-only, polled at 5s ONLY while the SYST3M tab is active (cpu is a diff --git a/nix/frontend.nix b/nix/frontend.nix index dcaf687a..87d43200 100644 --- a/nix/frontend.nix +++ b/nix/frontend.nix @@ -17,9 +17,9 @@ # frontend/packages/dashboard/build.mjs): # index.html (H0M3 hub, served at /) dashboard.html (operator SPA, # served at /dashboard.html) flow.html logs.html settings.html -# favicon.svg -# static/{home,tabs,flow,logs,settings,stream-worker}.js{,.map} -# static/{colors,theme,common,home,dashboard,flow,logs,settings}.css +# stats.html favicon.svg +# static/{home,tabs,flow,logs,settings,stats,stream-worker}.js{,.map} +# static/{colors,theme,common,home,dashboard,flow,logs,settings,stats}.css # $out/agent/ the per-agent default UI (layered with # hyperhive.frontend.extraFiles at activation time) # index.html stats.html screen.html From 7ade5f27ea5b5846aaa66a694a6002844c3f37e3 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 22:25:10 +0200 Subject: [PATCH 03/14] refactor(#1456): extract dashboard permission endpoints into dashboard/permissions.rs --- hive-c0re/src/dashboard.rs | 149 ++----------------------- hive-c0re/src/dashboard/permissions.rs | 148 ++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 137 deletions(-) create mode 100644 hive-c0re/src/dashboard/permissions.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 0a243645..0bf62c77 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -30,6 +30,8 @@ use crate::container_view::{ContainerView, claude_has_session}; use crate::coordinator::Coordinator; use crate::lifecycle::{self, MANAGER_NAME}; +mod permissions; + #[derive(Clone)] struct AppState { coord: Arc, @@ -81,10 +83,16 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/request-spawn", post(post_request_spawn)) .route("/api/topology/set-parent", post(post_set_parent)) .route("/api/topology/set-parent-bulk", post(post_set_parent_bulk)) - .route("/api/tool-groups", get(get_tool_groups)) - .route("/api/tool-groups/{agent}", post(post_tool_groups)) - .route("/api/capabilities", get(get_capabilities)) - .route("/api/capabilities/{agent}", post(post_capabilities)) + .route("/api/tool-groups", get(permissions::get_tool_groups)) + .route( + "/api/tool-groups/{agent}", + post(permissions::post_tool_groups), + ) + .route("/api/capabilities", get(permissions::get_capabilities)) + .route( + "/api/capabilities/{agent}", + post(permissions::post_capabilities), + ) .route("/op-send", post(post_op_send)) .route("/meta-update", post(post_meta_update)) .route("/api/schedules", get(api_schedules).post(post_schedule_new)) @@ -2586,139 +2594,6 @@ async fn post_set_parent_bulk( } } -// ── tool-group endpoints ────────────────────────────────── - -#[derive(Serialize)] -struct ToolGroupsSnapshot { - /// Ordered list of all known tool-group names. Drives the column - /// headers in the capabilities table — the UI does not hard-code them. - groups: Vec<&'static str>, - /// Short description for each group name. Keys match `groups` entries. - descriptions: std::collections::BTreeMap<&'static str, &'static str>, - /// Per-agent assignment map. Absent agents use the role default - /// (agents: messaging+meta+inbox+execution; manager: all groups). - assignments: std::collections::BTreeMap>, -} - -async fn get_tool_groups(State(_state): State) -> axum::Json { - let groups = hive_sh4re::ToolGroup::ALL - .iter() - .map(|g| g.as_str()) - .collect(); - let descriptions = hive_sh4re::ToolGroup::ALL - .iter() - .map(|g| (g.as_str(), g.description())) - .collect(); - let assignments = crate::tool_groups::read(); - axum::Json(ToolGroupsSnapshot { - groups, - descriptions, - assignments, - }) -} - -#[derive(Deserialize)] -struct SetToolGroupsBody { - groups: Vec, -} - -async fn post_tool_groups( - State(state): State, - AxumPath(name): AxumPath, - axum::Json(body): axum::Json, -) -> Response { - let logical = strip_container_prefix(&name); - if let Some(reject) = guard_agent_name(&state, &logical).await { - return reject; - } - // Validate group names before queuing — fail fast so the operator - // sees the error immediately rather than waiting for the worker. - if let Err(e) = crate::tool_groups::validate_groups(&body.groups) { - return error_response(&format!("invalid tool-groups for {logical}: {e}")); - } - // Enqueue a PermChange so the JSON file write is serialised through - // the FIFO worker. Prevents concurrent batch-apply actions for - // different agents from racing on the shared tool-groups.json. - state.coord.rebuild_queue.enqueue_with_perm( - logical.clone(), - crate::rebuild_queue::QueueSource::Manual, - "tool-group change via permissions UI".to_owned(), - crate::rebuild_queue::PermPayload::ToolGroups { - groups: body.groups.clone(), - }, - ); - state.coord.emit_rebuild_queue_snapshot(); - tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard"); - (StatusCode::OK, "ok").into_response() -} - -// ── capability endpoints ────────────────────────────────────────────────── - -#[derive(Serialize)] -struct CapabilitiesSnapshot { - /// Ordered list of all known capability names. Drives the column - /// headers in the capabilities table — the UI does not hard-code them. - caps: Vec<&'static str>, - /// Short description for each capability name. Keys match `caps` entries. - descriptions: std::collections::BTreeMap<&'static str, &'static str>, - /// Per-agent capability grant map. Absent agents have no extra caps. - assignments: std::collections::BTreeMap>, -} - -async fn get_capabilities(State(_state): State) -> axum::Json { - use hive_sh4re::Capability; - let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); - let descriptions = Capability::ALL - .iter() - .map(|c| (c.as_str(), c.description())) - .collect(); - let assignments = crate::capabilities::read(); - axum::Json(CapabilitiesSnapshot { - caps, - descriptions, - assignments, - }) -} - -#[derive(Deserialize)] -struct SetCapabilitiesBody { - caps: Vec, -} - -async fn post_capabilities( - State(state): State, - AxumPath(name): AxumPath, - axum::Json(body): axum::Json, -) -> Response { - let logical = strip_container_prefix(&name); - if let Some(reject) = guard_agent_name(&state, &logical).await { - return reject; - } - let known: Vec<&str> = hive_sh4re::Capability::ALL - .iter() - .map(|c| c.as_str()) - .collect(); - for cap in &body.caps { - if !known.contains(&cap.as_str()) { - return error_response(&format!("unknown capability: {cap}")); - } - } - // Enqueue a PermChange so the JSON file write is serialised through - // the FIFO worker. Prevents concurrent batch-apply actions for - // different agents from racing on the shared capabilities.json. - state.coord.rebuild_queue.enqueue_with_perm( - logical.clone(), - crate::rebuild_queue::QueueSource::Manual, - "capability change via dashboard".to_owned(), - crate::rebuild_queue::PermPayload::Capabilities { - caps: body.caps.clone(), - }, - ); - state.coord.emit_rebuild_queue_snapshot(); - tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard"); - (StatusCode::OK, "ok").into_response() -} - async fn post_rebuild(State(state): State, AxumPath(name): AxumPath) -> Response { let logical = strip_container_prefix(&name); if let Some(reject) = guard_agent_name(&state, &logical).await { diff --git a/hive-c0re/src/dashboard/permissions.rs b/hive-c0re/src/dashboard/permissions.rs new file mode 100644 index 00000000..b2923878 --- /dev/null +++ b/hive-c0re/src/dashboard/permissions.rs @@ -0,0 +1,148 @@ +//! Tool-group + capability permission endpoints for the dashboard. +//! +//! Read endpoints return the full set of known groups/capabilities plus +//! descriptions and the per-agent assignment map (the UI never hard-codes +//! the lists). Write endpoints validate, then enqueue a `PermChange` so the +//! JSON file write is serialised through the FIFO rebuild worker. + +use axum::{ + extract::{Path as AxumPath, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; + +use super::{AppState, error_response, guard_agent_name, strip_container_prefix}; + +#[derive(Serialize)] +pub(super) struct ToolGroupsSnapshot { + /// Ordered list of all known tool-group names. Drives the column + /// headers in the capabilities table — the UI does not hard-code them. + groups: Vec<&'static str>, + /// Short description for each group name. Keys match `groups` entries. + descriptions: std::collections::BTreeMap<&'static str, &'static str>, + /// Per-agent assignment map. Absent agents use the role default + /// (agents: messaging+meta+inbox+execution; manager: all groups). + assignments: std::collections::BTreeMap>, +} + +pub(super) async fn get_tool_groups( + State(_state): State, +) -> axum::Json { + let groups = hive_sh4re::ToolGroup::ALL + .iter() + .map(|g| g.as_str()) + .collect(); + let descriptions = hive_sh4re::ToolGroup::ALL + .iter() + .map(|g| (g.as_str(), g.description())) + .collect(); + let assignments = crate::tool_groups::read(); + axum::Json(ToolGroupsSnapshot { + groups, + descriptions, + assignments, + }) +} + +#[derive(Deserialize)] +pub(super) struct SetToolGroupsBody { + groups: Vec, +} + +pub(super) async fn post_tool_groups( + State(state): State, + AxumPath(name): AxumPath, + axum::Json(body): axum::Json, +) -> Response { + let logical = strip_container_prefix(&name); + if let Some(reject) = guard_agent_name(&state, &logical).await { + return reject; + } + // Validate group names before queuing — fail fast so the operator + // sees the error immediately rather than waiting for the worker. + if let Err(e) = crate::tool_groups::validate_groups(&body.groups) { + return error_response(&format!("invalid tool-groups for {logical}: {e}")); + } + // Enqueue a PermChange so the JSON file write is serialised through + // the FIFO worker. Prevents concurrent batch-apply actions for + // different agents from racing on the shared tool-groups.json. + state.coord.rebuild_queue.enqueue_with_perm( + logical.clone(), + crate::rebuild_queue::QueueSource::Manual, + "tool-group change via permissions UI".to_owned(), + crate::rebuild_queue::PermPayload::ToolGroups { + groups: body.groups.clone(), + }, + ); + state.coord.emit_rebuild_queue_snapshot(); + tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard"); + (StatusCode::OK, "ok").into_response() +} + +#[derive(Serialize)] +pub(super) struct CapabilitiesSnapshot { + /// Ordered list of all known capability names. Drives the column + /// headers in the capabilities table — the UI does not hard-code them. + caps: Vec<&'static str>, + /// Short description for each capability name. Keys match `caps` entries. + descriptions: std::collections::BTreeMap<&'static str, &'static str>, + /// Per-agent capability grant map. Absent agents have no extra caps. + assignments: std::collections::BTreeMap>, +} + +pub(super) async fn get_capabilities( + State(_state): State, +) -> axum::Json { + use hive_sh4re::Capability; + let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); + let descriptions = Capability::ALL + .iter() + .map(|c| (c.as_str(), c.description())) + .collect(); + let assignments = crate::capabilities::read(); + axum::Json(CapabilitiesSnapshot { + caps, + descriptions, + assignments, + }) +} + +#[derive(Deserialize)] +pub(super) struct SetCapabilitiesBody { + caps: Vec, +} + +pub(super) async fn post_capabilities( + State(state): State, + AxumPath(name): AxumPath, + axum::Json(body): axum::Json, +) -> Response { + let logical = strip_container_prefix(&name); + if let Some(reject) = guard_agent_name(&state, &logical).await { + return reject; + } + let known: Vec<&str> = hive_sh4re::Capability::ALL + .iter() + .map(|c| c.as_str()) + .collect(); + for cap in &body.caps { + if !known.contains(&cap.as_str()) { + return error_response(&format!("unknown capability: {cap}")); + } + } + // Enqueue a PermChange so the JSON file write is serialised through + // the FIFO worker. Prevents concurrent batch-apply actions for + // different agents from racing on the shared capabilities.json. + state.coord.rebuild_queue.enqueue_with_perm( + logical.clone(), + crate::rebuild_queue::QueueSource::Manual, + "capability change via dashboard".to_owned(), + crate::rebuild_queue::PermPayload::Capabilities { + caps: body.caps.clone(), + }, + ); + state.coord.emit_rebuild_queue_snapshot(); + tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard"); + (StatusCode::OK, "ok").into_response() +} From 302738362a5b78400015eec0f6642c5d6fa2a668 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 22:34:52 +0200 Subject: [PATCH 04/14] refactor(#1456): extract dashboard schedule + rebuild-queue endpoints into dashboard/schedules.rs --- hive-c0re/src/dashboard.rs | 236 ++------------------------- hive-c0re/src/dashboard/schedules.rs | 227 ++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 218 deletions(-) create mode 100644 hive-c0re/src/dashboard/schedules.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 0bf62c77..76eb89ef 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -31,6 +31,7 @@ use crate::coordinator::Coordinator; use crate::lifecycle::{self, MANAGER_NAME}; mod permissions; +mod schedules; #[derive(Clone)] struct AppState { @@ -95,13 +96,25 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { ) .route("/op-send", post(post_op_send)) .route("/meta-update", post(post_meta_update)) - .route("/api/schedules", get(api_schedules).post(post_schedule_new)) - .route("/api/schedules/{id}", axum::routing::patch(patch_schedule)) - .route("/api/schedules/{id}/cancel", post(post_schedule_cancel)) - .route("/api/schedules/{id}/fire-now", post(post_schedule_fire_now)) + .route( + "/api/schedules", + get(schedules::api_schedules).post(schedules::post_schedule_new), + ) + .route( + "/api/schedules/{id}", + axum::routing::patch(schedules::patch_schedule), + ) + .route( + "/api/schedules/{id}/cancel", + post(schedules::post_schedule_cancel), + ) + .route( + "/api/schedules/{id}/fire-now", + post(schedules::post_schedule_fire_now), + ) .route( "/api/rebuild-queue/{id}/cancel", - post(post_rebuild_queue_cancel), + post(schedules::post_rebuild_queue_cancel), ) .route("/dashboard/stream", get(dashboard_stream)) .route("/dashboard/history", get(dashboard_history)) @@ -2008,219 +2021,6 @@ async fn get_build_log_raw(State(state): State, AxumPath(id): AxumPath } } -/// `GET /api/schedules` — snapshot of every schedule for the -/// scheduled-prompts tab. Returns the wire shape directly -/// so the frontend can render without an extra translation layer. -async fn api_schedules(State(state): State) -> Response { - match state.coord.scheduled_prompts.list() { - Ok(rows) => axum::Json( - rows.into_iter() - .map(crate::manager_server::schedule_to_wire_public) - .collect::>(), - ) - .into_response(), - Err(e) => error_response(&format!("scheduled_prompts list: {e:#}")), - } -} - -/// `POST /api/schedules` — operator-direct schedule creation -/// (mara: "user can add them manually"). Accepts the same -/// `SchedulePromptPayload` shape as the manager request flow but -/// skips the approval gate — the operator click *is* the -/// approval. The schedule lands directly with -/// `source = Operator` and the worker picks it up at fire time. -async fn post_schedule_new( - State(state): State, - axum::Json(payload): axum::Json, -) -> Response { - if payload.targets.is_empty() { - return error_response("schedule must have at least one target"); - } - if payload.body.trim().is_empty() { - return error_response("schedule body must be non-empty"); - } - if let Some(0) = payload.interval_seconds { - return error_response("interval_seconds must be > 0 (use None for one-shot)"); - } - let new = crate::scheduled_prompts::NewSchedule { - owner: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), - targets: payload.targets, - body: payload.body, - first_fire_at_unix: payload.first_fire_at_unix, - interval_seconds: payload.interval_seconds, - description: payload.description, - source: crate::scheduled_prompts::ScheduleSource::Operator, - }; - match state.coord.scheduled_prompts.submit(&new) { - Ok(id) => { - state.coord.emit_schedules_snapshot(); - axum::Json(serde_json::json!({"id": id})).into_response() - } - Err(e) => error_response(&format!("schedule submit: {e:#}")), - } -} - -/// `POST /api/schedules/{id}/fire-now` — operator-initiated -/// out-of-band fire of a scheduled prompt. Runs the -/// per-target fan-out once immediately and reports per-target -/// outcome counts. Does NOT touch `next_fire_at_unix` on -/// recurring schedules (their cadence stays intact); one-shot -/// schedules are consumed (cancelled) by a manual fire — the -/// operator's intent is "send this now, the scheduled time was -/// wrong." -async fn post_schedule_fire_now( - State(state): State, - AxumPath(id): AxumPath, -) -> Response { - match crate::scheduled_prompts_worker::fire_now(&state.coord, id).await { - Ok(report) => { - state.coord.emit_schedules_snapshot(); - axum::Json(report).into_response() - } - Err(e) => error_response(&format!("fire schedule {id} now: {e:#}")), - } -} - -/// `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry -/// from the rebuild queue. Refuses `Running` / terminal -/// entries: an in-flight rebuild owns the agent's nix store + -/// nixos-container update lock and can't be safely interrupted -/// from the queue side. Always returns 200; the body is -/// `{"cancelled": true}` on a successful flip from Queued → -/// Cancelled, `{"cancelled": false}` when the row was Running / -/// terminal / gone. On success a fresh `RebuildQueueChanged` -/// snapshot fires so the row's state flip surfaces live. -async fn post_rebuild_queue_cancel( - State(state): State, - AxumPath(id): AxumPath, -) -> Response { - let cancelled = state.coord.rebuild_queue.cancel(id); - if cancelled { - state.coord.emit_rebuild_queue_snapshot(); - axum::Json(serde_json::json!({"cancelled": true})).into_response() - } else { - axum::Json(serde_json::json!({"cancelled": false})).into_response() - } -} - -#[derive(serde::Deserialize, Default)] -struct CancelScheduleForm { - /// `None` / absent / empty array → cancel whole schedule. - #[serde(default)] - targets: Option>, -} - -#[derive(serde::Deserialize, Default)] -#[allow( - clippy::option_option, - reason = "double-Option carries three-state PATCH semantics on the wire \ - (missing key = leave alone, JSON null = clear, value = set); \ - collapsing to a single Option would lose the 'clear' state" -)] -struct EditScheduleForm { - #[serde(default)] - body: Option, - /// Double-`Option` semantics on the wire: missing key = leave - /// alone, explicit `null` = clear, value = set. serde's - /// `deserialize_with` trick to distinguish missing from null: - /// we wrap each editable field in its own helper. Simpler - /// here — keep them plain `Option>` and document - /// that the dashboard caller passes JSON `null` to clear. - #[serde(default, deserialize_with = "deserialize_some")] - description: Option>, - #[serde(default, deserialize_with = "deserialize_some")] - interval_seconds: Option>, - #[serde(default)] - next_fire_at_unix: Option, - /// New targets to add. Replace-on-conflict: re-adding a - /// previously-cancelled target drops the tombstone and the - /// target starts fresh (operator intent on re-add = "this - /// target is active again, fresh start"). - #[serde(default)] - targets_add: Option>, - /// Targets to cancel. Same path as `cancel_targets`: - /// tombstones preserve audit and the parent schedule - /// auto-cancels when no active targets remain. - #[serde(default)] - targets_remove: Option>, -} - -/// serde adaptor: turns missing-key into `None`, explicit-null -/// into `Some(None)`, value into `Some(Some(v))`. Standard trick -/// for distinguishing "field absent" from "field set to null" in -/// JSON PATCH bodies. -fn deserialize_some<'de, T, D>(deserializer: D) -> Result, D::Error> -where - T: serde::Deserialize<'de>, - D: serde::Deserializer<'de>, -{ - T::deserialize(deserializer).map(Some) -} - -/// `PATCH /api/schedules/{id}` — partial update of an existing -/// schedule. Mutable fields: `body`, `description`, -/// `interval_seconds`, `next_fire_at_unix`, plus the target set -/// via `targets_add` / `targets_remove`. Both target lists -/// are applied in the same transaction as the scalar fields with -/// removes-before-adds; re-adding a previously-removed target -/// resets per-target history (fresh start); draining all targets -/// auto-cancels the parent schedule. JSON body uses missing-key -/// = "leave alone", explicit null = "clear" for `description` + -/// `interval_seconds`. Cancelled schedules are refused — submit -/// a new one instead. Returns the updated `WireSchedule` so the -/// caller's post-edit refresh has the new state inline. -async fn patch_schedule( - State(state): State, - AxumPath(id): AxumPath, - axum::Json(form): axum::Json, -) -> Response { - let patch = crate::scheduled_prompts::UpdateSchedule { - body: form.body, - description: form.description, - interval_seconds: form.interval_seconds, - next_fire_at_unix: form.next_fire_at_unix, - targets_add: form.targets_add, - targets_remove: form.targets_remove, - }; - if let Err(e) = state.coord.scheduled_prompts.update(id, patch) { - return error_response(&format!("edit schedule {id}: {e:#}")); - } - match state.coord.scheduled_prompts.get(id) { - Ok(Some(s)) => { - let wire = crate::manager_server::schedule_to_wire_public(s); - state.coord.emit_schedules_snapshot(); - axum::Json(wire).into_response() - } - Ok(None) => error_response(&format!("edit schedule {id}: row vanished post-update")), - Err(e) => error_response(&format!("re-read schedule {id}: {e:#}")), - } -} - -/// `POST /api/schedules/{id}/cancel` — operator-side cancel -/// (whole schedule when no `targets` field, partial when one is -/// provided). Operator bypasses the topology check; the manager -/// surface enforces it for agent callers. -async fn post_schedule_cancel( - State(state): State, - AxumPath(id): AxumPath, - body: Option>, -) -> Response { - let targets = body - .and_then(|axum::Json(b)| b.targets) - .filter(|t| !t.is_empty()); - let result = match targets.as_deref() { - Some(list) => state.coord.scheduled_prompts.cancel_targets(id, list), - None => state.coord.scheduled_prompts.cancel_all(id), - }; - match result { - Ok(()) => { - state.coord.emit_schedules_snapshot(); - (StatusCode::OK, "ok").into_response() - } - Err(e) => error_response(&format!("cancel schedule {id}: {e:#}")), - } -} - async fn post_cancel_reminder( State(state): State, AxumPath(id): AxumPath, diff --git a/hive-c0re/src/dashboard/schedules.rs b/hive-c0re/src/dashboard/schedules.rs new file mode 100644 index 00000000..7415f808 --- /dev/null +++ b/hive-c0re/src/dashboard/schedules.rs @@ -0,0 +1,227 @@ +//! Scheduled-prompt + rebuild-queue endpoints for the dashboard. +//! +//! Operator-direct schedule CRUD (`/api/schedules` GET/POST, `{id}` PATCH, +//! `{id}/cancel` + `{id}/fire-now`) — the operator click *is* the approval, +//! so these skip the manager approval gate. Also the co-located +//! `/api/rebuild-queue/{id}/cancel` endpoint. + +use axum::{ + extract::{Path as AxumPath, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use super::{AppState, error_response}; + +/// `GET /api/schedules` — snapshot of every schedule for the +/// scheduled-prompts tab. Returns the wire shape directly +/// so the frontend can render without an extra translation layer. +pub(super) async fn api_schedules(State(state): State) -> Response { + match state.coord.scheduled_prompts.list() { + Ok(rows) => axum::Json( + rows.into_iter() + .map(crate::manager_server::schedule_to_wire_public) + .collect::>(), + ) + .into_response(), + Err(e) => error_response(&format!("scheduled_prompts list: {e:#}")), + } +} + +/// `POST /api/schedules` — operator-direct schedule creation +/// (mara: "user can add them manually"). Accepts the same +/// `SchedulePromptPayload` shape as the manager request flow but +/// skips the approval gate — the operator click *is* the +/// approval. The schedule lands directly with +/// `source = Operator` and the worker picks it up at fire time. +pub(super) async fn post_schedule_new( + State(state): State, + axum::Json(payload): axum::Json, +) -> Response { + if payload.targets.is_empty() { + return error_response("schedule must have at least one target"); + } + if payload.body.trim().is_empty() { + return error_response("schedule body must be non-empty"); + } + if let Some(0) = payload.interval_seconds { + return error_response("interval_seconds must be > 0 (use None for one-shot)"); + } + let new = crate::scheduled_prompts::NewSchedule { + owner: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), + targets: payload.targets, + body: payload.body, + first_fire_at_unix: payload.first_fire_at_unix, + interval_seconds: payload.interval_seconds, + description: payload.description, + source: crate::scheduled_prompts::ScheduleSource::Operator, + }; + match state.coord.scheduled_prompts.submit(&new) { + Ok(id) => { + state.coord.emit_schedules_snapshot(); + axum::Json(serde_json::json!({"id": id})).into_response() + } + Err(e) => error_response(&format!("schedule submit: {e:#}")), + } +} + +/// `POST /api/schedules/{id}/fire-now` — operator-initiated +/// out-of-band fire of a scheduled prompt. Runs the +/// per-target fan-out once immediately and reports per-target +/// outcome counts. Does NOT touch `next_fire_at_unix` on +/// recurring schedules (their cadence stays intact); one-shot +/// schedules are consumed (cancelled) by a manual fire — the +/// operator's intent is "send this now, the scheduled time was +/// wrong." +pub(super) async fn post_schedule_fire_now( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match crate::scheduled_prompts_worker::fire_now(&state.coord, id).await { + Ok(report) => { + state.coord.emit_schedules_snapshot(); + axum::Json(report).into_response() + } + Err(e) => error_response(&format!("fire schedule {id} now: {e:#}")), + } +} + +/// `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry +/// from the rebuild queue. Refuses `Running` / terminal +/// entries: an in-flight rebuild owns the agent's nix store + +/// nixos-container update lock and can't be safely interrupted +/// from the queue side. Always returns 200; the body is +/// `{"cancelled": true}` on a successful flip from Queued → +/// Cancelled, `{"cancelled": false}` when the row was Running / +/// terminal / gone. On success a fresh `RebuildQueueChanged` +/// snapshot fires so the row's state flip surfaces live. +pub(super) async fn post_rebuild_queue_cancel( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + let cancelled = state.coord.rebuild_queue.cancel(id); + if cancelled { + state.coord.emit_rebuild_queue_snapshot(); + axum::Json(serde_json::json!({"cancelled": true})).into_response() + } else { + axum::Json(serde_json::json!({"cancelled": false})).into_response() + } +} + +#[derive(serde::Deserialize, Default)] +pub(super) struct CancelScheduleForm { + /// `None` / absent / empty array → cancel whole schedule. + #[serde(default)] + targets: Option>, +} + +#[derive(serde::Deserialize, Default)] +#[allow( + clippy::option_option, + reason = "double-Option carries three-state PATCH semantics on the wire \ + (missing key = leave alone, JSON null = clear, value = set); \ + collapsing to a single Option would lose the 'clear' state" +)] +pub(super) struct EditScheduleForm { + #[serde(default)] + body: Option, + /// Double-`Option` semantics on the wire: missing key = leave + /// alone, explicit `null` = clear, value = set. serde's + /// `deserialize_with` trick to distinguish missing from null: + /// we wrap each editable field in its own helper. Simpler + /// here — keep them plain `Option>` and document + /// that the dashboard caller passes JSON `null` to clear. + #[serde(default, deserialize_with = "deserialize_some")] + description: Option>, + #[serde(default, deserialize_with = "deserialize_some")] + interval_seconds: Option>, + #[serde(default)] + next_fire_at_unix: Option, + /// New targets to add. Replace-on-conflict: re-adding a + /// previously-cancelled target drops the tombstone and the + /// target starts fresh (operator intent on re-add = "this + /// target is active again, fresh start"). + #[serde(default)] + targets_add: Option>, + /// Targets to cancel. Same path as `cancel_targets`: + /// tombstones preserve audit and the parent schedule + /// auto-cancels when no active targets remain. + #[serde(default)] + targets_remove: Option>, +} + +/// serde adaptor: turns missing-key into `None`, explicit-null +/// into `Some(None)`, value into `Some(Some(v))`. Standard trick +/// for distinguishing "field absent" from "field set to null" in +/// JSON PATCH bodies. +fn deserialize_some<'de, T, D>(deserializer: D) -> Result, D::Error> +where + T: serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + T::deserialize(deserializer).map(Some) +} + +/// `PATCH /api/schedules/{id}` — partial update of an existing +/// schedule. Mutable fields: `body`, `description`, +/// `interval_seconds`, `next_fire_at_unix`, plus the target set +/// via `targets_add` / `targets_remove`. Both target lists +/// are applied in the same transaction as the scalar fields with +/// removes-before-adds; re-adding a previously-removed target +/// resets per-target history (fresh start); draining all targets +/// auto-cancels the parent schedule. JSON body uses missing-key +/// = "leave alone", explicit null = "clear" for `description` + +/// `interval_seconds`. Cancelled schedules are refused — submit +/// a new one instead. Returns the updated `WireSchedule` so the +/// caller's post-edit refresh has the new state inline. +pub(super) async fn patch_schedule( + State(state): State, + AxumPath(id): AxumPath, + axum::Json(form): axum::Json, +) -> Response { + let patch = crate::scheduled_prompts::UpdateSchedule { + body: form.body, + description: form.description, + interval_seconds: form.interval_seconds, + next_fire_at_unix: form.next_fire_at_unix, + targets_add: form.targets_add, + targets_remove: form.targets_remove, + }; + if let Err(e) = state.coord.scheduled_prompts.update(id, patch) { + return error_response(&format!("edit schedule {id}: {e:#}")); + } + match state.coord.scheduled_prompts.get(id) { + Ok(Some(s)) => { + let wire = crate::manager_server::schedule_to_wire_public(s); + state.coord.emit_schedules_snapshot(); + axum::Json(wire).into_response() + } + Ok(None) => error_response(&format!("edit schedule {id}: row vanished post-update")), + Err(e) => error_response(&format!("re-read schedule {id}: {e:#}")), + } +} + +/// `POST /api/schedules/{id}/cancel` — operator-side cancel +/// (whole schedule when no `targets` field, partial when one is +/// provided). Operator bypasses the topology check; the manager +/// surface enforces it for agent callers. +pub(super) async fn post_schedule_cancel( + State(state): State, + AxumPath(id): AxumPath, + body: Option>, +) -> Response { + let targets = body + .and_then(|axum::Json(b)| b.targets) + .filter(|t| !t.is_empty()); + let result = match targets.as_deref() { + Some(list) => state.coord.scheduled_prompts.cancel_targets(id, list), + None => state.coord.scheduled_prompts.cancel_all(id), + }; + match result { + Ok(()) => { + state.coord.emit_schedules_snapshot(); + (StatusCode::OK, "ok").into_response() + } + Err(e) => error_response(&format!("cancel schedule {id}: {e:#}")), + } +} From 1255268f4f6029a03c6c4bec057372ac9ac7b4a0 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 22:36:47 +0200 Subject: [PATCH 05/14] refactor(#1456): extract knowledge push-webhook endpoint into dashboard/webhook.rs --- hive-c0re/src/dashboard.rs | 56 +------------------------- hive-c0re/src/dashboard/webhook.rs | 64 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 54 deletions(-) create mode 100644 hive-c0re/src/dashboard/webhook.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 76eb89ef..6f0e050d 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -32,6 +32,7 @@ use crate::lifecycle::{self, MANAGER_NAME}; mod permissions; mod schedules; +mod webhook; #[derive(Clone)] struct AppState { @@ -118,7 +119,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { ) .route("/dashboard/stream", get(dashboard_stream)) .route("/dashboard/history", get(dashboard_history)) - .route("/webhook/knowledge", post(post_webhook_knowledge)) + .route("/webhook/knowledge", post(webhook::post_webhook_knowledge)) // Anything not matched by the dynamic routes above falls // through to the bundled dashboard dist (GET / → // dist/index.html, /favicon.svg → dist/favicon.svg, @@ -2755,56 +2756,3 @@ async fn get_approval_diff( fn plain_text(body: String) -> Response { (StatusCode::OK, body).into_response() } - -/// Minimal Forgejo push-webhook payload — only the fields we care about. -#[derive(Deserialize)] -struct PushWebhookPayload { - #[serde(rename = "ref")] - git_ref: Option, - repository: Option, -} - -#[derive(Deserialize)] -struct PushWebhookRepo { - full_name: Option, -} - -/// POST `/webhook/knowledge` — Forgejo push webhook for -/// `internal/knowledge`. Runs `git pull` on the local clone so -/// agents see up-to-date documents on their next turn. -/// -/// Expected Forgejo webhook configuration: -/// - URL: `http://127.0.0.1:/webhook/knowledge` -/// - Event: "Push" (fires on merge commits to main as well) -/// -/// No signature verification for now; the endpoint is loopback-only -/// and only triggers a read-only `git pull` on an operator-curated repo. -async fn post_webhook_knowledge( - axum::extract::Json(payload): axum::extract::Json, -) -> Response { - let expected_repo = format!("{}/{}", crate::knowledge::ORG, crate::knowledge::REPO); - let full_name = payload - .repository - .as_ref() - .and_then(|r| r.full_name.as_deref()) - .unwrap_or(""); - if full_name != expected_repo { - tracing::debug!( - full_name, - "webhook/knowledge: ignoring push from unexpected repo" - ); - return (StatusCode::OK, "ignored").into_response(); - } - let git_ref = payload.git_ref.as_deref().unwrap_or(""); - if git_ref != "refs/heads/main" { - tracing::debug!(git_ref, "webhook/knowledge: ignoring non-main push"); - return (StatusCode::OK, "ignored").into_response(); - } - tracing::info!("webhook/knowledge: pull triggered by push to {expected_repo}"); - tokio::spawn(async { - if let Err(e) = crate::knowledge::pull().await { - tracing::warn!(error = ?e, "webhook/knowledge: pull failed"); - } - }); - (StatusCode::OK, "ok").into_response() -} diff --git a/hive-c0re/src/dashboard/webhook.rs b/hive-c0re/src/dashboard/webhook.rs new file mode 100644 index 00000000..a10a4328 --- /dev/null +++ b/hive-c0re/src/dashboard/webhook.rs @@ -0,0 +1,64 @@ +//! Forgejo push-webhook endpoint for the `internal/knowledge` repo. +//! +//! Loopback-only; on a push to `main` of the knowledge repo it triggers a +//! read-only `git pull` on the local clone so agents see up-to-date +//! documents on their next turn. + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +/// Minimal Forgejo push-webhook payload — only the fields we care about. +#[derive(Deserialize)] +pub(super) struct PushWebhookPayload { + #[serde(rename = "ref")] + git_ref: Option, + repository: Option, +} + +#[derive(Deserialize)] +pub(super) struct PushWebhookRepo { + full_name: Option, +} + +/// POST `/webhook/knowledge` — Forgejo push webhook for +/// `internal/knowledge`. Runs `git pull` on the local clone so +/// agents see up-to-date documents on their next turn. +/// +/// Expected Forgejo webhook configuration: +/// - URL: `http://127.0.0.1:/webhook/knowledge` +/// - Event: "Push" (fires on merge commits to main as well) +/// +/// No signature verification for now; the endpoint is loopback-only +/// and only triggers a read-only `git pull` on an operator-curated repo. +pub(super) async fn post_webhook_knowledge( + axum::extract::Json(payload): axum::extract::Json, +) -> Response { + let expected_repo = format!("{}/{}", crate::knowledge::ORG, crate::knowledge::REPO); + let full_name = payload + .repository + .as_ref() + .and_then(|r| r.full_name.as_deref()) + .unwrap_or(""); + if full_name != expected_repo { + tracing::debug!( + full_name, + "webhook/knowledge: ignoring push from unexpected repo" + ); + return (StatusCode::OK, "ignored").into_response(); + } + let git_ref = payload.git_ref.as_deref().unwrap_or(""); + if git_ref != "refs/heads/main" { + tracing::debug!(git_ref, "webhook/knowledge: ignoring non-main push"); + return (StatusCode::OK, "ignored").into_response(); + } + tracing::info!("webhook/knowledge: pull triggered by push to {expected_repo}"); + tokio::spawn(async { + if let Err(e) = crate::knowledge::pull().await { + tracing::warn!(error = ?e, "webhook/knowledge: pull failed"); + } + }); + (StatusCode::OK, "ok").into_response() +} From e2fdaae841014b3346840ac6267796831810971e Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 22:46:01 +0200 Subject: [PATCH 06/14] refactor(#1456): extract dashboard journal-read endpoints into dashboard/journal.rs --- hive-c0re/src/dashboard.rs | 127 +------------------------- hive-c0re/src/dashboard/journal.rs | 139 +++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 124 deletions(-) create mode 100644 hive-c0re/src/dashboard/journal.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 6f0e050d..3d2ddd75 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -30,6 +30,7 @@ use crate::container_view::{ContainerView, claude_has_session}; use crate::coordinator::Coordinator; use crate::lifecycle::{self, MANAGER_NAME}; +mod journal; mod permissions; mod schedules; mod webhook; @@ -66,8 +67,8 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/answer-question/{id}", post(post_answer_question)) .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/journal-host", get(get_journal_host)) + .route("/api/journal/{name}", get(journal::get_journal)) + .route("/api/journal-host", get(journal::get_journal_host)) .route("/api/approval-diff/{id}", get(get_approval_diff)) .route("/api/state-file", get(get_state_file)) .route("/api/reminders", get(api_reminders)) @@ -1194,128 +1195,6 @@ async fn post_cancel_question( } } -#[derive(Deserialize)] -struct JournalQuery { - /// Optional systemd unit filter — e.g. `hive-ag3nt.service`. When - /// omitted, returns the full machine journal. - #[serde(default)] - unit: Option, - /// Number of trailing lines to return. Capped at 5000. - #[serde(default)] - lines: Option, -} - -/// Read `journalctl -M -b` and return its text output. -/// Operator-only by virtue of the dashboard being host-bound. hive-c0re -/// runs unprivileged (privsep), so the `-M` read — which enters the -/// container namespace and needs root — is delegated to hive-priv. -async fn get_journal( - AxumPath(name): AxumPath, - axum::extract::Query(q): axum::extract::Query, -) -> Response { - // Defense-in-depth format check so weird chars never reach the - // shellout below — the `lifecycle::list()` existence check would - // catch them anyway, but rejecting at the boundary keeps the - // failure mode crisp. - if let Some(reason) = validate_agent_name(&name) { - return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); - } - // Validate the container name against the list of managed - // containers so we don't shell out with arbitrary input. - let container = strip_container_prefix(&name); - let prefixed = format!("{}{container}", lifecycle::AGENT_PREFIX); - let live = lifecycle::list().await.unwrap_or_default(); - if !live.iter().any(|c| c == &prefixed) { - return error_response(&format!("journal: no managed container {prefixed:?}")); - } - let lines = q.lines.unwrap_or(500).min(5000); - let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) { - Some(u) => { - // accept hive-ag3nt[.service] — anything else refused. - let allowed = ["hive-ag3nt.service"]; - let unit = if u.ends_with(".service") { - u.to_owned() - } else { - format!("{u}.service") - }; - if !allowed.contains(&unit.as_str()) { - return error_response(&format!("journal: unknown unit {unit:?}")); - } - Some(unit) - } - None => None, - }; - match crate::priv_client::read_container_journal( - &prefixed, - hive_sh4re::priv_proto::JournalQuery { - lines, - boot: true, - output: hive_sh4re::priv_proto::JournalOutput::ShortIso, - unit, - ..Default::default() - }, - ) - .await - { - Ok((stdout, stderr)) => { - // Combine stdout + stderr — journalctl emits to both on errors. - let mut body = stdout; - if !stderr.is_empty() { - body.push_str("\n--- stderr ---\n"); - body.push_str(&stderr); - } - ([("content-type", "text/plain; charset=utf-8")], body).into_response() - } - Err(e) => error_response(&format!("journal read: {e:#}")), - } -} - -#[derive(Deserialize)] -struct JournalHostQuery { - /// Service unit name to filter to. If omitted, returns all logs. - #[serde(default)] - unit: Option, - /// Number of trailing lines. Capped at 5000. Default 500. - #[serde(default)] - lines: Option, -} - -/// `GET /api/journal-host?unit=&lines=N` — host-side journald (no -/// `-M` container flag). Restricted to an allow-list of known host services -/// so arbitrary unit names can't be probed. Operator-only by virtue of the -/// dashboard binding to a host-only port. -async fn get_journal_host( - axum::extract::Query(q): axum::extract::Query, -) -> Response { - let lines = q.lines.unwrap_or(500).min(5000); - let allowed = ["hive-c0re.service"]; - let mut cmd = tokio::process::Command::new("journalctl"); - cmd.args(["--no-pager", "--output=short-iso", "--lines"]) - .arg(lines.to_string()); - if let Some(u) = q.unit.as_deref().filter(|s| !s.is_empty()) { - let unit = if u.ends_with(".service") { - u.to_owned() - } else { - format!("{u}.service") - }; - if !allowed.contains(&unit.as_str()) { - return error_response(&format!("journal-host: unknown unit {unit:?}")); - } - cmd.args(["-u", &unit]); - } - match cmd.output().await { - Ok(out) => { - let mut body = String::from_utf8_lossy(&out.stdout).into_owned(); - if !out.status.success() { - body.push_str("\n--- stderr ---\n"); - body.push_str(&String::from_utf8_lossy(&out.stderr)); - } - ([("content-type", "text/plain; charset=utf-8")], body).into_response() - } - Err(e) => error_response(&format!("journalctl spawn: {e}")), - } -} - #[derive(Deserialize)] struct BuildLogsAllQuery { /// Max rows to return. Capped at 100. Default 30. diff --git a/hive-c0re/src/dashboard/journal.rs b/hive-c0re/src/dashboard/journal.rs new file mode 100644 index 00000000..cdcba9fb --- /dev/null +++ b/hive-c0re/src/dashboard/journal.rs @@ -0,0 +1,139 @@ +//! Journal-read endpoints for the dashboard. +//! +//! `GET /api/journal/{name}` reads a managed container's journal via the +//! root helper (`journalctl -M`, delegated to hive-priv since hive-c0re is +//! unprivileged). `GET /api/journal-host` reads host-side journald, both +//! gated by an allow-list of known units so arbitrary unit names can't be +//! probed. Operator-only by virtue of the dashboard binding host-only. + +use axum::{ + extract::Path as AxumPath, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +use super::{error_response, strip_container_prefix, validate_agent_name}; +use crate::lifecycle; + +#[derive(Deserialize)] +pub(super) struct JournalQuery { + /// Optional systemd unit filter — e.g. `hive-ag3nt.service`. When + /// omitted, returns the full machine journal. + #[serde(default)] + unit: Option, + /// Number of trailing lines to return. Capped at 5000. + #[serde(default)] + lines: Option, +} + +/// Read `journalctl -M -b` and return its text output. +/// Operator-only by virtue of the dashboard being host-bound. hive-c0re +/// runs unprivileged (privsep), so the `-M` read — which enters the +/// container namespace and needs root — is delegated to hive-priv. +pub(super) async fn get_journal( + AxumPath(name): AxumPath, + axum::extract::Query(q): axum::extract::Query, +) -> Response { + // Defense-in-depth format check so weird chars never reach the + // shellout below — the `lifecycle::list()` existence check would + // catch them anyway, but rejecting at the boundary keeps the + // failure mode crisp. + if let Some(reason) = validate_agent_name(&name) { + return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); + } + // Validate the container name against the list of managed + // containers so we don't shell out with arbitrary input. + let container = strip_container_prefix(&name); + let prefixed = format!("{}{container}", lifecycle::AGENT_PREFIX); + let live = lifecycle::list().await.unwrap_or_default(); + if !live.iter().any(|c| c == &prefixed) { + return error_response(&format!("journal: no managed container {prefixed:?}")); + } + let lines = q.lines.unwrap_or(500).min(5000); + let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) { + Some(u) => { + // accept hive-ag3nt[.service] — anything else refused. + let allowed = ["hive-ag3nt.service"]; + let unit = if u.ends_with(".service") { + u.to_owned() + } else { + format!("{u}.service") + }; + if !allowed.contains(&unit.as_str()) { + return error_response(&format!("journal: unknown unit {unit:?}")); + } + Some(unit) + } + None => None, + }; + match crate::priv_client::read_container_journal( + &prefixed, + hive_sh4re::priv_proto::JournalQuery { + lines, + boot: true, + output: hive_sh4re::priv_proto::JournalOutput::ShortIso, + unit, + ..Default::default() + }, + ) + .await + { + Ok((stdout, stderr)) => { + // Combine stdout + stderr — journalctl emits to both on errors. + let mut body = stdout; + if !stderr.is_empty() { + body.push_str("\n--- stderr ---\n"); + body.push_str(&stderr); + } + ([("content-type", "text/plain; charset=utf-8")], body).into_response() + } + Err(e) => error_response(&format!("journal read: {e:#}")), + } +} + +#[derive(Deserialize)] +pub(super) struct JournalHostQuery { + /// Service unit name to filter to. If omitted, returns all logs. + #[serde(default)] + unit: Option, + /// Number of trailing lines. Capped at 5000. Default 500. + #[serde(default)] + lines: Option, +} + +/// `GET /api/journal-host?unit=&lines=N` — host-side journald (no +/// `-M` container flag). Restricted to an allow-list of known host services +/// so arbitrary unit names can't be probed. Operator-only by virtue of the +/// dashboard binding to a host-only port. +pub(super) async fn get_journal_host( + axum::extract::Query(q): axum::extract::Query, +) -> Response { + let lines = q.lines.unwrap_or(500).min(5000); + let allowed = ["hive-c0re.service"]; + let mut cmd = tokio::process::Command::new("journalctl"); + cmd.args(["--no-pager", "--output=short-iso", "--lines"]) + .arg(lines.to_string()); + if let Some(u) = q.unit.as_deref().filter(|s| !s.is_empty()) { + let unit = if u.ends_with(".service") { + u.to_owned() + } else { + format!("{u}.service") + }; + if !allowed.contains(&unit.as_str()) { + return error_response(&format!("journal-host: unknown unit {unit:?}")); + } + cmd.args(["-u", &unit]); + } + match cmd.output().await { + Ok(out) => { + let mut body = String::from_utf8_lossy(&out.stdout).into_owned(); + if !out.status.success() { + body.push_str("\n--- stderr ---\n"); + body.push_str(&String::from_utf8_lossy(&out.stderr)); + } + ([("content-type", "text/plain; charset=utf-8")], body).into_response() + } + Err(e) => error_response(&format!("journalctl spawn: {e}")), + } +} From bee165ebc79a253f61754f0d93ce9f3a39255180 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 22:47:33 +0200 Subject: [PATCH 07/14] refactor(#1456): extract dashboard reminder endpoints into dashboard/reminders.rs --- hive-c0re/src/dashboard.rs | 52 ++++---------------------- hive-c0re/src/dashboard/reminders.rs | 55 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 45 deletions(-) create mode 100644 hive-c0re/src/dashboard/reminders.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 3d2ddd75..51deb00c 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -32,6 +32,7 @@ use crate::lifecycle::{self, MANAGER_NAME}; mod journal; mod permissions; +mod reminders; mod schedules; mod webhook; @@ -71,7 +72,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/api/journal-host", get(journal::get_journal_host)) .route("/api/approval-diff/{id}", get(get_approval_diff)) .route("/api/state-file", get(get_state_file)) - .route("/api/reminders", get(api_reminders)) + .route("/api/reminders", get(reminders::api_reminders)) .route("/api/operator-inbox", get(api_operator_inbox)) .route("/api/stats-hive", get(api_stats_hive)) .route("/api/container-resources", get(api_container_resources)) @@ -81,8 +82,11 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/api/build-logs/id/{id}/stream", get(get_build_log_stream)) .route("/api/build-logs/id/{id}/raw", get(get_build_log_raw)) .route("/api/agent/{name}/mark-all-read", post(post_mark_all_read)) - .route("/cancel-reminder/{id}", post(post_cancel_reminder)) - .route("/retry-reminder/{id}", post(post_retry_reminder)) + .route( + "/cancel-reminder/{id}", + post(reminders::post_cancel_reminder), + ) + .route("/retry-reminder/{id}", post(reminders::post_retry_reminder)) .route("/request-spawn", post(post_request_spawn)) .route("/api/topology/set-parent", post(post_set_parent)) .route("/api/topology/set-parent-bulk", post(post_set_parent_bulk)) @@ -1637,13 +1641,6 @@ fn image_content_type(path: &Path) -> Option<&'static str> { }) } -async fn api_reminders(State(state): State) -> Response { - match state.coord.broker.list_pending_reminders() { - Ok(rows) => axum::Json(rows).into_response(), - Err(e) => error_response(&format!("reminders: {e:#}")), - } -} - /// Unread operator-directed messages for the dashboard's Y3R C4LL inbox /// (#1469). Returns messages addressed to `"operator"` that haven't been /// acked yet (the operator clears them via the existing @@ -1901,41 +1898,6 @@ async fn get_build_log_raw(State(state): State, AxumPath(id): AxumPath } } -async fn post_cancel_reminder( - State(state): State, - AxumPath(id): AxumPath, -) -> Response { - match state.coord.broker.cancel_reminder(id) { - Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")), - Ok(_) => { - tracing::info!(%id, "operator cancelled reminder"); - state.coord.emit_reminders_snapshot(); - (StatusCode::OK, "ok").into_response() - } - Err(e) => error_response(&format!("cancel reminder {id} failed: {e:#}")), - } -} - -/// Reset a pending reminder's failure state so the scheduler -/// retries it on the next tick. Useful when the failure was -/// transient (sqlite lock contention, disk full → freed up) and -/// the operator wants delivery to resume immediately instead of -/// the row sitting in attempt-count-capped purgatory. -async fn post_retry_reminder( - State(state): State, - AxumPath(id): AxumPath, -) -> Response { - match state.coord.broker.reset_reminder_failure(id) { - Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")), - Ok(_) => { - tracing::info!(%id, "operator reset reminder failure for retry"); - state.coord.emit_reminders_snapshot(); - (StatusCode::OK, "ok").into_response() - } - Err(e) => error_response(&format!("retry reminder {id} failed: {e:#}")), - } -} - /// Validate that a path-param agent name conforms to the hyperhive /// naming whitelist: 1-63 chars of `[a-z0-9_-]`. Rejects empty, /// uppercase, slashes, dots, and any non-ASCII (incl. unicode diff --git a/hive-c0re/src/dashboard/reminders.rs b/hive-c0re/src/dashboard/reminders.rs new file mode 100644 index 00000000..e29e885c --- /dev/null +++ b/hive-c0re/src/dashboard/reminders.rs @@ -0,0 +1,55 @@ +//! Reminder endpoints for the dashboard. +//! +//! Lists pending reminders for the reminders tab, and lets the operator +//! cancel a pending reminder or reset its failure state so the scheduler +//! retries it on the next tick. + +use axum::{ + extract::{Path as AxumPath, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use super::{AppState, error_response}; + +pub(super) async fn api_reminders(State(state): State) -> Response { + match state.coord.broker.list_pending_reminders() { + Ok(rows) => axum::Json(rows).into_response(), + Err(e) => error_response(&format!("reminders: {e:#}")), + } +} + +pub(super) async fn post_cancel_reminder( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.coord.broker.cancel_reminder(id) { + Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")), + Ok(_) => { + tracing::info!(%id, "operator cancelled reminder"); + state.coord.emit_reminders_snapshot(); + (StatusCode::OK, "ok").into_response() + } + Err(e) => error_response(&format!("cancel reminder {id} failed: {e:#}")), + } +} + +/// Reset a pending reminder's failure state so the scheduler +/// retries it on the next tick. Useful when the failure was +/// transient (sqlite lock contention, disk full → freed up) and +/// the operator wants delivery to resume immediately instead of +/// the row sitting in attempt-count-capped purgatory. +pub(super) async fn post_retry_reminder( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.coord.broker.reset_reminder_failure(id) { + Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")), + Ok(_) => { + tracing::info!(%id, "operator reset reminder failure for retry"); + state.coord.emit_reminders_snapshot(); + (StatusCode::OK, "ok").into_response() + } + Err(e) => error_response(&format!("retry reminder {id} failed: {e:#}")), + } +} From aa6e422b78bbf45cd74293c3990132471cff8501 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 22:50:13 +0200 Subject: [PATCH 08/14] refactor(#1456): extract dashboard question answer/cancel endpoints into dashboard/questions.rs --- hive-c0re/src/dashboard.rs | 110 +++---------------------- hive-c0re/src/dashboard/questions.rs | 115 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 101 deletions(-) create mode 100644 hive-c0re/src/dashboard/questions.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 51deb00c..4bef6606 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -32,6 +32,7 @@ use crate::lifecycle::{self, MANAGER_NAME}; mod journal; mod permissions; +mod questions; mod reminders; mod schedules; mod webhook; @@ -65,8 +66,14 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/start/{name}", post(post_start)) .route("/rebuild/{name}", post(post_rebuild)) .route("/update-all", post(post_update_all)) - .route("/answer-question/{id}", post(post_answer_question)) - .route("/cancel-question/{id}", post(post_cancel_question)) + .route( + "/answer-question/{id}", + post(questions::post_answer_question), + ) + .route( + "/cancel-question/{id}", + post(questions::post_cancel_question), + ) .route("/purge-tombstone/{name}", post(post_purge_tombstone)) .route("/api/journal/{name}", get(journal::get_journal)) .route("/api/journal-host", get(journal::get_journal_host)) @@ -1100,105 +1107,6 @@ struct SetParentBulkEntry { new_parent: Option, } -#[derive(Deserialize)] -struct AnswerForm { - answer: String, -} - -/// Attach a permissive CORS header so the per-agent web UI — served on -/// a different port — can POST an operator answer here and read the -/// result. The dashboard has no auth, so `*` exposes nothing a plain -/// cross-origin form-POST couldn't already reach. This shim disappears -/// once the unifying gateway makes the agent page same-origin; see -/// `docs/boundary.md`. -fn with_cors(mut resp: Response) -> Response { - resp.headers_mut().insert( - axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, - axum::http::HeaderValue::from_static("*"), - ); - resp -} - -async fn post_answer_question( - State(state): State, - AxumPath(id): AxumPath, - Form(form): Form, -) -> Response { - let answer = form.answer.trim(); - if answer.is_empty() { - return with_cors(error_response("answer: required")); - } - let resp = match state - .coord - .questions - .answer(id, answer, hive_sh4re::OPERATOR_RECIPIENT) - { - Ok((question, asker, target)) => { - tracing::info!(%id, %asker, "operator answered question"); - state.coord.notify_agent( - &asker, - &hive_sh4re::HelperEvent::QuestionAnswered { - id, - question, - answer: answer.to_owned(), - answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), - }, - ); - state.coord.emit_question_resolved( - id, - answer, - hive_sh4re::OPERATOR_RECIPIENT, - false, - target.as_deref(), - ); - (StatusCode::OK, "ok").into_response() - } - Err(e) => error_response(&format!("answer {id} failed: {e:#}")), - }; - with_cors(resp) -} - -/// Resolve a pending operator question with a sentinel answer when -/// the operator decides not to / can't answer. The asker harness -/// receives a `QuestionAnswered` event with `answer = "[cancelled]"` -/// so it can fall back on whatever default it had. Same code path as -/// a real answer — just lets the operator close the loop instead of -/// letting the question dangle forever. -async fn post_cancel_question( - State(state): State, - AxumPath(id): AxumPath, -) -> Response { - const SENTINEL: &str = "[cancelled]"; - match state - .coord - .questions - .answer(id, SENTINEL, hive_sh4re::OPERATOR_RECIPIENT) - { - Ok((question, asker, target)) => { - tracing::info!(%id, %asker, "operator cancelled question"); - state.coord.emit_question_resolved( - id, - SENTINEL, - hive_sh4re::OPERATOR_RECIPIENT, - true, - target.as_deref(), - ); - state.coord.notify_agent_from( - hive_sh4re::OPERATOR_RECIPIENT, - &asker, - &hive_sh4re::HelperEvent::QuestionAnswered { - id, - question, - answer: SENTINEL.to_owned(), - answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), - }, - ); - (StatusCode::OK, "ok").into_response() - } - Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")), - } -} - #[derive(Deserialize)] struct BuildLogsAllQuery { /// Max rows to return. Capped at 100. Default 30. diff --git a/hive-c0re/src/dashboard/questions.rs b/hive-c0re/src/dashboard/questions.rs new file mode 100644 index 00000000..5d6530e0 --- /dev/null +++ b/hive-c0re/src/dashboard/questions.rs @@ -0,0 +1,115 @@ +//! Operator question answer/cancel endpoints for the dashboard. +//! +//! `POST /answer-question/{id}` records the operator's answer and fires a +//! `QuestionAnswered` event to the asker; `POST /cancel-question/{id}` +//! resolves a pending question with a `[cancelled]` sentinel. Both carry a +//! permissive CORS header so the per-agent web UI (different origin) can +//! POST here until the unifying gateway makes it same-origin. + +use axum::{ + extract::{Form, Path as AxumPath, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +use super::{AppState, error_response}; + +#[derive(Deserialize)] +pub(super) struct AnswerForm { + answer: String, +} + +/// Attach a permissive CORS header so the per-agent web UI — served on +/// a different port — can POST an operator answer here and read the +/// result. The dashboard has no auth, so `*` exposes nothing a plain +/// cross-origin form-POST couldn't already reach. This shim disappears +/// once the unifying gateway makes the agent page same-origin; see +/// `docs/boundary.md`. +fn with_cors(mut resp: Response) -> Response { + resp.headers_mut().insert( + axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, + axum::http::HeaderValue::from_static("*"), + ); + resp +} + +pub(super) async fn post_answer_question( + State(state): State, + AxumPath(id): AxumPath, + Form(form): Form, +) -> Response { + let answer = form.answer.trim(); + if answer.is_empty() { + return with_cors(error_response("answer: required")); + } + let resp = match state + .coord + .questions + .answer(id, answer, hive_sh4re::OPERATOR_RECIPIENT) + { + Ok((question, asker, target)) => { + tracing::info!(%id, %asker, "operator answered question"); + state.coord.notify_agent( + &asker, + &hive_sh4re::HelperEvent::QuestionAnswered { + id, + question, + answer: answer.to_owned(), + answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), + }, + ); + state.coord.emit_question_resolved( + id, + answer, + hive_sh4re::OPERATOR_RECIPIENT, + false, + target.as_deref(), + ); + (StatusCode::OK, "ok").into_response() + } + Err(e) => error_response(&format!("answer {id} failed: {e:#}")), + }; + with_cors(resp) +} + +/// Resolve a pending operator question with a sentinel answer when +/// the operator decides not to / can't answer. The asker harness +/// receives a `QuestionAnswered` event with `answer = "[cancelled]"` +/// so it can fall back on whatever default it had. Same code path as +/// a real answer — just lets the operator close the loop instead of +/// letting the question dangle forever. +pub(super) async fn post_cancel_question( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + const SENTINEL: &str = "[cancelled]"; + match state + .coord + .questions + .answer(id, SENTINEL, hive_sh4re::OPERATOR_RECIPIENT) + { + Ok((question, asker, target)) => { + tracing::info!(%id, %asker, "operator cancelled question"); + state.coord.emit_question_resolved( + id, + SENTINEL, + hive_sh4re::OPERATOR_RECIPIENT, + true, + target.as_deref(), + ); + state.coord.notify_agent_from( + hive_sh4re::OPERATOR_RECIPIENT, + &asker, + &hive_sh4re::HelperEvent::QuestionAnswered { + id, + question, + answer: SENTINEL.to_owned(), + answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), + }, + ); + (StatusCode::OK, "ok").into_response() + } + Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")), + } +} From aa8bf11c8bcedf7632ada268728263d0af769177 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 22:56:03 +0200 Subject: [PATCH 09/14] refactor(#1456): extract dashboard topology set-parent endpoints into dashboard/topology.rs --- hive-c0re/src/dashboard.rs | 115 ++++----------------------- hive-c0re/src/dashboard/topology.rs | 116 ++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 102 deletions(-) create mode 100644 hive-c0re/src/dashboard/topology.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 4bef6606..def9c56d 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -35,6 +35,7 @@ mod permissions; mod questions; mod reminders; mod schedules; +mod topology; mod webhook; #[derive(Clone)] @@ -42,6 +43,13 @@ struct AppState { coord: Arc, } +#[allow( + clippy::too_many_lines, + reason = "the body is dominated by the flat axum route table — one line \ + per endpoint mapping a URL to its (now per-concern submodule) \ + handler; splitting that exhaustive list across helpers would \ + obscure the route map for no readability gain" +)] pub async fn serve(port: u16, coord: Arc) -> Result<()> { let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR") .map(PathBuf::from) @@ -95,8 +103,11 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { ) .route("/retry-reminder/{id}", post(reminders::post_retry_reminder)) .route("/request-spawn", post(post_request_spawn)) - .route("/api/topology/set-parent", post(post_set_parent)) - .route("/api/topology/set-parent-bulk", post(post_set_parent_bulk)) + .route("/api/topology/set-parent", post(topology::post_set_parent)) + .route( + "/api/topology/set-parent-bulk", + post(topology::post_set_parent_bulk), + ) .route("/api/tool-groups", get(permissions::get_tool_groups)) .route( "/api/tool-groups/{agent}", @@ -1083,30 +1094,6 @@ struct RequestSpawnForm { name: String, } -/// `POST /api/topology/set-parent` body. `child` is required. -/// `new_parent` may be: -/// - absent or empty / whitespace-only → promote to root, -/// - non-empty → new parent's logical name. -/// -/// (The CLI surface gates "no parent specified" behind an explicit -/// `--root` flag for safety; the HTTP surface is permissive -/// because the dashboard form encodes "no value" as the empty -/// string for the optional radio-group input.) -#[derive(Deserialize)] -struct SetParentForm { - child: String, - new_parent: Option, -} - -/// One entry in a `POST /api/topology/set-parent-bulk` JSON array. -/// `new_parent`: absent/null/empty-string all mean "promote to root". -#[derive(Deserialize)] -struct SetParentBulkEntry { - child: String, - #[serde(default)] - new_parent: Option, -} - #[derive(Deserialize)] struct BuildLogsAllQuery { /// Max rows to return. Capped at 100. Default 30. @@ -2068,82 +2055,6 @@ async fn post_request_spawn( } } -/// `POST /api/topology/set-parent` — operator-driven parent move. -/// Form fields: `child` (required, agent name), `new_parent` -/// (optional — empty / absent string ⇒ promote to root). Refuses -/// cycles and unknown agents. The manager is reparentable like any -/// other agent — its privileges come from the privileged MCP socket, -/// not its tree position. On success -/// re-emits container snapshots so the dashboard tree repaints -/// without a refresh. -async fn post_set_parent( - State(state): State, - Form(form): Form, -) -> Response { - let child = form.child.trim().to_owned(); - if child.is_empty() { - return error_response("set-parent: `child` required"); - } - // Empty / whitespace-only `new_parent` ⇒ promote to root. Web - // forms submit the empty string for a "no value" radio button, - // so this is the ergonomic encoding. - let new_parent = form - .new_parent - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_owned); - // `reparent_with_notify` wraps `topology::set_parent` with the - // three notification messages + the ContainerView rescan. - // Idempotent same-parent calls skip both the messages and the - // disk write per the topology fast-path. - match state - .coord - .reparent_with_notify(&child, new_parent.as_deref()) - .await - { - Ok(()) => { - tracing::info!( - child = %child, - new_parent = ?new_parent, - "operator: set-parent via dashboard" - ); - (StatusCode::OK, "ok").into_response() - } - Err(e) => error_response(&format!("set-parent {child} failed: {e}")), - } -} - -/// `POST /api/topology/set-parent-bulk` — move multiple agents in a single -/// request, producing **one** git commit. JSON body: `[{"child":"name", -/// "new_parent":"target-or-null"}, ...]`. Empty array is a no-op (200 OK). -/// First validation error aborts the whole batch. -async fn post_set_parent_bulk( - State(state): State, - axum::Json(body): axum::Json>, -) -> Response { - if body.is_empty() { - return (StatusCode::OK, "ok").into_response(); - } - // Collect borrows for the coordinator call. - let moves: Vec<(&str, Option<&str>)> = body - .iter() - .map(|e| { - let child: &str = &e.child; - let parent: Option<&str> = e.new_parent.as_deref().filter(|s| !s.is_empty()); - (child, parent) - }) - .collect(); - match state.coord.reparent_bulk_with_notify(&moves).await { - Ok(()) => { - let names: Vec<&str> = body.iter().map(|e| e.child.as_str()).collect(); - tracing::info!(agents = ?names, "operator: set-parent-bulk via dashboard"); - (StatusCode::OK, "ok").into_response() - } - Err(e) => error_response(&format!("set-parent-bulk failed: {e}")), - } -} - async fn post_rebuild(State(state): State, AxumPath(name): AxumPath) -> Response { let logical = strip_container_prefix(&name); if let Some(reject) = guard_agent_name(&state, &logical).await { diff --git a/hive-c0re/src/dashboard/topology.rs b/hive-c0re/src/dashboard/topology.rs new file mode 100644 index 00000000..7482a846 --- /dev/null +++ b/hive-c0re/src/dashboard/topology.rs @@ -0,0 +1,116 @@ +//! Topology (set-parent) endpoints for the dashboard. +//! +//! Operator-driven agent reparenting — single (`/api/topology/set-parent`, +//! form-encoded) and bulk (`/api/topology/set-parent-bulk`, JSON array → +//! one git commit). Both go through `Coordinator::reparent*_with_notify`, +//! which wraps `topology::set_parent` with the move-notification messages +//! and the `ContainerView` rescan. + +use axum::{ + extract::{Form, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +use super::{AppState, error_response}; + +/// `POST /api/topology/set-parent` body. `child` is required. +/// `new_parent` may be: +/// - absent or empty / whitespace-only → promote to root, +/// - non-empty → new parent's logical name. +/// +/// (The CLI surface gates "no parent specified" behind an explicit +/// `--root` flag for safety; the HTTP surface is permissive +/// because the dashboard form encodes "no value" as the empty +/// string for the optional radio-group input.) +#[derive(Deserialize)] +pub(super) struct SetParentForm { + child: String, + new_parent: Option, +} + +/// One entry in a `POST /api/topology/set-parent-bulk` JSON array. +/// `new_parent`: absent/null/empty-string all mean "promote to root". +#[derive(Deserialize)] +pub(super) struct SetParentBulkEntry { + child: String, + #[serde(default)] + new_parent: Option, +} + +/// `POST /api/topology/set-parent` — operator-driven parent move. +/// Form fields: `child` (required, agent name), `new_parent` +/// (optional — empty / absent string ⇒ promote to root). Refuses +/// cycles and unknown agents. The manager is reparentable like any +/// other agent — its privileges come from the privileged MCP socket, +/// not its tree position. On success +/// re-emits container snapshots so the dashboard tree repaints +/// without a refresh. +pub(super) async fn post_set_parent( + State(state): State, + Form(form): Form, +) -> Response { + let child = form.child.trim().to_owned(); + if child.is_empty() { + return error_response("set-parent: `child` required"); + } + // Empty / whitespace-only `new_parent` ⇒ promote to root. Web + // forms submit the empty string for a "no value" radio button, + // so this is the ergonomic encoding. + let new_parent = form + .new_parent + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned); + // `reparent_with_notify` wraps `topology::set_parent` with the + // three notification messages + the ContainerView rescan. + // Idempotent same-parent calls skip both the messages and the + // disk write per the topology fast-path. + match state + .coord + .reparent_with_notify(&child, new_parent.as_deref()) + .await + { + Ok(()) => { + tracing::info!( + child = %child, + new_parent = ?new_parent, + "operator: set-parent via dashboard" + ); + (StatusCode::OK, "ok").into_response() + } + Err(e) => error_response(&format!("set-parent {child} failed: {e}")), + } +} + +/// `POST /api/topology/set-parent-bulk` — move multiple agents in a single +/// request, producing **one** git commit. JSON body: `[{"child":"name", +/// "new_parent":"target-or-null"}, ...]`. Empty array is a no-op (200 OK). +/// First validation error aborts the whole batch. +pub(super) async fn post_set_parent_bulk( + State(state): State, + axum::Json(body): axum::Json>, +) -> Response { + if body.is_empty() { + return (StatusCode::OK, "ok").into_response(); + } + // Collect borrows for the coordinator call. + let moves: Vec<(&str, Option<&str>)> = body + .iter() + .map(|e| { + let child: &str = &e.child; + let parent: Option<&str> = e.new_parent.as_deref().filter(|s| !s.is_empty()); + (child, parent) + }) + .collect(); + match state.coord.reparent_bulk_with_notify(&moves).await { + Ok(()) => { + let names: Vec<&str> = body.iter().map(|e| e.child.as_str()).collect(); + tracing::info!(agents = ?names, "operator: set-parent-bulk via dashboard"); + (StatusCode::OK, "ok").into_response() + } + Err(e) => error_response(&format!("set-parent-bulk failed: {e}")), + } +} From 4e06a9682d145efebf5c97a2b3dad5646a01c55d Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 23:16:05 +0200 Subject: [PATCH 10/14] refactor(#1456): extract dashboard build-log endpoints into dashboard/build_logs.rs --- hive-c0re/src/dashboard.rs | 230 +++----------------------- hive-c0re/src/dashboard/build_logs.rs | 229 +++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 211 deletions(-) create mode 100644 hive-c0re/src/dashboard/build_logs.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index def9c56d..4cbaffc2 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -21,7 +21,7 @@ use axum::{ }; use hive_sh4re::Approval; use serde::{Deserialize, Serialize}; -use tokio_stream::wrappers::{BroadcastStream, ReceiverStream}; +use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt}; use tower_http::services::ServeDir; @@ -30,6 +30,7 @@ use crate::container_view::{ContainerView, claude_has_session}; use crate::coordinator::Coordinator; use crate::lifecycle::{self, MANAGER_NAME}; +mod build_logs; mod journal; mod permissions; mod questions; @@ -91,11 +92,23 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/api/operator-inbox", get(api_operator_inbox)) .route("/api/stats-hive", get(api_stats_hive)) .route("/api/container-resources", get(api_container_resources)) - .route("/api/build-logs", get(get_build_logs_all)) - .route("/api/build-logs/{agent}", get(get_build_logs_agent)) - .route("/api/build-logs/id/{id}", get(get_build_log_full)) - .route("/api/build-logs/id/{id}/stream", get(get_build_log_stream)) - .route("/api/build-logs/id/{id}/raw", get(get_build_log_raw)) + .route("/api/build-logs", get(build_logs::get_build_logs_all)) + .route( + "/api/build-logs/{agent}", + get(build_logs::get_build_logs_agent), + ) + .route( + "/api/build-logs/id/{id}", + get(build_logs::get_build_log_full), + ) + .route( + "/api/build-logs/id/{id}/stream", + get(build_logs::get_build_log_stream), + ) + .route( + "/api/build-logs/id/{id}/raw", + get(build_logs::get_build_log_raw), + ) .route("/api/agent/{name}/mark-all-read", post(post_mark_all_read)) .route( "/cancel-reminder/{id}", @@ -1094,26 +1107,6 @@ struct RequestSpawnForm { name: String, } -#[derive(Deserialize)] -struct BuildLogsAllQuery { - /// Max rows to return. Capped at 100. Default 30. - #[serde(default)] - limit: Option, -} - -/// `GET /api/build-logs?limit=N` — most-recent build log headers across -/// all agents, newest first. Same JSON shape as the per-agent endpoint. -async fn get_build_logs_all( - State(state): State, - axum::extract::Query(q): axum::extract::Query, -) -> Response { - let limit = q.limit.unwrap_or(30); - match state.coord.build_logs.list_recent_all(limit) { - Ok(rows) => axum::Json(rows).into_response(), - Err(e) => error_response(&format!("build-logs all: {e:#}")), - } -} - #[derive(Deserialize)] struct StateFileQuery { path: String, @@ -1608,191 +1601,6 @@ async fn api_container_resources() -> Response { axum::Json(crate::container_stats::gather().await).into_response() } -#[derive(Deserialize)] -struct BuildLogsQuery { - /// Maximum number of rows to return. Capped server-side at 50 - /// (see `build_logs::list_recent_for_agent`). Default 10. - #[serde(default)] - limit: Option, -} - -/// `GET /api/build-logs/{agent}?limit=N` — most-recent build log -/// headers for one agent, newest first. Returns -/// `Vec` (JSON). Limit defaults to 10, server-side -/// cap at 50. Backs the per-agent log chip in the agent card and -/// the side-panel header list. -async fn get_build_logs_agent( - State(state): State, - AxumPath(name): AxumPath, - axum::extract::Query(q): axum::extract::Query, -) -> Response { - if let Some(reason) = validate_agent_name(&name) { - return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); - } - let limit = q.limit.unwrap_or(10); - match state.coord.build_logs.list_recent_for_agent(&name, limit) { - Ok(rows) => axum::Json(rows).into_response(), - Err(e) => error_response(&format!("build-logs {name}: {e:#}")), - } -} - -/// `GET /api/build-logs/id/{id}` — full build log row (stdout + -/// stderr concatenated) by id. Returns `BuildLogFull` (JSON), or -/// HTTP 404 when the id doesn't exist (vacuum-reaped, or the -/// operator passed a stale id from a refresh race). -async fn get_build_log_full( - State(state): State, - AxumPath(id): AxumPath, -) -> Response { - match state.coord.build_logs.get_full(id) { - Ok(Some(log)) => axum::Json(log).into_response(), - Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(), - Err(e) => error_response(&format!("build-log {id}: {e:#}")), - } -} - -/// JSON frame sent on the `/api/build-logs/id/{id}/stream` SSE channel. -/// `stdout_append` / `stderr_append` carry only the new bytes since the -/// last frame; `done = true` means the build finished and the stream -/// will close after this frame. -#[derive(Serialize)] -struct BuildLogFrame { - stdout_append: String, - stderr_append: String, - #[serde(skip_serializing_if = "Option::is_none")] - status: Option, - done: bool, -} - -/// `GET /api/build-logs/id/{id}/stream` — SSE stream that delivers -/// incremental stdout/stderr as a build runs. The client connects when -/// it opens a running-build panel; the stream closes automatically once -/// the build finishes (or the row disappears due to a vacuum). -/// -/// Each frame is a JSON-serialised `BuildLogFrame`. The first frame -/// always carries the full accumulated log so far (cursors start at 0); -/// subsequent frames carry only new bytes. `done: true` on the final -/// frame signals the browser to close the `EventSource`. -async fn get_build_log_stream( - State(state): State, - AxumPath(id): AxumPath, -) -> Sse>> { - let (tx, rx) = tokio::sync::mpsc::channel::>(32); - let logs = state.coord.build_logs.clone(); - - tokio::spawn(async move { - let mut notify_rx = logs.subscribe_notifications(); - let mut stdout_cursor = 0usize; - let mut stderr_cursor = 0usize; - - // ── initial snapshot ────────────────────────────────────────── - match logs.get_progress(id, stdout_cursor, stderr_cursor) { - Ok(Some(prog)) => { - stdout_cursor += prog.stdout_append.len(); - stderr_cursor += prog.stderr_append.len(); - let done = prog.finished_at.is_some(); - if let Ok(json) = serde_json::to_string(&BuildLogFrame { - stdout_append: prog.stdout_append, - stderr_append: prog.stderr_append, - status: prog.status, - done, - }) { - let _ = tx.send(Ok(Event::default().data(json))).await; - } - if done { - return; - } - } - Ok(None) => { - // Row missing — send a single error event and exit. - let _ = tx - .send(Ok(Event::default() - .event("error") - .data(format!("build log #{id} not found")))) - .await; - return; - } - Err(e) => { - let _ = tx - .send(Ok(Event::default() - .event("error") - .data(format!("build log #{id}: {e:#}")))) - .await; - return; - } - } - - // ── live delta loop ─────────────────────────────────────────── - loop { - match notify_rx.recv().await { - // Notification for a different build — ignore and wait - // for the next one. - Ok(notif_id) if notif_id != id => {} - Ok(_) => { - match logs.get_progress(id, stdout_cursor, stderr_cursor) { - Ok(Some(prog)) => { - stdout_cursor += prog.stdout_append.len(); - stderr_cursor += prog.stderr_append.len(); - let done = prog.finished_at.is_some(); - if let Ok(json) = serde_json::to_string(&BuildLogFrame { - stdout_append: prog.stdout_append, - stderr_append: prog.stderr_append, - status: prog.status, - done, - }) && tx.send(Ok(Event::default().data(json))).await.is_err() - { - return; // browser disconnected - } - if done { - return; - } - } - Ok(None) | Err(_) => return, // vacuum reaped row / channel closed - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} - Err(tokio::sync::broadcast::error::RecvError::Closed) => return, - } - } - }); - - Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default()) -} - -/// `GET /api/build-logs/id/{id}/raw` — full log as `text/plain` for -/// download. Stdout and stderr are concatenated with a `--- stderr ---` -/// separator (same layout the JS side-panel renders). The -/// `Content-Disposition` header triggers a browser download with a -/// descriptive filename so the operator can save and share the log. -async fn get_build_log_raw(State(state): State, AxumPath(id): AxumPath) -> Response { - match state.coord.build_logs.get_full(id) { - Ok(Some(log)) => { - let mut text = log.stdout; - if !log.stderr.is_empty() { - text.push_str("\n--- stderr ---\n"); - text.push_str(&log.stderr); - } - ( - StatusCode::OK, - [ - ("content-type", "text/plain; charset=utf-8".to_string()), - ( - "content-disposition", - format!( - "attachment; filename=\"build-log-{}-{}.txt\"", - log.header.agent, id - ), - ), - ], - text, - ) - .into_response() - } - Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(), - Err(e) => error_response(&format!("build-log {id}: {e:#}")), - } -} - /// Validate that a path-param agent name conforms to the hyperhive /// naming whitelist: 1-63 chars of `[a-z0-9_-]`. Rejects empty, /// uppercase, slashes, dots, and any non-ASCII (incl. unicode diff --git a/hive-c0re/src/dashboard/build_logs.rs b/hive-c0re/src/dashboard/build_logs.rs new file mode 100644 index 00000000..c469caf0 --- /dev/null +++ b/hive-c0re/src/dashboard/build_logs.rs @@ -0,0 +1,229 @@ +//! Build-log endpoints for the dashboard. +//! +//! Header lists (all-agents + per-agent), the full row by id, a `text/plain` +//! download, and an SSE stream that delivers incremental stdout/stderr while +//! a build runs (closing once it finishes or the row is vacuum-reaped). + +use std::convert::Infallible; + +use axum::{ + extract::{Path as AxumPath, State}, + http::StatusCode, + response::{ + IntoResponse, Response, + sse::{Event, KeepAlive, Sse}, + }, +}; +use serde::{Deserialize, Serialize}; +use tokio_stream::Stream; +use tokio_stream::wrappers::ReceiverStream; + +use super::{AppState, error_response, validate_agent_name}; + +#[derive(Deserialize)] +pub(super) struct BuildLogsAllQuery { + /// Max rows to return. Capped at 100. Default 30. + #[serde(default)] + limit: Option, +} + +/// `GET /api/build-logs?limit=N` — most-recent build log headers across +/// all agents, newest first. Same JSON shape as the per-agent endpoint. +pub(super) async fn get_build_logs_all( + State(state): State, + axum::extract::Query(q): axum::extract::Query, +) -> Response { + let limit = q.limit.unwrap_or(30); + match state.coord.build_logs.list_recent_all(limit) { + Ok(rows) => axum::Json(rows).into_response(), + Err(e) => error_response(&format!("build-logs all: {e:#}")), + } +} + +#[derive(Deserialize)] +pub(super) struct BuildLogsQuery { + /// Maximum number of rows to return. Capped server-side at 50 + /// (see `build_logs::list_recent_for_agent`). Default 10. + #[serde(default)] + limit: Option, +} + +/// `GET /api/build-logs/{agent}?limit=N` — most-recent build log +/// headers for one agent, newest first. Returns +/// `Vec` (JSON). Limit defaults to 10, server-side +/// cap at 50. Backs the per-agent log chip in the agent card and +/// the side-panel header list. +pub(super) async fn get_build_logs_agent( + State(state): State, + AxumPath(name): AxumPath, + axum::extract::Query(q): axum::extract::Query, +) -> Response { + if let Some(reason) = validate_agent_name(&name) { + return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); + } + let limit = q.limit.unwrap_or(10); + match state.coord.build_logs.list_recent_for_agent(&name, limit) { + Ok(rows) => axum::Json(rows).into_response(), + Err(e) => error_response(&format!("build-logs {name}: {e:#}")), + } +} + +/// `GET /api/build-logs/id/{id}` — full build log row (stdout + +/// stderr concatenated) by id. Returns `BuildLogFull` (JSON), or +/// HTTP 404 when the id doesn't exist (vacuum-reaped, or the +/// operator passed a stale id from a refresh race). +pub(super) async fn get_build_log_full( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.coord.build_logs.get_full(id) { + Ok(Some(log)) => axum::Json(log).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(), + Err(e) => error_response(&format!("build-log {id}: {e:#}")), + } +} + +/// JSON frame sent on the `/api/build-logs/id/{id}/stream` SSE channel. +/// `stdout_append` / `stderr_append` carry only the new bytes since the +/// last frame; `done = true` means the build finished and the stream +/// will close after this frame. +#[derive(Serialize)] +struct BuildLogFrame { + stdout_append: String, + stderr_append: String, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + done: bool, +} + +/// `GET /api/build-logs/id/{id}/stream` — SSE stream that delivers +/// incremental stdout/stderr as a build runs. The client connects when +/// it opens a running-build panel; the stream closes automatically once +/// the build finishes (or the row disappears due to a vacuum). +/// +/// Each frame is a JSON-serialised `BuildLogFrame`. The first frame +/// always carries the full accumulated log so far (cursors start at 0); +/// subsequent frames carry only new bytes. `done: true` on the final +/// frame signals the browser to close the `EventSource`. +pub(super) async fn get_build_log_stream( + State(state): State, + AxumPath(id): AxumPath, +) -> Sse>> { + let (tx, rx) = tokio::sync::mpsc::channel::>(32); + let logs = state.coord.build_logs.clone(); + + tokio::spawn(async move { + let mut notify_rx = logs.subscribe_notifications(); + let mut stdout_cursor = 0usize; + let mut stderr_cursor = 0usize; + + // ── initial snapshot ────────────────────────────────────────── + match logs.get_progress(id, stdout_cursor, stderr_cursor) { + Ok(Some(prog)) => { + stdout_cursor += prog.stdout_append.len(); + stderr_cursor += prog.stderr_append.len(); + let done = prog.finished_at.is_some(); + if let Ok(json) = serde_json::to_string(&BuildLogFrame { + stdout_append: prog.stdout_append, + stderr_append: prog.stderr_append, + status: prog.status, + done, + }) { + let _ = tx.send(Ok(Event::default().data(json))).await; + } + if done { + return; + } + } + Ok(None) => { + // Row missing — send a single error event and exit. + let _ = tx + .send(Ok(Event::default() + .event("error") + .data(format!("build log #{id} not found")))) + .await; + return; + } + Err(e) => { + let _ = tx + .send(Ok(Event::default() + .event("error") + .data(format!("build log #{id}: {e:#}")))) + .await; + return; + } + } + + // ── live delta loop ─────────────────────────────────────────── + loop { + match notify_rx.recv().await { + // Notification for a different build — ignore and wait + // for the next one. + Ok(notif_id) if notif_id != id => {} + Ok(_) => { + match logs.get_progress(id, stdout_cursor, stderr_cursor) { + Ok(Some(prog)) => { + stdout_cursor += prog.stdout_append.len(); + stderr_cursor += prog.stderr_append.len(); + let done = prog.finished_at.is_some(); + if let Ok(json) = serde_json::to_string(&BuildLogFrame { + stdout_append: prog.stdout_append, + stderr_append: prog.stderr_append, + status: prog.status, + done, + }) && tx.send(Ok(Event::default().data(json))).await.is_err() + { + return; // browser disconnected + } + if done { + return; + } + } + Ok(None) | Err(_) => return, // vacuum reaped row / channel closed + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Err(tokio::sync::broadcast::error::RecvError::Closed) => return, + } + } + }); + + Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default()) +} + +/// `GET /api/build-logs/id/{id}/raw` — full log as `text/plain` for +/// download. Stdout and stderr are concatenated with a `--- stderr ---` +/// separator (same layout the JS side-panel renders). The +/// `Content-Disposition` header triggers a browser download with a +/// descriptive filename so the operator can save and share the log. +pub(super) async fn get_build_log_raw( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.coord.build_logs.get_full(id) { + Ok(Some(log)) => { + let mut text = log.stdout; + if !log.stderr.is_empty() { + text.push_str("\n--- stderr ---\n"); + text.push_str(&log.stderr); + } + ( + StatusCode::OK, + [ + ("content-type", "text/plain; charset=utf-8".to_string()), + ( + "content-disposition", + format!( + "attachment; filename=\"build-log-{}-{}.txt\"", + log.header.agent, id + ), + ), + ], + text, + ) + .into_response() + } + Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(), + Err(e) => error_response(&format!("build-log {id}: {e:#}")), + } +} From 55705f17d3de7e83eceb39bccb228aef3b9575a5 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 23:19:30 +0200 Subject: [PATCH 11/14] refactor(#1456): extract dashboard lifecycle endpoints into dashboard/lifecycle_ops.rs --- hive-c0re/src/dashboard.rs | 176 +-------------------- hive-c0re/src/dashboard/lifecycle_ops.rs | 191 +++++++++++++++++++++++ 2 files changed, 198 insertions(+), 169 deletions(-) create mode 100644 hive-c0re/src/dashboard/lifecycle_ops.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 4cbaffc2..02eb9454 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -32,6 +32,7 @@ use crate::lifecycle::{self, MANAGER_NAME}; mod build_logs; mod journal; +mod lifecycle_ops; mod permissions; mod questions; mod reminders; @@ -69,12 +70,12 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/api/state", get(api_state)) .route("/approve/{id}", post(post_approve)) .route("/deny/{id}", post(post_deny)) - .route("/destroy/{name}", post(post_destroy)) - .route("/kill/{name}", post(post_kill)) - .route("/restart/{name}", post(post_restart)) - .route("/start/{name}", post(post_start)) - .route("/rebuild/{name}", post(post_rebuild)) - .route("/update-all", post(post_update_all)) + .route("/destroy/{name}", post(lifecycle_ops::post_destroy)) + .route("/kill/{name}", post(lifecycle_ops::post_kill)) + .route("/restart/{name}", post(lifecycle_ops::post_restart)) + .route("/start/{name}", post(lifecycle_ops::post_start)) + .route("/rebuild/{name}", post(lifecycle_ops::post_rebuild)) + .route("/update-all", post(lifecycle_ops::post_update_all)) .route( "/answer-question/{id}", post(questions::post_answer_question), @@ -1863,144 +1864,6 @@ async fn post_request_spawn( } } -async fn post_rebuild(State(state): State, AxumPath(name): AxumPath) -> Response { - let logical = strip_container_prefix(&name); - if let Some(reject) = guard_agent_name(&state, &logical).await { - return reject; - } - state.coord.rebuild_queue.enqueue( - crate::rebuild_queue::QueueKind::Rebuild, - logical, - crate::rebuild_queue::QueueSource::Manual, - "manual via dashboard ↻ R3BU1LD button".to_owned(), - None, - ); - state.coord.emit_rebuild_queue_snapshot(); - (StatusCode::OK, "ok").into_response() -} - -/// Common shape for the simple lifecycle action handlers (start / -/// stop / restart / rebuild): strip the container prefix, mark -/// transient for the duration so the dashboard can spinner, run the -/// lifecycle op, clear transient, redirect on success or surface the -/// error. `verb` only appears in the error message; `extra` runs on -/// success after `clear_transient` for handlers that need follow-up -/// (e.g. `kill` also unregisters the agent + fires `HelperEvent`). -async fn lifecycle_action( - state: &AppState, - name: &str, - kind: crate::coordinator::TransientKind, - verb: &str, - body: F, - extra: impl FnOnce(&AppState, &str), -) -> Response -where - F: FnOnce(String) -> Fut, - Fut: std::future::Future>, -{ - let logical = strip_container_prefix(name); - let guard = state.coord.transient_guard(&logical, kind); - let result = body(logical.clone()).await; - drop(guard); - match result { - Ok(()) => { - extra(state, &logical); - // Rescan so the running/needs_login/needs_update flip on - // the affected row lands on every dashboard's SSE channel - // without waiting for a snapshot poll. 200 + matching - // `data-no-refresh` on the form skip the post-submit - // /api/state refetch. - state.coord.rescan_containers_and_emit().await; - (StatusCode::OK, "ok").into_response() - } - Err(e) => error_response(&format!("{verb} {logical} failed: {e:#}")), - } -} - -async fn post_kill(State(state): State, AxumPath(name): AxumPath) -> Response { - let logical = strip_container_prefix(&name); - if let Some(reject) = guard_agent_name(&state, &logical).await { - return reject; - } - // Manager is stoppable from the dashboard like any other - // agent. The host's dashboard server keeps running (it's - // hive-c0re, not the manager container), per-agent approvals - // submitted by other sub-agents still process through the - // host-side approval queue without the manager up, and - // operator-driven meta-input updates work from the dashboard - // either way. The MCP-surface self-kill guard in - // `manager_server.rs::ManagerRequest::Kill` stays in place: a - // manager calling Kill on its own container is self-suicide - // mid-call, not a legitimate operator action. - lifecycle_action( - &state, - &name, - crate::coordinator::TransientKind::Stopping, - "kill", - |n| async move { lifecycle::kill(&n).await }, - |s, n| { - s.coord.unregister_agent(n); - s.coord.notify_manager(&hive_sh4re::HelperEvent::Killed { - agent: n.to_owned(), - }); - }, - ) - .await -} - -async fn post_restart(State(state): State, AxumPath(name): AxumPath) -> Response { - let logical = strip_container_prefix(&name); - if let Some(reject) = guard_agent_name(&state, &logical).await { - return reject; - } - state.coord.rebuild_queue.enqueue( - crate::rebuild_queue::QueueKind::Restart, - logical, - crate::rebuild_queue::QueueSource::Manual, - "manual via dashboard ↺ R3START button".to_owned(), - None, - ); - state.coord.emit_rebuild_queue_snapshot(); - (StatusCode::OK, "ok").into_response() -} - -async fn post_start(State(state): State, AxumPath(name): AxumPath) -> Response { - let logical = strip_container_prefix(&name); - if let Some(reject) = guard_agent_name(&state, &logical).await { - return reject; - } - lifecycle_action( - &state, - &name, - crate::coordinator::TransientKind::Starting, - "start", - |n| async move { lifecycle::start(&n).await }, - |s, n| s.coord.kick_agent(n, "container started"), - ) - .await -} - -async fn post_update_all(State(state): State) -> Response { - let containers = lifecycle::list().await.unwrap_or_default(); - for container in containers { - let Some(logical) = container - .strip_prefix(lifecycle::AGENT_PREFIX) - .map(str::to_owned) - else { - continue; - }; - state.coord.rebuild_queue.enqueue( - crate::rebuild_queue::QueueKind::Rebuild, - logical, - crate::rebuild_queue::QueueSource::Manual, - "manual via dashboard 🌀 UPDATE ALL".to_owned(), - None, - ); - } - state.coord.emit_rebuild_queue_snapshot(); - (StatusCode::OK, "ok").into_response() -} - fn transient_label(k: crate::coordinator::TransientKind) -> &'static str { use crate::coordinator::TransientKind::{ Destroying, Rebuilding, Restarting, Spawning, Starting, Stopping, @@ -2023,31 +1886,6 @@ fn strip_container_prefix(name: &str) -> String { .to_owned() } -#[derive(Deserialize, Default)] -struct DestroyForm { - #[serde(default)] - purge: Option, -} - -async fn post_destroy( - State(state): State, - AxumPath(name): AxumPath, - Form(form): Form, -) -> Response { - if let Some(reject) = guard_agent_name(&state, &name).await { - return reject; - } - // Checkbox semantics: any non-empty value (axum sends "on") = purge. - let purge = form.purge.as_deref().is_some_and(|v| !v.is_empty()); - // `actions::destroy` rescans the container list on success, so the - // `ContainerRemoved` event lands before we return 200. The matching - // form carries `data-no-refresh`. - match actions::destroy(&state.coord, &name, purge).await { - Ok(()) => (StatusCode::OK, "ok").into_response(), - Err(e) => error_response(&format!("destroy {name} failed: {e:#}")), - } -} - fn error_response(message: &str) -> Response { // Plain text — the JS app surfaces this in an alert(), so HTML // wrapping would just clutter the message. diff --git a/hive-c0re/src/dashboard/lifecycle_ops.rs b/hive-c0re/src/dashboard/lifecycle_ops.rs new file mode 100644 index 00000000..7bc7c0aa --- /dev/null +++ b/hive-c0re/src/dashboard/lifecycle_ops.rs @@ -0,0 +1,191 @@ +//! Container lifecycle endpoints for the dashboard. +//! +//! Rebuild / restart / update-all enqueue onto the rebuild queue; kill / +//! start run the lifecycle op directly through `lifecycle_action` (which +//! marks the container transient for the duration so the dashboard can +//! spinner); destroy delegates to `actions::destroy` (optionally purging). + +use axum::{ + extract::{Form, Path as AxumPath, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +use super::{AppState, error_response, guard_agent_name, strip_container_prefix}; +use crate::{actions, lifecycle}; + +pub(super) async fn post_rebuild( + State(state): State, + AxumPath(name): AxumPath, +) -> Response { + let logical = strip_container_prefix(&name); + if let Some(reject) = guard_agent_name(&state, &logical).await { + return reject; + } + state.coord.rebuild_queue.enqueue( + crate::rebuild_queue::QueueKind::Rebuild, + logical, + crate::rebuild_queue::QueueSource::Manual, + "manual via dashboard ↻ R3BU1LD button".to_owned(), + None, + ); + state.coord.emit_rebuild_queue_snapshot(); + (StatusCode::OK, "ok").into_response() +} + +/// Common shape for the simple lifecycle action handlers (start / +/// stop / restart / rebuild): strip the container prefix, mark +/// transient for the duration so the dashboard can spinner, run the +/// lifecycle op, clear transient, redirect on success or surface the +/// error. `verb` only appears in the error message; `extra` runs on +/// success after `clear_transient` for handlers that need follow-up +/// (e.g. `kill` also unregisters the agent + fires `HelperEvent`). +async fn lifecycle_action( + state: &AppState, + name: &str, + kind: crate::coordinator::TransientKind, + verb: &str, + body: F, + extra: impl FnOnce(&AppState, &str), +) -> Response +where + F: FnOnce(String) -> Fut, + Fut: std::future::Future>, +{ + let logical = strip_container_prefix(name); + let guard = state.coord.transient_guard(&logical, kind); + let result = body(logical.clone()).await; + drop(guard); + match result { + Ok(()) => { + extra(state, &logical); + // Rescan so the running/needs_login/needs_update flip on + // the affected row lands on every dashboard's SSE channel + // without waiting for a snapshot poll. 200 + matching + // `data-no-refresh` on the form skip the post-submit + // /api/state refetch. + state.coord.rescan_containers_and_emit().await; + (StatusCode::OK, "ok").into_response() + } + Err(e) => error_response(&format!("{verb} {logical} failed: {e:#}")), + } +} + +pub(super) async fn post_kill( + State(state): State, + AxumPath(name): AxumPath, +) -> Response { + let logical = strip_container_prefix(&name); + if let Some(reject) = guard_agent_name(&state, &logical).await { + return reject; + } + // Manager is stoppable from the dashboard like any other + // agent. The host's dashboard server keeps running (it's + // hive-c0re, not the manager container), per-agent approvals + // submitted by other sub-agents still process through the + // host-side approval queue without the manager up, and + // operator-driven meta-input updates work from the dashboard + // either way. The MCP-surface self-kill guard in + // `manager_server.rs::ManagerRequest::Kill` stays in place: a + // manager calling Kill on its own container is self-suicide + // mid-call, not a legitimate operator action. + lifecycle_action( + &state, + &name, + crate::coordinator::TransientKind::Stopping, + "kill", + |n| async move { lifecycle::kill(&n).await }, + |s, n| { + s.coord.unregister_agent(n); + s.coord.notify_manager(&hive_sh4re::HelperEvent::Killed { + agent: n.to_owned(), + }); + }, + ) + .await +} + +pub(super) async fn post_restart( + State(state): State, + AxumPath(name): AxumPath, +) -> Response { + let logical = strip_container_prefix(&name); + if let Some(reject) = guard_agent_name(&state, &logical).await { + return reject; + } + state.coord.rebuild_queue.enqueue( + crate::rebuild_queue::QueueKind::Restart, + logical, + crate::rebuild_queue::QueueSource::Manual, + "manual via dashboard ↺ R3START button".to_owned(), + None, + ); + state.coord.emit_rebuild_queue_snapshot(); + (StatusCode::OK, "ok").into_response() +} + +pub(super) async fn post_start( + State(state): State, + AxumPath(name): AxumPath, +) -> Response { + let logical = strip_container_prefix(&name); + if let Some(reject) = guard_agent_name(&state, &logical).await { + return reject; + } + lifecycle_action( + &state, + &name, + crate::coordinator::TransientKind::Starting, + "start", + |n| async move { lifecycle::start(&n).await }, + |s, n| s.coord.kick_agent(n, "container started"), + ) + .await +} + +pub(super) async fn post_update_all(State(state): State) -> Response { + let containers = lifecycle::list().await.unwrap_or_default(); + for container in containers { + let Some(logical) = container + .strip_prefix(lifecycle::AGENT_PREFIX) + .map(str::to_owned) + else { + continue; + }; + state.coord.rebuild_queue.enqueue( + crate::rebuild_queue::QueueKind::Rebuild, + logical, + crate::rebuild_queue::QueueSource::Manual, + "manual via dashboard 🌀 UPDATE ALL".to_owned(), + None, + ); + } + state.coord.emit_rebuild_queue_snapshot(); + (StatusCode::OK, "ok").into_response() +} + +#[derive(Deserialize, Default)] +pub(super) struct DestroyForm { + #[serde(default)] + purge: Option, +} + +pub(super) async fn post_destroy( + State(state): State, + AxumPath(name): AxumPath, + Form(form): Form, +) -> Response { + if let Some(reject) = guard_agent_name(&state, &name).await { + return reject; + } + // Checkbox semantics: any non-empty value (axum sends "on") = purge. + let purge = form.purge.as_deref().is_some_and(|v| !v.is_empty()); + // `actions::destroy` rescans the container list on success, so the + // `ContainerRemoved` event lands before we return 200. The matching + // form carries `data-no-refresh`. + match actions::destroy(&state.coord, &name, purge).await { + Ok(()) => (StatusCode::OK, "ok").into_response(), + Err(e) => error_response(&format!("destroy {name} failed: {e:#}")), + } +} From ec3ca216c517ae7ed33a8b743c9b6e5b390bc841 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 23:29:15 +0200 Subject: [PATCH 12/14] refactor(#1456): extract dashboard approval + diff endpoints into dashboard/approvals.rs --- hive-c0re/src/dashboard.rs | 220 ++----------------------- hive-c0re/src/dashboard/approvals.rs | 229 +++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 209 deletions(-) create mode 100644 hive-c0re/src/dashboard/approvals.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 02eb9454..7fa508d4 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -25,11 +25,11 @@ use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt}; use tower_http::services::ServeDir; -use crate::actions; use crate::container_view::{ContainerView, claude_has_session}; use crate::coordinator::Coordinator; use crate::lifecycle::{self, MANAGER_NAME}; +mod approvals; mod build_logs; mod journal; mod lifecycle_ops; @@ -40,6 +40,12 @@ mod schedules; mod topology; mod webhook; +// Pre-computed at approval-submit time by the manager-socket handler +// (`manager_server.rs`) and embedded in the `ApprovalAdded` event, so +// re-exported at the module root to preserve the `crate::dashboard::approval_diff` +// path across the submodule split. +pub(crate) use approvals::approval_diff; + #[derive(Clone)] struct AppState { coord: Arc, @@ -68,8 +74,8 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { tracing::info!(static_dir = %static_dir.display(), "dashboard static dir resolved"); let app = Router::new() .route("/api/state", get(api_state)) - .route("/approve/{id}", post(post_approve)) - .route("/deny/{id}", post(post_deny)) + .route("/approve/{id}", post(approvals::post_approve)) + .route("/deny/{id}", post(approvals::post_deny)) .route("/destroy/{name}", post(lifecycle_ops::post_destroy)) .route("/kill/{name}", post(lifecycle_ops::post_kill)) .route("/restart/{name}", post(lifecycle_ops::post_restart)) @@ -87,7 +93,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/purge-tombstone/{name}", post(post_purge_tombstone)) .route("/api/journal/{name}", get(journal::get_journal)) .route("/api/journal-host", get(journal::get_journal_host)) - .route("/api/approval-diff/{id}", get(get_approval_diff)) + .route("/api/approval-diff/{id}", get(approvals::get_approval_diff)) .route("/api/state-file", get(get_state_file)) .route("/api/reminders", get(reminders::api_reminders)) .route("/api/operator-inbox", get(api_operator_inbox)) @@ -476,7 +482,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J let containers = state.coord.containers_snapshot().await; let any_stale = containers.iter().any(|c| c.needs_update); let transient_snapshot = state.coord.transient_snapshot(); - let pending_approvals = gc_orphans( + let pending_approvals = approvals::gc_orphans( &state.coord, log_default("approvals.pending", state.coord.approvals.pending()), ); @@ -1070,39 +1076,6 @@ async fn dashboard_stream( Sse::new(stream).keep_alive(KeepAlive::default()) } -async fn post_approve(State(state): State, AxumPath(id): AxumPath) -> Response { - match actions::approve(state.coord.clone(), id).await { - // 200 instead of 303 — `actions::approve` fires - // `ApprovalResolved` (success path) or the eventual failure - // event, both of which the dashboard's derived store applies - // live. The matching form carries `data-no-refresh`. - Ok(()) => (StatusCode::OK, "ok").into_response(), - Err(e) => error_response(&format!("approve {id} failed: {e:#}")), - } -} - -#[derive(Deserialize, Default)] -struct DenyForm { - #[serde(default)] - note: Option, -} - -async fn post_deny( - State(state): State, - AxumPath(id): AxumPath, - Form(form): Form, -) -> Response { - let note = form - .note - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()); - match actions::deny(&state.coord, id, note).await { - Ok(()) => (StatusCode::OK, "ok").into_response(), - Err(e) => error_response(&format!("deny {id} failed: {e:#}")), - } -} - #[derive(Deserialize)] struct RequestSpawnForm { name: String, @@ -1891,174 +1864,3 @@ fn error_response(message: &str) -> Response { // wrapping would just clutter the message. (StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response() } - -/// Filter out approvals whose agent state dir was wiped out from under us -/// (e.g. by a test script's cleanup). Marks them failed so they fall out of -/// `pending` on next render. -fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { - approvals - .into_iter() - .filter(|a| { - // Spawn and InitConfig approvals are for not-yet-existent agents; - // the proposed dir is supposed to be missing. - if matches!( - a.kind, - hive_sh4re::ApprovalKind::Spawn | hive_sh4re::ApprovalKind::InitConfig - ) { - return true; - } - if Coordinator::agent_proposed_dir(&a.agent).exists() { - true - } else { - let note = "agent state dir missing"; - let _ = coord.approvals.mark_failed(a.id, note); - tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval"); - let sha_short = a - .fetched_sha - .as_deref() - .map(|s| s[..s.len().min(12)].to_owned()); - coord.emit_approval_resolved( - a.id, - &a.agent, - "apply_commit", - sha_short, - "failed", - Some(note.to_owned()), - a.description.clone(), - ); - false - } - }) - .collect() -} - -/// Multi-file unified diff between the currently-deployed tree and -/// the proposal for this approval. Runs against the applied repo -/// since the canonical proposal commit lives there (manager-side -/// amendments don't move it). Empty output means proposal == main — -/// a no-op approval. -/// -/// `pub(crate)` so the manager-socket handler can pre-compute the -/// diff once at submission time and embed it in the `ApprovalAdded` -/// dashboard event (instead of forcing the dashboard to wait a -/// `/api/state` cycle to see the diff for newly-queued approvals). -pub(crate) async fn approval_diff(agent: &str, approval_id: i64) -> String { - let applied = Coordinator::agent_applied_dir(agent); - if !applied.join(".git").exists() { - return format!("(no applied git repo at {})", applied.display()); - } - let proposal_ref = format!("refs/tags/proposal/{approval_id}"); - 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_refs(applied_dir: &Path, base_ref: &str, target_ref: &str) -> Result { - let out = lifecycle::git_command() - .current_dir(applied_dir) - .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 {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/hive-c0re/src/dashboard/approvals.rs b/hive-c0re/src/dashboard/approvals.rs new file mode 100644 index 00000000..3990678c --- /dev/null +++ b/hive-c0re/src/dashboard/approvals.rs @@ -0,0 +1,229 @@ +//! Approval endpoints + diff machinery for the dashboard. +//! +//! Approve/deny actions, the orphan-approval GC sweep used by the +//! `/api/state` builder, and the unified-diff endpoints (on-demand +//! `/api/approval-diff/{id}` against a chosen base, plus the `pub(crate)` +//! `approval_diff` the manager-socket handler pre-computes at submit time). + +use std::path::Path; + +use anyhow::{Context, Result}; +use axum::{ + extract::{Form, Path as AxumPath, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use hive_sh4re::Approval; +use serde::Deserialize; + +use super::{AppState, error_response}; +use crate::actions; +use crate::coordinator::Coordinator; +use crate::lifecycle; + +pub(super) async fn post_approve( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match actions::approve(state.coord.clone(), id).await { + // 200 instead of 303 — `actions::approve` fires + // `ApprovalResolved` (success path) or the eventual failure + // event, both of which the dashboard's derived store applies + // live. The matching form carries `data-no-refresh`. + Ok(()) => (StatusCode::OK, "ok").into_response(), + Err(e) => error_response(&format!("approve {id} failed: {e:#}")), + } +} + +#[derive(Deserialize, Default)] +pub(super) struct DenyForm { + #[serde(default)] + note: Option, +} + +pub(super) async fn post_deny( + State(state): State, + AxumPath(id): AxumPath, + Form(form): Form, +) -> Response { + let note = form + .note + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + match actions::deny(&state.coord, id, note).await { + Ok(()) => (StatusCode::OK, "ok").into_response(), + Err(e) => error_response(&format!("deny {id} failed: {e:#}")), + } +} + +/// Filter out approvals whose agent state dir was wiped out from under us +/// (e.g. by a test script's cleanup). Marks them failed so they fall out of +/// `pending` on next render. +pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { + approvals + .into_iter() + .filter(|a| { + // Spawn and InitConfig approvals are for not-yet-existent agents; + // the proposed dir is supposed to be missing. + if matches!( + a.kind, + hive_sh4re::ApprovalKind::Spawn | hive_sh4re::ApprovalKind::InitConfig + ) { + return true; + } + if Coordinator::agent_proposed_dir(&a.agent).exists() { + true + } else { + let note = "agent state dir missing"; + let _ = coord.approvals.mark_failed(a.id, note); + tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval"); + let sha_short = a + .fetched_sha + .as_deref() + .map(|s| s[..s.len().min(12)].to_owned()); + coord.emit_approval_resolved( + a.id, + &a.agent, + "apply_commit", + sha_short, + "failed", + Some(note.to_owned()), + a.description.clone(), + ); + false + } + }) + .collect() +} + +/// Multi-file unified diff between the currently-deployed tree and +/// the proposal for this approval. Runs against the applied repo +/// since the canonical proposal commit lives there (manager-side +/// amendments don't move it). Empty output means proposal == main — +/// a no-op approval. +/// +/// `pub(crate)` so the manager-socket handler can pre-compute the +/// diff once at submission time and embed it in the `ApprovalAdded` +/// dashboard event (instead of forcing the dashboard to wait a +/// `/api/state` cycle to see the diff for newly-queued approvals). +pub(crate) async fn approval_diff(agent: &str, approval_id: i64) -> String { + let applied = Coordinator::agent_applied_dir(agent); + if !applied.join(".git").exists() { + return format!("(no applied git repo at {})", applied.display()); + } + let proposal_ref = format!("refs/tags/proposal/{approval_id}"); + 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_refs(applied_dir: &Path, base_ref: &str, target_ref: &str) -> Result { + let out = lifecycle::git_command() + .current_dir(applied_dir) + .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 {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)] +pub(super) 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. +pub(super) 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() +} From df3058e311dda8bd47a69af6e3ecc04edbd5589d Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 23:46:47 +0200 Subject: [PATCH 13/14] refactor(#1456): extract dashboard state-file proxy + path-validation into dashboard/state_files.rs --- hive-c0re/src/dashboard.rs | 294 +---------------------- hive-c0re/src/dashboard/state_files.rs | 308 +++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 288 deletions(-) create mode 100644 hive-c0re/src/dashboard/state_files.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 7fa508d4..d5e20fc3 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -37,6 +37,7 @@ mod permissions; mod questions; mod reminders; mod schedules; +mod state_files; mod topology; mod webhook; @@ -45,6 +46,10 @@ mod webhook; // re-exported at the module root to preserve the `crate::dashboard::approval_diff` // path across the submodule split. pub(crate) use approvals::approval_diff; +// Run at broker-message ingest by the coordinator + the operator-msg path +// (`main.rs`); re-exported to preserve the `crate::dashboard::scan_validated_paths` +// path across the split. +pub use state_files::scan_validated_paths; #[derive(Clone)] struct AppState { @@ -94,7 +99,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/api/journal/{name}", get(journal::get_journal)) .route("/api/journal-host", get(journal::get_journal_host)) .route("/api/approval-diff/{id}", get(approvals::get_approval_diff)) - .route("/api/state-file", get(get_state_file)) + .route("/api/state-file", get(state_files::get_state_file)) .route("/api/reminders", get(reminders::api_reminders)) .route("/api/operator-inbox", get(api_operator_inbox)) .route("/api/stats-hive", get(api_stats_hive)) @@ -1081,139 +1086,9 @@ struct RequestSpawnForm { name: String, } -#[derive(Deserialize)] -struct StateFileQuery { - path: String, -} - -/// Resolve a caller-supplied path against the allow-listed roots -/// (`agents//state/` and `shared/`). Applies defense-in-depth -/// symlink + traversal checks before serving. Security model and -/// all five layers: `docs/security.md::State-file endpoint`. -fn resolve_state_path( - raw: &str, -) -> std::result::Result<(std::path::PathBuf, std::fs::Metadata), String> { - use std::os::unix::fs::PermissionsExt as _; - const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents"; - const SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; - let raw = raw.trim(); - let (mapped, root): (std::path::PathBuf, &str) = - if let Some(rest) = raw.strip_prefix("/agents/") { - ( - std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")), - AGENTS_ROOT, - ) - } else if let Some(rest) = raw.strip_prefix("/shared/") { - ( - std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")), - SHARED_ROOT, - ) - } else if let Some(rest) = raw.strip_prefix(&format!("{AGENTS_ROOT}/")) { - ( - std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")), - AGENTS_ROOT, - ) - } else if let Some(rest) = raw.strip_prefix(&format!("{SHARED_ROOT}/")) { - ( - std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")), - SHARED_ROOT, - ) - } else { - return Err(format!("path not in allow-list: {raw}")); - }; - reject_symlinks_below(std::path::Path::new(root), &mapped)?; - let canonical = - std::fs::canonicalize(&mapped).map_err(|e| format!("{}: {e}", mapped.display()))?; - if !(canonical.starts_with(AGENTS_ROOT) || canonical.starts_with(SHARED_ROOT)) { - return Err(format!( - "resolved path escapes allow-list: {}", - canonical.display() - )); - } - if let Ok(rel) = canonical.strip_prefix(AGENTS_ROOT) { - let mut components = rel.components(); - let _agent = components.next(); - let dir = components.next().and_then(|c| c.as_os_str().to_str()); - if dir != Some("state") { - return Err(format!( - "only per-agent state/ is readable here ({} dir not allowed)", - dir.unwrap_or("(root)") - )); - } - } - let meta = - std::fs::metadata(&canonical).map_err(|e| format!("stat {}: {e}", canonical.display()))?; - if meta.is_file() { - let mode = meta.permissions().mode(); - if mode & 0o004 == 0 { - return Err(format!( - "{} not world-readable (mode 0{:o}); refusing to proxy non-public file", - canonical.display(), - mode & 0o777, - )); - } - } - Ok((canonical, meta)) -} - -/// Walk every path component under `root` and refuse if any of -/// them is a symlink. The roots themselves (`AGENTS_ROOT`, -/// `SHARED_ROOT`) are hive-c0re-owned and assumed trusted; only -/// the parts the agent / operator can plant matter. Components -/// that don't exist yet are skipped — `canonicalize` reports -/// non-existence separately, and missing-component checks would -/// just race the filesystem. -fn reject_symlinks_below( - root: &std::path::Path, - mapped: &std::path::Path, -) -> std::result::Result<(), String> { - let Ok(rel) = mapped.strip_prefix(root) else { - return Ok(()); - }; - let mut cumulative = root.to_path_buf(); - for component in rel.components() { - match component { - std::path::Component::Normal(name) => { - cumulative.push(name); - match std::fs::symlink_metadata(&cumulative) { - Ok(m) if m.file_type().is_symlink() => { - return Err(format!( - "symlink at {} not allowed (canonicalize would resolve it past the \ - allow-list check; refuse outright)", - cumulative.display() - )); - } - Ok(_) | Err(_) => {} - } - } - std::path::Component::ParentDir => { - return Err(format!( - "path contains `..` traversal below {}; refuse outright", - root.display() - )); - } - _ => {} - } - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; - use std::os::unix::fs::symlink; - - /// Make a unique tmp subdir for the calling test. Caller is responsible - /// for cleanup (we leak on panic, fine for ephemeral CI runs). - fn tmproot(tag: &str) -> std::path::PathBuf { - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_nanos()); - let p = std::env::temp_dir().join(format!("hyperhive-test-{tag}-{ts}")); - std::fs::create_dir_all(&p).unwrap(); - p - } - #[test] fn walk_meta_inputs_keeps_nixpkgs_under_hyperhive_post_follows_refactor() { // Reproduce the shape where meta has @@ -1318,61 +1193,6 @@ mod tests { assert!(validate_agent_name("damóclès").is_some()); assert!(validate_agent_name("alice\u{2013}bob").is_some()); // en-dash } - - #[test] - fn reject_symlinks_below_accepts_plain_dirs_and_files() { - let root = tmproot("symlink-ok"); - std::fs::create_dir_all(root.join("alice/state")).unwrap(); - std::fs::write(root.join("alice/state/notes.md"), b"hi").unwrap(); - assert!(reject_symlinks_below(&root, &root.join("alice/state/notes.md")).is_ok()); - } - - #[test] - fn reject_symlinks_below_rejects_leaf_symlink() { - let root = tmproot("symlink-leaf"); - std::fs::create_dir_all(root.join("alice/state")).unwrap(); - // Plant a symlink that points anywhere; resolve_state_path's - // canonicalize would happily resolve it past the allow-list - // check, so we have to refuse at the un-canonical layer. - symlink("/etc/shadow", root.join("alice/state/peek")).unwrap(); - let err = reject_symlinks_below(&root, &root.join("alice/state/peek")).unwrap_err(); - assert!(err.contains("symlink at"), "msg = {err}"); - assert!(err.contains("peek"), "msg = {err}"); - } - - #[test] - fn reject_symlinks_below_rejects_directory_symlink_in_middle() { - let root = tmproot("symlink-mid"); - std::fs::create_dir_all(root.join("real/state")).unwrap(); - std::fs::write(root.join("real/state/secret.md"), b"hi").unwrap(); - // alice's "state" dir is actually a symlink to real/state — a - // sub-agent shouldn't be able to plant this and proxy real's - // private files via the dashboard. - std::fs::create_dir_all(root.join("alice")).unwrap(); - symlink(root.join("real/state"), root.join("alice/state")).unwrap(); - let err = reject_symlinks_below(&root, &root.join("alice/state/secret.md")).unwrap_err(); - assert!(err.contains("symlink at"), "msg = {err}"); - } - - #[test] - fn reject_symlinks_below_rejects_parent_dir_traversal() { - let root = tmproot("symlink-dotdot"); - // `..` doesn't survive canonicalize anyway, but we want a - // friendlier error than "path escapes allow-list" — refusing - // upfront also avoids walking ancestors with `symlink_metadata`. - let p = root.join("alice/state/../escape"); - let err = reject_symlinks_below(&root, &p).unwrap_err(); - assert!(err.contains("`..`"), "msg = {err}"); - } - - #[test] - fn reject_symlinks_below_passes_through_when_path_not_under_root() { - // resolve_state_path's earlier allow-list check would reject - // this; reject_symlinks_below stays a no-op so the caller - // surfaces the better-fit error. - let root = std::path::Path::new("/var/lib/hyperhive/agents"); - assert!(reject_symlinks_below(root, std::path::Path::new("/etc/shadow")).is_ok()); - } } /// Snapshot the current tombstone list and emit a @@ -1401,108 +1221,6 @@ pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) { }); } -/// Scan `body` for path-shaped tokens and return those that pass the -/// allow-list + `is_file` check via `resolve_state_path`. Called at -/// broker-message ingest so the dashboard event already carries the -/// verified set; security rules stay in sync with the read endpoint. -pub fn scan_validated_paths(body: &str) -> Vec { - const PREFIXES: [&str; 4] = [ - "/agents/", - "/shared/", - "/var/lib/hyperhive/agents/", - "/var/lib/hyperhive/shared/", - ]; - let mut out = Vec::::new(); - for raw in body.split(|c: char| c.is_whitespace()) { - // Trim trailing natural-language punctuation that wouldn't - // be part of any real path. Inline rather than via a regex - // dep — the set is small and the call is hot. - let token = raw.trim_end_matches([',', ';', ':', ')', ']', '}', '.', '\'', '"']); - if token.is_empty() { - continue; - } - if !PREFIXES.iter().any(|p| token.starts_with(p)) { - continue; - } - // Cheap dedupe — typical message has 0-3 refs. - if out.iter().any(|s| s == token) { - continue; - } - if let Ok((_canonical, meta)) = resolve_state_path(token) - && meta.is_file() - { - out.push(token.to_owned()); - } - } - out -} - -async fn get_state_file(axum::extract::Query(q): axum::extract::Query) -> Response { - const MAX_BYTES: usize = 1 << 20; // 1 MiB - let (canonical, meta) = match resolve_state_path(&q.path) { - Ok(pair) => pair, - Err(e) => return error_response(&format!("state-file: {e}")), - }; - if !meta.is_file() { - return error_response(&format!( - "state-file: {} is not a regular file", - canonical.display() - )); - } - let size = meta.len(); - let bytes = match std::fs::read(&canonical) { - Ok(b) => b, - Err(e) => return error_response(&format!("state-file: read {}: {e}", canonical.display())), - }; - // Raster images: serve the raw bytes with their real content-type - // so the dashboard can render them in an . Not truncated — - // a clipped binary is corrupt, so over-cap images are rejected - // instead. (SVG stays on the text path: it's text, and the client - // renders it via a data: URI.) - if let Some(ct) = image_content_type(&canonical) { - if bytes.len() > MAX_BYTES { - return error_response(&format!( - "state-file: image {} is {size} bytes, over the {MAX_BYTES}-byte preview cap", - canonical.display() - )); - } - return ([("content-type", ct)], bytes).into_response(); - } - let truncated = bytes.len() > MAX_BYTES; - let body_bytes = if truncated { - &bytes[..MAX_BYTES] - } else { - &bytes[..] - }; - let mut body = String::from_utf8_lossy(body_bytes).into_owned(); - if truncated { - use std::fmt::Write as _; - let _ = write!( - body, - "\n\n--- truncated at {MAX_BYTES} of {size} bytes ---\n" - ); - } - ([("content-type", "text/plain; charset=utf-8")], body).into_response() -} - -/// Content-type for a raster image the dashboard can preview in an -/// ``, keyed off the file extension. `None` for non-image, SVG, -/// and text files (SVG is served on the text path and rendered -/// client-side via a `data:` URI). -fn image_content_type(path: &Path) -> Option<&'static str> { - let ext = path.extension()?.to_str()?.to_ascii_lowercase(); - Some(match ext.as_str() { - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - "bmp" => "image/bmp", - "ico" => "image/x-icon", - "avif" => "image/avif", - _ => return None, - }) -} - /// Unread operator-directed messages for the dashboard's Y3R C4LL inbox /// (#1469). Returns messages addressed to `"operator"` that haven't been /// acked yet (the operator clears them via the existing diff --git a/hive-c0re/src/dashboard/state_files.rs b/hive-c0re/src/dashboard/state_files.rs new file mode 100644 index 00000000..002abde7 --- /dev/null +++ b/hive-c0re/src/dashboard/state_files.rs @@ -0,0 +1,308 @@ +//! State-file proxy + path-validation for the dashboard. +//! +//! `GET /api/state-file?path=…` serves an allow-listed file (per-agent +//! `state/` or `shared/`) with defense-in-depth symlink + traversal checks +//! (see `docs/security.md::State-file endpoint`); raster images are served +//! with their real content-type, everything else as truncated text. +//! `scan_validated_paths` runs the same allow-list at broker-message ingest +//! so dashboard events carry a pre-verified file-ref set. + +use std::path::Path; + +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; + +use super::error_response; + +#[derive(Deserialize)] +pub(super) struct StateFileQuery { + path: String, +} + +/// Resolve a caller-supplied path against the allow-listed roots +/// (`agents//state/` and `shared/`). Applies defense-in-depth +/// symlink + traversal checks before serving. Security model and +/// all five layers: `docs/security.md::State-file endpoint`. +fn resolve_state_path( + raw: &str, +) -> std::result::Result<(std::path::PathBuf, std::fs::Metadata), String> { + use std::os::unix::fs::PermissionsExt as _; + const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents"; + const SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; + let raw = raw.trim(); + let (mapped, root): (std::path::PathBuf, &str) = + if let Some(rest) = raw.strip_prefix("/agents/") { + ( + std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")), + AGENTS_ROOT, + ) + } else if let Some(rest) = raw.strip_prefix("/shared/") { + ( + std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")), + SHARED_ROOT, + ) + } else if let Some(rest) = raw.strip_prefix(&format!("{AGENTS_ROOT}/")) { + ( + std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")), + AGENTS_ROOT, + ) + } else if let Some(rest) = raw.strip_prefix(&format!("{SHARED_ROOT}/")) { + ( + std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")), + SHARED_ROOT, + ) + } else { + return Err(format!("path not in allow-list: {raw}")); + }; + reject_symlinks_below(std::path::Path::new(root), &mapped)?; + let canonical = + std::fs::canonicalize(&mapped).map_err(|e| format!("{}: {e}", mapped.display()))?; + if !(canonical.starts_with(AGENTS_ROOT) || canonical.starts_with(SHARED_ROOT)) { + return Err(format!( + "resolved path escapes allow-list: {}", + canonical.display() + )); + } + if let Ok(rel) = canonical.strip_prefix(AGENTS_ROOT) { + let mut components = rel.components(); + let _agent = components.next(); + let dir = components.next().and_then(|c| c.as_os_str().to_str()); + if dir != Some("state") { + return Err(format!( + "only per-agent state/ is readable here ({} dir not allowed)", + dir.unwrap_or("(root)") + )); + } + } + let meta = + std::fs::metadata(&canonical).map_err(|e| format!("stat {}: {e}", canonical.display()))?; + if meta.is_file() { + let mode = meta.permissions().mode(); + if mode & 0o004 == 0 { + return Err(format!( + "{} not world-readable (mode 0{:o}); refusing to proxy non-public file", + canonical.display(), + mode & 0o777, + )); + } + } + Ok((canonical, meta)) +} + +/// Walk every path component under `root` and refuse if any of +/// them is a symlink. The roots themselves (`AGENTS_ROOT`, +/// `SHARED_ROOT`) are hive-c0re-owned and assumed trusted; only +/// the parts the agent / operator can plant matter. Components +/// that don't exist yet are skipped — `canonicalize` reports +/// non-existence separately, and missing-component checks would +/// just race the filesystem. +fn reject_symlinks_below( + root: &std::path::Path, + mapped: &std::path::Path, +) -> std::result::Result<(), String> { + let Ok(rel) = mapped.strip_prefix(root) else { + return Ok(()); + }; + let mut cumulative = root.to_path_buf(); + for component in rel.components() { + match component { + std::path::Component::Normal(name) => { + cumulative.push(name); + match std::fs::symlink_metadata(&cumulative) { + Ok(m) if m.file_type().is_symlink() => { + return Err(format!( + "symlink at {} not allowed (canonicalize would resolve it past the \ + allow-list check; refuse outright)", + cumulative.display() + )); + } + Ok(_) | Err(_) => {} + } + } + std::path::Component::ParentDir => { + return Err(format!( + "path contains `..` traversal below {}; refuse outright", + root.display() + )); + } + _ => {} + } + } + Ok(()) +} + +/// Scan `body` for path-shaped tokens and return those that pass the +/// allow-list + `is_file` check via `resolve_state_path`. Called at +/// broker-message ingest so the dashboard event already carries the +/// verified set; security rules stay in sync with the read endpoint. +pub fn scan_validated_paths(body: &str) -> Vec { + const PREFIXES: [&str; 4] = [ + "/agents/", + "/shared/", + "/var/lib/hyperhive/agents/", + "/var/lib/hyperhive/shared/", + ]; + let mut out = Vec::::new(); + for raw in body.split(|c: char| c.is_whitespace()) { + // Trim trailing natural-language punctuation that wouldn't + // be part of any real path. Inline rather than via a regex + // dep — the set is small and the call is hot. + let token = raw.trim_end_matches([',', ';', ':', ')', ']', '}', '.', '\'', '"']); + if token.is_empty() { + continue; + } + if !PREFIXES.iter().any(|p| token.starts_with(p)) { + continue; + } + // Cheap dedupe — typical message has 0-3 refs. + if out.iter().any(|s| s == token) { + continue; + } + if let Ok((_canonical, meta)) = resolve_state_path(token) + && meta.is_file() + { + out.push(token.to_owned()); + } + } + out +} + +pub(super) async fn get_state_file( + axum::extract::Query(q): axum::extract::Query, +) -> Response { + const MAX_BYTES: usize = 1 << 20; // 1 MiB + let (canonical, meta) = match resolve_state_path(&q.path) { + Ok(pair) => pair, + Err(e) => return error_response(&format!("state-file: {e}")), + }; + if !meta.is_file() { + return error_response(&format!( + "state-file: {} is not a regular file", + canonical.display() + )); + } + let size = meta.len(); + let bytes = match std::fs::read(&canonical) { + Ok(b) => b, + Err(e) => return error_response(&format!("state-file: read {}: {e}", canonical.display())), + }; + // Raster images: serve the raw bytes with their real content-type + // so the dashboard can render them in an . Not truncated — + // a clipped binary is corrupt, so over-cap images are rejected + // instead. (SVG stays on the text path: it's text, and the client + // renders it via a data: URI.) + if let Some(ct) = image_content_type(&canonical) { + if bytes.len() > MAX_BYTES { + return error_response(&format!( + "state-file: image {} is {size} bytes, over the {MAX_BYTES}-byte preview cap", + canonical.display() + )); + } + return ([("content-type", ct)], bytes).into_response(); + } + let truncated = bytes.len() > MAX_BYTES; + let body_bytes = if truncated { + &bytes[..MAX_BYTES] + } else { + &bytes[..] + }; + let mut body = String::from_utf8_lossy(body_bytes).into_owned(); + if truncated { + use std::fmt::Write as _; + let _ = write!( + body, + "\n\n--- truncated at {MAX_BYTES} of {size} bytes ---\n" + ); + } + ([("content-type", "text/plain; charset=utf-8")], body).into_response() +} + +/// Content-type for a raster image the dashboard can preview in an +/// ``, keyed off the file extension. `None` for non-image, SVG, +/// and text files (SVG is served on the text path and rendered +/// client-side via a `data:` URI). +fn image_content_type(path: &Path) -> Option<&'static str> { + let ext = path.extension()?.to_str()?.to_ascii_lowercase(); + Some(match ext.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "bmp" => "image/bmp", + "ico" => "image/x-icon", + "avif" => "image/avif", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + + /// Make a unique tmp subdir for the calling test. Caller is responsible + /// for cleanup (we leak on panic, fine for ephemeral CI runs). + fn tmproot(tag: &str) -> std::path::PathBuf { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let p = std::env::temp_dir().join(format!("hyperhive-test-{tag}-{ts}")); + std::fs::create_dir_all(&p).unwrap(); + p + } + + #[test] + fn reject_symlinks_below_accepts_plain_dirs_and_files() { + let root = tmproot("symlink-ok"); + std::fs::create_dir_all(root.join("alice/state")).unwrap(); + std::fs::write(root.join("alice/state/notes.md"), b"hi").unwrap(); + assert!(reject_symlinks_below(&root, &root.join("alice/state/notes.md")).is_ok()); + } + + #[test] + fn reject_symlinks_below_rejects_leaf_symlink() { + let root = tmproot("symlink-leaf"); + std::fs::create_dir_all(root.join("alice/state")).unwrap(); + // Plant a symlink that points anywhere; resolve_state_path's + // canonicalize would happily resolve it past the allow-list + // check, so we have to refuse at the un-canonical layer. + symlink("/etc/shadow", root.join("alice/state/peek")).unwrap(); + let err = reject_symlinks_below(&root, &root.join("alice/state/peek")).unwrap_err(); + assert!(err.contains("symlink at"), "msg = {err}"); + assert!(err.contains("peek"), "msg = {err}"); + } + + #[test] + fn reject_symlinks_below_rejects_directory_symlink_in_middle() { + let root = tmproot("symlink-mid"); + std::fs::create_dir_all(root.join("real/state")).unwrap(); + std::fs::write(root.join("real/state/secret.md"), b"hi").unwrap(); + // alice's "state" dir is actually a symlink to real/state — a + // sub-agent shouldn't be able to plant this and proxy real's + // private files via the dashboard. + std::fs::create_dir_all(root.join("alice")).unwrap(); + symlink(root.join("real/state"), root.join("alice/state")).unwrap(); + let err = reject_symlinks_below(&root, &root.join("alice/state/secret.md")).unwrap_err(); + assert!(err.contains("symlink at"), "msg = {err}"); + } + + #[test] + fn reject_symlinks_below_rejects_parent_dir_traversal() { + let root = tmproot("symlink-dotdot"); + // `..` doesn't survive canonicalize anyway, but we want a + // friendlier error than "path escapes allow-list" — refusing + // upfront also avoids walking ancestors with `symlink_metadata`. + let p = root.join("alice/state/../escape"); + let err = reject_symlinks_below(&root, &p).unwrap_err(); + assert!(err.contains("`..`"), "msg = {err}"); + } + + #[test] + fn reject_symlinks_below_passes_through_when_path_not_under_root() { + // resolve_state_path's earlier allow-list check would reject + // this; reject_symlinks_below stays a no-op so the caller + // surfaces the better-fit error. + let root = std::path::Path::new("/var/lib/hyperhive/agents"); + assert!(reject_symlinks_below(root, std::path::Path::new("/etc/shadow")).is_ok()); + } +} From 293e608ca4a5caa305773d2b1111684784080ff0 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 9 Jun 2026 00:18:01 +0200 Subject: [PATCH 14/14] refactor(frontend): invert the dashboard + H0M3 gutter (#1537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up from #1532. Previously `body.dashboard-shell` / `body.home-shell` carried the 1.5em horizontal gutter, and full-width chrome broke out of it with negative margins (`.dashboard-chrome { margin: 0 -1.5em }`, the `#server-warnings { margin: 0 -1.5em }` override). Invert it: `` is now full-bleed, and the padded page content lives in a single inner `.page-content` wrapper that carries the gutter. The server-warnings banner, the sticky tab strip, and the footer then span the full width for free — so the `-1.5em` breakout hacks are gone. - `.page-content { padding: 0 1.5em }` is a shared primitive in common.css (the dashboard + H0M3 both opt in by wrapping their content; FL0W / L0GS / ST4TS / S3TT1NGS stay full-bleed with their own `.-main` padding). - dashboard.html / index.html wrap their content in `.page-content`. - dashboard.css / home.css: drop the body horizontal gutter (keep `padding-bottom` for foot breathing room); `.dashboard-chrome` margin `0 -1.5em 1em` → `0 0 1em`. - common.css: drop the `#server-warnings` breakout override. Behaviour/visual-neutral by intent (content gutter unchanged; chrome + banner already rendered full-width via the old breakout). Worth a gui screenshot-diff to confirm — esp. the now-full-width footer divider. Last fold-in of #1464 step 2. --- frontend/packages/dashboard/src/common.css | 25 +++++++++++-------- frontend/packages/dashboard/src/dashboard.css | 17 +++++++------ .../packages/dashboard/src/dashboard.html | 5 ++++ frontend/packages/dashboard/src/home.css | 5 +++- frontend/packages/dashboard/src/index.html | 5 ++++ 5 files changed, 38 insertions(+), 19 deletions(-) diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css index b377eb58..305d68d5 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -519,6 +519,16 @@ body.side-panel-resizing * { cursor: ew-resize !important; } padding: 0.2em 0.5em; } +/* ─── page-content gutter ───────────────────────────────────────────── + Inverted-gutter layout: the dashboard + H0M3 keep `` full-bleed + and wrap their padded content in a single `.page-content` div that + carries the horizontal gutter. Full-width chrome (the tab strip, the + server-warnings banner, the footer) then spans edge-to-edge for free, + with no negative-margin breakout hacks. Only matches on pages that opt + in by adding the wrapper (dashboard.html, index.html); FL0W / L0GS / + ST4TS / S3TT1NGS are full-bleed with their own `.-main` padding. */ +.page-content { padding: 0 1.5em; } + /* ─── server warnings banner ────────────────────────────────────────── Generic top-of-page strip, injected at the top of by common.js (renderServerWarnings) on every page. One row per warning; @@ -530,16 +540,11 @@ body.side-panel-resizing * { cursor: ew-resize !important; } top: 0; z-index: 50; } -/* The bar is prepended as a direct child of . On pages that pad - the body gutter (the dashboard + H0M3 use a 1.5em horizontal gutter), - break out of that padding so the warning spans the full width - edge-to-edge — the same trick `.dashboard-chrome` uses. FL0W + L0GS - are already full-bleed (no body padding), so they need no override. */ -body.dashboard-shell #server-warnings, -body.home-shell #server-warnings { - margin-left: -1.5em; - margin-right: -1.5em; -} +/* The bar is prepended as a direct child of and spans the full + width edge-to-edge. The dashboard + H0M3 are full-bleed at the body + level — their 1.5em horizontal gutter lives on an inner `.page-content` + wrapper (see below), so the banner is full-width for free with no + breakout. FL0W + L0GS are full-bleed too. */ .server-warnings[hidden] { display: none; } .server-warn { text-align: center; diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 5cc492ea..dfd2eeb0 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -9,14 +9,15 @@ routing model. */ body.dashboard-shell { - /* Full-width layout — no max-width cap so wide screens don't - waste real estate on empty side margins. `padding: 0 1.5em - 1.5em` keeps a small gutter on the left/right so cards don't - kiss the viewport edge; `.dashboard-chrome { margin: 0 -1.5em - ... }` still pulls the chrome bar edge-to-edge through that - gutter. */ + /* Full-bleed body (no horizontal gutter). The 1.5em side gutter lives + on the inner `.page-content` wrapper (see common.css), so the sticky + chrome bar, the server-warnings banner, and the footer span the full + width edge-to-edge with no negative-margin breakout. `padding-bottom` + keeps a little breathing room above the viewport edge (overridden to + clear the selection bar via `.has-selection` below). No max-width cap + — wide screens use the full width rather than empty side margins. */ margin: 0; - padding: 0 1.5em 1.5em; + padding-bottom: 1.5em; } .dashboard-chrome { @@ -28,7 +29,7 @@ body.dashboard-shell { backdrop-filter: blur(8px) saturate(120%); border-bottom: 1px solid var(--purple-dim); padding: 0.4em 0 0; - margin: 0 -1.5em 1em; + margin: 0 0 1em; } /* ← home back-link to the H0M3 hub. Small purple link top-left of the chrome, mirroring the .logs-back treatment the sub-pages use so the diff --git a/frontend/packages/dashboard/src/dashboard.html b/frontend/packages/dashboard/src/dashboard.html index 07ef0a9b..676cac03 100644 --- a/frontend/packages/dashboard/src/dashboard.html +++ b/frontend/packages/dashboard/src/dashboard.html @@ -83,6 +83,10 @@ visible by toggling the `hidden` attribute, resolved from the URL hash (default SW4RM). Panes start `hidden` to avoid a flash before the script runs. --> + +
+