Compare commits

...
Author SHA1 Message Date
iris
293e608ca4 refactor(frontend): invert the dashboard + H0M3 gutter (#1537)
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: `<body>` 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 `.<page>-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.
2026-06-09 00:18:01 +02:00
damocles
df3058e311 refactor(#1456): extract dashboard state-file proxy + path-validation into dashboard/state_files.rs 2026-06-09 00:12:15 +02:00
damocles
ec3ca216c5 refactor(#1456): extract dashboard approval + diff endpoints into dashboard/approvals.rs 2026-06-09 00:12:15 +02:00
damocles
55705f17d3 refactor(#1456): extract dashboard lifecycle endpoints into dashboard/lifecycle_ops.rs 2026-06-09 00:12:15 +02:00
damocles
4e06a9682d refactor(#1456): extract dashboard build-log endpoints into dashboard/build_logs.rs 2026-06-09 00:12:15 +02:00
damocles
aa8bf11c8b refactor(#1456): extract dashboard topology set-parent endpoints into dashboard/topology.rs 2026-06-09 00:12:15 +02:00
damocles
aa6e422b78 refactor(#1456): extract dashboard question answer/cancel endpoints into dashboard/questions.rs 2026-06-09 00:12:15 +02:00
damocles
bee165ebc7 refactor(#1456): extract dashboard reminder endpoints into dashboard/reminders.rs 2026-06-09 00:12:15 +02:00
damocles
e2fdaae841 refactor(#1456): extract dashboard journal-read endpoints into dashboard/journal.rs 2026-06-09 00:12:15 +02:00
damocles
1255268f4f refactor(#1456): extract knowledge push-webhook endpoint into dashboard/webhook.rs 2026-06-09 00:12:15 +02:00
damocles
302738362a refactor(#1456): extract dashboard schedule + rebuild-queue endpoints into dashboard/schedules.rs 2026-06-09 00:12:15 +02:00
damocles
7ade5f27ea refactor(#1456): extract dashboard permission endpoints into dashboard/permissions.rs 2026-06-09 00:12:15 +02:00
iris
09cb705738 refactor(frontend): move ST4TS to its own /stats.html page (#1464 step 2)
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.
2026-06-09 00:11:43 +02:00
atlas
fdf05c1673 refactor(gateway): make the gateway unconditional — remove gateway.enable
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.
2026-06-09 00:11:10 +02:00
29 changed files with 2367 additions and 2065 deletions

View file

@ -52,7 +52,7 @@ mkdirSync(staticDir(''), { recursive: true });
// follow-up once asset sizes warrant it). esbuild writes each entry // follow-up once asset sizes warrant it). esbuild writes each entry
// to `static/<name>.js` based on the entryPoint basename. // to `static/<name>.js` based on the entryPoint basename.
await build({ 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(''), outdir: staticDir(''),
bundle: true, bundle: true,
format: 'esm', format: 'esm',
@ -91,7 +91,7 @@ await build({
// so a swap replaces only it) + theme.css (the semantic derivation // so a swap replaces only it) + theme.css (the semantic derivation
// layer) + common.css (shared typography, badges, buttons, inbox, side // layer) + common.css (shared typography, badges, buttons, inbox, side
// panel) plus its own page-specific bundle. // 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({ await build({
entryPoints: [src(entry)], entryPoints: [src(entry)],
outfile: staticDir(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)); copyFileSync(src(html), dist(html));
} }

View file

@ -519,6 +519,16 @@ body.side-panel-resizing * { cursor: ew-resize !important; }
padding: 0.2em 0.5em; padding: 0.2em 0.5em;
} }
/* page-content gutter
Inverted-gutter layout: the dashboard + H0M3 keep `<body>` 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 `.<page>-main` padding. */
.page-content { padding: 0 1.5em; }
/* server warnings banner /* server warnings banner
Generic top-of-page strip, injected at the top of <body> by Generic top-of-page strip, injected at the top of <body> by
common.js (renderServerWarnings) on every page. One row per warning; common.js (renderServerWarnings) on every page. One row per warning;
@ -530,16 +540,11 @@ body.side-panel-resizing * { cursor: ew-resize !important; }
top: 0; top: 0;
z-index: 50; z-index: 50;
} }
/* The bar is prepended as a direct child of <body>. On pages that pad /* The bar is prepended as a direct child of <body> and spans the full
the body gutter (the dashboard + H0M3 use a 1.5em horizontal gutter), width edge-to-edge. The dashboard + H0M3 are full-bleed at the body
break out of that padding so the warning spans the full width level their 1.5em horizontal gutter lives on an inner `.page-content`
edge-to-edge the same trick `.dashboard-chrome` uses. FL0W + L0GS wrapper (see below), so the banner is full-width for free with no
are already full-bleed (no body padding), so they need no override. */ breakout. FL0W + L0GS are full-bleed too. */
body.dashboard-shell #server-warnings,
body.home-shell #server-warnings {
margin-left: -1.5em;
margin-right: -1.5em;
}
.server-warnings[hidden] { display: none; } .server-warnings[hidden] { display: none; }
.server-warn { .server-warn {
text-align: center; text-align: center;
@ -558,3 +563,31 @@ body.home-shell #server-warnings {
background: color-mix(in srgb, var(--red) 18%, transparent); background: color-mix(in srgb, var(--red) 18%, transparent);
border-bottom: 1px solid var(--red); 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;
}

View file

@ -9,14 +9,15 @@
routing model. */ routing model. */
body.dashboard-shell { body.dashboard-shell {
/* Full-width layout no max-width cap so wide screens don't /* Full-bleed body (no horizontal gutter). The 1.5em side gutter lives
waste real estate on empty side margins. `padding: 0 1.5em on the inner `.page-content` wrapper (see common.css), so the sticky
1.5em` keeps a small gutter on the left/right so cards don't chrome bar, the server-warnings banner, and the footer span the full
kiss the viewport edge; `.dashboard-chrome { margin: 0 -1.5em width edge-to-edge with no negative-margin breakout. `padding-bottom`
... }` still pulls the chrome bar edge-to-edge through that keeps a little breathing room above the viewport edge (overridden to
gutter. */ 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; margin: 0;
padding: 0 1.5em 1.5em; padding-bottom: 1.5em;
} }
.dashboard-chrome { .dashboard-chrome {
@ -28,7 +29,7 @@ body.dashboard-shell {
backdrop-filter: blur(8px) saturate(120%); backdrop-filter: blur(8px) saturate(120%);
border-bottom: 1px solid var(--purple-dim); border-bottom: 1px solid var(--purple-dim);
padding: 0.4em 0 0; 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 /* 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 chrome, mirroring the .logs-back treatment the sub-pages use so the
@ -1392,97 +1393,14 @@ body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
cursor: default; cursor: default;
} }
/* ST4TS tab: hive-wide turn-stats rollup /* ST4TS moved to its own page (`/stats.html`): the
Plain tables + CSS bars (no chart lib in the dashboard bundle). */ window selector / summary chips / bars live in stats.css, and the
.hive-stats-windows { shared `.hive-stats-table` moved to common.css (the SYST3M
display: flex; C0NT41N3R L04D table below still uses it). */
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;
}
/* SYST3M C0NT41N3R L04D: live cgroup cpu/mem /* SYST3M C0NT41N3R L04D: live cgroup cpu/mem
Reuses the `.hive-stats-table` styling from the ST4TS tab; only the Reuses the shared `.hive-stats-table` styling (now in common.css);
inline meter bar is new. */ only the inline meter bar is new. */
.cload-meter { .cload-meter {
display: inline-block; display: inline-block;
width: 6em; width: 6em;

View file

@ -60,14 +60,9 @@
<span class="tab-count" id="tab-count-schedules" hidden></span> <span class="tab-count" id="tab-count-schedules" hidden></span>
</a> </a>
<!-- ST4TS: hive-wide turn-stats rollup (swarm totals, busiest <!-- ST4TS lives on its own page now (`/stats.html`), reached from
agents, model mix, est cost). Fetched on tab activation + the H0M3 hub — the hive-wide rollup is a standalone read-only
window change from GET /api/stats-hive. --> view, not part of the operational tab strip. -->
<a class="tab" id="tab-stats" href="#stats" role="tab"
aria-controls="tab-pane-stats"
data-tab="stats">
<span class="tab-label">◆ ST4TS ◆</span>
</a>
<!-- (Peer hives are no longer a tab — they render as a headline <!-- (Peer hives are no longer a tab — they render as a headline
under SW4RM. See tab-pane-swarm.) --> under SW4RM. See tab-pane-swarm.) -->
@ -88,6 +83,10 @@
visible by toggling the `hidden` attribute, resolved from the URL visible by toggling the `hidden` attribute, resolved from the URL
hash (default SW4RM). Panes start `hidden` to avoid a flash before hash (default SW4RM). Panes start `hidden` to avoid a flash before
the script runs. --> the script runs. -->
<!-- Padded content wrapper: carries the 1.5em side gutter
(.page-content, common.css) while <body> stays full-bleed so the
sticky chrome above + the footer below span the full width. -->
<div class="page-content">
<main class="dashboard-main"> <main class="dashboard-main">
<!-- SW4RM: the swarm itself. Container cards (the central thing <!-- SW4RM: the swarm itself. Container cards (the central thing
@ -241,35 +240,11 @@
</div> </div>
</section> </section>
<!-- ST4TS: hive-wide turn-stats aggregate. Plain tables/bars (the <!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
dashboard bundle has no chart lib); data from GET (`/stats.html`), reached from the H0M3 hub. The markup + the
/api/stats-hive?window=, fetched on tab activation + on hive-stats render JS moved there; when tabs.js boots on the
window change. Per-agent trend charts stay on each agent's dashboard the renderers are simply gone (no stats tab to
own /stats page. --> activate). -->
<section class="tab-pane" id="tab-pane-stats" data-tab-pane="stats" hidden
role="tabpanel" aria-labelledby="tab-stats">
<h2>◆ ST4TS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">hive-wide turn statistics, aggregated across every agent over the selected window. <strong>cost is a rough estimate</strong> from approximate per-model list prices — it drifts and is a ballpark, not a bill.</p>
<div class="hive-stats-windows" id="hive-stats-windows">
<button type="button" class="btn" data-w="1h">1h</button>
<button type="button" class="btn" data-w="4h">4h</button>
<button type="button" class="btn active" data-w="24h">24h</button>
<button type="button" class="btn" data-w="3d">3d</button>
<button type="button" class="btn" data-w="7d">7d</button>
<button type="button" class="btn" data-w="30d">30d</button>
</div>
<div class="hive-stats-chips" id="hive-stats-summary"></div>
<h3>◇ busiest agents</h3>
<div id="hive-stats-agents"><p class="meta">loading…</p></div>
<h3>◇ model mix (turns across the swarm)</h3>
<div id="hive-stats-models"></div>
<!-- "favorite tools": most-run bash commands across the swarm.
Header + list hidden until the hive-bash-mcp capture has
recorded data, so the section never shows an empty block. -->
<h3 id="hive-stats-bash-h" hidden>◇ favorite tools (bash commands across the swarm)</h3>
<div id="hive-stats-bash" hidden></div>
</section>
<!-- FL0W: lives on its own page now (`/flow.html`). The <!-- FL0W: lives on its own page now (`/flow.html`). The
message-flow + inbox + compose DOM only exists there — when message-flow + inbox + compose DOM only exists there — when
@ -283,6 +258,7 @@
localStorage + browser permission the settings page sets). --> localStorage + browser permission the settings page sets). -->
</main> </main>
</div><!-- /.page-content -->
<footer> <footer>
<pre class="banner banner-thin">░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre> <pre class="banner banner-thin">░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre>

View file

@ -5,8 +5,11 @@
styling can fold into it. */ styling can fold into it. */
body.home-shell { body.home-shell {
/* Full-bleed body; the 1.5em side gutter lives on the inner
`.page-content` wrapper (common.css) so the server-warnings banner
spans full width. `padding-bottom` keeps breathing room at the foot. */
margin: 0; margin: 0;
padding: 0 1.5em 2em; padding-bottom: 2em;
min-height: 100vh; min-height: 100vh;
} }

View file

@ -16,6 +16,10 @@
surface. This is the page served at `/` — the landing page — with surface. This is the page served at `/` — the landing page — with
the dashboard relocated to /dashboard.html. No tabbar / SSE — the dashboard relocated to /dashboard.html. No tabbar / SSE —
it's a static portal. --> it's a static portal. -->
<!-- Padded content wrapper: carries the 1.5em side gutter
(.page-content, common.css) while <body> stays full-bleed so the
server-warnings banner spans the full width. -->
<div class="page-content">
<header class="home-header"> <header class="home-header">
<p class="banner-thin" id="hive-identity" hidden></p> <p class="banner-thin" id="hive-identity" hidden></p>
<pre class="banner">░▒▓█▓▒░ ░▒▓█▓▒░ H Y P E R H I V E · H0M3 ░▒▓█▓▒░ ░▒▓█▓▒░</pre> <pre class="banner">░▒▓█▓▒░ ░▒▓█▓▒░ H Y P E R H I V E · H0M3 ░▒▓█▓▒░ ░▒▓█▓▒░</pre>
@ -26,7 +30,7 @@
<a class="home-tile" href="/dashboard.html"> <a class="home-tile" href="/dashboard.html">
<span class="home-tile-label">Dashboard</span> <span class="home-tile-label">Dashboard</span>
<span class="home-tile-desc">containers · approvals · system · stats</span> <span class="home-tile-desc">containers · approvals · permissions · schedules · system</span>
</a> </a>
<a class="home-tile" href="/flow.html"> <a class="home-tile" href="/flow.html">
@ -39,6 +43,11 @@
<span class="home-tile-desc">build · agent · system logs</span> <span class="home-tile-desc">build · agent · system logs</span>
</a> </a>
<a class="home-tile" href="/stats.html">
<span class="home-tile-label">Stats</span>
<span class="home-tile-desc">hive-wide turn stats · cost · model mix</span>
</a>
<a class="home-tile" href="/settings.html"> <a class="home-tile" href="/settings.html">
<span class="home-tile-label">Settings</span> <span class="home-tile-label">Settings</span>
<span class="home-tile-desc">operator-local prefs · browser notifications</span> <span class="home-tile-desc">operator-local prefs · browser notifications</span>
@ -54,6 +63,7 @@
</nav> </nav>
</main> </main>
</div><!-- /.page-content -->
<script type="module" src="/static/home.js" defer></script> <script type="module" src="/static/home.js" defer></script>
</body> </body>

View file

@ -0,0 +1,84 @@
/* /stats.html hive-wide turn-stats rollup
Extracted from the dashboard ST4TS tab. Same minimal-
chrome pattern as /flow.html and /logs.html: the back-link header +
title (.logs-header / .logs-back / .logs-title) live in common.css.
`.hive-stats-table` also lives in common.css it's shared with the
dashboard SYST3M C0NT41N3R L04D table. Only the ST4TS-specific
window selector, summary chips, and bars are page-specific. */
body.stats-shell {
margin: 0;
padding: 0;
}
.stats-main {
padding: 1.2em 1.5em 2em;
}
/* Window selector. Buttons keep the shared `.btn` pill look; the active
one is flagged by createTabStrip via `.hive-tab--active` (was a bespoke
`.active` toggle). */
.hive-stats-windows {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 12px 0;
}
.hive-stats-windows .btn.hive-tab--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-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;
}

View file

@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // ST4TS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/colors.css">
<link rel="stylesheet" href="/static/theme.css">
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/stats.css">
</head>
<body class="stats-shell">
<!-- Minimal chrome: back link + title. Same pattern as flow.html /
logs.html — no full dashboard tabbar. Back link points to the
H0M3 hub (served at /), not the dashboard. -->
<header class="logs-header">
<a class="logs-back" href="/">← home</a>
<span class="logs-title">ST4TS</span>
</header>
<main class="stats-main">
<p class="meta">hive-wide turn statistics, aggregated across every agent over the selected window. <strong>cost is a rough estimate</strong> from approximate per-model list prices — it drifts and is a ballpark, not a bill.</p>
<!-- Window selector — a hash-routed createTabStrip (#1h / #24h / …),
like the per-agent /stats page. Buttons keep the `.btn` pill
look; createTabStrip toggles `.hive-tab--active` and the page
re-fetches on change via onShow. -->
<nav class="hive-stats-windows" id="hive-stats-windows" role="tablist">
<button type="button" class="btn" data-tab="1h">1h</button>
<button type="button" class="btn" data-tab="4h">4h</button>
<button type="button" class="btn" data-tab="24h">24h</button>
<button type="button" class="btn" data-tab="3d">3d</button>
<button type="button" class="btn" data-tab="7d">7d</button>
<button type="button" class="btn" data-tab="30d">30d</button>
</nav>
<div class="hive-stats-chips" id="hive-stats-summary"></div>
<h3>◇ busiest agents</h3>
<div id="hive-stats-agents"><p class="meta">loading…</p></div>
<h3>◇ model mix (turns across the swarm)</h3>
<div id="hive-stats-models"></div>
<!-- "favorite tools": most-run bash commands across the swarm.
Header + list hidden until the hive-bash-mcp capture has
recorded data, so the section never shows an empty block. -->
<h3 id="hive-stats-bash-h" hidden>◇ favorite tools (bash commands across the swarm)</h3>
<div id="hive-stats-bash" hidden></div>
</main>
<script type="module" src="/static/stats.js" defer></script>
</body>
</html>

View file

@ -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 = '<thead><tr><th>agent</th><th>turns</th><th>input</th>'
+ '<th>output</th><th>cache read</th><th>est cost</th></tr></thead>';
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(); },
});

View file

@ -4055,11 +4055,11 @@ window.marked = marked;
})(); })();
// ─── tab routing ─────────────────────────────────────────────────────── // ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#swarm` / `#call` / `#system` / `#schedules` / // Hash-based: `#swarm` / `#call` / `#system` / `#permissions` /
// `#permissions` / `#stats` activate the matching pane on the // `#schedules` activate the matching pane on the dashboard. Empty
// dashboard. Empty hash defaults to SW4RM. FL0W and S3TT1NGS are NOT // hash defaults to SW4RM. FL0W, S3TT1NGS, and ST4TS are NOT tabs —
// tabs — they're separate pages (`/flow.html`, `/settings.html`) // they're separate pages (`/flow.html`, `/settings.html`,
// reached from the H0M3 hub. Tab routing only // `/stats.html`) reached from the H0M3 hub. Tab routing only
// applies when the tab DOM is present (e.g. not on the flow page // 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). // itself, where these elements don't exist and the loop no-ops).
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the // The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
@ -4085,176 +4085,16 @@ window.marked = marked;
fetchAndRenderCapabilities(); fetchAndRenderCapabilities();
fetchAndRenderToolGroups(); fetchAndRenderToolGroups();
} }
// ST4TS: hive-wide rollup is a pull (no SSE) — fetch on activation.
if (target === 'stats') { refreshHiveStats(); }
if (target === 'call') { refreshOperatorInbox(); } if (target === 'call') { refreshOperatorInbox(); }
// SYST3M C0NT41N3R L04D: live cgroup poll only while the tab is // SYST3M C0NT41N3R L04D: live cgroup poll only while the tab is
// open (cpu needs a short two-sample read each refresh). // open (cpu needs a short two-sample read each refresh).
if (target === 'system') { startContainerLoadPolling(); } else { stopContainerLoadPolling(); } if (target === 'system') { startContainerLoadPolling(); } else { stopContainerLoadPolling(); }
} }
// ─── ST4TS: hive-wide turn-stats rollup ────────────────────────────────── // ST4TS (hive-wide turn-stats rollup) moved to its own page,
// Pull-only (no SSE): fetched from /api/stats-hive on tab activation and // `/stats.html` — the render JS + the window selector live in
// on window change. Plain tables/bars — the dashboard bundle has no chart // stats.js now. The dashboard no longer fetches
// lib, and per-agent trend charts live on each agent's own /stats page. // /api/stats-hive.
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 = '<thead><tr><th>agent</th><th>turns</th><th>input</th>'
+ '<th>output</th><th>cache read</th><th>est cost</th></tr></thead>';
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();
// ─── SYST3M C0NT41N3R L04D: live per-container cgroup cpu/mem ──────────── // ─── SYST3M C0NT41N3R L04D: live per-container cgroup cpu/mem ────────────
// Pull-only, polled at 5s ONLY while the SYST3M tab is active (cpu is a // Pull-only, polled at 5s ONLY while the SYST3M tab is active (cpu is a

