refactor(hive-c0re): shrink dashboard mod root

convert to dashboard/mod.rs; state snapshot, meta inputs, tombstones,
and misc api handlers move to their own files
This commit is contained in:
müde 2026-07-06 21:05:52 +02:00
commit 58e86a3adf
5 changed files with 1522 additions and 1421 deletions

View file

@ -0,0 +1,279 @@
//! META INPUTS panel backend: walks `meta/flake.lock` into
//! `MetaInputView` rows for the snapshot, emits the `MetaInputsChanged`
//! event after lock bumps, and handles `POST /api/meta-update` (bulk
//! flake-input update + rebuild ripple via the job queue).
use axum::{
extract::{Form, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use crate::coordinator::Coordinator;
use super::{AppState, error_response};
#[derive(Serialize, Clone, Debug)]
pub struct MetaInputView {
/// Input key in meta's `flake.nix` — `hyperhive`, `agent-<n>`, etc.
pub name: String,
/// Full locked sha. Not displayed verbatim; the dashboard
/// truncates to the first 12 chars for the chip.
pub rev: String,
/// Unix seconds — `locked.lastModified`. Drives the relative
/// "2h ago" timestamp on each input row.
pub last_modified: i64,
/// `original.url` if available, for the tooltip / row meta text.
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
/// Walk `flake.lock`'s `nodes` graph from `root` and emit one
/// `MetaInputView` per fetched input, at **every** depth. That
/// surfaces the direct meta inputs (`hyperhive`, `agent-<n>`), the
/// agent flakes' own inputs (`agent-dmatrix/mcp-matrix`,
/// `hyperhive/nixpkgs`), and any deeper transitive inputs — so the
/// operator can bump any of them individually. Names are
/// slash-separated paths from root, the syntax `nix flake update`
/// accepts for transitive inputs.
///
/// Filtering:
/// - Inputs that resolve via a `follows` chain (lock value is an
/// array) are skipped — they alias another node, not their own
/// fetched derivation, so updating them does nothing.
/// - A node is emitted only when it carries a `locked.rev`.
/// - Each fetched node is walked exactly once (a `visited` set):
/// the lock graph shares nodes (many flakes reference one
/// nixpkgs), so without this a shared subtree re-walks per parent
/// and a cycle would recurse forever. The result is a spanning
/// tree — every input shown once, at its shallowest path.
pub(super) fn read_meta_inputs() -> Vec<MetaInputView> {
let mut out = Vec::new();
let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else {
return out;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
return out;
};
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
return out;
};
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
return out;
};
let mut visited = std::collections::HashSet::new();
visited.insert(root_name.to_owned());
walk_meta_inputs(nodes, root_name, "", &mut visited, &mut out);
// hyperhive first, then alphabetical. String-sorting the
// slash-paths puts every node directly above its own children
// (`agent-foo`, `agent-foo/bar`, `agent-foo/bar/baz`), so the
// result is a pre-order traversal the tree renderer can consume.
out.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
("hyperhive", _) => std::cmp::Ordering::Less,
(_, "hyperhive") => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
out
}
fn walk_meta_inputs(
nodes: &serde_json::Map<String, serde_json::Value>,
node_name: &str,
prefix: &str,
visited: &mut std::collections::HashSet<String>,
out: &mut Vec<MetaInputView>,
) {
let Some(node) = nodes.get(node_name) else {
return;
};
let Some(inputs_map) = node.get("inputs").and_then(|v| v.as_object()) else {
return;
};
// Two passes: claim (and emit) every direct input of this node
// before descending into any of them. A shallow input that a
// deeper flake also references then keeps its shallow path
// rather than being captured first by the deep walk.
let mut to_recurse: Vec<(String, String)> = Vec::new();
for (alias, target) in inputs_map {
// Inputs map value is either a string (node name) or an
// array (a `follows` chain). The latter just aliases another
// node — we can't `nix flake update` it directly, so skip.
let serde_json::Value::String(target_name) = target else {
continue;
};
// Walk each fetched node once — guards shared subtrees and
// cycles, and keeps the panel free of duplicate rows.
if !visited.insert(target_name.clone()) {
continue;
}
let Some(target_node) = nodes.get(target_name) else {
continue;
};
let path = if prefix.is_empty() {
alias.clone()
} else {
format!("{prefix}/{alias}")
};
if let Some(rev) = target_node
.get("locked")
.and_then(|v| v.get("rev"))
.and_then(|v| v.as_str())
{
let last_modified = target_node
.get("locked")
.and_then(|v| v.get("lastModified"))
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let url = target_node
.get("original")
.and_then(|v| v.get("url"))
.and_then(|v| v.as_str())
.map(str::to_owned);
out.push(MetaInputView {
name: path.clone(),
rev: rev.to_owned(),
last_modified,
url,
});
}
to_recurse.push((target_name.clone(), path));
}
// Recurse hyperhive's subtree before any agent's — without this,
// when meta's top-level `nixpkgs` is a `follows` alias the
// `String` check above skips it, and the alphabetical BTreeMap
// iteration descends into `agent-*` first. The agent walk then
// claims `nixpkgs` at `agent-X/nixpkgs` instead of
// `hyperhive/nixpkgs`, which is where the operator expects it.
// Sort by the same "hyperhive first, then alpha"
// priority `read_meta_inputs` uses for the final output.
to_recurse.sort_by(|(a, _), (b, _)| match (a.as_str(), b.as_str()) {
("hyperhive", _) => std::cmp::Ordering::Less,
(_, "hyperhive") => std::cmp::Ordering::Greater,
_ => a.cmp(b),
});
for (target_name, path) in to_recurse {
walk_meta_inputs(nodes, &target_name, &path, visited, out);
}
}
/// Snapshot meta/flake.lock's root inputs + emit
/// `MetaInputsChanged`. Call after any mutation that bumps a lock
/// (`run_meta_update`, `auto_update::rebuild_agent`).
pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) {
let inputs = read_meta_inputs();
coord.emit_dashboard_event(crate::dashboard_events::DashboardEvent::MetaInputsChanged {
seq: coord.next_seq(),
inputs,
});
}
/// Form for `POST /meta-update`. Inputs ride in as a comma-separated
/// list under the `inputs` field — the JS submitter joins the
/// checked boxes since axum's `Form` extractor doesn't natively
/// decode repeated keys without a helper.
#[derive(Deserialize)]
pub(super) struct MetaUpdateForm {
inputs: String,
}
/// Bulk-update selected meta flake inputs, then rebuild the affected
/// agents in the background. Idempotent w.r.t. selection — choosing
/// an input that's already at the latest sha is a no-op (no commit,
/// no rebuild ripple). Returns immediately after queueing the work;
/// dashboard polls for progress via container `pending` spinners +
/// the meta-inputs row sha update.
pub(super) async fn post_meta_update(
State(state): State<AppState>,
Form(form): Form<MetaUpdateForm>,
) -> Response {
let inputs: Vec<String> = form
.inputs
.split(',')
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect();
if inputs.is_empty() {
return error_response("meta-update: no inputs selected");
}
let inputs_label = inputs.join(", ");
// Cascade rebuild children fan out from the MetaLock node when the
// lock bump lands — appended by the scheduler so they build against
// the post-bump lock, and a failed bump simply fans out nothing.
crate::job_queue::submit::meta_update(
&state.coord,
inputs,
crate::job_queue::Source::Manual,
format!("meta-update via dashboard ({inputs_label})"),
);
(StatusCode::OK, "ok").into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn walk_meta_inputs_keeps_nixpkgs_under_hyperhive_post_follows_refactor() {
// Reproduce the shape where meta has
// `nixpkgs.follows = "hyperhive/nixpkgs"` at the top level
// (rendered as an array — `["hyperhive" "nixpkgs"]` — which
// walk_meta_inputs skips because we can't `nix flake update`
// a follows alias). The remaining top-level inputs are
// `hyperhive` (string) and `agent-z` (string). Without the
// hyperhive-first recursion sort, the BTreeMap alphabetical
// order descends into `agent-z` first and claims
// `nixpkgs` at `agent-z/nixpkgs`.
let raw = r#"{
"root": "root",
"version": 7,
"nodes": {
"root": {
"inputs": {
"hyperhive": "hyperhive",
"nixpkgs": ["hyperhive", "nixpkgs"],
"agent-z": "agent-z"
}
},
"hyperhive": {
"inputs": { "nixpkgs": "nixpkgs" },
"locked": {"rev": "hhrev", "lastModified": 1},
"original": {"url": "git+file:///tmp/hyperhive"}
},
"agent-z": {
"inputs": { "nixpkgs": "nixpkgs" },
"locked": {"rev": "azrev", "lastModified": 2},
"original": {"url": "git+file:///tmp/agent-z"}
},
"nixpkgs": {
"locked": {"rev": "npkrev", "lastModified": 3},
"original": {"url": "github:NixOS/nixpkgs/nixos-26.05"}
}
}
}"#;
let json: serde_json::Value = serde_json::from_str(raw).unwrap();
let nodes = json.get("nodes").unwrap().as_object().unwrap();
let root_name = json.get("root").unwrap().as_str().unwrap();
let mut visited = std::collections::HashSet::new();
visited.insert(root_name.to_owned());
let mut out = Vec::new();
walk_meta_inputs(nodes, root_name, "", &mut visited, &mut out);
let nixpkgs = out
.iter()
.find(|v| v.rev == "npkrev")
.expect("nixpkgs node should be emitted exactly once");
assert_eq!(
nixpkgs.name, "hyperhive/nixpkgs",
"nixpkgs should be claimed under hyperhive, not under agent-z. \
got: {:?}",
nixpkgs.name
);
// And the agent-z path should NOT also carry a nixpkgs entry —
// the spanning-tree visited set guarantees it's claimed once.
assert!(
!out.iter().any(|v| v.name == "agent-z/nixpkgs"),
"agent-z/nixpkgs should not be emitted (already claimed under hyperhive)"
);
}
}

