remove the 1NFR4 dashboard panel and the now-writer-less audit log

This commit is contained in:
damocles 2026-08-30 23:54:24 +02:00 committed by mara
commit 22adfd1451
21 changed files with 61 additions and 983 deletions

View file

@ -106,7 +106,7 @@ Passive / rare-interaction state. No longer a dashboard tab — it's a
standalone page reached from the **Core** tile on the H0M3 hub (served at standalone page reached from the **Core** tile on the H0M3 hub (served at
`/core.html`), with the same minimal chrome as `/logs.html`: a `← home` `/core.html`), with the same minimal chrome as `/logs.html`: a `← home`
back-link + a `createTabStrip` sub-tab nav (**K3PT ST4T3** default, back-link + a `createTabStrip` sub-tab nav (**K3PT ST4T3** default,
then **C0NT41N3R L04D**, then **1NFR4**). The page is its own esbuild bundle (`core.js`) then **C0NT41N3R L04D**). The page is its own esbuild bundle (`core.js`)
that cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for that cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for
`tombstones_changed`, `capabilities_changed`, and `tool_groups_changed` `tombstones_changed`, `capabilities_changed`, and `tool_groups_changed`
(the latter two re-render the stale-perms sub-section when permission (the latter two re-render the stale-perms sub-section when permission
@ -159,20 +159,10 @@ omitted — agents share the host netns, so there is no per-container net
counter (per-agent network needs the netns-isolation roadmap in counter (per-agent network needs the netns-isolation roadmap in
`docs/network.md`). `docs/network.md`).
**1NFR4** — start / stop the four hive infrastructure services (`hive-ci`, Hive infrastructure services (`hive-ci`, `hive-forge`, `hive-gateway`,
`hive-forge`, `hive-gateway`, `hive-matrix`) directly from the dashboard — `hive-matrix`) have no dashboard panel — `hivectl stop`/`start`/`restart`
operator-only, no agent-facing equivalent. One row per service: name, a is the only control surface, a separate host-admin-socket path with no
`badge-ok`/`badge-fail` running/stopped dot, and `■ ST0P` (running) or HTTP route and no agent-facing equivalent.
`▶ ST4RT` (stopped) buttons, same themed-confirm pattern as the K3PT ST4T3
tombstone actions. Backed by `POST /api/infra-container/{name}/{action}`
(`action``start|stop`), which calls `priv_client::control_infra_container`
— no capability check (the dashboard is already operator-authenticated).
Every attempt is written to the audit log (actor `"operator"`, action
`start_infra`/`stop_infra`). Status rows ride the `infra_containers` field
on `GET /api/state`'s `StateSnapshot` (`{name, running}`, live
`systemctl is-active container@<unit>.service` read); `core.js` polls
`/api/state` every 5 s only while the 1NFR4 sub-tab is active, same
cadence/lifecycle as C0NT41N3R L04D's polling.
## BU1LDS page (`/builds.html`) ## BU1LDS page (`/builds.html`)
@ -722,7 +712,7 @@ navigation.
A dedicated log-viewer page (not a tab pane — a separate HTML page), A dedicated log-viewer page (not a tab pane — a separate HTML page),
reachable from the Logs tile on the H0M3 hub. Minimal chrome: reachable from the Logs tile on the H0M3 hub. Minimal chrome:
a `← home` back link and a three-item sub-tab strip. Tab a `← home` back link and a three-item sub-tab strip. Tab
routing is hash-based (`#agent`, `#system`, `#audit`); default is routing is hash-based (`#agent`, `#infra`, `#system`); default is
`#agent`. (Build log history has moved to the BU1LDS page — see above.) `#agent`. (Build log history has moved to the BU1LDS page — see above.)
**AGENT sub-tab** — per-container journald viewer. Two selects: agent **AGENT sub-tab** — per-container journald viewer. Two selects: agent
@ -738,6 +728,16 @@ deep-link directly to a specific agent's journal. A "fetched N ago"
chip appears after the `↻ refresh` button following each successful chip appears after the `↻ refresh` button following each successful
fetch and ticks every 30 s. fetch and ticks every 30 s.
**INFRA sub-tab** — journald viewer for the four hive infrastructure
containers (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`), a
fixed client-side list (`INFRA_NAMES` in `logs.js` — no dashboard API
exposes just the name list). No unit filter (infra containers don't run
the per-agent hive daemons) — always the full machine journal (or, for
the gateway, the host journal filtered to its own unit). Fetches
`GET /api/journal/{name}?lines=500`, same "fetched N ago" ticker as
AGENT. A `?agent=<name>` deep-link routes here instead of AGENT when
the name is one of the four infra containers.
**SYSTEM sub-tab** — host-side service logs. Unit selector **SYSTEM sub-tab** — host-side service logs. Unit selector
(`hive-c0re.service` / `hive-priv.service`). Fetches (`hive-c0re.service` / `hive-priv.service`). Fetches
`GET /api/journal-host?unit=<unit>&lines=500` on activation `GET /api/journal-host?unit=<unit>&lines=500` on activation
@ -746,27 +746,6 @@ chip ticks every 30 s. Available to the operator unconditionally (not
capability-gated — the endpoint lives on the hive-c0re dashboard, capability-gated — the endpoint lives on the hive-c0re dashboard,
behind the gateway). behind the gateway).
**AUDIT sub-tab** — operator-visible trail of privileged actions worth a
durable who/what/when record (currently: infra-container start/stop from
the 1NFR4 panel — see `hive-c0re/src/stores/audit_log.rs`'s doc comment
for what's in scope). Lazy-fetched on tab show (like SYSTEM) from
`GET /api/audit-log`, which returns `{ entries, total }``entries`
newest-first, server-clamped to the latest 500; `total` drives a "latest
500 of N" count so the clamp is never silent. Rendered as a filterable
table (when / agent / action / target / outcome / detail); the filter box
is a client-side substring match over the cached rows. The outcome badge
colours `ok` green and `err` red, with an `err` whose `detail` starts
`denied:` (a capability refusal) shown amber and labelled `denied` so it
reads apart from an execution failure — generic styling for whichever
future privileged action writes that prefix, nothing currently produces
it. `ts_unix` is an RFC 3339 string; a 30 s ticker keeps the
relative "ago" column honest while the tab is in view. The backing
`audit_log` store records every privileged-action attempt (ok / err /
denied). New entries live-append without a refresh: an `audit_entry_added`
event on `/api/dashboard/stream` (the flattened row) is prepended to the table
and the "latest N of M" count bumped, de-duped by id against the cold
fetch.
## Container row ## Container row
A full-height **square agent icon** (5em, capped) on the left. The A full-height **square agent icon** (5em, capped) on the left. The
@ -1203,12 +1182,6 @@ below — some endpoints aren't in it yet.
build logs. build logs.
- `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for - `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for
a managed container; rendered in the side panel. a managed container; rendered in the side panel.
- `GET /api/audit-log` — agent-initiated privileged-action audit
trail. Returns `{ entries, total }`: `entries` is a `Vec<AuditEntry>`
(`id`, `ts_unix` as RFC 3339, `agent`, `action`, `target`, `outcome`
`"ok"`/`"err"`, `detail` nullable), newest first, server-clamped to
500; `total` is the full row count for a "latest 500 of N" header.
Backs the LOGS page AUDIT sub-tab.
- `GET /static/marked.js` serves the vendored `marked` bundle used - `GET /static/marked.js` serves the vendored `marked` bundle used
for markdown previews. for markdown previews.
- `GET /api/state-file?path=<host-or-container-path>` — bounded - `GET /api/state-file?path=<host-or-container-path>` — bounded
@ -1249,14 +1222,6 @@ below — some endpoints aren't in it yet.
a background `du -sxb` of the agent's state dir + container writable a background `du -sxb` of the agent's state dir + container writable
rootfs every ~5 min, `-x` excluding the shared read-only nix store. rootfs every ~5 min, `-x` excluding the shared read-only nix store.
`null` until the first sample lands. `null` until the first sample lands.
- `POST /api/infra-container/{name}/{action}` — start / stop a hive infra
service (C0R3 1NFR4 panel, operator-only). `name` parses into the
`InfraContainer` allowlist (`hive-ci`/`hive-forge`/`hive-gateway`/
`hive-matrix`, 400 on unknown), and the variant decides the unit —
`container@<name>.service`, or `nginx.service` for the gateway.
`action``start|stop`. Calls `priv_client::control_infra_container`;
records an `audit_log` entry (`start_infra`/`stop_infra`, actor
`"operator"`) either way.
- `POST /api/cancel-reminder/{id}` — hard-delete a pending reminder. - `POST /api/cancel-reminder/{id}` — hard-delete a pending reminder.
- `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery - `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery
failed (clears the failure state so the scheduler retries). failed (clears the failure state so the scheduler retries).
@ -1422,10 +1387,6 @@ payload):
- `meta_update_running` (running: bool) — emitted when a - `meta_update_running` (running: bool) — emitted when a
`nix flake update` ripple starts or completes. BU1LDS M3T4 1NPUTS `nix flake update` ripple starts or completes. BU1LDS M3T4 1NPUTS
tab uses this to show/hide the "⏳ meta-update running" banner. tab uses this to show/hide the "⏳ meta-update running" banner.
- `audit_entry_added` (flattened `AuditEntry` fields: id, ts_unix,
agent, action, target, outcome, detail) — a single new audit-log
row. L0GS AUDIT sub-tab live-prepends the row and bumps the
"latest N of M" count, de-duped by id against the cold fetch.
`/api/state` is **only fetched on cold-load and on the few `/api/state` is **only fetched on cold-load and on the few
forms that mutate non-event-derived state** (PURG3 + forms that mutate non-event-derived state** (PURG3 +

View file

@ -5,7 +5,7 @@
// dist/dashboard.html the operator dashboard SPA — served // dist/dashboard.html the operator dashboard SPA — served
// at GET /dashboard.html // at GET /dashboard.html
// dist/flow.html served at GET /flow.html // dist/flow.html served at GET /flow.html
// dist/logs.html served at GET /logs.html (AGENT/SYSTEM/AUDIT) // dist/logs.html served at GET /logs.html (AGENT/INFRA/SYSTEM)
// dist/core.html served at GET /core.html (C0R3: kept // dist/core.html served at GET /core.html (C0R3: kept
// state / container load) // state / container load)
// dist/builds.html served at GET /builds.html (BU1LDS: // dist/builds.html served at GET /builds.html (BU1LDS:
@ -18,7 +18,7 @@
// tab routing + refreshState // tab routing + refreshState
// dist/static/flow.js /flow.html entry — broker terminal + // dist/static/flow.js /flow.html entry — broker terminal +
// @-mention composer // @-mention composer
// dist/static/logs.js /logs.html entry — agent/system/audit // dist/static/logs.js /logs.html entry — agent/infra/system
// log viewer sub-tabs // log viewer sub-tabs
// dist/static/builds.js /builds.html entry — rebuild queue, // dist/static/builds.js /builds.html entry — rebuild queue,
// meta inputs, build log history // meta inputs, build log history

View file

@ -22,11 +22,11 @@ body.core-shell {
panes; ensure it wins over any inherited display. */ panes; ensure it wins over any inherited display. */
.core-pane[hidden] { display: none; } .core-pane[hidden] { display: none; }
/* K3PT ST4T3 + 1NFR4 container cards /* K3PT ST4T3 container cards
core.html doesn't load dashboard.css (that's the operator SPA), core.html doesn't load dashboard.css (that's the operator SPA),
so the .container-row card styles aren't inherited. Redefine them so the .container-row card styles aren't inherited. Redefine them
here so tombstone and infra entries look like proper cards (similar here so tombstone entries look like proper cards (similar to agent
to agent cards on the main dashboard) rather than bare list items. */ cards on the main dashboard) rather than bare list items. */
.containers { .containers {
list-style: none; list-style: none;
padding: 0; padding: 0;

View file

@ -16,12 +16,14 @@
/core.html) carved out of the dashboard's old SYST3M tab so the /core.html) carved out of the dashboard's old SYST3M tab so the
dashboard tab strip stays lean. Same minimal chrome as dashboard tab strip stays lean. Same minimal chrome as
/logs.html — a `← home` back-link to the H0M3 hub + a /logs.html — a `← home` back-link to the H0M3 hub + a
<hive-tab-strip> sub-tab nav. Three sub-tabs: kept state (tombstones), <hive-tab-strip> sub-tab nav. Two sub-tabs: kept state (tombstones)
container load, and infra containers (start/stop/restart the four and container load. Rebuild queue + meta inputs have moved to
hive infra containers). Rebuild queue + meta inputs have moved to
/builds.html (the build lifecycle hub). Default tab: K3PT ST4T3. /builds.html (the build lifecycle hub). Default tab: K3PT ST4T3.
The section <div> ids (tombstones-section, container-load-section, The section <div> ids (tombstones-section, container-load-section)
infra-containers-section) match what core.js's renderers target. --> match what core.js's renderers target. Hive infra containers
(`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`) have no
dashboard panel — `hivectl stop`/`start`/`restart` is the only
control surface. -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← home</a>
<hive-tab-strip class="hive-tabbar core-tabbar" id="core-tabbar" prefix="core" <hive-tab-strip class="hive-tabbar core-tabbar" id="core-tabbar" prefix="core"
@ -55,19 +57,6 @@
</div> </div>
</section> </section>
<!-- 1NFR4: start / stop the four hive infrastructure containers
(hive-ci, hive-forge, hive-gateway, hive-matrix) directly from
the dashboard — operator-only, no agent-facing equivalent.
Status polled every 5s while this sub-tab is open, same cadence
as C0NT41N3R L04D. -->
<section class="core-pane" id="core-pane-infra" data-tab-pane="infra"
role="tabpanel" aria-labelledby="core-tab-infra">
<p class="meta">hive infrastructure containers — ci, forge, gateway, matrix. actions are logged to the AUDIT log.</p>
<div id="infra-containers-section">
<p class="meta">loading…</p>
</div>
</section>
</main> </main>
<script type="module" src="/static/core.js" defer></script> <script type="module" src="/static/core.js" defer></script>

View file

@ -381,66 +381,6 @@ function stopContainerLoadPolling() {
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; } if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
} }
// ─── infra containers (start/stop the 4 hive infra containers) ────────────
let infraTimer = null;
function renderInfraContainers(rows) {
const root = $('infra-containers-section');
if (!root) return;
root.replaceChildren();
if (!Array.isArray(rows) || !rows.length) {
root.append(el('p', { class: 'meta' }, 'no infra container data'));
return;
}
const ul = el('ul', { class: 'containers' });
for (const c of rows) {
const li = el('li', { class: 'container-row' });
const head = el('div', { class: 'head' });
head.append(
el('span', { class: 'name' }, c.name),
el('span', { class: 'hive-pill-sm ' + (c.running ? 'badge-ok' : 'badge-fail') },
c.running ? 'running' : 'stopped'),
);
li.append(head);
const actions = el('div', { class: 'actions' });
const base = '/api/infra-container/' + encodeURIComponent(c.name) + '/';
if (c.running) {
actions.append(form(base + 'stop', 'btn-stop', '■ ST0P',
'stop ' + c.name + '?'));
} else {
actions.append(form(base + 'start', 'btn-start', '▶ ST4RT',
'start ' + c.name + '?'));
}
li.append(actions);
ul.append(li);
}
root.append(ul);
}
async function refreshInfraContainers() {
try {
const resp = await fetch('/api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
renderInfraContainers(s.infra_containers || []);
} catch (e) {
const root = $('infra-containers-section');
if (root) {
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'infra container fetch failed: ' + e));
}
}
}
function startInfraPolling() {
refreshInfraContainers();
if (infraTimer) return;
infraTimer = setInterval(refreshInfraContainers, 5000);
}
function stopInfraPolling() {
if (infraTimer) { clearInterval(infraTimer); infraTimer = null; }
}
// ─── render-all (cold load + any full re-render) ────────────────────────── // ─── render-all (cold load + any full re-render) ──────────────────────────
function renderAll() { function renderAll() {
renderTombstones({ tombstones: tombstonesState }); renderTombstones({ tombstones: tombstonesState });
@ -505,14 +445,11 @@ async function init() {
tabs: [ tabs: [
{ id: 'kept', label: 'K3PT ST4T3' }, { id: 'kept', label: 'K3PT ST4T3' },
{ id: 'load', label: 'C0NT41N3R L04D' }, { id: 'load', label: 'C0NT41N3R L04D' },
{ id: 'infra', label: '1NFR4' },
], ],
defaultId: 'kept', defaultId: 'kept',
onShow: (id) => { onShow: (id) => {
if (id === 'load') startContainerLoadPolling(); if (id === 'load') startContainerLoadPolling();
else stopContainerLoadPolling(); else stopContainerLoadPolling();
if (id === 'infra') startInfraPolling();
else stopInfraPolling();
// Lazy-load stale-perms on first K3PT ST4T3 activation; always // Lazy-load stale-perms on first K3PT ST4T3 activation; always
// re-fetch on subsequent visits in case perms changed. // re-fetch on subsequent visits in case perms changed.
if (id === 'kept') fetchAndRenderStalePerms(); if (id === 'kept') fetchAndRenderStalePerms();

View file

@ -13,7 +13,7 @@
<body class="cred-shell"> <body class="cred-shell">
<!-- Minimal chrome: back link + sub-tab strip, same pattern as <!-- Minimal chrome: back link + sub-tab strip, same pattern as
logs.html (MATRIX / GITHUB instead of AGENT/SYSTEM/AUDIT). Back logs.html (MATRIX / GITHUB instead of AGENT/INFRA/SYSTEM). Back
link points to the H0M3 hub (served at /). --> link points to the H0M3 hub (served at /). -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← home</a>

View file

@ -65,7 +65,7 @@
<span class="home-tile-icon" aria-hidden="true">📜</span> <span class="home-tile-icon" aria-hidden="true">📜</span>
<span class="home-tile-label">Logs</span> <span class="home-tile-label">Logs</span>
</span> </span>
<span class="home-tile-desc">agent · system logs · privileged-action audit</span> <span class="home-tile-desc">agent · infra · system logs</span>
</a> </a>
<a class="home-tile" href="/stats.html"> <a class="home-tile" href="/stats.html">

View file

@ -41,14 +41,6 @@ body.logs-shell {
flex-direction: column; flex-direction: column;
padding-bottom: 1.2em; padding-bottom: 1.2em;
} }
/* The AUDIT tab uses a <div> for its list instead of a <pre>, so give
it the same flex-grow + internal scroll that .journal-output already has. */
#audit-list {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
}
.logs-toolbar { .logs-toolbar {
display: flex; display: flex;
gap: 0.6em; gap: 0.6em;
@ -202,65 +194,3 @@ body.logs-shell {
.build-logs-runtime { font-size: 0.85em; color: var(--muted); } .build-logs-runtime { font-size: 0.85em; color: var(--muted); }
/* ─── AUDIT tab — privileged-actions audit trail table ──────────────── */
.audit-filter {
flex: 1 1 18em;
min-width: 0;
padding: 0.35em 0.6em;
background: var(--bg-elev);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
font-family: inherit;
font-size: 0.9em;
}
.audit-table-wrap { overflow-x: auto; }
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88em;
}
.audit-table th,
.audit-table td {
text-align: left;
padding: 0.4em 0.7em;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.audit-table th {
color: var(--muted);
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.82em;
white-space: nowrap;
}
.audit-table tbody tr:hover {
background: color-mix(in srgb, var(--bg-elev) 45%, transparent);
}
.audit-when, .audit-when-th { white-space: nowrap; }
.audit-agent { font-weight: bold; white-space: nowrap; }
.audit-action { font-family: monospace; white-space: nowrap; }
.audit-target { white-space: nowrap; }
.audit-detail { word-break: break-word; }
.audit-outcome-th, .audit-outcome-td { white-space: nowrap; }
.audit-outcome {
display: inline-block;
padding: 0 0.5em;
border-radius: 999px;
font-size: 0.82em;
font-weight: bold;
white-space: nowrap;
}
.audit-outcome-ok {
background: color-mix(in srgb, var(--green) 22%, transparent);
color: var(--green);
}
.audit-outcome-err {
background: color-mix(in srgb, var(--red) 22%, transparent);
color: var(--red);
}
.audit-outcome-denied {
background: color-mix(in srgb, var(--yellow) 22%, transparent);
color: var(--yellow);
}

View file

@ -15,7 +15,7 @@
<!-- Minimal chrome: back link + sub-tab strip. <!-- Minimal chrome: back link + sub-tab strip.
Same pattern as flow.html — no full dashboard tabbar. Back link Same pattern as flow.html — no full dashboard tabbar. Back link
points to the H0M3 hub (served at /), not the dashboard. points to the H0M3 hub (served at /), not the dashboard.
Four sub-tabs: AGENT, INFRA, SYSTEM, AUDIT. Build log history has Three sub-tabs: AGENT, INFRA, SYSTEM. Build log history has
moved to /builds.html (the build lifecycle hub). --> moved to /builds.html (the build lifecycle hub). -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← home</a>
@ -75,21 +75,6 @@
<pre id="system-output" class="journal-output">loading…</pre> <pre id="system-output" class="journal-output">loading…</pre>
</section> </section>
<!-- AUDIT: operator-visible trail of agent-initiated privileged
actions (infra-container restarts, etc.). Filterable table backed
by GET /api/audit-log ({ entries, total }), newest first, server
clamped to the latest 500. -->
<section class="logs-pane" id="logs-pane-audit" data-tab-pane="audit"
role="tabpanel" aria-labelledby="logs-tab-audit">
<div class="logs-toolbar">
<input type="text" id="audit-filter" class="audit-filter"
placeholder="filter agent / action / target / detail…" autocomplete="off">
<button type="button" class="btn btn-restart" id="audit-refresh">↻ refresh</button>
<span id="audit-count" class="meta"></span>
</div>
<div id="audit-list"><p class="meta">loading…</p></div>
</section>
</main> </main>
<script type="module" src="/static/logs.js" defer></script> <script type="module" src="/static/logs.js" defer></script>

View file

@ -1,13 +1,11 @@
// /logs.html entry point: log viewer with four sub-tabs (AGENT, INFRA, // /logs.html entry point: log viewer with three sub-tabs (AGENT, INFRA,
// SYSTEM, AUDIT). Build log history has moved to /builds.html. // SYSTEM). Build log history has moved to /builds.html.
// AGENT — per-container journald viewer, backed by GET /api/journal/{name} // AGENT — per-container journald viewer, backed by GET /api/journal/{name}
// INFRA — infra-container journald viewer (hive-ci, hive-forge, …), // INFRA — infra-container journald viewer (hive-ci, hive-forge, …),
// same API but full machine journal only (no unit filter) // same API but full machine journal only (no unit filter)
// SYSTEM — host service logs, backed by GET /api/journal-host // SYSTEM — host service logs, backed by GET /api/journal-host
// AUDIT — agent-initiated privileged-action trail, GET /api/audit-log;
// live-appends via the `audit_entry_added` /dashboard/stream event
// //
// Tab routing via URL hash (#agent, #infra, #system, #audit). Default: #agent. // Tab routing via URL hash (#agent, #infra, #system). Default: #agent.
// //
// URL params `?agent=name` and `?unit=svc` pre-select the agent + unit. // URL params `?agent=name` and `?unit=svc` pre-select the agent + unit.
// When `?agent=` names an infra container the INFRA tab is activated instead. // When `?agent=` names an infra container the INFRA tab is activated instead.
@ -15,10 +13,9 @@
// INFRA, and SYSTEM tabs so the operator knows how stale the output is. // INFRA, and SYSTEM tabs so the operator knows how stale the output is.
import { import {
$, fmtAgeSecs, openStream, initServerWarnings, $, fmtAgeSecs, initServerWarnings,
} from './common.js'; } from './common.js';
import { el } from '@hive/shared/dom.js'; import { el } from '@hive/shared/dom.js';
import { epochSec } from './util.js';
import '@hive/shared/hive-tab-strip.js'; import '@hive/shared/hive-tab-strip.js';
(() => { (() => {
@ -127,12 +124,25 @@ import '@hive/shared/hive-tab-strip.js';
if (infraSelect) infraSelect.addEventListener('change', fetchInfra); if (infraSelect) infraSelect.addEventListener('change', fetchInfra);
if (infraRefresh) infraRefresh.addEventListener('click', fetchInfra); if (infraRefresh) infraRefresh.addEventListener('click', fetchInfra);
// Fixed allowlist — the four hive infra services never change at
// runtime, and there's no dashboard API exposing just the name list
// (the one that used to, `/api/state`'s `infra_containers` field, was
// start/stop-panel-only and is gone). Mirrors `hive_priv_sock::InfraContainer::ALL`.
const INFRA_NAMES = ['hive-ci', 'hive-forge', 'hive-gateway', 'hive-matrix'];
// ─── container list init ────────────────────────────────────────────── // ─── container list init ──────────────────────────────────────────────
// Fetch /api/state once and populate both the AGENT selector (agents only) // Fetch /api/state once to populate the AGENT selector (agents only);
// and the INFRA selector (infra containers only). Also handles the // INFRA is a fixed list (see INFRA_NAMES). Also handles the ?agent= /
// ?agent= / ?unit= deep-link, routing to the INFRA tab when the named // ?unit= deep-link, routing to the INFRA tab when the named container
// container is an infra container. // is an infra container.
async function loadContainerLists() { async function loadContainerLists() {
if (infraSelect) {
infraSelect.replaceChildren();
infraSelect.append(el('option', { value: '' }, '— select container —'));
for (const name of INFRA_NAMES) {
infraSelect.append(el('option', { value: name }, name));
}
}
try { try {
const resp = await fetch('/api/state'); const resp = await fetch('/api/state');
if (!resp.ok) return; if (!resp.ok) return;
@ -147,21 +157,11 @@ import '@hive/shared/hive-tab-strip.js';
} }
} }
// Populate INFRA selector.
const infraNames = new Set((state.infra_containers || []).map((c) => c.name));
if (infraSelect) {
infraSelect.replaceChildren();
infraSelect.append(el('option', { value: '' }, '— select container —'));
for (const name of infraNames) {
infraSelect.append(el('option', { value: name }, name));
}
}
// Deep-link: honour ?agent= and ?unit= URL params. // Deep-link: honour ?agent= and ?unit= URL params.
const urlAgent = new URLSearchParams(location.search).get('agent'); const urlAgent = new URLSearchParams(location.search).get('agent');
const urlUnit = new URLSearchParams(location.search).get('unit'); const urlUnit = new URLSearchParams(location.search).get('unit');
if (urlAgent) { if (urlAgent) {
if (infraNames.has(urlAgent)) { if (INFRA_NAMES.includes(urlAgent)) {
// Route to INFRA tab. // Route to INFRA tab.
logTabs.show('infra'); logTabs.show('infra');
if (infraSelect) { if (infraSelect) {
@ -225,180 +225,25 @@ import '@hive/shared/hive-tab-strip.js';
if (systemUnitSelect) systemUnitSelect.addEventListener('change', fetchSystem); if (systemUnitSelect) systemUnitSelect.addEventListener('change', fetchSystem);
if (systemRefresh) systemRefresh.addEventListener('click', fetchSystem); if (systemRefresh) systemRefresh.addEventListener('click', fetchSystem);
// ─── AUDIT tab ──────────────────────────────────────────────────────
// Operator-visible trail of agent-initiated privileged actions, backed
// by GET /api/audit-log → { entries: [...], total: N } (entries
// newest-first, server-clamped to 500; `total` drives "latest 500 of N").
// Per-entry: { id, ts_unix (secs), agent, action, target, outcome, detail }.
// outcome is 'ok' | 'err'; a capability denial is 'err' with detail
// starting "denied:" — coloured amber to read apart from an execution
// failure. Lazy-fetched on tab show (like SYSTEM); filter is a
// client-side substring on the cached rows.
const auditList = $('audit-list');
const auditFilter = $('audit-filter');
const auditRefresh = $('audit-refresh');
const auditCount = $('audit-count');
let auditEntries = [];
let auditTotal = 0;
let auditFetching = false;
// ts_unix arrives as an RFC 3339 string — fmtAgeSecs wants an age
// in seconds, so normalize via epochSec first.
function auditFmtWhen(ts) {
if (!ts) return '';
const age = Math.floor(Date.now() / 1000) - epochSec(ts);
return fmtAgeSecs(Math.max(0, age)) + ' ago';
}
// outcome → badge. 'ok' green; an 'err' whose detail starts "denied:" is a
// capability refusal (amber, labelled "denied"); other 'err' red. The
// literal outcome is the fallback label so a new value still renders.
function auditOutcomeBadge(outcome, detail) {
const denied = outcome === 'err'
&& typeof detail === 'string' && detail.startsWith('denied:');
const cls = outcome === 'ok'
? 'audit-outcome audit-outcome-ok'
: denied
? 'audit-outcome audit-outcome-denied'
: 'audit-outcome audit-outcome-err';
return el('span', { class: cls }, denied ? 'denied' : (outcome || '?'));
}
function auditMatches(e, q) {
if (!q) return true;
return `${e.agent || ''} ${e.action || ''} ${e.target || ''} ${e.detail || ''}`
.toLowerCase().includes(q);
}
function renderAudit() {
if (!auditList) return;
const q = (auditFilter ? auditFilter.value : '').trim().toLowerCase();
const rows = auditEntries.filter((e) => auditMatches(e, q));
if (auditCount) {
const shown = auditEntries.length;
const clamped = auditTotal > shown;
let txt = clamped
? `latest ${shown} of ${auditTotal}`
: `${shown} entr${shown === 1 ? 'y' : 'ies'}`;
if (q) txt += ` · ${rows.length} match${rows.length === 1 ? '' : 'es'}`;
auditCount.textContent = txt;
}
auditList.replaceChildren();
if (rows.length === 0) {
auditList.append(el('p', { class: 'meta' },
q ? '(no matching entries)' : '(no privileged actions recorded yet)'));
return;
}
const table = el('table', { class: 'audit-table' });
table.append(el('thead', {},
el('tr', {},
el('th', { class: 'audit-when-th' }, 'when'),
el('th', {}, 'agent'),
el('th', {}, 'action'),
el('th', {}, 'target'),
el('th', { class: 'audit-outcome-th' }, 'outcome'),
el('th', {}, 'detail'),
)));
const tbody = el('tbody', {});
for (const e of rows) {
tbody.append(el('tr', {},
el('td', {
class: 'audit-when meta',
title: e.ts_unix ? new Date(e.ts_unix).toISOString() : '',
}, auditFmtWhen(e.ts_unix)),
el('td', { class: 'audit-agent' }, e.agent || ''),
el('td', { class: 'audit-action' }, e.action || ''),
el('td', { class: 'audit-target' }, e.target || ''),
el('td', { class: 'audit-outcome-td' }, auditOutcomeBadge(e.outcome, e.detail)),
el('td', { class: 'audit-detail meta' }, e.detail || ''),
));
}
table.append(tbody);
const wrap = el('div', { class: 'audit-table-wrap' });
wrap.append(table);
auditList.append(wrap);
}
async function fetchAudit() {
if (!auditList || auditFetching) return;
auditFetching = true;
try {
const resp = await fetch('/api/audit-log');
if (!resp.ok) throw new Error('http ' + resp.status);
const data = await resp.json();
auditEntries = Array.isArray(data.entries) ? data.entries : [];
auditTotal = typeof data.total === 'number' ? data.total : auditEntries.length;
renderAudit();
} catch (err) {
auditList.replaceChildren();
auditList.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
} finally {
auditFetching = false;
}
}
if (auditRefresh) auditRefresh.addEventListener('click', fetchAudit);
if (auditFilter) auditFilter.addEventListener('input', renderAudit);
// Live-append: an `audit_entry_added` event on /dashboard/stream carries a
// new row flattened at the top level ({ kind, seq, id, ts_unix, agent,
// action, target, outcome, detail }). Prepend it (newest-first), de-duped
// by id against whatever the cold fetch already returned, and bump the
// total so the "latest N of M" header stays right. Re-render only while
// the AUDIT tab is in view; otherwise the next tab-show fetch is
// authoritative anyway. Wired into the shared stream onmessage above.
function onAuditEntryAdded(ev) {
if (auditEntries.some((e) => e.id === ev.id)) return;
auditEntries.unshift({
id: ev.id, ts_unix: ev.ts_unix, agent: ev.agent, action: ev.action,
target: ev.target, outcome: ev.outcome, detail: ev.detail,
});
auditTotal += 1;
if (logTabs.active() === 'audit') renderAudit();
}
// ─── init ───────────────────────────────────────────────────────────── // ─── init ─────────────────────────────────────────────────────────────
// Wire the shared tab strip now that fetchSystem + the element refs it // Wire the shared tab strip now that fetchSystem + the element refs it
// needs are defined. Its initial show() paints the active pane and, if // needs are defined. Its initial show() paints the active pane and, if
// the deep-linked tab is SYSTEM/AUDIT, kicks off the lazy fetch via onShow. // the deep-linked tab is SYSTEM, kicks off the lazy fetch via onShow.
// Default: AGENT (build logs moved to /builds.html). // Default: AGENT (build logs moved to /builds.html).
logTabs = document.getElementById('logs-tabbar').configure({ logTabs = document.getElementById('logs-tabbar').configure({
tabs: [ tabs: [
{ id: 'agent', label: 'AGENT' }, { id: 'agent', label: 'AGENT' },
{ id: 'infra', label: 'INFRA' }, { id: 'infra', label: 'INFRA' },
{ id: 'system', label: 'SYSTEM' }, { id: 'system', label: 'SYSTEM' },
{ id: 'audit', label: 'AUDIT' },
], ],
defaultId: 'agent', defaultId: 'agent',
onShow: (id) => { onShow: (id) => {
if (id === 'system') fetchSystem(); if (id === 'system') fetchSystem();
else if (id === 'audit') fetchAudit();
}, },
}); });
loadContainerLists(); loadContainerLists();
// Subscribe to the dashboard SSE stream for audit live-appends.
// kinds= narrows this from all 17 wire kinds down to the 1 this page
// acts on. This page was one of 4 unfiltered `/api/dashboard/stream`
// subscribers before this (subscription discipline, part 1 of the
// dashboard-event-stream-split issue).
{
const es = openStream('/api/dashboard/stream?kinds=audit_entry_added');
if (es) {
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
if (ev.kind === 'audit_entry_added') onAuditEntryAdded(ev);
};
}
}
// Tick the last-fetched timestamps every 30s so "fetched 1m ago" stays // Tick the last-fetched timestamps every 30s so "fetched 1m ago" stays
// accurate without a manual refresh. // accurate without a manual refresh.
setInterval(() => { setInterval(() => {
@ -411,8 +256,6 @@ import '@hive/shared/hive-tab-strip.js';
if (systemLastFetch && systemFetchTs && !systemFetchTs.hidden) { if (systemLastFetch && systemFetchTs && !systemFetchTs.hidden) {
systemFetchTs.textContent = fmtFetchTs(systemLastFetch); systemFetchTs.textContent = fmtFetchTs(systemLastFetch);
} }
// Keep the audit "ago" column honest while that tab is in view.
if (auditEntries.length && logTabs.active() === 'audit') renderAudit();
}, 30_000); }, 30_000);
})(); })();

View file

@ -308,7 +308,7 @@ window.marked = marked;
// //
// kinds= matches MUTATION_HANDLERS below verbatim, plus `sent` // kinds= matches MUTATION_HANDLERS below verbatim, plus `sent`
// (checked separately, just above, for the operator inbox) — // (checked separately, just above, for the operator inbox) —
// narrows this from all 17 wire kinds down to the 11 this page // narrows this from all 15 wire kinds down to the 11 this page
// actually acts on. This page was one of 4 unfiltered // actually acts on. This page was one of 4 unfiltered
// `/api/dashboard/stream` subscribers before this (subscription // `/api/dashboard/stream` subscribers before this (subscription
// discipline, part 1 of the dashboard-event-stream-split issue). // discipline, part 1 of the dashboard-event-stream-split issue).

View file

@ -42,9 +42,6 @@ pub struct Coordinator {
/// `get_full` for the per-card chip + side-panel viewer. See /// `get_full` for the per-card chip + side-panel viewer. See
/// `build_logs.rs` for retention. /// `build_logs.rs` for retention.
pub build_logs: Arc<crate::build_logs::BuildLogs>, pub build_logs: Arc<crate::build_logs::BuildLogs>,
/// Audit trail of agent-initiated privileged actions (infra restart,
/// …). See `audit_log.rs`. Same dir as `build_logs`.
pub audit_log: Arc<crate::audit_log::AuditLog>,
/// URL of the hyperhive flake (no fragment). Inlined into per-agent /// URL of the hyperhive flake (no fragment). Inlined into per-agent
/// `flake.nix` files as `inputs.hyperhive.url`. /// `flake.nix` files as `inputs.hyperhive.url`.
pub hyperhive_flake: String, pub hyperhive_flake: String,
@ -480,13 +477,6 @@ impl Coordinator {
// to thread an `Arc<BuildLogs>` through every public entry // to thread an `Arc<BuildLogs>` through every public entry
// point in the lifecycle surface. // point in the lifecycle surface.
crate::build_logs::install(build_logs.clone()); crate::build_logs::install(build_logs.clone());
// Audit log shares the same db dir; install its process-wide
// handle so privileged-action recording sites (e.g.
// `dashboard::infra_containers::post_infra_container`) write
// without threading an `Arc<AuditLog>` through the surface.
let audit_log =
Arc::new(crate::audit_log::AuditLog::open(build_logs_dir).context("open audit_log")?);
crate::audit_log::install(audit_log.clone());
let power = Arc::new(crate::power::PowerStore::open(db_path).context("open agent_power")?); let power = Arc::new(crate::power::PowerStore::open(db_path).context("open agent_power")?);
let (dashboard_events, _) = broadcast::channel(DASHBOARD_CHANNEL); let (dashboard_events, _) = broadcast::channel(DASHBOARD_CHANNEL);
let (shutdown_tx, _) = watch::channel(false); let (shutdown_tx, _) = watch::channel(false);
@ -495,7 +485,6 @@ impl Coordinator {
approvals: Arc::new(approvals), approvals: Arc::new(approvals),
scheduled_prompts: Arc::new(scheduled_prompts), scheduled_prompts: Arc::new(scheduled_prompts),
build_logs, build_logs,
audit_log,
hyperhive_flake, hyperhive_flake,
hyperhive_docs_flake, hyperhive_docs_flake,
nixpkgs_flake, nixpkgs_flake,
@ -751,18 +740,6 @@ impl Coordinator {
self.meta_updates_active.load(Ordering::SeqCst) > 0 self.meta_updates_active.load(Ordering::SeqCst) > 0
} }
/// Emit `AuditEntryAdded` immediately after a privileged-action row
/// is recorded, so the dashboard audit view live-appends it off
/// `/dashboard/stream`. Pass the [`AuditEntry`](crate::audit_log::AuditEntry)
/// returned by `audit_log::record` so the streamed event is the same
/// canonical row that was stored.
pub fn emit_audit_entry(&self, entry: crate::audit_log::AuditEntry) {
self.emit_dashboard_event(DashboardEvent::AuditEntryAdded {
seq: self.next_seq(),
entry,
});
}
/// Emit `ApprovalAdded` immediately after the row is inserted in /// Emit `ApprovalAdded` immediately after the row is inserted in
/// sqlite. /// sqlite.
pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) { pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) {

View file

@ -1,70 +0,0 @@
//! Dashboard endpoint for operator-driven infra lifecycle (start / stop on
//! `hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`). Operator-only —
//! there is no agent-facing equivalent for either action.
//! Already fully operator-authenticated by the time a request reaches here,
//! so no capability check is needed, just the audit trail.
use axum::{
extract::{Path as AxumPath, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use hive_priv_sock::{InfraAction, InfraContainer};
use super::{AppState, error_response};
/// Start / stop a hive infrastructure container from the dashboard.
///
/// `name` parses into [`InfraContainer`] (the allowlist; unrecognised
/// names 400), `action` into `start` / `stop`. Every attempt lands in the
/// audit log (actor `"operator"`, action `start_infra` / `stop_infra`) and
/// streams as an `AuditEntryAdded` event.
#[utoipa::path(
post,
path = "/api/infra-container/{name}/{action}",
params(
("name" = String, Path, description = "infra service name (hive-ci/hive-forge/hive-gateway/hive-matrix)"),
("action" = String, Path, description = "start | stop"),
),
responses(
(status = 200, description = "action completed", body = String),
(status = 500, description = "unknown container/action, or the systemd action failed"),
),
tag = "infra_containers"
)]
pub(super) async fn post_infra_container(
State(state): State<AppState>,
AxumPath((name, action)): AxumPath<(String, String)>,
) -> Response {
let Ok(container) = name.parse::<InfraContainer>() else {
return error_response(&format!("unknown infra container: {name}"));
};
let (infra_action, action_label) = match action.as_str() {
"start" => (InfraAction::Start, "start_infra"),
"stop" => (InfraAction::Stop, "stop_infra"),
other => {
return error_response(&format!("unknown action: {other} (want start|stop)"));
}
};
let target = container.name();
tracing::info!(%target, %action, "dashboard: infra container action");
let result = crate::priv_client::control_infra_container(container, infra_action).await;
let outcome = if result.is_ok() {
crate::audit_log::AuditOutcome::Ok
} else {
crate::audit_log::AuditOutcome::Err
};
let detail = result.as_ref().err().map(|e| format!("{e:#}"));
if let Some(entry) =
state
.coord
.audit_log
.record("operator", action_label, target, outcome, detail.as_deref())
{
state.coord.emit_audit_entry(entry);
}
match result {
Ok(()) => (StatusCode::OK, "ok").into_response(),
Err(e) => error_response(&format!("{target}: {e:#}")),
}
}

View file

@ -1,7 +1,6 @@
//! Remaining single-endpoint dashboard handlers: the operator inbox //! Remaining single-endpoint dashboard handlers: the operator inbox
//! (`Y3R C4LL`) + mark-all-read, operator compose (`op-send`), //! (`Y3R C4LL`) + mark-all-read, operator compose (`op-send`),
//! spawn-request, hive-wide turn stats, container resources, and the //! spawn-request, hive-wide turn stats, and container resources.
//! audit log.
use axum::{ use axum::{
extract::{Form, Path as AxumPath, State}, extract::{Form, Path as AxumPath, State},
@ -12,7 +11,6 @@ use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema}; use utoipa::{IntoParams, ToSchema};
use super::{AppState, Ident, error_response, scan_validated_paths}; use super::{AppState, Ident, error_response, scan_validated_paths};
use crate::audit_log::AuditEntry;
use crate::container_stats::ContainerResource; use crate::container_stats::ContainerResource;
use crate::hive_stats::HiveStats; use crate::hive_stats::HiveStats;
@ -129,40 +127,6 @@ pub(super) async fn api_container_resources() -> Response {
axum::Json(crate::container_stats::gather().await).into_response() axum::Json(crate::container_stats::gather().await).into_response()
} }
#[derive(Serialize, ToSchema)]
pub(super) struct AuditLogBody {
entries: Vec<AuditEntry>,
total: i64,
}
/// Most-recent agent-initiated privileged-action
/// audit entries, newest first (server-clamped to 500).
///
/// Backs the operator dashboard's audit view. `total` lets the UI show
/// "latest 500 of N" rather than silently capping. `ts_unix` is in
/// **seconds**.
#[utoipa::path(
get,
path = "/api/audit-log",
responses(
(status = 200, description = "recent audit entries + total count", body = AuditLogBody),
(status = 500, description = "sqlite read failed"),
),
tag = "misc_api"
)]
pub(super) async fn api_audit_log(State(state): State<AppState>) -> Response {
const LIMIT: usize = 500;
let entries = match state.coord.audit_log.list_recent(LIMIT) {
Ok(rows) => rows,
Err(e) => return error_response(&format!("audit-log: {e:#}")),
};
let total = match state.coord.audit_log.count_total() {
Ok(n) => n,
Err(e) => return error_response(&format!("audit-log count: {e:#}")),
};
axum::Json(AuditLogBody { entries, total }).into_response()
}
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub(super) struct MarkAllReadBody { pub(super) struct MarkAllReadBody {
marked: u64, marked: u64,

View file

@ -38,11 +38,10 @@ use crate::lifecycle;
(name = "approvals", description = "approve/deny pending approval rows"), (name = "approvals", description = "approve/deny pending approval rows"),
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"), (name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(name = "extra_forges", description = "external (non-internal) forge account provisioning"), (name = "extra_forges", description = "external (non-internal) forge account provisioning"),
(name = "infra_containers", description = "start/stop of hive infrastructure containers"),
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"), (name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "matrix_accounts", description = "matrix + github account provisioning for agents"), (name = "matrix_accounts", description = "matrix + github account provisioning for agents"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"), (name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats, audit log"), (name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
(name = "permissions", description = "tool-group + capability assignment for agents"), (name = "permissions", description = "tool-group + capability assignment for agents"),
(name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"), (name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"),
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"), (name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
@ -63,7 +62,6 @@ mod extra_forges;
// server reach it as `crate::dashboard::Ident`. // server reach it as `crate::dashboard::Ident`.
pub(crate) use hive_types::Ident; pub(crate) use hive_types::Ident;
mod health; mod health;
mod infra_containers;
mod journal; mod journal;
mod lifecycle_ops; mod lifecycle_ops;
mod matrix_accounts; mod matrix_accounts;
@ -154,7 +152,6 @@ pub async fn serve(
.routes(routes!(misc_api::api_operator_inbox)) .routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources)) .routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::api_audit_log))
.routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_request_spawn)) .routes(routes!(misc_api::post_request_spawn))
.routes(routes!(misc_api::post_op_send)) .routes(routes!(misc_api::post_op_send))
@ -195,7 +192,6 @@ pub async fn serve(
.routes(routes!(lifecycle_ops::post_resume)) .routes(routes!(lifecycle_ops::post_resume))
.routes(routes!(lifecycle_ops::post_resource_limits)) .routes(routes!(lifecycle_ops::post_resource_limits))
.routes(routes!(lifecycle_ops::post_update_all)) .routes(routes!(lifecycle_ops::post_update_all))
.routes(routes!(infra_containers::post_infra_container))
.routes(routes!(tombstones::post_purge_tombstone)) .routes(routes!(tombstones::post_purge_tombstone))
.routes(routes!(meta_inputs::post_meta_update)) .routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream)) .routes(routes!(build_logs::get_build_log_stream))
@ -397,7 +393,6 @@ mod router_build_probe {
.routes(routes!(misc_api::api_operator_inbox)) .routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources)) .routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::api_audit_log))
.routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_request_spawn)) .routes(routes!(misc_api::post_request_spawn))
.routes(routes!(misc_api::post_op_send)) .routes(routes!(misc_api::post_op_send))
@ -438,7 +433,6 @@ mod router_build_probe {
.routes(routes!(lifecycle_ops::post_resume)) .routes(routes!(lifecycle_ops::post_resume))
.routes(routes!(lifecycle_ops::post_resource_limits)) .routes(routes!(lifecycle_ops::post_resource_limits))
.routes(routes!(lifecycle_ops::post_update_all)) .routes(routes!(lifecycle_ops::post_update_all))
.routes(routes!(infra_containers::post_infra_container))
.routes(routes!(tombstones::post_purge_tombstone)) .routes(routes!(tombstones::post_purge_tombstone))
.routes(routes!(meta_inputs::post_meta_update)) .routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream)) .routes(routes!(build_logs::get_build_log_stream))

View file

@ -117,33 +117,6 @@ pub(super) struct StateSnapshot {
/// `host_stats::server_warnings`; the frontend renders this list /// `host_stats::server_warnings`; the frontend renders this list
/// generically, so new warning kinds need no frontend change. /// generically, so new warning kinds need no frontend change.
server_warnings: Vec<crate::host_stats::ServerWarning>, server_warnings: Vec<crate::host_stats::ServerWarning>,
/// Live running/stopped status for the four hive infra containers
/// (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`). Feeds the
/// C0R3 page's 1NFR4 sub-tab, the operator-only surface for starting
/// and stopping them.
infra_containers: Vec<InfraContainerView>,
}
/// One row for the C0R3 page's 1NFR4 sub-tab.
#[derive(Serialize)]
struct InfraContainerView {
/// Container / systemd-unit name (e.g. `"hive-ci"`).
name: &'static str,
running: bool,
}
/// Live running/stopped status for all four hive infra containers.
/// Extracted out of [`api_state`] to keep it under clippy's
/// `too_many_lines` limit.
async fn infra_container_views() -> Vec<InfraContainerView> {
let mut infra_containers = Vec::with_capacity(hive_priv_sock::InfraContainer::ALL.len());
for container in hive_priv_sock::InfraContainer::ALL {
infra_containers.push(InfraContainerView {
name: container.name(),
running: crate::lifecycle::infra_is_running(container).await,
});
}
infra_containers
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -328,8 +301,6 @@ pub(super) async fn api_state(
w w
}; };
let infra_containers = infra_container_views().await;
axum::Json(StateSnapshot { axum::Json(StateSnapshot {
seq, seq,
hostname, hostname,
@ -368,7 +339,6 @@ pub(super) async fn api_state(
.ok() .ok()
.filter(|s| !s.is_empty()), .filter(|s| !s.is_empty()),
server_warnings, server_warnings,
infra_containers,
}) })
} }