File diff suppressed because it is too large Load diff

View file

@ -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<AppState>,
AxumPath(id): AxumPath<i64>,
) -> 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<String>,
}
pub(super) async fn post_deny(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
Form(form): Form<DenyForm>,
) -> 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<Approval>) -> Vec<Approval> {
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<String> {
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 `<prefix>/<n>` 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<i64> {
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::<i64>().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<String>,
}
/// On-demand unified diff for one `ApplyCommit` approval against a
/// chosen base. `applied` = `applied/main` (what's running);
/// `approved` = the most recent earlier `approved/<n>` tag (the last
/// proposal the operator OK'd, even if its build then failed);
/// `previous` = the prior queued `proposal/<n>` (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<AppState>,
AxumPath(id): AxumPath<i64>,
axum::extract::Query(q): axum::extract::Query<DiffBaseQuery>,
) -> 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()
}

View file

@ -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<usize>,
}
/// `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<AppState>,
axum::extract::Query(q): axum::extract::Query<BuildLogsAllQuery>,
) -> 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<usize>,
}
/// `GET /api/build-logs/{agent}?limit=N` — most-recent build log
/// headers for one agent, newest first. Returns
/// `Vec<BuildLogHeader>` (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<AppState>,
AxumPath(name): AxumPath<String>,
axum::extract::Query(q): axum::extract::Query<BuildLogsQuery>,
) -> 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<AppState>,
AxumPath(id): AxumPath<i64>,
) -> 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<String>,
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<AppState>,
AxumPath(id): AxumPath<i64>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(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<AppState>,
AxumPath(id): AxumPath<i64>,
) -> 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:#}")),
}
}

