hyperhive/hive-c0re/src/dashboard/mod.rs
iris 07b62612b0 docs: restructure into topic subdirectories, collapse duplicated index
Per mara's go-ahead on hyperhive#3902 ("getting started is good, but
terminal rendering does not go in there i think"):

Moved 21 top-level docs/*.md files into 7 new topic subdirectories
(existing web-ui/, turn-loop/, swarm/, tools/, crates/ untouched):
  getting-started/  setup.md
  agent-lifecycle/  agent-hierarchy.md, approvals.md, persistence.md
  trust-boundary/   boundary.md, security.md
  integrations/     forge.md, matrix.md, github.md, knowledge.md
  networking/       gateway.md, network.md, snapshot-store.md
  scheduler/        jobq.md, coordinator.md, ci.md, observability.md
  process/          conventions.md, gotchas.md, pr-review-gate.md
  web-ui/           terminal-rendering.md (moved into the EXISTING dir,
                    per mara's correction to the original getting-started
                    guess -- it's UI implementation detail, not onboarding)

The physical layout now matches docs/README.md's own topical headers,
which already amounted to this taxonomy -- see the scoping comment on
the issue for the two findings that motivated this (a genuine
duplication between CLAUDE.md's old "Reading paths" list and
docs/README.md's grouped one, since drifted out of sync with each
other; and the flat layout not matching the grouping we already had).

Fixed every cross-reference this moved across the whole repo (~120
files: docs/ internal links at every depth, Rust doc comments, nix
module option docs, crate READMEs) -- verified two ways: a grep sweep
confirming zero remaining references to any old path, and a script
that resolves every markdown link in docs/**/*.md + CLAUDE.md +
README.md against the filesystem and reports anything that doesn't
exist (zero broken links).

Collapsed CLAUDE.md's "Reading paths" section (the duplicate) down to
a pointer at docs/README.md, now the single index. Rewrote
docs/README.md itself to use the new subdirectory paths and added the
one doc it was missing that CLAUDE.md's old copy had (pr-review-gate.md).

Classified all 22 docs/*.md files first via a haiku subagent (mara's
suggestion) on two axes -- proposed grouping and operator-vs-
implementation focus -- before finalizing the taxonomy; spot-checked
the report and found internal inconsistencies (its classification
table disagreed with its own summary section for a few files), so this
taxonomy is my original proposal + the one correction mara gave
directly, not a blind application of the subagent's table. The
operator-focus data it gathered is still useful for a follow-up
content pass (docs skewing 'mixed' rather than pure operator-facing),
not addressed in this PR -- structure only.

nix fmt clean, both pre-push lints clean.
2026-09-02 01:55:37 +02:00

444 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 = "topology", description = "operator-driven agent reparenting"),
(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 topology;
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!(topology::post_set_parent))
.routes(routes!(topology::post_set_parent_bulk))
.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!(topology::post_set_parent))
.routes(routes!(topology::post_set_parent_bulk))
.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));
}
}