View file

@ -13,17 +13,6 @@ use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")] #[serde(rename_all = "snake_case", tag = "kind")]
pub enum DashboardEvent { pub enum DashboardEvent {
/// A new agent-initiated privileged action was recorded in the audit
/// log. The audit view (`/audit.html`) prepends `entry` live off
/// `/dashboard/stream` instead of polling. The `AuditEntry` fields
/// are flattened alongside the `kind` tag + `seq`, so the wire shape
/// matches one row of the `/api/audit-log` `entries` array exactly
/// (`{kind, seq, id, ts_unix, agent, action, target, outcome, detail}`).
AuditEntryAdded {
seq: u64,
#[serde(flatten)]
entry: crate::audit_log::AuditEntry,
},
/// Broker `Sent` event mirrored onto the dashboard channel. /// Broker `Sent` event mirrored onto the dashboard channel.
/// `file_refs` carries every path-shaped token in `body` that /// `file_refs` carries every path-shaped token in `body` that
/// hive-c0re verified is a regular file under the allow-listed /// hive-c0re verified is a regular file under the allow-listed
@ -270,7 +259,6 @@ impl DashboardEvent {
DashboardEvent::SchedulesChanged { .. } => "schedules_changed", DashboardEvent::SchedulesChanged { .. } => "schedules_changed",
DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed", DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed",
DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed", DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed",
DashboardEvent::AuditEntryAdded { .. } => "audit_entry_added",
} }
} }
} }
@ -385,18 +373,6 @@ mod tests {
agents: Vec::new(), agents: Vec::new(),
effective: std::collections::BTreeMap::new(), effective: std::collections::BTreeMap::new(),
}, },
DashboardEvent::AuditEntryAdded {
seq: 1,
entry: crate::audit_log::AuditEntry {
id: 1,
ts_unix: hive_sh4re::wire_time::from_secs(0),
agent: "operator".into(),
action: "stop_infra".into(),
target: "hive-ci".into(),
outcome: "ok".into(),
detail: None,
},
},
]; ];
for ev in samples { for ev in samples {
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise"); let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");
@ -407,34 +383,4 @@ mod tests {
assert_eq!(ev.kind_tag(), serde_kind, "kind_tag() drift on {ev:?}"); assert_eq!(ev.kind_tag(), serde_kind, "kind_tag() drift on {ev:?}");
} }
} }
/// The flattened `AuditEntry` fields must sit alongside `kind`/`seq`
/// at the top level (not nested under `entry`) so the wire shape
/// matches one `/api/audit-log` row — the audit view prepends it
/// directly.
#[test]
fn audit_entry_added_flattens_to_top_level() {
let ev = DashboardEvent::AuditEntryAdded {
seq: 7,
entry: crate::audit_log::AuditEntry {
id: 42,
ts_unix: hive_sh4re::wire_time::from_secs(1_700_000_000),
agent: "operator".into(),
action: "stop_infra".into(),
target: "hive-gateway".into(),
outcome: "err".into(),
detail: Some("systemctl stop failed".into()),
},
};
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");
assert_eq!(v["kind"], "audit_entry_added");
assert_eq!(v["seq"], 7);
assert_eq!(v["id"], 42);
assert_eq!(v["agent"], "operator");
assert_eq!(v["target"], "hive-gateway");
assert_eq!(v["outcome"], "err");
assert_eq!(v["detail"], "systemctl stop failed");
// Not nested — there must be no `entry` sub-object.
assert!(v.get("entry").is_none());
}
} }