View file

@ -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<String>,
/// Number of trailing lines to return. Capped at 5000.
#[serde(default)]
lines: Option<u32>,
}
/// Read `journalctl -M <container> -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<String>,
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
) -> 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<String>,
/// Number of trailing lines. Capped at 5000. Default 500.
#[serde(default)]
lines: Option<u32>,
}
/// `GET /api/journal-host?unit=<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<JournalHostQuery>,
) -> 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}")),
}
}

View file

@ -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<AppState>,
AxumPath(name): AxumPath<String>,
) -> 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<F, Fut>(
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<Output = anyhow::Result<()>>,
{
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<AppState>,
AxumPath(name): AxumPath<String>,
) -> 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<AppState>,
AxumPath(name): AxumPath<String>,
) -> 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<AppState>,
AxumPath(name): AxumPath<String>,
) -> 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<AppState>) -> 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<String>,
}
pub(super) async fn post_destroy(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
Form(form): Form<DestroyForm>,
) -> 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:#}")),
}
}

View file

@ -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<String, Vec<String>>,
}
pub(super) async fn get_tool_groups(
State(_state): State<AppState>,
) -> axum::Json<ToolGroupsSnapshot> {
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<String>,
}
pub(super) async fn post_tool_groups(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
axum::Json(body): axum::Json<SetToolGroupsBody>,
) -> 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<String, Vec<String>>,
}
pub(super) async fn get_capabilities(
State(_state): State<AppState>,
) -> axum::Json<CapabilitiesSnapshot> {
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<String>,
}
pub(super) async fn post_capabilities(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
axum::Json(body): axum::Json<SetCapabilitiesBody>,
) -> 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()
}

