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
// to `static/<name>.js` based on the entryPoint basename.
await build({
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js')],
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js')],
outdir: staticDir(''),
bundle: true,
format: 'esm',
@ -91,7 +91,7 @@ await build({
// so a swap replaces only it) + theme.css (the semantic derivation
// layer) + common.css (shared typography, badges, buttons, inbox, side
// panel) plus its own page-specific bundle.
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css']) {
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css']) {
await build({
entryPoints: [src(entry)],
outfile: staticDir(entry),
@ -101,7 +101,7 @@ for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', '
});
}
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html']) {
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html']) {
copyFileSync(src(html), dist(html));
}

View file

@ -519,6 +519,16 @@ body.side-panel-resizing * { cursor: ew-resize !important; }
padding: 0.2em 0.5em;
}
/* page-content gutter
Inverted-gutter layout: the dashboard + H0M3 keep `<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
Generic top-of-page strip, injected at the top of <body> by
common.js (renderServerWarnings) on every page. One row per warning;
@ -530,16 +540,11 @@ body.side-panel-resizing * { cursor: ew-resize !important; }
top: 0;
z-index: 50;
}
/* The bar is prepended as a direct child of <body>. On pages that pad
the body gutter (the dashboard + H0M3 use a 1.5em horizontal gutter),
break out of that padding so the warning spans the full width
edge-to-edge the same trick `.dashboard-chrome` uses. FL0W + L0GS
are already full-bleed (no body padding), so they need no override. */
body.dashboard-shell #server-warnings,
body.home-shell #server-warnings {
margin-left: -1.5em;
margin-right: -1.5em;
}
/* The bar is prepended as a direct child of <body> and spans the full
width edge-to-edge. The dashboard + H0M3 are full-bleed at the body
level their 1.5em horizontal gutter lives on an inner `.page-content`
wrapper (see below), so the banner is full-width for free with no
breakout. FL0W + L0GS are full-bleed too. */
.server-warnings[hidden] { display: none; }
.server-warn {
text-align: center;
@ -558,3 +563,31 @@ body.home-shell #server-warnings {
background: color-mix(in srgb, var(--red) 18%, transparent);
border-bottom: 1px solid var(--red);
}
/* stats table
Shared right-aligned numeric table. Used by /stats.html (busiest
agents) and the dashboard SYST3M C0NT41N3R L04D table moved here
from dashboard.css when ST4TS became its own page. */
.hive-stats-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin-bottom: 8px;
}
.hive-stats-table th,
.hive-stats-table td {
border-bottom: 1px solid var(--border);
padding: 5px 8px;
text-align: right;
}
.hive-stats-table th:first-child,
.hive-stats-table td:first-child {
text-align: left;
}
.hive-stats-table th {
color: var(--muted);
font-weight: normal;
}
.hive-stats-table td.num {
font-variant-numeric: tabular-nums;
}

View file