View file

@ -680,21 +680,6 @@ pub async fn is_running(name: &str) -> bool {
.is_ok_and(|s| s.success()) .is_ok_and(|s| s.success())
} }
/// True when a hive infrastructure service's systemd unit is active.
/// Sibling of [`is_running`] for sub-agents, but infra names (`hive-ci`, …)
/// have no `h-` prefix to strip and are not all containers, so the unit
/// comes from the variant itself rather than from [`container_name`]. Used
/// by the dashboard C0R3 page's 1NFR4 sub-tab to show each one's live
/// status dot.
pub async fn infra_is_running(container: hive_priv_sock::InfraContainer) -> bool {
let unit = container.service_unit();
Command::new("systemctl")
.args(["is-active", "--quiet", &unit])
.status()
.await
.is_ok_and(|s| s.success())
}
/// Fully tear down a sub-agent's container: stop + remove via `nixos-container /// Fully tear down a sub-agent's container: stop + remove via `nixos-container
/// destroy`, then clean our own systemd drop-in. Leaves it to the caller to /// destroy`, then clean our own systemd drop-in. Leaves it to the caller to
/// wipe `/var/lib/hyperhive/...` state and the per-agent runtime dir. /// wipe `/var/lib/hyperhive/...` state and the per-agent runtime dir.