View file

@ -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<AppState>,
AxumPath(id): AxumPath<i64>,
Form(form): Form<AnswerForm>,
) -> 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<AppState>,
AxumPath(id): AxumPath<i64>,
) -> 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:#}")),
}
}

View file

@ -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<AppState>) -> 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<AppState>,
AxumPath(id): AxumPath<i64>,
) -> 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<AppState>,
AxumPath(id): AxumPath<i64>,
) -> 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:#}")),
}
}

View file

@ -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<AppState>) -> Response {
match state.coord.scheduled_prompts.list() {
Ok(rows) => axum::Json(
rows.into_iter()
.map(crate::manager_server::schedule_to_wire_public)
.collect::<Vec<_>>(),
)
.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<AppState>,
axum::Json(payload): axum::Json<hive_sh4re::SchedulePromptPayload>,
) -> 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<AppState>,
AxumPath(id): AxumPath<i64>,
) -> 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<AppState>,
AxumPath(id): AxumPath<u64>,
) -> 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<Vec<String>>,
}
#[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<String>,
/// 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<Option<_>>` and document
/// that the dashboard caller passes JSON `null` to clear.
#[serde(default, deserialize_with = "deserialize_some")]
description: Option<Option<String>>,
#[serde(default, deserialize_with = "deserialize_some")]
interval_seconds: Option<Option<u64>>,
#[serde(default)]
next_fire_at_unix: Option<i64>,
/// 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<Vec<String>>,
/// 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<Vec<String>>,
}
/// 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<Option<T>, 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<AppState>,
AxumPath(id): AxumPath<i64>,
axum::Json(form): axum::Json<EditScheduleForm>,
) -> 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<AppState>,
AxumPath(id): AxumPath<i64>,
body: Option<axum::Json<CancelScheduleForm>>,
) -> 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:#}")),
}
}