View file

@ -0,0 +1,220 @@
//! Remaining single-endpoint dashboard handlers: the operator inbox
//! (`Y3R C4LL`) + mark-all-read, operator compose (`op-send`),
//! spawn-request, hive-wide turn stats, container resources, and the
//! audit log.
use axum::{
extract::{Form, Path as AxumPath, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use super::{AppState, error_response, scan_validated_paths, validate_agent_name};
/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox.
/// Returns messages addressed to `"operator"` that haven't been
/// acked yet (the operator clears them via the existing
/// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped
/// tokens are validated so the client renders file links like the
/// terminal does. Shape: `{ "messages": [{ id, from, body, at,
/// in_reply_to, file_refs }] }`.
pub(super) async fn api_operator_inbox(State(state): State<AppState>) -> Response {
const INBOX_LIMIT: u64 = 100;
match state
.coord
.broker
.unread_for_recipient("operator", INBOX_LIMIT)
{
Ok(messages) => {
let items: Vec<serde_json::Value> = messages
.into_iter()
.filter_map(|m| {
let crate::broker::MessageEvent::Sent {
id,
from,
body,
at,
in_reply_to,
..
} = m
else {
return None;
};
let file_refs = scan_validated_paths(&body);
Some(serde_json::json!({
"id": id,
"from": from,
"body": body,
"at": hive_sh4re::wire_time::from_secs(at),
"in_reply_to": in_reply_to,
"file_refs": file_refs,
}))
})
.collect();
axum::Json(serde_json::json!({ "messages": items })).into_response()
}
Err(e) => error_response(&format!("operator-inbox failed: {e:#}")),
}
}
#[derive(Deserialize)]
pub(super) struct StatsHiveQuery {
window: Option<String>,
}
/// Hive-wide turn-stats rollup for the dashboard swarm-stats view.
/// Aggregates every agent's `hyperhive-turn-stats.sqlite` read-only
/// (skips missing/unreadable ones). Window defaults to `24h`.
pub(super) async fn api_stats_hive(
State(state): State<AppState>,
axum::extract::Query(q): axum::extract::Query<StatsHiveQuery>,
) -> Response {
let window = crate::hive_stats::Window::parse(q.window.as_deref().unwrap_or("24h"));
axum::Json(crate::hive_stats::hive_snapshot(
window,
&state.coord.model_prices,
))
.into_response()
}
/// Live per-agent-container CPU + memory load from cgroup v2. Samples
/// CPU over a short interval (~200 ms), so this call briefly awaits.
pub(super) async fn api_container_resources() -> Response {
axum::Json(crate::container_stats::gather().await).into_response()
}
/// `GET /api/audit-log` — most-recent agent-initiated privileged-action
/// audit entries, newest first (server-clamped to 500). Backs the
/// operator dashboard's audit view. Returns
/// `{ "entries": [AuditEntry…], "total": N }` so the UI can show
/// "latest 500 of N" rather than silently capping. `ts_unix` is in
/// **seconds**.
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(serde_json::json!({ "entries": entries, "total": total })).into_response()
}
/// Operator-driven "clear this agent's inbox" — backs the side-panel
/// "mark all read" button. Marks every message addressed to the
/// agent as acked (backfilling `delivered_at` for any still-pending
/// rows so vacuum can collect them). Returns `{ "marked": N }` so the
/// frontend can show "cleared N messages" feedback without an extra
/// fetch.
pub(super) async fn post_mark_all_read(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
) -> Response {
if let Some(reason) = validate_agent_name(&name) {
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
}
match state.coord.broker.mark_all_read(&name) {
Ok(n) => {
tracing::info!(%name, marked = n, "operator marked all messages read");
axum::Json(serde_json::json!({ "marked": n })).into_response()
}
Err(e) => error_response(&format!("mark-all-read {name} failed: {e:#}")),
}
}
/// Operator-side compose form on the dashboard terminal. Drops a
/// message into the broker as `{from: "operator", to, body}`. Same
/// shape that per-agent web UIs use via `OperatorMsg`, but here the
/// operator picks the recipient explicitly with `@name`. No
/// validation that `to` resolves to a known agent — broker accepts
/// arbitrary recipients (and the agent's inbox grows whether or not
/// they exist, which is fine for spawn-then-greet flows).
#[derive(Deserialize)]
pub(super) struct OpSendForm {
to: String,
body: String,
}
pub(super) async fn post_op_send(
State(state): State<AppState>,
Form(form): Form<OpSendForm>,
) -> Response {
let to = form.to.trim().to_owned();
let body = form.body.trim().to_owned();
if to.is_empty() {
return error_response("op-send: `to` required");
}
if body.is_empty() {
return error_response("op-send: `body` required");
}
if to == "*" {
let errors = state
.coord
.broadcast_send(hive_sh4re::OPERATOR_RECIPIENT, &body);
if !errors.is_empty() {
return error_response(&format!(
"op-send broadcast partial fail: {}",
errors.join("; ")
));
}
} else if let Err(e) = state.coord.broker.send(&hive_sh4re::Message {
from: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
to: to.clone(),
body,
in_reply_to: None,
}) {
return error_response(&format!("op-send to {to} failed: {e:#}"));
}
// 200 instead of 303 → the client doesn't refetch /api/state. The
// broker `send` already emitted a `MessageEvent` which the
// dashboard channel forwarder mirrors as `DashboardEvent::Sent`,
// and the page's terminal + inbox derive from that stream — so the
// operator's send shows up the same way an agent's send does, with
// no full-state refresh in between.
(axum::http::StatusCode::OK, "ok").into_response()
}
#[derive(Deserialize)]
pub(super) struct RequestSpawnForm {
name: String,
}
pub(super) async fn post_request_spawn(
State(state): State<AppState>,
Form(form): Form<RequestSpawnForm>,
) -> Response {
let name = form.name.trim().to_owned();
if name.is_empty() {
return error_response("spawn: `name` required");
}
match state.coord.approvals.submit_kind(
&name,
hive_sh4re::ApprovalKind::Spawn,
"",
None,
"operator",
) {
Ok(id) => {
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard");
// Phase 5b: notify the dashboard event channel so live
// subscribers can append the row without a snapshot
// refetch. Spawn approvals carry no diff/sha.
state
.coord
.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
agent: &name,
approval_kind: "spawn",
sha_short: None,
diff: None,
description: None,
pr_number: None,
});
(StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,757 @@
//! `/api/state` cold-load snapshot plus the dashboard's live read side:
//! the `StateSnapshot` shape and its view builders, the
//! `/api/dashboard/stream` SSE channel, and the `/api/dashboard/history`
//! backfill. SPA shape + SSE channels: docs/web-ui/shape.md.
use std::convert::Infallible;
use axum::{
extract::State,
http::HeaderMap,
response::{
IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
},
};
use chrono::{DateTime, Utc};
use hive_sh4re::Approval;
use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::{Stream, StreamExt};
use crate::container_view::ContainerView;
use super::meta_inputs::{MetaInputView, read_meta_inputs};
use super::tombstones::{TombstoneView, build_tombstone_views};
use super::{AppState, approval_diff, approvals, error_response, scan_validated_paths};
#[allow(clippy::struct_excessive_bools)]
#[derive(Serialize)]
pub(super) struct StateSnapshot {
/// Broker seq at the moment this snapshot was assembled. Clients
/// dedupe their buffered SSE traffic against this value: any
/// `MessageEvent` with `seq <= snapshot.seq` is already reflected in
/// the snapshot (or pre-dates it); anything with `seq > snapshot.seq`
/// is post-snapshot and should be applied. Set to 0 in the
/// pre-emit case (no events ever fired) — clients treat that as
/// "apply everything you've buffered".
seq: u64,
hostname: String,
any_stale: bool,
containers: Vec<ContainerView>,
transients: Vec<TransientView>,
approvals: Vec<ApprovalView>,
/// Last 30 resolved approvals (approved / denied / failed), newest-
/// first. Drives the "history" tab on the approvals section.
approval_history: Vec<ApprovalHistoryView>,
/// Pending operator-targeted questions (`target IS NULL`). Any
/// agent can `ask` the operator and `ask` returns immediately with
/// the id; on `/answer-question` we mark the row answered and
/// fire `HelperEvent::QuestionAnswered` back into the asker's
/// inbox. Peer-to-peer questions live in the same table but never
/// surface here (see `OperatorQuestions::pending`).
questions: Vec<QuestionView>,
/// Last 20 answered questions, newest-first.
question_history: Vec<QuestionView>,
/// State dirs (config history + claude creds + /state/ notes) that
/// survive after a destroy-without-purge. The operator can re-spawn
/// with the same name to resume, or PURG3 to wipe them.
tombstones: Vec<TombstoneView>,
/// Sub-agents whose FNV-1a hashed web UI port collides with at
/// least one other agent. Operator resolves by renaming. The
/// dashboard renders a banner at the top listing each cluster.
port_conflicts: Vec<PortConflict>,
/// Inputs in `meta/flake.lock` the operator can selectively
/// `nix flake update`. Hyperhive first, then `agent-<n>` rows.
meta_inputs: Vec<MetaInputView>,
/// True while a dashboard-triggered `meta-update` (flake lock bump +
/// agent rebuild ripple) is running in the background. Lets a
/// client that cold-loads mid-update render the META INPUTS panel's
/// disabled "updating…" state; live transitions arrive via the
/// `MetaUpdateRunning` event.
meta_update_running: bool,
/// Current state of the global job queue — pending + running DAGs
/// (rebuild / meta-update / spawn / power ops) with their per-node
/// breakdowns, plus the most recent few terminal DAGs the queue
/// retains for history. Live transitions arrive via the
/// `RebuildQueueChanged` event. See `job_queue/`. Field name kept
/// from the old flat queue for wire compatibility.
rebuild_queue: Vec<crate::job_queue::DagView>,
/// Whether the hive-forge container is up. When true the dashboard
/// links each container's config + each approval's commit into the
/// forge's `agent-configs` repos.
forge_present: bool,
/// Whether the matrix GUI is reachable at `/matrix/`. Sourced from
/// `HIVE_MATRIX_GUI_ENABLED` env var (set by the c0re NixOS module
/// when `services.hyperhive.matrix.gui.enable` is on). The gateway
/// (hive-gateway.nix) does the actual `/matrix/` static serving;
/// this flag is just an availability signal for iris's dashboard
/// chrome so the `M4TR1X →` tab doesn't flash when the GUI is off.
matrix_gui_enabled: bool,
/// Whether `hive-gateway` is in front of this dashboard. Sourced
/// from the `HIVE_GATEWAY_ENABLED` env var, which the c0re NixOS
/// module now always sets (the gateway runs unconditionally
/// alongside hyperhive), so this is effectively always true: the
/// dashboard frontend builds same-origin `/agent/<name>/` links to
/// the per-agent web UI (the gateway routes them via the
/// runtime-generated `agents.conf` include file — see
/// `gateway_nginx.rs`). The `false` branch (direct
/// `http://<hostname>:<port>/` TCP links) is retained as a defensive
/// fallback for the env being unset. See `docs/gateway.md::Vhost map`.
gateway_enabled: bool,
/// Public URL of the forge vhost served by hive-gateway (e.g.
/// `"https://forge.pr1ma.darkest.space"`). Sourced from the
/// `HIVE_FORGE_PUBLIC_URL` env var, which the c0re NixOS module
/// sets when `forge.behindGateway = true`. `None` when absent —
/// the frontend falls back to `http://<hostname>:3000`.
forge_public_url: Option<String>,
/// Human name of this single-host hive instance (e.g. `"pr1ma"`).
/// Sourced from `HYPERHIVE_HIVE_NAME` env var, set by the c0re
/// NixOS module from `services.hyperhive.hiveName`. `None` when
/// the option is unset — chrome falls back to `hostname`.
hive_name: Option<String>,
/// Human name of the wider swarm this hive belongs to (e.g.
/// `"constellat1on"`). Sourced from `HYPERHIVE_SWARM_NAME` env
/// var, set from `services.hyperhive.swarmName`. `None` when
/// unset — chrome omits the swarm segment of the breadcrumb.
swarm_name: Option<String>,
/// Peer hives in the same swarm. Parsed from `HYPERHIVE_PEERS`
/// (JSON array of `{domain,cert_fingerprint}` objects, emitted by
/// the c0re NixOS module from `services.hyperhive.swarm.peers`).
/// Empty on single-hive deploys. Feeds the P33RS dashboard tab.
peer_hives: Vec<PeerHiveView>,
/// Server-level warnings for the dashboard's top-of-page banner
/// (currently host disk-pressure; more producers can be added
/// backend-side). Empty when all clear. Built by
/// `host_stats::server_warnings`; the frontend renders this list
/// generically, so new warning kinds need no frontend change.
server_warnings: Vec<crate::host_stats::ServerWarning>,
}
/// One peer hive for the P33RS dashboard tab. Derived from
/// `HYPERHIVE_PEERS` env; `url` is the peer's HTTPS dashboard root.
/// `cert_fingerprint` is `Some("sha256:<hex64>")` when the peer uses a
/// self-signed cert and the operator pinned its fingerprint in
/// `services.hyperhive.swarm.peers`.
#[derive(Serialize)]
struct PeerHiveView {
name: String,
url: String,
cert_fingerprint: Option<String>,
}
/// `OpQuestion` + computed `question_refs` / `answer_refs`. Built
/// from the snapshot read; the live channel attaches the same
/// fields directly on `QuestionAdded` / `QuestionResolved`.
#[derive(Serialize)]
struct QuestionView {
#[serde(flatten)]
inner: crate::operator_questions::OpQuestion,
#[serde(skip_serializing_if = "Vec::is_empty")]
question_refs: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
answer_refs: Vec<String>,
}
impl QuestionView {
fn from_question(q: crate::operator_questions::OpQuestion) -> Self {
let question_refs = scan_validated_paths(&q.question);
let answer_refs = q
.answer
.as_deref()
.map(scan_validated_paths)
.unwrap_or_default();
Self {
inner: q,
question_refs,
answer_refs,
}
}
}
#[derive(Serialize)]
struct PortConflict {
port: u16,
/// All agent names sharing this port (sorted, ≥2 entries).
agents: Vec<String>,
}
#[derive(Serialize)]
struct TransientView {
name: String,
kind: &'static str,
secs: u64,
}
#[derive(Serialize)]
struct ApprovalHistoryView {
id: i64,
agent: String,
kind: &'static str,
/// First 12 chars of the canonical sha (preferred) or
/// manager-supplied ref. None for resolved spawn approvals.
sha_short: Option<String>,
/// `approved` / `denied` / `failed`.
status: &'static str,
/// RFC 3339 UTC. Renders as a relative time on the dashboard.
resolved_at: DateTime<Utc>,
/// Operator-supplied deny reason (for `denied`) or build error
/// (for `failed`). None on `approved`.
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
#[derive(Serialize)]
struct ApprovalView {
id: i64,
agent: String,
kind: &'static str,
/// First 12 chars of the `commit_ref`, for `ApplyCommit` only.
sha_short: Option<String>,
/// Raw unified diff text, for `ApplyCommit` only. The client splits
/// on `\n` and per-line classifies (`+` / `-` / `@@` / `--- ` / `+++ `
/// → diff-add / diff-del / diff-hunk / diff-file). Shipping raw
/// instead of pre-rendered HTML saves bytes on the wire (no
/// per-line `<span>` markup) and removes the only HTML-escape
/// surface from the snapshot.
diff: Option<String>,
/// Manager-supplied description shown on the approval card.
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
/// Forge PR number, for `MergeConfigPr` only. Lets the frontend
/// build a "review PR on forge" link
/// (`{forgeBase}/agent-configs/{agent}/pulls/{pr_number}`) the same
/// way it builds the `apply_commit` "commit on forge" link from the
/// sha. `None` for every other kind.
#[serde(skip_serializing_if = "Option::is_none")]
pr_number: Option<u64>,
/// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded
/// `Vec<String>` of input names; `"[]"` = all inputs) and
/// `SchedulePrompt` (JSON-encoded `SchedulePromptPayload`). The
/// frontend parses this to render a human-readable card body.
/// `None` for every other kind.
#[serde(skip_serializing_if = "Option::is_none")]
commit_ref: Option<String>,
/// RFC 3339 UTC time the approval was queued. Rendered as a
/// relative time on the card so the operator can spot a stale
/// request.
requested_at: DateTime<Utc>,
}
/// Replace silent `.unwrap_or_default()` on the data sources behind
/// `/api/state` so that whichever query degrades surfaces in journald
/// instead of leaving the operator staring at an empty list. The
/// dashboard still degrades to a sensible default value; the warn
/// is just the diagnostic breadcrumb the old code swallowed.
fn log_default<T, E>(what: &str, result: std::result::Result<T, E>) -> T
where
T: Default,
E: std::fmt::Debug,
{
match result {
Ok(v) => v,
Err(e) => {
tracing::warn!(target: "api_state", source = %what, error = ?e, "snapshot source failed; using default");
T::default()
}
}
}
/// Window over which container crashes count toward the `agents_crashing`
/// banner warning. Wide enough that a crash-looping container (restarted
/// by `Restart=on-failure` every few seconds) keeps the warning lit
/// between flaps, short enough that a single recovered crash clears within
/// minutes.
const CRASH_WARNING_WINDOW: std::time::Duration = std::time::Duration::from_mins(10);
pub(super) async fn api_state(
headers: HeaderMap,
State(state): State<AppState>,
) -> axum::Json<StateSnapshot> {
let host = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost");
let hostname = host.split(':').next().unwrap_or(host).to_owned();
// Capture the unified dashboard-channel seq *before* any read so the
// dedupe contract is "events with seq > snapshot.seq are
// post-snapshot, never missed." An event landing during snapshot
// construction may be doubly applied (snapshot caught the write +
// client also applies the SSE frame) — that's a renderer's problem
// to make idempotent, not ours to avoid here.
let seq = state.coord.current_seq();
// Refresh the coordinator's cached container snapshot before
// reading. Cold-load clients then see whatever the latest rescan
// produced; live clients converge via the matching
// `ContainerStateChanged` / `ContainerRemoved` events the rescan
// emits.
//
// Bound the rescan: it shells out (`nixos-container list` etc.), so a
// saturated/wedged build backend — e.g. hive-c0re mid-startup-sweep
// hammering slow `nixos-container update` subprocesses — can stall it
// long enough that `/api/state` hangs for the whole request (the
// ~minute-long /state reported in the field). On timeout we skip the
// fresh rescan and serve the last cached snapshot instead; live
// clients still converge via the SSE events a later successful rescan
// emits, and the next /state call retries the refresh. Introspection
// stays responsive regardless of the build backend's health.
if tokio::time::timeout(
std::time::Duration::from_secs(3),
state.coord.rescan_containers_and_emit(),
)
.await
.is_err()
{
tracing::warn!(
"api_state: container rescan exceeded 3s (build backend likely saturated); \
serving last cached snapshot"
);
}
let containers = state.coord.containers_snapshot().await;
let any_stale = containers.iter().any(|c| c.needs_update);
let transient_snapshot = state.coord.transient_snapshot();
let pending_approvals = approvals::gc_orphans(
&state.coord,
log_default("approvals.pending", state.coord.approvals.pending()),
);
let transients = build_transient_views(&containers, &transient_snapshot);
let approvals = build_approval_views(pending_approvals).await;
let approval_history = log_default(
"approvals.recent_resolved",
state.coord.approvals.recent_resolved(30),
)
.into_iter()
.map(history_view)
.collect();
let tombstones = build_tombstone_views(&state.coord, &containers, &transient_snapshot);
let port_conflicts = build_port_conflicts(&containers);
// Both operator-targeted and peer threads surface on the dashboard
// (the client filters by target). Each row is wrapped in QuestionView
// so the snapshot carries the same file_refs the live event variants
// attach.
let questions: Vec<QuestionView> =
log_default("questions.pending_all", state.coord.questions.pending_all())
.into_iter()
.map(QuestionView::from_question)
.collect();
let question_history: Vec<QuestionView> = log_default(
"questions.recent_answered_all",
state.coord.questions.recent_answered_all(20),
)
.into_iter()
.map(QuestionView::from_question)
.collect();
// Banner warnings: host probes (disk) + agent-state (pending logins,
// crashing agents). Built before the response struct because the
// agent-state producer borrows `containers`, which moves in below.
let server_warnings = {
let mut w = crate::host_stats::server_warnings();
w.extend(crate::host_stats::agent_state_warnings(
&containers,
&state.coord.recent_crash_counts(CRASH_WARNING_WINDOW),
));
w
};
axum::Json(StateSnapshot {
seq,
hostname,
any_stale,
containers,
transients,
approvals,
approval_history,
meta_inputs: read_meta_inputs(),
meta_update_running: state.coord.meta_update_in_progress(),
questions,
question_history,
tombstones,
port_conflicts,
rebuild_queue: state.coord.job_queue.snapshot(),
forge_present: crate::forge::is_present().await,
matrix_gui_enabled: std::env::var_os("HIVE_MATRIX_GUI_ENABLED").is_some_and(|v| {
// Accept any truthy string ("1", "true", "yes") since the
// env var is set by NixOS module wiring with the literal
// "1"; defensive parse so manual overrides also work.
let s = v.to_string_lossy().to_ascii_lowercase();
matches!(s.as_str(), "1" | "true" | "yes")
}),
gateway_enabled: std::env::var_os("HIVE_GATEWAY_ENABLED").is_some_and(|v| {
// Same truthy-string parse as `matrix_gui_enabled`; the
// env var is set by the c0re NixOS module to the literal
// "1" — the gateway always runs alongside hyperhive.
let s = v.to_string_lossy().to_ascii_lowercase();
matches!(s.as_str(), "1" | "true" | "yes")
}),
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL")
.ok()
.filter(|s| !s.is_empty()),
hive_name: std::env::var("HYPERHIVE_HIVE_NAME")
.ok()
.filter(|s| !s.is_empty()),
swarm_name: std::env::var("HYPERHIVE_SWARM_NAME")
.ok()
.filter(|s| !s.is_empty()),
peer_hives: parse_peer_hives(),
server_warnings,
})
}
/// Parse `HYPERHIVE_PEERS` env var into dashboard-ready `PeerHiveView`
/// entries. The env var is a JSON array of `{domain, cert_fingerprint}`
/// objects emitted by the c0re NixOS module from
/// `services.hyperhive.swarm.peers`. Each entry becomes
/// `{ name: domain, url: "https://domain/" }` for the P33RS tab.
/// Returns empty vec when unset (single-hive deploy).
fn parse_peer_hives() -> Vec<PeerHiveView> {
#[derive(serde::Deserialize)]
struct Raw {
domain: String,
cert_fingerprint: Option<String>,
}
let Ok(json) = std::env::var("HYPERHIVE_PEERS") else {
return Vec::new();
};
let Ok(raw): Result<Vec<Raw>, _> = serde_json::from_str(&json) else {
tracing::warn!("HYPERHIVE_PEERS is not valid JSON; ignoring");
return Vec::new();
};
raw.into_iter()
.map(|r| {
let cert_fingerprint = r.cert_fingerprint.and_then(|fp| {
if validate_cert_fingerprint(&fp) {
Some(fp)
} else {
tracing::warn!(
domain = %r.domain,
fingerprint = %fp,
"HYPERHIVE_PEERS: invalid cert_fingerprint format \
(expected `sha256:<64 hex chars>`); ignoring fingerprint"
);
None
}
});
PeerHiveView {
name: r.domain.clone(),
url: format!("https://{}/", r.domain),
cert_fingerprint,
}
})
.collect()
}
/// Validate a TLS certificate fingerprint string from `HYPERHIVE_PEERS`.
/// Accepts `sha256:<64 hex chars>` (upper or lower case).
fn validate_cert_fingerprint(fp: &str) -> bool {
let Some(hex) = fp.strip_prefix("sha256:") else {
return false;
};
hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit())
}
/// Group live containers by their assigned web UI port; clusters with
/// more than one member are port-hash collisions the operator needs
/// to resolve by renaming. Manager (fixed at 8000) and sub-agents
/// (8100..8999) can't collide with each other — collisions are
/// strictly between sub-agents.
fn build_port_conflicts(containers: &[ContainerView]) -> Vec<PortConflict> {
let mut by_port: std::collections::BTreeMap<u16, Vec<String>> =
std::collections::BTreeMap::new();
for c in containers {
by_port.entry(c.port).or_default().push(c.name.clone());
}
by_port
.into_iter()
.filter(|(_, agents)| agents.len() > 1)
.map(|(port, mut agents)| {
agents.sort();
PortConflict { port, agents }
})
.collect()
}
/// Transient state for agents whose container does NOT yet exist
/// (`Spawning`). Lifecycle ops on existing containers surface as
/// `ContainerView.pending` inline; this list only catches pre-creation.
fn build_transient_views(
containers: &[ContainerView],
transient_snapshot: &std::collections::HashMap<String, crate::coordinator::TransientState>,
) -> Vec<TransientView> {
transient_snapshot
.iter()
.filter(|(name, _)| !containers.iter().any(|c| &c.name == *name))
.map(|(name, st)| TransientView {
name: name.clone(),
kind: transient_label(st.kind),
secs: st.since.elapsed().as_secs(),
})
.collect()
}
fn transient_label(k: crate::coordinator::TransientKind) -> &'static str {
use crate::coordinator::TransientKind::{
Destroying, Rebuilding, Restarting, Spawning, Starting, Stopping,
};
match k {
Spawning => "spawning",
Starting => "starting",
Stopping => "stopping",
Restarting => "restarting",
Rebuilding => "rebuilding",
Destroying => "destroying",
}
}
/// Render each pending approval into its dashboard view (short sha +
/// unified diff for `ApplyCommit`, just the name for `Spawn`).
/// Project a resolved sqlite row into the lean shape the dashboard
/// history tab consumes — no `diff_html` (rendering 30 of them
/// per /api/state poll would mean 30 git diffs per refresh).
fn history_view(a: Approval) -> ApprovalHistoryView {
let displayed = a.fetched_sha.as_deref().unwrap_or(&a.commit_ref);
let sha_short = if displayed.is_empty() {
None
} else {
Some(displayed[..displayed.len().min(12)].to_owned())
};
let status = match a.status {
hive_sh4re::ApprovalStatus::Approved => "approved",
hive_sh4re::ApprovalStatus::Denied => "denied",
hive_sh4re::ApprovalStatus::Failed => "failed",
hive_sh4re::ApprovalStatus::Cancelled => "cancelled",
// Pending shouldn't appear in recent_resolved, but be defensive.
hive_sh4re::ApprovalStatus::Pending => "pending",
};
let kind = match a.kind {
hive_sh4re::ApprovalKind::ApplyCommit => "apply_commit",
hive_sh4re::ApprovalKind::Spawn => "spawn",
hive_sh4re::ApprovalKind::InitConfig => "init_config",
hive_sh4re::ApprovalKind::UpdateMetaInputs => "update_meta_inputs",
hive_sh4re::ApprovalKind::SchedulePrompt => "schedule_prompt",
hive_sh4re::ApprovalKind::MergeConfigPr => "merge_config_pr",
};
ApprovalHistoryView {
id: a.id,
agent: a.agent,
kind,
sha_short,
status,
resolved_at: a.resolved_at.unwrap_or_default(),
note: a.note,
}
}
async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
let mut out = Vec::with_capacity(approvals.len());
for a in approvals {
out.push(match a.kind {
hive_sh4re::ApprovalKind::ApplyCommit => {
// Prefer the canonical fetched sha from applied;
// commit_ref is only the manager's claim and may be
// amended out from under us.
let displayed = a.fetched_sha.as_deref().unwrap_or(&a.commit_ref);
let sha = displayed[..displayed.len().min(12)].to_owned();
let diff = approval_diff(&a.agent, a.id).await;
ApprovalView {
id: a.id,
agent: a.agent.clone(),
kind: "apply_commit",
sha_short: Some(sha),
diff: Some(diff),
description: a.description,
pr_number: None,
commit_ref: None,
requested_at: a.requested_at,
}
}
hive_sh4re::ApprovalKind::Spawn => ApprovalView {
id: a.id,
agent: a.agent,
kind: "spawn",
sha_short: None,
diff: None,
description: a.description,
pr_number: None,
commit_ref: None,
requested_at: a.requested_at,
},
hive_sh4re::ApprovalKind::InitConfig => ApprovalView {
id: a.id,
agent: a.agent,
kind: "init_config",
sha_short: None,
diff: None,
description: a.description,
pr_number: None,
commit_ref: None,
requested_at: a.requested_at,
},
hive_sh4re::ApprovalKind::UpdateMetaInputs => ApprovalView {
id: a.id,
agent: a.agent,
kind: "update_meta_inputs",
sha_short: None,
diff: None,
description: a.description,
pr_number: None,
commit_ref: Some(a.commit_ref),
requested_at: a.requested_at,
},
hive_sh4re::ApprovalKind::SchedulePrompt => ApprovalView {
id: a.id,
agent: a.agent,
kind: "schedule_prompt",
sha_short: None,
diff: None,
description: a.description,
pr_number: None,
commit_ref: Some(a.commit_ref),
requested_at: a.requested_at,
},
hive_sh4re::ApprovalKind::MergeConfigPr => {
// commit_ref = PR number; fetched_sha = the reviewed PR
// head. Show the head sha; the forge PR diff surface is
// a later phase of the PR-based config flow — None for now.
let sha = a
.fetched_sha
.as_deref()
.map(|s| s[..s.len().min(12)].to_owned());
// Surface the PR number so the frontend can link to the
// PR on the forge. commit_ref holds the number as text.
let pr_number = a.commit_ref.parse::<u64>().ok();
ApprovalView {
id: a.id,
agent: a.agent,
kind: "merge_config_pr",
sha_short: sha,
diff: None,
description: a.description,
pr_number,
commit_ref: None,
requested_at: a.requested_at,
}
}
});
}
out
}
pub(super) async fn dashboard_history(State(state): State<AppState>) -> Response {
// Backfill source for the dashboard terminal. Returns up to ~200
// historical broker messages (no other event kinds are persisted)
// converted to `DashboardEvent::Sent` JSON so the client can replay
// through the same dispatch path as live frames. Wrapped in
// `{ seq, events }`: the seq is the dashboard channel's high-water
// mark at fetch time. Clients use it to dedupe their buffered live
// SSE traffic (drop anything with `seq <= history_seq`) so a frame
// that lands between SSE-subscribe and history-fetch isn't shown
// twice and isn't lost. Historical rows carry `seq = 0`; the
// boundary seq is what closes the dedupe window.
const HISTORY_LIMIT: u64 = 200;
let seq = state.coord.current_seq();
match state.coord.broker.recent_all(HISTORY_LIMIT) {
Ok(mut messages) => {
messages.reverse();
let events: Vec<crate::dashboard_events::DashboardEvent> = messages
.into_iter()
.map(|m| match m {
crate::broker::MessageEvent::Sent {
id,
from,
to,
body,
at,
in_reply_to,
} => {
let file_refs = scan_validated_paths(&body);
crate::dashboard_events::DashboardEvent::Sent {
seq: 0,
id,
from,
to,
body,
at: hive_sh4re::wire_time::from_secs(at),
in_reply_to,
file_refs,
}
}
crate::broker::MessageEvent::Delivered {
id,
from,
to,
body,
at,
in_reply_to,
} => {
let file_refs = scan_validated_paths(&body);
crate::dashboard_events::DashboardEvent::Delivered {
seq: 0,
id,
from,
to,
body,
at: hive_sh4re::wire_time::from_secs(at),
in_reply_to,
file_refs,
}
}
})
.collect();
axum::Json(serde_json::json!({ "seq": seq, "events": events })).into_response()
}
Err(e) => error_response(&format!("dashboard/history failed: {e:#}")),
}
}
/// `/dashboard/stream` query string. Today's only field is `kinds`:
/// a comma-separated allow-list of event-`kind` strings.
/// Empty / absent ⇒ no filter (current behaviour, all variants
/// forwarded). Set ⇒ only the named kinds reach the subscriber,
/// non-matches are skipped before the JSON serialise cost.
///
/// Useful for narrow pages (e.g. `flow.js` only cares about `sent`
/// / `delivered` / `container_state_changed` / `container_removed`)
/// that want to drop the dispatch overhead on every unrelated mutation.
#[derive(Deserialize, Default)]
pub(super) struct DashboardStreamQuery {
/// Comma-separated event kinds to forward. Each token is
/// trimmed; unknown kinds are silently ignored on lookup
/// (subscriber sees nothing instead of an error).
kinds: Option<String>,
}
pub(super) async fn dashboard_stream(
State(state): State<AppState>,
axum::extract::Query(q): axum::extract::Query<DashboardStreamQuery>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let rx = state.coord.dashboard_subscribe();
// Pre-parse the allow-list once at subscription time, so the
// per-event hot path is just a `HashSet::contains` on a
// `&'static str` — no string churn per frame.
let kind_filter: Option<std::collections::HashSet<String>> = q.kinds.and_then(|raw| {
let set: std::collections::HashSet<String> = raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
if set.is_empty() { None } else { Some(set) }
});
let stream = BroadcastStream::new(rx).filter_map(move |res| {
// Drop lagged frames. Browsers reconnect; the seq dedupe on
// reconnect skips any frame already reflected in the snapshot.
let event = res.ok()?;
if let Some(filter) = kind_filter.as_ref()
&& !filter.contains(event.kind_tag())
{
return None;
}
let json = serde_json::to_string(&event).ok()?;
Some(Ok(Event::default().data(json)))
});
Sse::new(stream).keep_alive(KeepAlive::default())
}

View file

@ -0,0 +1,159 @@
//! Tombstone rows for the dashboard: state dirs surviving a
//! destroy-without-purge. Builds `TombstoneView`s for the snapshot,
//! emits the `TombstonesChanged` event after mutations, and handles
//! `POST /api/purge-tombstone/{name}`.
use std::path::Path;
use std::sync::Arc;
use axum::{
extract::{Path as AxumPath, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use crate::container_view::{ContainerView, claude_has_session};
use crate::coordinator::Coordinator;
use crate::lifecycle;
use super::{AppState, error_response, validate_agent_name};
#[derive(Serialize, Clone, Debug)]
pub struct TombstoneView {
pub name: String,
/// Bytes used by the state dir tree. Cheap-ish to compute; let the
/// operator know how much they're holding onto.
pub state_bytes: u64,
/// Mtime (unix seconds) of the state dir; rough "last seen".
pub last_seen: i64,
pub has_creds: bool,
}
/// State-dir names that don't appear in the live container list. Each
/// one surfaces in the dashboard as a row with R3V1V3 + PURG3 actions.
pub(super) fn build_tombstone_views(
coord: &Coordinator,
containers: &[ContainerView],
transient_snapshot: &std::collections::HashMap<String, crate::coordinator::TransientState>,
) -> Vec<TombstoneView> {
let _ = coord; // kept_state_names is a free fn but takes &self by future plan
let live: std::collections::HashSet<&str> = containers
.iter()
.map(|c| c.name.as_str())
.chain(transient_snapshot.keys().map(String::as_str))
.collect();
Coordinator::kept_state_names()
.into_iter()
.filter(|name| !live.contains(name.as_str()))
.map(|name| {
let root = Coordinator::agent_state_root(&name);
let state_bytes = dir_size_bytes(&root);
let last_seen = std::fs::metadata(&root)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
let has_creds = claude_has_session(&Coordinator::agent_claude_dir(&name));
TombstoneView {
name,
state_bytes,
last_seen,
has_creds,
}
})
.collect()
}
/// Sum the byte size of every regular file under `root`. Cheap to compute
/// for typical agent state (config repo + claude creds + notes file —
/// usually a few MB); fine to do inline on each /api/state. Returns 0 on
/// any error.
fn dir_size_bytes(root: &Path) -> u64 {
fn walk(p: &Path, acc: &mut u64) {
let Ok(rd) = std::fs::read_dir(p) else { return };
for entry in rd.flatten() {
let Ok(ft) = entry.file_type() else { continue };
if ft.is_dir() {
walk(&entry.path(), acc);
} else if ft.is_file()
&& let Ok(meta) = entry.metadata()
{
*acc += meta.len();
}
}
}
let mut total = 0u64;
walk(root, &mut total);
total
}
/// Snapshot the current tombstone list and emit a
/// `TombstonesChanged` event. Call after any mutation that could
/// add or remove a tombstone (`actions::destroy`,
/// `post_purge_tombstone`, spawn finalisation). Cheap — the list
/// is tiny.
pub(crate) async fn emit_tombstones_snapshot(coord: &Arc<Coordinator>) {
let containers = coord.containers_snapshot().await;
let transient_snapshot = coord.transient_snapshot();
let tombstones = build_tombstone_views(coord, &containers, &transient_snapshot);
coord.emit_dashboard_event(crate::dashboard_events::DashboardEvent::TombstonesChanged {
seq: coord.next_seq(),
tombstones,
});
}
pub(super) async fn post_purge_tombstone(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
) -> Response {
// Format guard FIRST so a name like `..` can't traverse into the
// parent of `/var/lib/hyperhive/agents/{name}` and have
// `remove_dir_all` wipe `/var/lib/hyperhive/` itself. Existing
// manager + live-container checks below don't catch `..` — only
// the whitelist does. Existence check via
// `containers_snapshot()` is deliberately NOT used here:
// tombstoned agents are gone from the snapshot by design; that's
// the whole point of this endpoint.
if let Some(reason) = validate_agent_name(&name) {
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
}
// Sanity: refuse to purge if a live container still exists with this
// name. The dashboard already filters tombstones to non-live names,
// but the operator could send a stale POST.
let live = lifecycle::list().await.unwrap_or_default();
if live
.iter()
.any(|c| c == &format!("{}{name}", lifecycle::AGENT_PREFIX) || c == &name)
{
return error_response(&format!(
"refusing to purge {name}: container still exists — use DESTR0Y first"
));
}
let mut errors = Vec::new();
for dir in [
Coordinator::agent_state_root(&name),
Coordinator::agent_applied_dir(&name),
] {
if dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&dir)
{
errors.push(format!("{}: {e}", dir.display()));
}
}
let _ = state
.coord
.approvals
.fail_pending_for_agent(&name, "agent state purged");
if errors.is_empty() {
tracing::info!(%name, "tombstone purged");
// Fire the post-purge tombstones snapshot so dashboards
// drop the row live; matching form carries
// `data-no-refresh`.
emit_tombstones_snapshot(&state.coord).await;
(StatusCode::OK, "ok").into_response()
} else {
error_response(&format!("purge {name} partial: {}", errors.join(", ")))
}
}