@ -9,14 +9,15 @@
routing model. */
body.dashboard-shell {
/* Full-width layout no max-width cap so wide screens don't
waste real estate on empty side margins. `padding: 0 1.5em
1.5em` keeps a small gutter on the left/right so cards don't
kiss the viewport edge; `.dashboard-chrome { margin: 0 -1.5em
... }` still pulls the chrome bar edge-to-edge through that
gutter. */
/* Full-bleed body (no horizontal gutter). The 1.5em side gutter lives
on the inner `.page-content` wrapper (see common.css), so the sticky
chrome bar, the server-warnings banner, and the footer span the full
width edge-to-edge with no negative-margin breakout. `padding-bottom`
keeps a little breathing room above the viewport edge (overridden to
clear the selection bar via `.has-selection` below). No max-width cap
wide screens use the full width rather than empty side margins. */
margin: 0;
padding: 0 1.5em 1.5em;
padding-bottom: 1.5em;
}
.dashboard-chrome {
@ -28,7 +29,7 @@ body.dashboard-shell {
backdrop-filter: blur(8px) saturate(120%);
border-bottom: 1px solid var(--purple-dim);
padding: 0.4em 0 0;
margin: 0 -1.5em 1em;
margin: 0 0 1em;
}
/* home back-link to the H0M3 hub. Small purple link top-left of the
chrome, mirroring the .logs-back treatment the sub-pages use so the
@ -1392,97 +1393,14 @@ body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
cursor: default;
}
/* ST4TS tab: hive-wide turn-stats rollup
Plain tables + CSS bars (no chart lib in the dashboard bundle). */
.hive-stats-windows {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 12px 0;
}
.hive-stats-windows .btn.active {
border-color: var(--amber);
color: var(--amber);
}
.hive-stats-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.hive-stats-chip {
display: inline-flex;
flex-direction: column;
padding: 6px 10px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 4px;
min-width: 7em;
}
.hive-stats-chip .k {
font-size: 0.72rem;
color: var(--muted);
text-transform: lowercase;
}
.hive-stats-chip .v {
font-size: 1.05rem;
color: var(--fg);
}
.hive-stats-chip.est .v {
color: var(--amber);
}
.hive-stats-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin-bottom: 8px;
}
.hive-stats-table th,
.hive-stats-table td {
border-bottom: 1px solid var(--border);
padding: 5px 8px;
text-align: right;
}
.hive-stats-table th:first-child,
.hive-stats-table td:first-child {
text-align: left;
}
.hive-stats-table th {
color: var(--muted);
font-weight: normal;
}
.hive-stats-table td.num {
font-variant-numeric: tabular-nums;
}
.hive-stats-bar {
display: grid;
grid-template-columns: 12em 1fr 4em;
align-items: center;
gap: 8px;
margin: 3px 0;
font-size: 0.85rem;
}
.hive-stats-bar .track {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 3px;
height: 14px;
overflow: hidden;
}
.hive-stats-bar .fill {
display: block;
height: 100%;
background: var(--purple);
}
.hive-stats-bar .cnt {
text-align: right;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
/* ST4TS moved to its own page (`/stats.html`): the
window selector / summary chips / bars live in stats.css, and the
shared `.hive-stats-table` moved to common.css (the SYST3M
C0NT41N3R L04D table below still uses it). */
/* SYST3M C0NT41N3R L04D: live cgroup cpu/mem
Reuses the `.hive-stats-table` styling from the ST4TS tab; only the
inline meter bar is new. */
Reuses the shared `.hive-stats-table` styling (now in common.css);
only the inline meter bar is new. */
.cload-meter {
display: inline-block;
width: 6em;

View file

@ -60,14 +60,9 @@
<span class="tab-count" id="tab-count-schedules" hidden></span>
</a>
<!-- ST4TS: hive-wide turn-stats rollup (swarm totals, busiest
agents, model mix, est cost). Fetched on tab activation +
window change from GET /api/stats-hive. -->
<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>
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
the H0M3 hub — the hive-wide rollup is a standalone read-only
view, not part of the operational tab strip. -->
<!-- (Peer hives are no longer a tab — they render as a headline
under SW4RM. See tab-pane-swarm.) -->
@ -88,6 +83,10 @@
visible by toggling the `hidden` attribute, resolved from the URL
hash (default SW4RM). Panes start `hidden` to avoid a flash before
the script runs. -->
<!-- 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">
<!-- SW4RM: the swarm itself. Container cards (the central thing
@ -241,35 +240,11 @@
</div>
</section>
<!-- ST4TS: hive-wide turn-stats aggregate. Plain tables/bars (the
dashboard bundle has no chart lib); data from GET
/api/stats-hive?window=, fetched on tab activation + on
window change. Per-agent trend charts stay on each agent's
own /stats page. -->
<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>
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
(`/stats.html`), reached from the H0M3 hub. The markup + the
hive-stats render JS moved there; when tabs.js boots on the
dashboard the renderers are simply gone (no stats tab to
activate). -->
<!-- FL0W: lives on its own page now (`/flow.html`). The
message-flow + inbox + compose DOM only exists there — when
@ -283,6 +258,7 @@
localStorage + browser permission the settings page sets). -->
</main>
</div><!-- /.page-content -->
<footer>
<pre class="banner banner-thin">░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre>

View file

@ -5,8 +5,11 @@
styling can fold into it. */
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;
padding: 0 1.5em 2em;
padding-bottom: 2em;
min-height: 100vh;
}

View file

@ -16,6 +16,10 @@
surface. This is the page served at `/` — the landing page — with
the dashboard relocated to /dashboard.html. No tabbar / SSE —
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">
<p class="banner-thin" id="hive-identity" hidden></p>
<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">
<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 class="home-tile" href="/flow.html">
@ -39,6 +43,11 @@
<span class="home-tile-desc">build · agent · system logs</span>
</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">
<span class="home-tile-label">Settings</span>
<span class="home-tile-desc">operator-local prefs · browser notifications</span>
@ -54,6 +63,7 @@
</nav>
</main>
</div><!-- /.page-content -->
<script type="module" src="/static/home.js" defer></script>
</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 ───────────────────────────────────────────────────────
// Hash-based: `#swarm` / `#call` / `#system` / `#schedules` /
// `#permissions` / `#stats` activate the matching pane on the
// dashboard. Empty hash defaults to SW4RM. FL0W and S3TT1NGS are NOT
// tabs — they're separate pages (`/flow.html`, `/settings.html`)
// reached from the H0M3 hub. Tab routing only
// Hash-based: `#swarm` / `#call` / `#system` / `#permissions` /
// `#schedules` activate the matching pane on the dashboard. Empty
// hash defaults to SW4RM. FL0W, S3TT1NGS, and ST4TS are NOT tabs —
// they're separate pages (`/flow.html`, `/settings.html`,
// `/stats.html`) reached from the H0M3 hub. Tab routing only
// applies when the tab DOM is present (e.g. not on the flow page
// itself, where these elements don't exist and the loop no-ops).
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
@ -4085,176 +4085,16 @@ window.marked = marked;
fetchAndRenderCapabilities();
fetchAndRenderToolGroups();
}
// ST4TS: hive-wide rollup is a pull (no SSE) — fetch on activation.
if (target === 'stats') { refreshHiveStats(); }
if (target === 'call') { refreshOperatorInbox(); }
// SYST3M C0NT41N3R L04D: live cgroup poll only while the tab is
// open (cpu needs a short two-sample read each refresh).
if (target === 'system') { startContainerLoadPolling(); } else { stopContainerLoadPolling(); }
}
// ─── ST4TS: hive-wide turn-stats rollup ──────────────────────────────────
// Pull-only (no SSE): fetched from /api/stats-hive on tab activation and
// on window change. Plain tables/bars — the dashboard bundle has no chart
// lib, and per-agent trend charts live on each agent's own /stats page.
let hiveStatsWindow = '24h';
function hsFmtInt(n) {
return Number.isFinite(n) ? new Intl.NumberFormat().format(Math.round(n)) : '0';
}
function hsFmtTokens(n) {
if (!Number.isFinite(n)) return '0';
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return String(Math.round(n));
}
function hsFmtUsd(n) {
if (!Number.isFinite(n)) return '$0';
if (n >= 100) return '$' + n.toFixed(0);
if (n >= 1) return '$' + n.toFixed(2);
return '$' + n.toFixed(3);
}
function hsChip(parent, label, value, est) {
const c = document.createElement('span');
c.className = 'hive-stats-chip' + (est ? ' est' : '');
const k = document.createElement('span'); k.className = 'k'; k.textContent = label;
const v = document.createElement('span'); v.className = 'v'; v.textContent = value;
c.append(k, v);
parent.append(c);
}
function hsMeta(parent, text) {
parent.replaceChildren();
const p = document.createElement('p');
p.className = 'meta';
p.textContent = text;
parent.append(p);
}
function renderHiveStats(s) {
const sum = $('hive-stats-summary');
if (sum) {
sum.replaceChildren();
hsChip(sum, 'window', s.window);
hsChip(sum, 'active agents', hsFmtInt(s.active_agents));
hsChip(sum, 'turns', hsFmtInt(s.total_turns));
const totalTok = (s.total_input_tokens || 0) + (s.total_output_tokens || 0)
+ (s.total_cache_read_tokens || 0) + (s.total_cache_creation_tokens || 0);
hsChip(sum, 'tokens', hsFmtTokens(totalTok));
hsChip(sum, 'input', hsFmtTokens(s.total_input_tokens));
hsChip(sum, 'output', hsFmtTokens(s.total_output_tokens));
hsChip(sum, 'cache read', hsFmtTokens(s.total_cache_read_tokens));
hsChip(sum, 'est cost', hsFmtUsd(s.est_cost_usd), true);
}
const at = $('hive-stats-agents');
if (at) {
const agents = s.agents || [];
if (!agents.length) {
hsMeta(at, 'no turns in window');
} else {
at.replaceChildren();
const table = document.createElement('table');
table.className = 'hive-stats-table';
table.innerHTML = '<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();
// ST4TS (hive-wide turn-stats rollup) moved to its own page,
// `/stats.html` — the render JS + the window selector live in
// stats.js now. The dashboard no longer fetches
// /api/stats-hive.
// ─── SYST3M C0NT41N3R L04D: live per-container cgroup cpu/mem ────────────
// Pull-only, polled at 5s ONLY while the SYST3M tab is active (cpu is a

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.forge.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):
# index.html (H0M3 hub, served at /) dashboard.html (operator SPA,
# served at /dashboard.html) flow.html logs.html settings.html
# favicon.svg
# static/{home,tabs,flow,logs,settings,stream-worker}.js{,.map}
# static/{colors,theme,common,home,dashboard,flow,logs,settings}.css
# stats.html favicon.svg
# static/{home,tabs,flow,logs,settings,stats,stream-worker}.js{,.map}
# static/{colors,theme,common,home,dashboard,flow,logs,settings,stats}.css
# $out/agent/ the per-agent default UI (layered with
# hyperhive.frontend.extraFiles at activation time)
# index.html stats.html screen.html

View file

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

View file

@ -112,8 +112,8 @@ in
behindGateway = lib.mkOption {
type = lib.types.bool;
default = gatewayCfg.enable or false;
defaultText = lib.literalExpression "config.services.hyperhive.gateway.enable";
default = config.services.hyperhive.enable;
defaultText = lib.literalExpression "config.services.hyperhive.enable";
description = ''
Serve forgejo through the hive-gateway nginx as a sub-domain
vhost (`server_name = cfg.domain`) instead of directly on
@ -127,9 +127,9 @@ in
- `gateway.localHostsEntry = true` extends `/etc/hosts` to
include `cfg.domain 127.0.0.1` for local dev.
Defaults to `services.hyperhive.gateway.enable` flipping
the gateway on/off auto-routes forge through it. Set `false`
explicitly to keep forge on the direct port even when the
Defaults to `services.hyperhive.enable` (the gateway always runs
alongside hyperhive, so forge auto-routes through it). Set `false`
explicitly to keep forge on the direct port even though the
gateway is running (e.g. an external git client that doesn't
traverse the gateway).
@ -211,21 +211,6 @@ in
or "git.internal".
'';
}
{
# behindGateway requires the gateway module to actually be on.
# Otherwise the configured `ROOT_URL` flips to a sub-domain
# shape that has no nginx vhost backing it → broken on the
# rebuild.
assertion = !cfg.behindGateway || (gatewayCfg.enable or false);
message = ''
services.hyperhive.forge.behindGateway = true requires
services.hyperhive.gateway.enable = true (the gateway vhost
serving forge needs the gateway container to actually be
running). Either turn the gateway on, or set
services.hyperhive.forge.behindGateway = false to keep forge
on its direct port.
'';
}
];
containers.hive-forge = {

View file

@ -69,22 +69,11 @@ in
# `docs/gateway.md`.
options.services.hyperhive.gateway = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Run hive-gateway a single nginx in front of every hyperhive
surface. On by default: the gateway hosts the matrix GUI static
dist (when `services.hyperhive.matrix.gui.enable` is true) and
proxies everything else to hive-c0re's dashboard upstream. Set
`services.hyperhive.gateway.enable = false` to bypass nginx
entirely and reach hive-c0re directly on its dashboard port
(7000 by default).
v0 is HTTP-only; TLS / public-domain shape is tracked
separately.
'';
};
# The gateway is always run alongside hyperhive (it's the single nginx
# in front of every surface and the only thing exposed to the outside);
# there is no enable flag. An operator who wants their own reverse proxy
# in front points it at the gateway's `port`. The gateway config below
# is gated on the top-level `services.hyperhive.enable`.
port = lib.mkOption {
type = lib.types.port;
@ -375,7 +364,7 @@ in
};
config = lib.mkIf cfg.enable {
config = lib.mkIf config.services.hyperhive.enable {
assertions = [
{
assertion = !cfg.localHostsEntry || hyperhiveDomain != null;

View file

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

View file

@ -154,16 +154,6 @@ in
`services.hyperhive.network.enable = false` explicitly.
'';
}
{
assertion = config.services.hyperhive.gateway.enable;
message = ''
services.hyperhive.network.enable = true requires
services.hyperhive.gateway.enable = true the dnsmasq
resolver runs inside the hive-gateway container (single
front-door for both DNS and HTTP). Enable the gateway or
set `services.hyperhive.network.enable = false` explicitly.
'';
}
];
# Virtual bridge — veth pairs attach when isolateContainers flips on.
@ -196,17 +186,6 @@ in
resolver must be running before isolation is flipped on).
'';
}
{
assertion = !config.services.hyperhive.forge.enable || config.services.hyperhive.gateway.enable;
message = ''
services.hyperhive.network.isolateContainers = true with
services.hyperhive.forge.enable = true requires
services.hyperhive.gateway.enable = true isolated agents
reach the forge via `http://forge.<domain>` which nginx (in
the gateway container) proxies to forgejo. Without the gateway
there is nothing listening on port 80 to serve that hostname.
'';
}
];
})