View file

@ -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/<n>/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<String> {
const PREFIXES: [&str; 4] = [
"/agents/",
"/shared/",
"/var/lib/hyperhive/agents/",
"/var/lib/hyperhive/shared/",
];
let mut out = Vec::<String>::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<StateFileQuery>,
) -> 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 <img>. 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
/// `<img>`, 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());
}
}

View file

@ -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<String>,
}
/// 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<String>,
}
/// `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<AppState>,
Form(form): Form<SetParentForm>,
) -> 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<AppState>,
axum::Json(body): axum::Json<Vec<SetParentBulkEntry>>,
) -> 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}")),
}
}

View file

@ -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<String>,
repository: Option<PushWebhookRepo>,
}
#[derive(Deserialize)]
pub(super) struct PushWebhookRepo {
full_name: Option<String>,
}
/// 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:<dashboard_port>/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<PushWebhookPayload>,
) -> 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()
}

View file

@ -31,7 +31,6 @@ let
services.hyperhive.enable = lib.mkForce false; services.hyperhive.enable = lib.mkForce false;
services.hyperhive.forge.enable = lib.mkForce false; services.hyperhive.forge.enable = lib.mkForce false;
services.hyperhive.matrix.enable = lib.mkForce false; services.hyperhive.matrix.enable = lib.mkForce false;
services.hyperhive.gateway.enable = lib.mkForce false;
} }
) )
]; ];

