hyperhive/hive-c0re/src/dashboard/mod.rs
atlas d94bc2188d topology: drop the parent field and the hierarchy it fed
`topology.json` was a map of `name -> parent | null`, and that value fed
the whole agent hierarchy: `<parent>` / `<children>` recipient sentinels,
the reparenting API (CLI verb, wire verb, dashboard endpoints, DAG node),
the dashboard tree, the rebuild depth sort, and an unconditional
bind-mount grant giving every agent RW on its direct children's state.

Per the operator's ruling the field goes, and with it all of the above.
The file survives as what remains once the value is gone: the roster of
agent names, which is the set `ManageRootAgent` grants mounts over. It is
now a JSON array; `read` still accepts the old map shape and keeps its
keys, so a hive that upgrades across this does not blank its roster (and
so no capability holder loses its mounts for the length of that window).

Two sites kept their behaviour under a different recipient rather than
losing it. Both addressed `<parent>`, which the broker already resolved to
`operator` for a root agent, and every agent is now what that fallback
called a root:

- the harness's turn-failure / plugin-failure notification
  (`Surface::send_to_parent` -> `send_to_operator`), and
- the send allow-list's always-permitted escape hatch, so an agent with a
  restrictive allow-list still has a way to say it is stuck.

What is NOT preserved, deliberately: an agent with no capability no longer
sees any other agent's dirs. `ManageRootAgent`'s own grant is unchanged --
still every agent in the roster, still state RW + config RO, still no
`harness`.

The dashboard's reparenting control (the M0V3 picker) is deleted with its
CSS. The tree rendering that reads `ContainerView.parent` is left for the
frontend owner -- it degrades to a flat list with the field gone.
2026-09-21 22:08:47 +02:00

438 lines
20 KiB
Rust