View file

@ -46,7 +46,7 @@ pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups
pub(crate) use stats::{ pub(crate) use stats::{
container_stats, hive_stats, host_stats, otel_metrics, sweep_health, warnings, container_stats, hive_stats, host_stats, otel_metrics, sweep_health, warnings,
}; };
pub(crate) use stores::{approvals, audit_log, broker, build_logs, db, power, scheduled_prompts}; pub(crate) use stores::{approvals, broker, build_logs, db, power, scheduled_prompts};
pub(crate) use workers::{ pub(crate) use workers::{
agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets, scheduled_prompts_worker, agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets, scheduled_prompts_worker,
}; };
@ -518,9 +518,6 @@ async fn cmd_serve(
// build_logs.sqlite vacuum: c0re-side (single db). Failures kept // build_logs.sqlite vacuum: c0re-side (single db). Failures kept
// 30d, successes 24h — see `build_logs::vacuum` for the rule. // 30d, successes 24h — see `build_logs::vacuum` for the rule.
crate::build_logs::spawn_vacuum(&coord); crate::build_logs::spawn_vacuum(&coord);
// audit_log.sqlite vacuum: agent-initiated privileged-action trail,
// 90d retention — see `audit_log::vacuum`.
crate::audit_log::spawn_vacuum(&coord);
// Container crash watcher: emits HelperEvent::ContainerCrash // Container crash watcher: emits HelperEvent::ContainerCrash
// when a previously-running container goes away without an // when a previously-running container goes away without an
// operator-initiated transient state. // operator-initiated transient state.

View file

@ -1,329 +0,0 @@
//! Sqlite-backed audit trail of privileged actions worth a durable,
//! operator-visible who/what/when record beyond hive-priv's low-level
//! journal trace — currently the dashboard's operator-driven infra
//! container start/stop (`dashboard::infra_containers::post_infra_container`).
//!
//! Deliberately narrow: the bulk of `PrivRequest` traffic (token writes,
//! nspawn-flag edits) fires constantly during normal lifecycle and is
//! hive-c0re's own bookkeeping, not a privileged action worth a standalone
//! record — logging all of it would drown the signal the operator
//! actually wants. Nothing agent-initiated lands here today; the module
//! stays generic for whatever privileged action needs this record next.
//!
//! Same process-singleton handle pattern as `build_logs`: installed once
//! at `Coordinator::open`, and fetched by recording sites so they don't
//! have to thread an `Arc<AuditLog>` through every call path. Recording is
//! best-effort: a sqlite blip must never fail the underlying privileged
//! action.
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{Connection, params};
use serde::Serialize;
use utoipa::ToSchema;
/// Process-singleton handle, set once at coordinator startup. Mirrors
/// `build_logs::GLOBAL` — lets recording sites write without threading an
/// `Arc<AuditLog>` through every entry point.
static GLOBAL: OnceLock<Arc<AuditLog>> = OnceLock::new();
/// Install the process-wide `AuditLog` handle. Idempotent: a second call
/// silently keeps the first handle.
pub fn install(handle: Arc<AuditLog>) {
let _ = GLOBAL.set(handle);
}
/// Retain audit rows for 90 days. Longer than build-log retention — this
/// is a security/accountability record, not debug noise; the operator may
/// want to review "who restarted what" well after the fact.
const KEEP_SECS: i64 = 90 * 24 * 3600;
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_unix INTEGER NOT NULL,
agent TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
outcome TEXT NOT NULL,
detail TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log (ts_unix DESC);
";
/// Outcome of a recorded privileged action. Stored as the literal string
/// in the `outcome` column.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditOutcome {
/// The privileged action succeeded.
Ok,
/// The privileged action was attempted but failed (e.g. the
/// underlying systemctl call errored). Denied-by-capability attempts
/// are recorded too — see the recording site.
Err,
}
impl AuditOutcome {
fn as_str(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Err => "err",
}
}
}
/// One audit row as returned to the dashboard.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct AuditEntry {
pub id: i64,
pub ts_unix: DateTime<Utc>,
/// Actor who took the action (e.g. `"operator"`, or an agent name for
/// a future agent-initiated entry).
pub agent: String,
/// What was done (e.g. `stop_infra`).
pub action: String,
/// What it acted on (e.g. `hive-ci`).
pub target: String,
/// `"ok"` | `"err"`.
pub outcome: String,
/// Optional free-text detail (e.g. the error message on failure).
pub detail: Option<String>,
}
/// Sqlite-backed audit-log store. `Arc<AuditLog>`-friendly: all methods
/// take `&self`, an internal `Mutex<Connection>` serializes access.
pub struct AuditLog {
conn: Mutex<Connection>,
}
impl AuditLog {
/// Open (creating if absent) the `audit_log.sqlite` store under
/// `db_dir` and apply the schema. `db_dir` is shared with
/// `build_logs` (the broker db's parent directory).
///
/// # Errors
/// Returns an error if the directory can't be created, the sqlite
/// file can't be opened, or applying the schema fails.
pub fn open(db_dir: &Path) -> Result<Self> {
let path = db_dir.join("audit_log.sqlite");
let conn = crate::db::open(&path, "audit_log")?;
conn.execute_batch(SCHEMA)
.context("apply audit_log schema")?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Record one privileged action. Best-effort: a sqlite error is logged
/// but never returned, so a transient blip never fails the underlying
/// privileged action (the action already happened — losing its audit
/// row is strictly less bad than failing the action retroactively).
///
/// Returns the inserted [`AuditEntry`] (with its assigned id +
/// timestamp) on success, or `None` if the insert failed. The
/// returned row is the canonical record — callers that also push a
/// live event (e.g. the dashboard stream) emit *this* rather than
/// re-deriving the fields, so the stored row and the streamed event
/// can't drift.
#[must_use]
pub fn record(
&self,
agent: &str,
action: &str,
target: &str,
outcome: AuditOutcome,
detail: Option<&str>,
) -> Option<AuditEntry> {
let now = Utc::now().timestamp();
let conn = self.conn.lock().unwrap();
match conn.execute(
"INSERT INTO audit_log (ts_unix, agent, action, target, outcome, detail)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![now, agent, action, target, outcome.as_str(), detail],
) {
Ok(_) => Some(AuditEntry {
id: conn.last_insert_rowid(),
ts_unix: hive_sh4re::wire_time::from_secs(now),
agent: agent.to_owned(),
action: action.to_owned(),
target: target.to_owned(),
outcome: outcome.as_str().to_owned(),
detail: detail.map(str::to_owned),
}),
Err(e) => {
tracing::warn!(
%agent, %action, %target,
error = ?e,
"audit_log: record failed (dropping entry)"
);
None
}
}
}
/// Return the most recent `limit` rows, newest first. Limit is
/// hard-clamped to 500 to bound the worst-case payload.
///
/// # Errors
/// Returns an error if the query fails to prepare or a row fails to
/// deserialize.
pub fn list_recent(&self, limit: usize) -> Result<Vec<AuditEntry>> {
let limit = limit.min(500);
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, ts_unix, agent, action, target, outcome, detail
FROM audit_log
ORDER BY ts_unix DESC, id DESC
LIMIT ?1",
)?;
let rows = stmt.query_map(params![i64::try_from(limit).unwrap_or(500)], row_to_entry)?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Total row count, regardless of the `list_recent` clamp. Lets the
/// dashboard show "latest N of TOTAL" instead of silently capping.
///
/// # Errors
/// Returns an error if the `COUNT(*)` query fails.
pub fn count_total(&self) -> Result<i64> {
let conn = self.conn.lock().unwrap();
let n: i64 = conn.query_row("SELECT COUNT(*) FROM audit_log", [], |r| r.get(0))?;
Ok(n)
}
/// Drop rows older than the retention window. Returns the number of
/// rows deleted. Called from the hourly vacuum loop.
///
/// # Errors
/// Returns an error if the `DELETE` query fails.
pub fn vacuum(&self) -> Result<u64> {
let cutoff = Utc::now().timestamp() - KEEP_SECS;
let conn = self.conn.lock().unwrap();
let removed = conn.execute("DELETE FROM audit_log WHERE ts_unix < ?1", params![cutoff])?;
Ok(u64::try_from(removed).unwrap_or(0))
}
}
/// Spawn the hourly retention sweep. Mirrors `build_logs::spawn_vacuum`
/// in cadence + shutdown handling.
pub fn spawn_vacuum(coord: &Arc<crate::coordinator::Coordinator>) {
use std::time::Duration;
let audit = coord.audit_log.clone();
let mut shutdown = coord.shutdown_rx();
let interval = Duration::from_hours(1);
tokio::spawn(async move {
loop {
match audit.vacuum() {
Ok(0) => {}
Ok(n) => tracing::info!(removed = n, "audit_log vacuum"),
Err(e) => tracing::warn!(error = ?e, "audit_log vacuum failed"),
}
tokio::select! {
() = tokio::time::sleep(interval) => {}
_ = shutdown.changed() => {
tracing::info!("audit_log vacuum: shutdown signal received");
break;
}
}
}
});
}
fn row_to_entry(r: &rusqlite::Row) -> rusqlite::Result<AuditEntry> {
Ok(AuditEntry {
id: r.get(0)?,
ts_unix: hive_sh4re::wire_time::from_secs(r.get(1)?),
agent: r.get(2)?,
action: r.get(3)?,
target: r.get(4)?,
outcome: r.get(5)?,
detail: r.get(6)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpdb() -> (tempfile::TempDir, AuditLog) {
let dir = tempfile::tempdir().expect("tempdir");
let db = AuditLog::open(dir.path()).expect("open");
(dir, db)
}
#[test]
fn record_and_list_newest_first() {
let (_d, db) = tmpdb();
// record() returns the canonical inserted row (id + ts assigned).
let entry = db
.record("operator", "stop_infra", "hive-ci", AuditOutcome::Ok, None)
.expect("record returns the inserted entry");
assert!(entry.id > 0);
assert_eq!(entry.target, "hive-ci");
assert_eq!(entry.outcome, "ok");
assert!(entry.detail.is_none());
let _ = db.record(
"operator",
"stop_infra",
"hive-gateway",
AuditOutcome::Err,
Some("systemctl failed"),
);
let rows = db.list_recent(10).expect("list");
assert_eq!(rows.len(), 2);
// Newest first: the gateway/err row was inserted last.
assert_eq!(rows[0].target, "hive-gateway");
assert_eq!(rows[0].outcome, "err");
assert_eq!(rows[0].detail.as_deref(), Some("systemctl failed"));
assert_eq!(rows[1].target, "hive-ci");
assert_eq!(rows[1].outcome, "ok");
assert!(rows[1].detail.is_none());
assert_eq!(rows[0].agent, "operator");
assert_eq!(rows[0].action, "stop_infra");
assert_eq!(db.count_total().expect("count"), 2);
}
#[test]
fn list_clamps_to_500() {
let (_d, db) = tmpdb();
let _ = db.record("a", "x", "t", AuditOutcome::Ok, None);
let rows = db.list_recent(999_999).expect("list");
assert!(rows.len() <= 500);
}
#[test]
fn vacuum_drops_only_old_rows() {
let (_d, db) = tmpdb();
let _ = db.record("operator", "stop_infra", "hive-ci", AuditOutcome::Ok, None);
// Backdate it past the retention window.
{
let conn = db.conn.lock().unwrap();
conn.execute(
"UPDATE audit_log SET ts_unix = ?1",
params![Utc::now().timestamp() - KEEP_SECS - 60],
)
.unwrap();
}
let _ = db.record(
"operator",
"stop_infra",
"hive-forge",
AuditOutcome::Ok,
None,
);
let removed = db.vacuum().expect("vacuum");
assert_eq!(removed, 1, "only the backdated row should be reaped");
let rows = db.list_recent(10).expect("list");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].target, "hive-forge");
}
}

View file

@ -1,10 +1,9 @@
//! Sqlite-backed host-side stores (broker, approval / schedule queues, //! Sqlite-backed host-side stores (broker, approval / schedule queues,
//! build logs, audit trail, power intent) plus the shared connection //! build logs, power intent) plus the shared connection open/migration
//! open/migration helper (`db`). Each submodule is re-exported at the //! helper (`db`). Each submodule is re-exported at the crate root, so
//! crate root, so `crate::broker::…` etc. keep working unchanged. //! `crate::broker::…` etc. keep working unchanged.
pub mod approvals; pub mod approvals;
pub mod audit_log;
pub mod broker; pub mod broker;
pub mod build_logs; pub mod build_logs;
pub mod db; pub mod db;