View file

@ -17,9 +17,9 @@
# frontend/packages/dashboard/build.mjs): # frontend/packages/dashboard/build.mjs):
# index.html (H0M3 hub, served at /) dashboard.html (operator SPA, # index.html (H0M3 hub, served at /) dashboard.html (operator SPA,
# served at /dashboard.html) flow.html logs.html settings.html # served at /dashboard.html) flow.html logs.html settings.html
# favicon.svg # stats.html favicon.svg
# static/{home,tabs,flow,logs,settings,stream-worker}.js{,.map} # static/{home,tabs,flow,logs,settings,stats,stream-worker}.js{,.map}
# static/{colors,theme,common,home,dashboard,flow,logs,settings}.css # static/{colors,theme,common,home,dashboard,flow,logs,settings,stats}.css
# $out/agent/ the per-agent default UI (layered with # $out/agent/ the per-agent default UI (layered with
# hyperhive.frontend.extraFiles at activation time) # hyperhive.frontend.extraFiles at activation time)
# index.html stats.html screen.html # index.html stats.html screen.html

View file

@ -634,15 +634,9 @@ in
}; };
users.groups.hive-core = { }; users.groups.hive-core = { };
# Open the per-agent web-port range when the gateway is *off* — # The gateway nginx is always the sole external entry point (it runs
# otherwise the gateway nginx is the sole external entry point. # alongside hyperhive), so the per-agent web-port range stays closed on
# See `docs/gateway.md::Firewall posture (host-level)`. # the host firewall. See `docs/gateway.md::Firewall posture (host-level)`.
networking.firewall.allowedTCPPortRanges = lib.mkIf (!config.services.hyperhive.gateway.enable) [
{
from = 8100;
to = 8999;
}
];
# WireGuard inter-hive mesh. Enabled when # WireGuard inter-hive mesh. Enabled when
# `services.hyperhive.swarm.wireguard.enable = true`. Brings up a # `services.hyperhive.swarm.wireguard.enable = true`. Brings up a
@ -777,9 +771,11 @@ in
# docs/gateway.md::Vhost map. # docs/gateway.md::Vhost map.
HIVE_MATRIX_GUI_ENABLED = "1"; HIVE_MATRIX_GUI_ENABLED = "1";
} }
// lib.optionalAttrs config.services.hyperhive.gateway.enable { // {
# When true the dashboard builds same-origin `/agent/<name>/` # The gateway always runs, so the dashboard always builds
# links; when false it falls back to direct `<host>:<port>` TCP. # same-origin `/agent/<name>/` links (never the direct
# `<host>:<port>` TCP fallback). Kept as an env flag so the
# dashboard doesn't need to learn the gateway is unconditional.
HIVE_GATEWAY_ENABLED = "1"; HIVE_GATEWAY_ENABLED = "1";
} }
// //