//! Hyperhive dashboard. Lists managed containers (with deep-links to each
//! container's web UI), pending approvals (with unified diff vs the applied
//! repo, plus approve/deny buttons), and the manager.
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::{Context, Result};
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use utoipa::OpenApi;
use utoipa_axum::{router::OpenApiRouter, routes};
use crate::coordinator::Coordinator;
use crate::lifecycle;
/// Root of the auto-generated `OpenAPI` spec, served raw at
/// `/api/openapi.json` — see [`utoipa`]. Swagger UI itself (browsable at
/// `/api/docs`) is nginx-hosted from the nix store (see
/// `nix/host-modules/hive-gateway/vhosts.nix`'s `swaggerUiLocations`).
/// Only routes carrying a `#[utoipa::path(...)]` annotation show up in
/// the spec — an unannotated route just doesn't appear, nothing breaks.
#[derive(OpenApi)]
#[openapi(
info(
title = "hyperhive dashboard API",
description = "hive-c0re's HTTP surface, served on the loopback \
dashboard port behind the gateway's /api/ + \
/health/ proxy prefixes."
),
tags(
(name = "health", description = "hive-wide liveness/readiness probes"),
(name = "journal", description = "container + host journal reads"),
(name = "approvals", description = "approve/deny pending approval rows"),
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(name = "extra_forges", description = "external (non-internal) forge account provisioning"),
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "matrix_accounts", description = "matrix + github account provisioning for agents"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
(name = "permissions", description = "tool-group + capability assignment for agents"),
(name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"),
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
(name = "tombstones", description = "purge of retained state for destroyed agents"),
(name = "webhook", description = "forgejo webhook receivers"),
)
)]
struct ApiDoc;
mod approvals;
mod build_logs;
mod extra_forges;
// The single validated identifier type — homed in `hive-host-sock` (the crate
// owning agent-path facts) so every dashboard path-param validates through the
// same type used to build agent paths. Re-exported so submodules + the socket
// server reach it as `crate::dashboard::Ident`.
pub(crate) use hive_types::Ident;
mod health;
mod journal;
mod lifecycle_ops;
mod matrix_accounts;
mod meta_inputs;
mod misc_api;
pub(crate) mod permissions;
mod schedules;
mod state_files;
mod state_snapshot;
mod tombstones;
mod webhook;
// Run after lock bumps by the job queue (`job_queue/exec.rs`); the view
// type feeds `DashboardEvent::MetaInputsChanged` (`dashboard_events.rs`).
// Re-exported to preserve the `crate::dashboard::*` paths across the split.
pub use meta_inputs::MetaInputView;
pub(crate) use meta_inputs::emit_meta_inputs_snapshot;
// Run at broker-message ingest by the coordinator + the operator-msg path
// (`main.rs`); re-exported to preserve the `crate::dashboard::scan_validated_paths`
// path across the split.
pub use state_files::scan_validated_paths;
// Called after destroy/purge/spawn finalisation (`actions.rs`); the view
// type feeds `DashboardEvent::TombstonesChanged` (`dashboard_events.rs`).
// Re-exported to preserve the `crate::dashboard::*` paths across the split.
pub use tombstones::TombstoneView;
pub(crate) use tombstones::emit_tombstones_snapshot;
#[derive(Clone)]
struct AppState {
coord: Arc<Coordinator>,
/// HMAC-SHA256 secret shared with Forgejo webhook registrations.
/// Verified on every incoming `/webhook/*` POST.
/// `None` when the secret could not be loaded at startup — all
/// `/webhook/*` requests are rejected with 503 in that case.
webhook_secret: Option<String>,
}
#[allow(
clippy::too_many_lines,
reason = "the body is dominated by the flat axum route table — one line \
per endpoint mapping a URL to its (now per-concern submodule) \
handler; splitting that exhaustive list across helpers would \
obscure the route map for no readability gain"
)]
pub async fn serve(
port: u16,
coord: Arc<Coordinator>,
webhook_secret: Option<String>,
) -> Result<()> {
// API-only: the gateway static-serves the dashboard dist and proxies
// non-static requests here (see `nix/host-modules/hive-gateway/vhosts.nix`).
// Unmatched paths 404.
// No static fallback needed here either. Every route (SSE streams
// included — `utoipa::path` can document an SSE body as an opaque
// `text/event-stream` string, it just can't model individual frame
// shapes) is registered below via `OpenApiRouter`; see the `router`/
// `api` split.
//
// `routes!()` folds every handler it's given into ONE shared
// `MethodRouter` for the whole macro invocation — it does NOT group
// by path internally, so passing it handlers for more than one
// distinct path panics at runtime ("Overlapping method route")
// the moment two of them share an HTTP method, which most
// GET-vs-GET or POST-vs-POST pairs across different paths do. Each
// call below is therefore scoped to exactly one path — two handlers
// in the same call only when they genuinely share a path with
// different methods (`schedules::api_schedules`/`post_schedule_new`
// on `/api/schedules`, `matrix_accounts::get_github_account`/
// `post_github_account` on `/api/github-account`) — chained via
// repeated `.routes(...)` calls instead of one giant `routes!(...)`
// with everything in it.
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
.routes(routes!(health::get_health_live))
.routes(routes!(health::get_health_ready))
.routes(routes!(journal::get_journal))
.routes(routes!(journal::get_journal_host))
.routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(matrix_accounts::get_matrix_accounts))
.routes(routes!(matrix_accounts::post_matrix_account_login))
.routes(routes!(
matrix_accounts::post_github_account,
matrix_accounts::get_github_account
))
.routes(routes!(extra_forges::get_extra_forges))
.routes(routes!(extra_forges::post_extra_forge_account))
.routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_request_spawn))
.routes(routes!(misc_api::post_op_send))
.routes(routes!(build_logs::get_build_logs_all))
.routes(routes!(build_logs::get_build_log_for_node))
.routes(routes!(build_logs::get_build_log_raw_for_node))
.routes(routes!(build_logs::get_build_logs_agent))
.routes(routes!(build_logs::get_build_log_full))
.routes(routes!(build_logs::get_build_log_raw))
.routes(routes!(permissions::get_tool_groups))
.routes(routes!(permissions::post_tool_groups))
.routes(routes!(permissions::get_capabilities))
.routes(routes!(permissions::post_capabilities))
.routes(routes!(permissions::post_permissions))
.routes(routes!(permissions::get_stale_permissions))
.routes(routes!(permissions::delete_agent_permissions))
.routes(routes!(
schedules::api_schedules,
schedules::post_schedule_new
))
.routes(routes!(schedules::patch_schedule))
.routes(routes!(schedules::post_schedule_cancel))
.routes(routes!(schedules::post_schedule_pause))
.routes(routes!(schedules::post_schedule_resume))
.routes(routes!(schedules::post_schedule_fire_now))
.routes(routes!(schedules::post_rebuild_queue_cancel))
.routes(routes!(webhook::post_webhook_config_pr))
.routes(routes!(approvals::post_approve))
.routes(routes!(approvals::post_deny))
.routes(routes!(lifecycle_ops::post_destroy))
.routes(routes!(lifecycle_ops::post_kill))
.routes(routes!(lifecycle_ops::post_restart))
.routes(routes!(lifecycle_ops::post_start))
.routes(routes!(lifecycle_ops::post_rebuild))
.routes(routes!(lifecycle_ops::post_pause))
.routes(routes!(lifecycle_ops::post_resume))
.routes(routes!(lifecycle_ops::post_resource_limits))
.routes(routes!(lifecycle_ops::post_update_all))
.routes(routes!(tombstones::post_purge_tombstone))
.routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream))
.routes(routes!(state_snapshot::dashboard_stream))
.routes(routes!(state_snapshot::dashboard_history))
.routes(routes!(state_snapshot::jobq_graph))
.routes(routes!(state_snapshot::jobq_rollup))
.split_for_parts();
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
// the nix store (see the module doc comment above `ApiDoc`). `api`
// is `Clone`; each request gets its own owned copy for `Json` to
// serialize.
let app = router
.route(
"/api/openapi.json",
get(move || async move { Json(api.clone()) }),
)
.with_state(AppState {
coord,
webhook_secret,
});
// Binds loopback-only; external access via gateway.
// Rationale: docs/networking/gateway.md::Firewall posture.
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = bind_with_retry(addr).await?;
tracing::info!(%addr, "dashboard listening");
axum::serve(listener, app).await?;
Ok(())
}
// SPA shape + SSE channels: docs/web-ui/shape.md.
/// `SO_REUSEADDR` bind with retry. Retry mechanics, attempt-cap
/// rationale, and log-level cadence: `docs/web-ui/shape.md::Listener bind`.
async fn bind_with_retry(addr: SocketAddr) -> Result<tokio::net::TcpListener> {
let mut delay_ms = 250u64;
let mut attempts = 0u32;
loop {
match try_bind(addr) {
Ok(l) => {
if attempts > 0 {
tracing::info!(
%addr, attempts,
"dashboard: bind succeeded after retry"
);
}
return Ok(l);
}
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
let attempt = attempts + 1;
if attempt <= 12 {
tracing::warn!(
%addr, attempt,
"dashboard: AddrInUse, retrying in {delay_ms}ms"
);
} else {
tracing::info!(
%addr, attempt,
"dashboard: AddrInUse still holding, retrying in {delay_ms}ms"
);
}
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
attempts += 1;
delay_ms = (delay_ms * 2).min(2000);
}
Err(e) => {
return Err(e).with_context(|| format!("bind dashboard on {addr}"));
}
}
}
}
fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
let sock = match addr {
SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4()?,
SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6()?,
};
sock.set_reuseaddr(true)?;
sock.bind(addr)?;
sock.listen(1024)
}
/// Two-axis path-param guard for write routes. Combines:
///
/// 1. **format validation** ([`Ident::parse`]) — rejects
/// path traversal / unicode homoglyphs / empty + too-long names with
/// HTTP 400.
/// 2. **existence check** — looks up `name` in the coordinator's
/// container snapshot; unknown name → HTTP 404 with a clear
/// "no such agent" message. catches the operator-typo case where
/// a destructive POST would otherwise hit silently (mark-all-read
/// returning 0) or hit downstream lifecycle code that fails with
/// a confusing nspawn error.
///
/// Returns `None` when both checks pass (caller proceeds), `Some(Response)`
/// when the request should be rejected. Use at the top of every write
/// handler taking a name path-param. Read-only GET handlers and
/// handlers that legitimately operate on tombstoned agents (e.g.
/// `mark-all-read` on broker rows for a destroyed agent) call
/// [`Ident::parse`] directly and skip the existence check.
async fn guard_agent_name(state: &AppState, name: &str) -> Option<Response> {
if let Err(reason) = Ident::parse(name) {
return Some(
(StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(),
);
}
let snapshot = state.coord.containers_snapshot().await;
if !snapshot.iter().any(|c| c.name == name) {
return Some((StatusCode::NOT_FOUND, format!("no such agent: {name}")).into_response());
}
None
}
/// Convert either a logical name or a container name back to the logical
/// name. Sub-agents are `h-foo` → `foo`; manager stays `root`.
fn strip_container_prefix(name: &str) -> String {
name.strip_prefix(lifecycle::AGENT_PREFIX)
.unwrap_or(name)
.to_owned()
}
/// The common internal-error case as a `ProblemDetails`: a 500 RFC 9457
/// (`application/problem+json`) value via the `problem_details` crate.
/// `from_status_code` sets `status` + `title` (the canonical reason phrase)
/// and leaves `type` as the default `about:blank`; `with_detail` carries the
/// caller message; the crate's axum `IntoResponse` emits the
/// `application/problem+json` body the frontend parses (it reads `detail`).
/// Handlers that surface client failures return `Result<_, ProblemDetails>`
/// and hand this (or an inline `from_status_code(4xx)`) straight to `Err` —
/// no manual `.into_response()`.
fn error_problem(message: &str) -> problem_details::ProblemDetails {
problem_details::ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR)
.with_detail(message)
}
/// `Response` wrapper around [`error_problem`] for the many handlers typed
/// `-> Response` whose only failure mode is a 500 — they funnel errors
/// through here rather than threading a `Result` return type.
fn error_response(message: &str) -> Response {
error_problem(message).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn problem_details_carry_rfc9457_status_and_detail() {
// Contract the frontend depends on: the problem_details crate
// serialises the RFC 9457 members we rely on — `status` (numeric)
// and `detail` (the caller message; the FE reads `.detail`).
let pd = problem_details::ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail("bad input");
let v = serde_json::to_value(&pd).expect("problem details serialise");
assert_eq!(v["status"], 400);
assert_eq!(v["detail"], "bad input");
// The 500 wrapper path carries the internal-error status.
let five =
problem_details::ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR)
.with_detail("boom");
let fv = serde_json::to_value(&five).expect("problem details serialise");
assert_eq!(fv["status"], 500);
}
}
#[cfg(test)]
mod router_build_probe {
use super::*;
/// Regression test for the exact panic this file's `serve()` used to
/// hit at startup: `routes!()` folds every handler passed to ONE
/// macro invocation into a single shared `MethodRouter`, so two
/// handlers on different paths but the same HTTP method panic with
/// "Overlapping method route" the moment they're in the same
/// `routes!(...)` call — see `serve()`'s comment above its own
/// `.routes(...)` chain. This mirrors that exact chain (one
/// `.routes()` call per distinct path, multi-method pairs grouped
/// only where they share a path) so a future regression that
/// accidentally merges two single-path calls back into one
/// multi-path `routes!(...)` call fails here instead of at daemon
/// startup.
#[test]
fn probe_multi_path_routes_macro_does_not_panic() {
let _ = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
.routes(routes!(health::get_health_live))
.routes(routes!(health::get_health_ready))
.routes(routes!(journal::get_journal))
.routes(routes!(journal::get_journal_host))
.routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(matrix_accounts::get_matrix_accounts))
.routes(routes!(matrix_accounts::post_matrix_account_login))
.routes(routes!(
matrix_accounts::post_github_account,
matrix_accounts::get_github_account
))
.routes(routes!(extra_forges::get_extra_forges))
.routes(routes!(extra_forges::post_extra_forge_account))
.routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_request_spawn))
.routes(routes!(misc_api::post_op_send))
.routes(routes!(build_logs::get_build_logs_all))
.routes(routes!(build_logs::get_build_log_for_node))
.routes(routes!(build_logs::get_build_log_raw_for_node))
.routes(routes!(build_logs::get_build_logs_agent))
.routes(routes!(build_logs::get_build_log_full))
.routes(routes!(build_logs::get_build_log_raw))
.routes(routes!(permissions::get_tool_groups))
.routes(routes!(permissions::post_tool_groups))
.routes(routes!(permissions::get_capabilities))
.routes(routes!(permissions::post_capabilities))
.routes(routes!(permissions::post_permissions))
.routes(routes!(permissions::get_stale_permissions))
.routes(routes!(permissions::delete_agent_permissions))
.routes(routes!(
schedules::api_schedules,
schedules::post_schedule_new
))
.routes(routes!(schedules::patch_schedule))
.routes(routes!(schedules::post_schedule_cancel))
.routes(routes!(schedules::post_schedule_pause))
.routes(routes!(schedules::post_schedule_resume))
.routes(routes!(schedules::post_schedule_fire_now))
.routes(routes!(schedules::post_rebuild_queue_cancel))
.routes(routes!(webhook::post_webhook_config_pr))
.routes(routes!(approvals::post_approve))
.routes(routes!(approvals::post_deny))
.routes(routes!(lifecycle_ops::post_destroy))
.routes(routes!(lifecycle_ops::post_kill))
.routes(routes!(lifecycle_ops::post_restart))
.routes(routes!(lifecycle_ops::post_start))
.routes(routes!(lifecycle_ops::post_rebuild))
.routes(routes!(lifecycle_ops::post_pause))
.routes(routes!(lifecycle_ops::post_resume))
.routes(routes!(lifecycle_ops::post_resource_limits))
.routes(routes!(lifecycle_ops::post_update_all))
.routes(routes!(tombstones::post_purge_tombstone))
.routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream))
.routes(routes!(state_snapshot::dashboard_stream))
.routes(routes!(state_snapshot::dashboard_history))
.routes(routes!(state_snapshot::jobq_graph))
.routes(routes!(state_snapshot::jobq_rollup));
}
}