View file

@ -112,8 +112,8 @@ in
behindGateway = lib.mkOption { behindGateway = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = gatewayCfg.enable or false; default = config.services.hyperhive.enable;
defaultText = lib.literalExpression "config.services.hyperhive.gateway.enable"; defaultText = lib.literalExpression "config.services.hyperhive.enable";
description = '' description = ''
Serve forgejo through the hive-gateway nginx as a sub-domain Serve forgejo through the hive-gateway nginx as a sub-domain
vhost (`server_name = cfg.domain`) instead of directly on vhost (`server_name = cfg.domain`) instead of directly on
@ -127,9 +127,9 @@ in
- `gateway.localHostsEntry = true` extends `/etc/hosts` to - `gateway.localHostsEntry = true` extends `/etc/hosts` to
include `cfg.domain 127.0.0.1` for local dev. include `cfg.domain 127.0.0.1` for local dev.
Defaults to `services.hyperhive.gateway.enable` flipping Defaults to `services.hyperhive.enable` (the gateway always runs
the gateway on/off auto-routes forge through it. Set `false` alongside hyperhive, so forge auto-routes through it). Set `false`
explicitly to keep forge on the direct port even when the explicitly to keep forge on the direct port even though the
gateway is running (e.g. an external git client that doesn't gateway is running (e.g. an external git client that doesn't
traverse the gateway). traverse the gateway).
@ -211,21 +211,6 @@ in
or "git.internal". 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 = { containers.hive-forge = {

View file

@ -69,22 +69,11 @@ in
# `docs/gateway.md`. # `docs/gateway.md`.
options.services.hyperhive.gateway = { options.services.hyperhive.gateway = {
enable = lib.mkOption { # The gateway is always run alongside hyperhive (it's the single nginx
type = lib.types.bool; # in front of every surface and the only thing exposed to the outside);
default = true; # there is no enable flag. An operator who wants their own reverse proxy
description = '' # in front points it at the gateway's `port`. The gateway config below
Run hive-gateway a single nginx in front of every hyperhive # is gated on the top-level `services.hyperhive.enable`.
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.
'';
};
port = lib.mkOption { port = lib.mkOption {
type = lib.types.port; type = lib.types.port;
@ -375,7 +364,7 @@ in
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf config.services.hyperhive.enable {
assertions = [ assertions = [
{ {
assertion = !cfg.localHostsEntry || hyperhiveDomain != null; assertion = !cfg.localHostsEntry || hyperhiveDomain != null;

View file

@ -248,8 +248,8 @@ in
defaultText = lib.literalExpression "config.services.hyperhive.matrix.enable"; defaultText = lib.literalExpression "config.services.hyperhive.matrix.enable";
description = '' description = ''
Serve a matrix web client at `matrix.''${services.hyperhive.domain}/`. Serve a matrix web client at `matrix.''${services.hyperhive.domain}/`.
Requires `gateway.enable` + `matrix.gatewayHost != null` Requires `matrix.gatewayHost != null` (default `matrix.<hive>`
(default true / `matrix.<hive>` when hive-domain set). When when hive-domain set); the gateway itself always runs. When
off, the dashboard's `M4TR1X ` tab is hidden. See off, the dashboard's `M4TR1X ` tab is hidden. See
`docs/gateway.md` for the discovery flow that lets clients `docs/gateway.md` for the discovery flow that lets clients
auto-find the sub-domain. auto-find the sub-domain.
@ -435,9 +435,9 @@ in
# boot failure this module fixes was an *empty* resolv.conf, a parse # boot failure this module fixes was an *empty* resolv.conf, a parse
# error, not a connectivity one) — so this is robustness, not a boot # error, not a connectivity one) — so this is robustness, not a boot
# requirement. Soft `after` ordering (not `requires`) keeps the matrix # requirement. Soft `after` ordering (not `requires`) keeps the matrix
# container's lifecycle decoupled from the gateway's. `network.enable` # container's lifecycle decoupled from the gateway's. The gateway
# asserts `gateway.enable`, so the gateway container unit always exists # always runs alongside hyperhive, so the gateway container unit always
# here. (Declarative `containers.<n>` → `container@<n>.service` — the # exists here. (Declarative `containers.<n>` → `container@<n>.service` — the
# nspawn template NixOS generates, confirmed from the live # nspawn template NixOS generates, confirmed from the live
# `container@hive-matrix.service` host unit.) # `container@hive-matrix.service` host unit.)
systemd.services."container@hive-matrix".after = lib.mkIf networkCfg.enable [ systemd.services."container@hive-matrix".after = lib.mkIf networkCfg.enable [

View file

@ -154,16 +154,6 @@ in
`services.hyperhive.network.enable = false` explicitly. `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. # Virtual bridge — veth pairs attach when isolateContainers flips on.
@ -196,17 +186,6 @@ in
resolver must be running before isolation is flipped on). 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.<domain>` which nginx (in
the gateway container) proxies to forgejo. Without the gateway
there is nothing listening on port 80 to serve that hostname.
'';
}
]; ];
}) })