The hive-wide cost estimate on the dashboard's ST4TS tab used a
hard-coded model->price table in hive_stats.rs. Anthropic list pricing
drifts, so move the table to a nix option operators can keep current
without a code change.
- New `services.hyperhive.modelPrices` option: attrset of model-family
short name -> { input, output, cache_read, cache_write } USD per
million tokens. Passed to `hive-c0re serve --model-prices <json>`.
- hive_stats: `Prices` is now public + Deserialize; add `PriceTable`
type and `resolve_prices` (longest case-insensitive substring key
wins) with the old hard-coded table preserved as `builtin_prices`
fallback for any model not covered.
- Coordinator holds the parsed table (hive-c0re-local, not injected
into containers, so not part of HiveEnv); `/api/stats-hive` reads it.
- Docs: dashboard.md ST4TS cost note updated; option self-documents
via nixosOptionsDoc.
Closes #1434
3129 lines
123 KiB
Rust
3129 lines
123 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::convert::Infallible;
|
|
use std::net::SocketAddr;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Context, Result};
|
|
use axum::extract::Form;
|
|
use axum::{
|
|
Router,
|
|
extract::{Path as AxumPath, State},
|
|
http::{HeaderMap, StatusCode},
|
|
response::{
|
|
IntoResponse, Response,
|
|
sse::{Event, KeepAlive, Sse},
|
|
},
|
|
routing::{get, post},
|
|
};
|
|
use hive_sh4re::Approval;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio_stream::wrappers::{BroadcastStream, ReceiverStream};
|
|
use tokio_stream::{Stream, StreamExt};
|
|
use tower_http::services::ServeDir;
|
|
|
|
use crate::actions;
|
|
use crate::container_view::{ContainerView, claude_has_session};
|
|
use crate::coordinator::Coordinator;
|
|
use crate::lifecycle::{self, MANAGER_NAME};
|
|
|
|
#[derive(Clone)]
|
|
struct AppState {
|
|
coord: Arc<Coordinator>,
|
|
}
|
|
|
|
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
|
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
|
.map(PathBuf::from)
|
|
.context(
|
|
"HIVE_STATIC_DIR env var not set — point it at the bundled \
|
|
dashboard dist (see services.hive-c0re.frontend in nix)",
|
|
)?;
|
|
if !static_dir.is_dir() {
|
|
anyhow::bail!(
|
|
"HIVE_STATIC_DIR ({}) is not a directory",
|
|
static_dir.display()
|
|
);
|
|
}
|
|
tracing::info!(static_dir = %static_dir.display(), "dashboard static dir resolved");
|
|
let app = Router::new()
|
|
.route("/api/state", get(api_state))
|
|
.route("/approve/{id}", post(post_approve))
|
|
.route("/deny/{id}", post(post_deny))
|
|
.route("/destroy/{name}", post(post_destroy))
|
|
.route("/kill/{name}", post(post_kill))
|
|
.route("/restart/{name}", post(post_restart))
|
|
.route("/start/{name}", post(post_start))
|
|
.route("/rebuild/{name}", post(post_rebuild))
|
|
.route("/update-all", post(post_update_all))
|
|
.route("/answer-question/{id}", post(post_answer_question))
|
|
.route("/cancel-question/{id}", post(post_cancel_question))
|
|
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
|
|
.route("/api/journal/{name}", get(get_journal))
|
|
.route("/api/journal-host", get(get_journal_host))
|
|
.route("/api/approval-diff/{id}", get(get_approval_diff))
|
|
.route("/api/state-file", get(get_state_file))
|
|
.route("/api/reminders", get(api_reminders))
|
|
.route("/api/operator-inbox", get(api_operator_inbox))
|
|
.route("/api/stats-hive", get(api_stats_hive))
|
|
.route("/api/container-resources", get(api_container_resources))
|
|
.route("/api/build-logs", get(get_build_logs_all))
|
|
.route("/api/build-logs/{agent}", get(get_build_logs_agent))
|
|
.route("/api/build-logs/id/{id}", get(get_build_log_full))
|
|
.route("/api/build-logs/id/{id}/stream", get(get_build_log_stream))
|
|
.route("/api/build-logs/id/{id}/raw", get(get_build_log_raw))
|
|
.route("/api/agent/{name}/mark-all-read", post(post_mark_all_read))
|
|
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
|
|
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
|
.route("/request-spawn", post(post_request_spawn))
|
|
.route("/api/topology/set-parent", post(post_set_parent))
|
|
.route("/api/topology/set-parent-bulk", post(post_set_parent_bulk))
|
|
.route("/api/tool-groups", get(get_tool_groups))
|
|
.route("/api/tool-groups/{agent}", post(post_tool_groups))
|
|
.route("/api/capabilities", get(get_capabilities))
|
|
.route("/api/capabilities/{agent}", post(post_capabilities))
|
|
.route("/op-send", post(post_op_send))
|
|
.route("/meta-update", post(post_meta_update))
|
|
.route("/api/schedules", get(api_schedules).post(post_schedule_new))
|
|
.route("/api/schedules/{id}", axum::routing::patch(patch_schedule))
|
|
.route("/api/schedules/{id}/cancel", post(post_schedule_cancel))
|
|
.route("/api/schedules/{id}/fire-now", post(post_schedule_fire_now))
|
|
.route(
|
|
"/api/rebuild-queue/{id}/cancel",
|
|
post(post_rebuild_queue_cancel),
|
|
)
|
|
.route("/dashboard/stream", get(dashboard_stream))
|
|
.route("/dashboard/history", get(dashboard_history))
|
|
.route("/webhook/knowledge", post(post_webhook_knowledge))
|
|
// Anything not matched by the dynamic routes above falls
|
|
// through to the bundled dashboard dist (GET / →
|
|
// dist/index.html, /favicon.svg → dist/favicon.svg,
|
|
// /static/dashboard.css → dist/static/dashboard.css, etc.).
|
|
.fallback_service(ServeDir::new(&static_dir))
|
|
.with_state(AppState { coord });
|
|
// Binds loopback-only; external access via gateway.
|
|
// Rationale: docs/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)
|
|
}
|
|
|
|
#[allow(clippy::struct_excessive_bools)]
|
|
#[derive(Serialize)]
|
|
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,
|
|
manager_port: u16,
|
|
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 rebuild queue — pending + running
|
|
/// long-lived ops (rebuild / meta-update / spawn) plus the most
|
|
/// recent few terminal entries the queue retains for history.
|
|
/// Live transitions arrive via the `RebuildQueueChanged` event.
|
|
/// See `rebuild_queue.rs`.
|
|
rebuild_queue: Vec<crate::rebuild_queue::QueueEntry>,
|
|
/// 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 `HIVE_GATEWAY_ENABLED` env var (set by the c0re NixOS
|
|
/// module when `services.hyperhive.gateway.enable` is on). When
|
|
/// 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`); when false it falls back to
|
|
/// direct `http://<hostname>:<port>/` TCP links so gateway-off /
|
|
/// local-dev deploys keep working. 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>,
|
|
}
|
|
|
|
/// 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, 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,
|
|
}
|
|
|
|
#[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,
|
|
/// Unix seconds. Renders as a relative time on the dashboard.
|
|
resolved_at: i64,
|
|
/// 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>,
|
|
/// Unix seconds the approval was queued. Rendered as a relative
|
|
/// time on the card so the operator can spot a stale request.
|
|
requested_at: i64,
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
}
|
|
}
|
|
|
|
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.
|
|
state.coord.rescan_containers_and_emit().await;
|
|
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 = 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);
|
|
|
|
// operator_inbox used to be served here as a 50-row array; the
|
|
// dashboard now derives it client-side from the message stream
|
|
// (terminal backfill + live SSE), so the snapshot stops shipping it.
|
|
// Both operator-targeted and peer threads now surface on the
|
|
// dashboard. Client filters by target client-side. 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();
|
|
|
|
axum::Json(StateSnapshot {
|
|
seq,
|
|
hostname,
|
|
manager_port: lifecycle::agent_web_port(MANAGER_NAME),
|
|
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.rebuild_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" when `services.hyperhive.gateway.enable` is on.
|
|
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(),
|
|
})
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
#[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.
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// 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",
|
|
};
|
|
ApprovalHistoryView {
|
|
id: a.id,
|
|
agent: a.agent,
|
|
kind,
|
|
sha_short,
|
|
status,
|
|
resolved_at: a.resolved_at.unwrap_or(0),
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
requested_at: a.requested_at,
|
|
},
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// State-dir names that don't appear in the live container list (and
|
|
/// aren't the manager). Each one surfaces in the dashboard as a row
|
|
/// with R3V1V3 + PURG3 actions.
|
|
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| name != MANAGER_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
|
|
}
|
|
|
|
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()
|
|
.filter_map(|m| match m {
|
|
crate::broker::MessageEvent::Sent {
|
|
id,
|
|
from,
|
|
to,
|
|
body,
|
|
at,
|
|
in_reply_to,
|
|
} => {
|
|
let file_refs = scan_validated_paths(&body);
|
|
Some(crate::dashboard_events::DashboardEvent::Sent {
|
|
seq: 0,
|
|
id,
|
|
from,
|
|
to,
|
|
body,
|
|
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);
|
|
Some(crate::dashboard_events::DashboardEvent::Delivered {
|
|
seq: 0,
|
|
id,
|
|
from,
|
|
to,
|
|
body,
|
|
at,
|
|
in_reply_to,
|
|
file_refs,
|
|
})
|
|
}
|
|
// Ping events are never persisted to sqlite — this arm is
|
|
// unreachable in practice but required for exhaustiveness.
|
|
crate::broker::MessageEvent::Ping { .. } => None,
|
|
})
|
|
.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)]
|
|
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>,
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
async fn post_approve(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response {
|
|
match actions::approve(state.coord.clone(), id).await {
|
|
// 200 instead of 303 — `actions::approve` fires
|
|
// `ApprovalResolved` (success path) or the eventual failure
|
|
// event, both of which the dashboard's derived store applies
|
|
// live. The matching form carries `data-no-refresh`.
|
|
Ok(()) => (StatusCode::OK, "ok").into_response(),
|
|
Err(e) => error_response(&format!("approve {id} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct DenyForm {
|
|
#[serde(default)]
|
|
note: Option<String>,
|
|
}
|
|
|
|
async fn post_deny(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<i64>,
|
|
Form(form): Form<DenyForm>,
|
|
) -> Response {
|
|
let note = form
|
|
.note
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty());
|
|
match actions::deny(&state.coord, id, note).await {
|
|
Ok(()) => (StatusCode::OK, "ok").into_response(),
|
|
Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RequestSpawnForm {
|
|
name: String,
|
|
}
|
|
|
|
/// `POST /api/topology/set-parent` body. `child` is required.
|
|
/// `new_parent` may be:
|
|
/// - absent or empty / whitespace-only → promote to root,
|
|
/// - non-empty → new parent's logical name.
|
|
///
|
|
/// (The CLI surface gates "no parent specified" behind an explicit
|
|
/// `--root` flag for safety; the HTTP surface is permissive
|
|
/// because the dashboard form encodes "no value" as the empty
|
|
/// string for the optional radio-group input.)
|
|
#[derive(Deserialize)]
|
|
struct SetParentForm {
|
|
child: String,
|
|
new_parent: Option<String>,
|
|
}
|
|
|
|
/// One entry in a `POST /api/topology/set-parent-bulk` JSON array.
|
|
/// `new_parent`: absent/null/empty-string all mean "promote to root".
|
|
#[derive(Deserialize)]
|
|
struct SetParentBulkEntry {
|
|
child: String,
|
|
#[serde(default)]
|
|
new_parent: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct AnswerForm {
|
|
answer: String,
|
|
}
|
|
|
|
/// Attach a permissive CORS header so the per-agent web UI — served on
|
|
/// a different port — can POST an operator answer here and read the
|
|
/// result. The dashboard has no auth, so `*` exposes nothing a plain
|
|
/// cross-origin form-POST couldn't already reach. This shim disappears
|
|
/// once the unifying gateway makes the agent page same-origin; see
|
|
/// `docs/boundary.md`.
|
|
fn with_cors(mut resp: Response) -> Response {
|
|
resp.headers_mut().insert(
|
|
axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
|
axum::http::HeaderValue::from_static("*"),
|
|
);
|
|
resp
|
|
}
|
|
|
|
async fn post_answer_question(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<i64>,
|
|
Form(form): Form<AnswerForm>,
|
|
) -> Response {
|
|
let answer = form.answer.trim();
|
|
if answer.is_empty() {
|
|
return with_cors(error_response("answer: required"));
|
|
}
|
|
let resp = match state
|
|
.coord
|
|
.questions
|
|
.answer(id, answer, hive_sh4re::OPERATOR_RECIPIENT)
|
|
{
|
|
Ok((question, asker, target)) => {
|
|
tracing::info!(%id, %asker, "operator answered question");
|
|
state.coord.notify_agent(
|
|
&asker,
|
|
&hive_sh4re::HelperEvent::QuestionAnswered {
|
|
id,
|
|
question,
|
|
answer: answer.to_owned(),
|
|
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
|
},
|
|
);
|
|
state.coord.emit_question_resolved(
|
|
id,
|
|
answer,
|
|
hive_sh4re::OPERATOR_RECIPIENT,
|
|
false,
|
|
target.as_deref(),
|
|
);
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
|
|
};
|
|
with_cors(resp)
|
|
}
|
|
|
|
/// Resolve a pending operator question with a sentinel answer when
|
|
/// the operator decides not to / can't answer. The asker harness
|
|
/// receives a `QuestionAnswered` event with `answer = "[cancelled]"`
|
|
/// so it can fall back on whatever default it had. Same code path as
|
|
/// a real answer — just lets the operator close the loop instead of
|
|
/// letting the question dangle forever.
|
|
async fn post_cancel_question(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<i64>,
|
|
) -> Response {
|
|
const SENTINEL: &str = "[cancelled]";
|
|
match state
|
|
.coord
|
|
.questions
|
|
.answer(id, SENTINEL, hive_sh4re::OPERATOR_RECIPIENT)
|
|
{
|
|
Ok((question, asker, target)) => {
|
|
tracing::info!(%id, %asker, "operator cancelled question");
|
|
state.coord.emit_question_resolved(
|
|
id,
|
|
SENTINEL,
|
|
hive_sh4re::OPERATOR_RECIPIENT,
|
|
true,
|
|
target.as_deref(),
|
|
);
|
|
state.coord.notify_agent_from(
|
|
hive_sh4re::OPERATOR_RECIPIENT,
|
|
&asker,
|
|
&hive_sh4re::HelperEvent::QuestionAnswered {
|
|
id,
|
|
question,
|
|
answer: SENTINEL.to_owned(),
|
|
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
|
},
|
|
);
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct JournalQuery {
|
|
/// Optional systemd unit filter — e.g. `hive-ag3nt.service`. When
|
|
/// omitted, returns the full machine journal.
|
|
#[serde(default)]
|
|
unit: Option<String>,
|
|
/// Number of trailing lines to return. Capped at 5000.
|
|
#[serde(default)]
|
|
lines: Option<u32>,
|
|
}
|
|
|
|
/// Read `journalctl -M <container> -b` and return its text output.
|
|
/// Operator-only by virtue of the dashboard being host-bound. hive-c0re
|
|
/// runs unprivileged (privsep), so the `-M` read — which enters the
|
|
/// container namespace and needs root — is delegated to hive-priv.
|
|
async fn get_journal(
|
|
AxumPath(name): AxumPath<String>,
|
|
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
|
|
) -> Response {
|
|
// Defense-in-depth format check so weird chars never reach the
|
|
// shellout below — the `lifecycle::list()` existence check would
|
|
// catch them anyway, but rejecting at the boundary keeps the
|
|
// failure mode crisp.
|
|
if let Some(reason) = validate_agent_name(&name) {
|
|
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
|
|
}
|
|
// Validate the container name against the list of managed
|
|
// containers so we don't shell out with arbitrary input.
|
|
let container = strip_container_prefix(&name);
|
|
let prefixed = format!("{}{container}", lifecycle::AGENT_PREFIX);
|
|
let live = lifecycle::list().await.unwrap_or_default();
|
|
if !live.iter().any(|c| c == &prefixed) {
|
|
return error_response(&format!("journal: no managed container {prefixed:?}"));
|
|
}
|
|
let lines = q.lines.unwrap_or(500).min(5000);
|
|
let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) {
|
|
Some(u) => {
|
|
// accept hive-ag3nt[.service] — anything else refused.
|
|
let allowed = ["hive-ag3nt.service"];
|
|
let unit = if u.ends_with(".service") {
|
|
u.to_owned()
|
|
} else {
|
|
format!("{u}.service")
|
|
};
|
|
if !allowed.contains(&unit.as_str()) {
|
|
return error_response(&format!("journal: unknown unit {unit:?}"));
|
|
}
|
|
Some(unit)
|
|
}
|
|
None => None,
|
|
};
|
|
match crate::priv_client::read_container_journal(
|
|
&prefixed,
|
|
lines,
|
|
true,
|
|
hive_sh4re::priv_proto::JournalOutput::ShortIso,
|
|
unit,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
{
|
|
Ok((stdout, stderr)) => {
|
|
// Combine stdout + stderr — journalctl emits to both on errors.
|
|
let mut body = stdout;
|
|
if !stderr.is_empty() {
|
|
body.push_str("\n--- stderr ---\n");
|
|
body.push_str(&stderr);
|
|
}
|
|
([("content-type", "text/plain; charset=utf-8")], body).into_response()
|
|
}
|
|
Err(e) => error_response(&format!("journal read: {e:#}")),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct JournalHostQuery {
|
|
/// Service unit name to filter to. If omitted, returns all logs.
|
|
#[serde(default)]
|
|
unit: Option<String>,
|
|
/// Number of trailing lines. Capped at 5000. Default 500.
|
|
#[serde(default)]
|
|
lines: Option<u32>,
|
|
}
|
|
|
|
/// `GET /api/journal-host?unit=<unit>&lines=N` — host-side journald (no
|
|
/// `-M` container flag). Restricted to an allow-list of known host services
|
|
/// so arbitrary unit names can't be probed. Operator-only by virtue of the
|
|
/// dashboard binding to a host-only port.
|
|
async fn get_journal_host(
|
|
axum::extract::Query(q): axum::extract::Query<JournalHostQuery>,
|
|
) -> Response {
|
|
let lines = q.lines.unwrap_or(500).min(5000);
|
|
let allowed = ["hive-c0re.service"];
|
|
let mut cmd = tokio::process::Command::new("journalctl");
|
|
cmd.args(["--no-pager", "--output=short-iso", "--lines"])
|
|
.arg(lines.to_string());
|
|
if let Some(u) = q.unit.as_deref().filter(|s| !s.is_empty()) {
|
|
let unit = if u.ends_with(".service") {
|
|
u.to_owned()
|
|
} else {
|
|
format!("{u}.service")
|
|
};
|
|
if !allowed.contains(&unit.as_str()) {
|
|
return error_response(&format!("journal-host: unknown unit {unit:?}"));
|
|
}
|
|
cmd.args(["-u", &unit]);
|
|
}
|
|
match cmd.output().await {
|
|
Ok(out) => {
|
|
let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
|
|
if !out.status.success() {
|
|
body.push_str("\n--- stderr ---\n");
|
|
body.push_str(&String::from_utf8_lossy(&out.stderr));
|
|
}
|
|
([("content-type", "text/plain; charset=utf-8")], body).into_response()
|
|
}
|
|
Err(e) => error_response(&format!("journalctl spawn: {e}")),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct BuildLogsAllQuery {
|
|
/// Max rows to return. Capped at 100. Default 30.
|
|
#[serde(default)]
|
|
limit: Option<usize>,
|
|
}
|
|
|
|
/// `GET /api/build-logs?limit=N` — most-recent build log headers across
|
|
/// all agents, newest first. Same JSON shape as the per-agent endpoint.
|
|
async fn get_build_logs_all(
|
|
State(state): State<AppState>,
|
|
axum::extract::Query(q): axum::extract::Query<BuildLogsAllQuery>,
|
|
) -> Response {
|
|
let limit = q.limit.unwrap_or(30);
|
|
match state.coord.build_logs.list_recent_all(limit) {
|
|
Ok(rows) => axum::Json(rows).into_response(),
|
|
Err(e) => error_response(&format!("build-logs all: {e:#}")),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct StateFileQuery {
|
|
path: String,
|
|
}
|
|
|
|
/// Resolve a caller-supplied path against the allow-listed roots
|
|
/// (`agents/<n>/state/` and `shared/`). Applies defense-in-depth
|
|
/// symlink + traversal checks before serving. Security model and
|
|
/// all five layers: `docs/security.md::State-file endpoint`.
|
|
fn resolve_state_path(
|
|
raw: &str,
|
|
) -> std::result::Result<(std::path::PathBuf, std::fs::Metadata), String> {
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents";
|
|
const SHARED_ROOT: &str = "/var/lib/hyperhive/shared";
|
|
let raw = raw.trim();
|
|
let (mapped, root): (std::path::PathBuf, &str) =
|
|
if let Some(rest) = raw.strip_prefix("/agents/") {
|
|
(
|
|
std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")),
|
|
AGENTS_ROOT,
|
|
)
|
|
} else if let Some(rest) = raw.strip_prefix("/shared/") {
|
|
(
|
|
std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")),
|
|
SHARED_ROOT,
|
|
)
|
|
} else if let Some(rest) = raw.strip_prefix(&format!("{AGENTS_ROOT}/")) {
|
|
(
|
|
std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")),
|
|
AGENTS_ROOT,
|
|
)
|
|
} else if let Some(rest) = raw.strip_prefix(&format!("{SHARED_ROOT}/")) {
|
|
(
|
|
std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")),
|
|
SHARED_ROOT,
|
|
)
|
|
} else {
|
|
return Err(format!("path not in allow-list: {raw}"));
|
|
};
|
|
reject_symlinks_below(std::path::Path::new(root), &mapped)?;
|
|
let canonical =
|
|
std::fs::canonicalize(&mapped).map_err(|e| format!("{}: {e}", mapped.display()))?;
|
|
if !(canonical.starts_with(AGENTS_ROOT) || canonical.starts_with(SHARED_ROOT)) {
|
|
return Err(format!(
|
|
"resolved path escapes allow-list: {}",
|
|
canonical.display()
|
|
));
|
|
}
|
|
if let Ok(rel) = canonical.strip_prefix(AGENTS_ROOT) {
|
|
let mut components = rel.components();
|
|
let _agent = components.next();
|
|
let dir = components.next().and_then(|c| c.as_os_str().to_str());
|
|
if dir != Some("state") {
|
|
return Err(format!(
|
|
"only per-agent state/ is readable here ({} dir not allowed)",
|
|
dir.unwrap_or("(root)")
|
|
));
|
|
}
|
|
}
|
|
let meta =
|
|
std::fs::metadata(&canonical).map_err(|e| format!("stat {}: {e}", canonical.display()))?;
|
|
if meta.is_file() {
|
|
let mode = meta.permissions().mode();
|
|
if mode & 0o004 == 0 {
|
|
return Err(format!(
|
|
"{} not world-readable (mode 0{:o}); refusing to proxy non-public file",
|
|
canonical.display(),
|
|
mode & 0o777,
|
|
));
|
|
}
|
|
}
|
|
Ok((canonical, meta))
|
|
}
|
|
|
|
/// Walk every path component under `root` and refuse if any of
|
|
/// them is a symlink. The roots themselves (`AGENTS_ROOT`,
|
|
/// `SHARED_ROOT`) are hive-c0re-owned and assumed trusted; only
|
|
/// the parts the agent / operator can plant matter. Components
|
|
/// that don't exist yet are skipped — `canonicalize` reports
|
|
/// non-existence separately, and missing-component checks would
|
|
/// just race the filesystem.
|
|
fn reject_symlinks_below(
|
|
root: &std::path::Path,
|
|
mapped: &std::path::Path,
|
|
) -> std::result::Result<(), String> {
|
|
let Ok(rel) = mapped.strip_prefix(root) else {
|
|
return Ok(());
|
|
};
|
|
let mut cumulative = root.to_path_buf();
|
|
for component in rel.components() {
|
|
match component {
|
|
std::path::Component::Normal(name) => {
|
|
cumulative.push(name);
|
|
match std::fs::symlink_metadata(&cumulative) {
|
|
Ok(m) if m.file_type().is_symlink() => {
|
|
return Err(format!(
|
|
"symlink at {} not allowed (canonicalize would resolve it past the \
|
|
allow-list check; refuse outright)",
|
|
cumulative.display()
|
|
));
|
|
}
|
|
Ok(_) | Err(_) => {}
|
|
}
|
|
}
|
|
std::path::Component::ParentDir => {
|
|
return Err(format!(
|
|
"path contains `..` traversal below {}; refuse outright",
|
|
root.display()
|
|
));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::os::unix::fs::symlink;
|
|
|
|
/// Make a unique tmp subdir for the calling test. Caller is responsible
|
|
/// for cleanup (we leak on panic, fine for ephemeral CI runs).
|
|
fn tmproot(tag: &str) -> std::path::PathBuf {
|
|
let ts = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_or(0, |d| d.as_nanos());
|
|
let p = std::env::temp_dir().join(format!("hyperhive-test-{tag}-{ts}"));
|
|
std::fs::create_dir_all(&p).unwrap();
|
|
p
|
|
}
|
|
|
|
#[test]
|
|
fn 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)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_agent_name_accepts_canonical_shapes() {
|
|
assert!(validate_agent_name("damocles").is_none());
|
|
assert!(validate_agent_name("hm1nd").is_none());
|
|
assert!(validate_agent_name("agent-with-dashes").is_none());
|
|
assert!(validate_agent_name("snake_case").is_none());
|
|
assert!(validate_agent_name("mixed_2-3").is_none());
|
|
let max = "a".repeat(63);
|
|
assert!(
|
|
validate_agent_name(&max).is_none(),
|
|
"63-char name should pass"
|
|
);
|
|
}
|
|
|
|
// The two-axis guard (`guard_agent_name`) wires `validate_agent_name`
|
|
// + an async coordinator lookup. The lookup needs a populated
|
|
// `Coordinator`, which needs sqlite + tokio runtime; rather than
|
|
// build that scaffolding for an integration-flavoured test we cover
|
|
// the format axis here (the existence axis is enforced by the
|
|
// shared `containers_snapshot` API, tested in `coordinator.rs`'s
|
|
// own suite). 9 cases below cover the boundary-length case and
|
|
// other expected rejects to make the contract explicit.
|
|
#[test]
|
|
fn validate_agent_name_rejects_bad_input() {
|
|
assert!(validate_agent_name("").is_some());
|
|
let too_long = "a".repeat(64);
|
|
assert!(validate_agent_name(&too_long).is_some());
|
|
// Path-traversal attempts.
|
|
assert!(validate_agent_name("../etc/passwd").is_some());
|
|
assert!(validate_agent_name("alice/bob").is_some());
|
|
// Uppercase rejected — canonical lowercase convention.
|
|
assert!(validate_agent_name("Alice").is_some());
|
|
// No spaces, dots, special chars.
|
|
assert!(validate_agent_name("alice bob").is_some());
|
|
assert!(validate_agent_name("alice.bob").is_some());
|
|
assert!(validate_agent_name("alice;DROP TABLE messages").is_some());
|
|
// Non-ASCII (incl. unicode homoglyphs of ASCII dash).
|
|
assert!(validate_agent_name("damóclès").is_some());
|
|
assert!(validate_agent_name("alice\u{2013}bob").is_some()); // en-dash
|
|
}
|
|
|
|
#[test]
|
|
fn reject_symlinks_below_accepts_plain_dirs_and_files() {
|
|
let root = tmproot("symlink-ok");
|
|
std::fs::create_dir_all(root.join("alice/state")).unwrap();
|
|
std::fs::write(root.join("alice/state/notes.md"), b"hi").unwrap();
|
|
assert!(reject_symlinks_below(&root, &root.join("alice/state/notes.md")).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn reject_symlinks_below_rejects_leaf_symlink() {
|
|
let root = tmproot("symlink-leaf");
|
|
std::fs::create_dir_all(root.join("alice/state")).unwrap();
|
|
// Plant a symlink that points anywhere; resolve_state_path's
|
|
// canonicalize would happily resolve it past the allow-list
|
|
// check, so we have to refuse at the un-canonical layer.
|
|
symlink("/etc/shadow", root.join("alice/state/peek")).unwrap();
|
|
let err = reject_symlinks_below(&root, &root.join("alice/state/peek")).unwrap_err();
|
|
assert!(err.contains("symlink at"), "msg = {err}");
|
|
assert!(err.contains("peek"), "msg = {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn reject_symlinks_below_rejects_directory_symlink_in_middle() {
|
|
let root = tmproot("symlink-mid");
|
|
std::fs::create_dir_all(root.join("real/state")).unwrap();
|
|
std::fs::write(root.join("real/state/secret.md"), b"hi").unwrap();
|
|
// alice's "state" dir is actually a symlink to real/state — a
|
|
// sub-agent shouldn't be able to plant this and proxy real's
|
|
// private files via the dashboard.
|
|
std::fs::create_dir_all(root.join("alice")).unwrap();
|
|
symlink(root.join("real/state"), root.join("alice/state")).unwrap();
|
|
let err = reject_symlinks_below(&root, &root.join("alice/state/secret.md")).unwrap_err();
|
|
assert!(err.contains("symlink at"), "msg = {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn reject_symlinks_below_rejects_parent_dir_traversal() {
|
|
let root = tmproot("symlink-dotdot");
|
|
// `..` doesn't survive canonicalize anyway, but we want a
|
|
// friendlier error than "path escapes allow-list" — refusing
|
|
// upfront also avoids walking ancestors with `symlink_metadata`.
|
|
let p = root.join("alice/state/../escape");
|
|
let err = reject_symlinks_below(&root, &p).unwrap_err();
|
|
assert!(err.contains("`..`"), "msg = {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn reject_symlinks_below_passes_through_when_path_not_under_root() {
|
|
// resolve_state_path's earlier allow-list check would reject
|
|
// this; reject_symlinks_below stays a no-op so the caller
|
|
// surfaces the better-fit error.
|
|
let root = std::path::Path::new("/var/lib/hyperhive/agents");
|
|
assert!(reject_symlinks_below(root, std::path::Path::new("/etc/shadow")).is_ok());
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
});
|
|
}
|
|
|
|
/// 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,
|
|
});
|
|
}
|
|
|
|
/// Scan `body` for path-shaped tokens and return those that pass the
|
|
/// allow-list + `is_file` check via `resolve_state_path`. Called at
|
|
/// broker-message ingest so the dashboard event already carries the
|
|
/// verified set; security rules stay in sync with the read endpoint.
|
|
pub fn scan_validated_paths(body: &str) -> Vec<String> {
|
|
const PREFIXES: [&str; 4] = [
|
|
"/agents/",
|
|
"/shared/",
|
|
"/var/lib/hyperhive/agents/",
|
|
"/var/lib/hyperhive/shared/",
|
|
];
|
|
let mut out = Vec::<String>::new();
|
|
for raw in body.split(|c: char| c.is_whitespace()) {
|
|
// Trim trailing natural-language punctuation that wouldn't
|
|
// be part of any real path. Inline rather than via a regex
|
|
// dep — the set is small and the call is hot.
|
|
let token = raw.trim_end_matches([',', ';', ':', ')', ']', '}', '.', '\'', '"']);
|
|
if token.is_empty() {
|
|
continue;
|
|
}
|
|
if !PREFIXES.iter().any(|p| token.starts_with(p)) {
|
|
continue;
|
|
}
|
|
// Cheap dedupe — typical message has 0-3 refs.
|
|
if out.iter().any(|s| s == token) {
|
|
continue;
|
|
}
|
|
if let Ok((_canonical, meta)) = resolve_state_path(token)
|
|
&& meta.is_file()
|
|
{
|
|
out.push(token.to_owned());
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
async fn get_state_file(axum::extract::Query(q): axum::extract::Query<StateFileQuery>) -> Response {
|
|
const MAX_BYTES: usize = 1 << 20; // 1 MiB
|
|
let (canonical, meta) = match resolve_state_path(&q.path) {
|
|
Ok(pair) => pair,
|
|
Err(e) => return error_response(&format!("state-file: {e}")),
|
|
};
|
|
if !meta.is_file() {
|
|
return error_response(&format!(
|
|
"state-file: {} is not a regular file",
|
|
canonical.display()
|
|
));
|
|
}
|
|
let size = meta.len();
|
|
let bytes = match std::fs::read(&canonical) {
|
|
Ok(b) => b,
|
|
Err(e) => return error_response(&format!("state-file: read {}: {e}", canonical.display())),
|
|
};
|
|
// Raster images: serve the raw bytes with their real content-type
|
|
// so the dashboard can render them in an <img>. Not truncated —
|
|
// a clipped binary is corrupt, so over-cap images are rejected
|
|
// instead. (SVG stays on the text path: it's text, and the client
|
|
// renders it via a data: URI.)
|
|
if let Some(ct) = image_content_type(&canonical) {
|
|
if bytes.len() > MAX_BYTES {
|
|
return error_response(&format!(
|
|
"state-file: image {} is {size} bytes, over the {MAX_BYTES}-byte preview cap",
|
|
canonical.display()
|
|
));
|
|
}
|
|
return ([("content-type", ct)], bytes).into_response();
|
|
}
|
|
let truncated = bytes.len() > MAX_BYTES;
|
|
let body_bytes = if truncated {
|
|
&bytes[..MAX_BYTES]
|
|
} else {
|
|
&bytes[..]
|
|
};
|
|
let mut body = String::from_utf8_lossy(body_bytes).into_owned();
|
|
if truncated {
|
|
use std::fmt::Write as _;
|
|
let _ = write!(
|
|
body,
|
|
"\n\n--- truncated at {MAX_BYTES} of {size} bytes ---\n"
|
|
);
|
|
}
|
|
([("content-type", "text/plain; charset=utf-8")], body).into_response()
|
|
}
|
|
|
|
/// Content-type for a raster image the dashboard can preview in an
|
|
/// `<img>`, keyed off the file extension. `None` for non-image, SVG,
|
|
/// and text files (SVG is served on the text path and rendered
|
|
/// client-side via a `data:` URI).
|
|
fn image_content_type(path: &Path) -> Option<&'static str> {
|
|
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
|
|
Some(match ext.as_str() {
|
|
"png" => "image/png",
|
|
"jpg" | "jpeg" => "image/jpeg",
|
|
"gif" => "image/gif",
|
|
"webp" => "image/webp",
|
|
"bmp" => "image/bmp",
|
|
"ico" => "image/x-icon",
|
|
"avif" => "image/avif",
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
async fn api_reminders(State(state): State<AppState>) -> Response {
|
|
match state.coord.broker.list_pending_reminders() {
|
|
Ok(rows) => axum::Json(rows).into_response(),
|
|
Err(e) => error_response(&format!("reminders: {e:#}")),
|
|
}
|
|
}
|
|
|
|
/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox
|
|
/// (#1469). 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 }] }`.
|
|
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| match m {
|
|
crate::broker::MessageEvent::Sent {
|
|
id,
|
|
from,
|
|
body,
|
|
at,
|
|
in_reply_to,
|
|
..
|
|
} => {
|
|
let file_refs = scan_validated_paths(&body);
|
|
Some(serde_json::json!({
|
|
"id": id,
|
|
"from": from,
|
|
"body": body,
|
|
"at": at,
|
|
"in_reply_to": in_reply_to,
|
|
"file_refs": file_refs,
|
|
}))
|
|
}
|
|
crate::broker::MessageEvent::Delivered { .. }
|
|
| crate::broker::MessageEvent::Ping { .. } => None,
|
|
})
|
|
.collect();
|
|
axum::Json(serde_json::json!({ "messages": items })).into_response()
|
|
}
|
|
Err(e) => error_response(&format!("operator-inbox failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
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`.
|
|
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.
|
|
async fn api_container_resources() -> Response {
|
|
axum::Json(crate::container_stats::gather().await).into_response()
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct BuildLogsQuery {
|
|
/// Maximum number of rows to return. Capped server-side at 50
|
|
/// (see `build_logs::list_recent_for_agent`). Default 10.
|
|
#[serde(default)]
|
|
limit: Option<usize>,
|
|
}
|
|
|
|
/// `GET /api/build-logs/{agent}?limit=N` — most-recent build log
|
|
/// headers for one agent, newest first. Returns
|
|
/// `Vec<BuildLogHeader>` (JSON). Limit defaults to 10, server-side
|
|
/// cap at 50. Backs the per-agent log chip in the agent card and
|
|
/// the side-panel header list.
|
|
async fn get_build_logs_agent(
|
|
State(state): State<AppState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
axum::extract::Query(q): axum::extract::Query<BuildLogsQuery>,
|
|
) -> Response {
|
|
if let Some(reason) = validate_agent_name(&name) {
|
|
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
|
|
}
|
|
let limit = q.limit.unwrap_or(10);
|
|
match state.coord.build_logs.list_recent_for_agent(&name, limit) {
|
|
Ok(rows) => axum::Json(rows).into_response(),
|
|
Err(e) => error_response(&format!("build-logs {name}: {e:#}")),
|
|
}
|
|
}
|
|
|
|
/// `GET /api/build-logs/id/{id}` — full build log row (stdout +
|
|
/// stderr concatenated) by id. Returns `BuildLogFull` (JSON), or
|
|
/// HTTP 404 when the id doesn't exist (vacuum-reaped, or the
|
|
/// operator passed a stale id from a refresh race).
|
|
async fn get_build_log_full(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<i64>,
|
|
) -> Response {
|
|
match state.coord.build_logs.get_full(id) {
|
|
Ok(Some(log)) => axum::Json(log).into_response(),
|
|
Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(),
|
|
Err(e) => error_response(&format!("build-log {id}: {e:#}")),
|
|
}
|
|
}
|
|
|
|
/// JSON frame sent on the `/api/build-logs/id/{id}/stream` SSE channel.
|
|
/// `stdout_append` / `stderr_append` carry only the new bytes since the
|
|
/// last frame; `done = true` means the build finished and the stream
|
|
/// will close after this frame.
|
|
#[derive(Serialize)]
|
|
struct BuildLogFrame {
|
|
stdout_append: String,
|
|
stderr_append: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
status: Option<String>,
|
|
done: bool,
|
|
}
|
|
|
|
/// `GET /api/build-logs/id/{id}/stream` — SSE stream that delivers
|
|
/// incremental stdout/stderr as a build runs. The client connects when
|
|
/// it opens a running-build panel; the stream closes automatically once
|
|
/// the build finishes (or the row disappears due to a vacuum).
|
|
///
|
|
/// Each frame is a JSON-serialised `BuildLogFrame`. The first frame
|
|
/// always carries the full accumulated log so far (cursors start at 0);
|
|
/// subsequent frames carry only new bytes. `done: true` on the final
|
|
/// frame signals the browser to close the `EventSource`.
|
|
async fn get_build_log_stream(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<i64>,
|
|
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
|
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(32);
|
|
let logs = state.coord.build_logs.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let mut notify_rx = logs.subscribe_notifications();
|
|
let mut stdout_cursor = 0usize;
|
|
let mut stderr_cursor = 0usize;
|
|
|
|
// ── initial snapshot ──────────────────────────────────────────
|
|
match logs.get_progress(id, stdout_cursor, stderr_cursor) {
|
|
Ok(Some(prog)) => {
|
|
stdout_cursor += prog.stdout_append.len();
|
|
stderr_cursor += prog.stderr_append.len();
|
|
let done = prog.finished_at.is_some();
|
|
if let Ok(json) = serde_json::to_string(&BuildLogFrame {
|
|
stdout_append: prog.stdout_append,
|
|
stderr_append: prog.stderr_append,
|
|
status: prog.status,
|
|
done,
|
|
}) {
|
|
let _ = tx.send(Ok(Event::default().data(json))).await;
|
|
}
|
|
if done {
|
|
return;
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
// Row missing — send a single error event and exit.
|
|
let _ = tx
|
|
.send(Ok(Event::default()
|
|
.event("error")
|
|
.data(format!("build log #{id} not found"))))
|
|
.await;
|
|
return;
|
|
}
|
|
Err(e) => {
|
|
let _ = tx
|
|
.send(Ok(Event::default()
|
|
.event("error")
|
|
.data(format!("build log #{id}: {e:#}"))))
|
|
.await;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ── live delta loop ───────────────────────────────────────────
|
|
loop {
|
|
match notify_rx.recv().await {
|
|
// Notification for a different build — ignore and wait
|
|
// for the next one.
|
|
Ok(notif_id) if notif_id != id => {}
|
|
Ok(_) => {
|
|
match logs.get_progress(id, stdout_cursor, stderr_cursor) {
|
|
Ok(Some(prog)) => {
|
|
stdout_cursor += prog.stdout_append.len();
|
|
stderr_cursor += prog.stderr_append.len();
|
|
let done = prog.finished_at.is_some();
|
|
if let Ok(json) = serde_json::to_string(&BuildLogFrame {
|
|
stdout_append: prog.stdout_append,
|
|
stderr_append: prog.stderr_append,
|
|
status: prog.status,
|
|
done,
|
|
}) && tx.send(Ok(Event::default().data(json))).await.is_err()
|
|
{
|
|
return; // browser disconnected
|
|
}
|
|
if done {
|
|
return;
|
|
}
|
|
}
|
|
Ok(None) | Err(_) => return, // vacuum reaped row / channel closed
|
|
}
|
|
}
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
|
}
|
|
}
|
|
});
|
|
|
|
Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
|
|
}
|
|
|
|
/// `GET /api/build-logs/id/{id}/raw` — full log as `text/plain` for
|
|
/// download. Stdout and stderr are concatenated with a `--- stderr ---`
|
|
/// separator (same layout the JS side-panel renders). The
|
|
/// `Content-Disposition` header triggers a browser download with a
|
|
/// descriptive filename so the operator can save and share the log.
|
|
async fn get_build_log_raw(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response {
|
|
match state.coord.build_logs.get_full(id) {
|
|
Ok(Some(log)) => {
|
|
let mut text = log.stdout;
|
|
if !log.stderr.is_empty() {
|
|
text.push_str("\n--- stderr ---\n");
|
|
text.push_str(&log.stderr);
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
[
|
|
("content-type", "text/plain; charset=utf-8".to_string()),
|
|
(
|
|
"content-disposition",
|
|
format!(
|
|
"attachment; filename=\"build-log-{}-{}.txt\"",
|
|
log.header.agent, id
|
|
),
|
|
),
|
|
],
|
|
text,
|
|
)
|
|
.into_response()
|
|
}
|
|
Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(),
|
|
Err(e) => error_response(&format!("build-log {id}: {e:#}")),
|
|
}
|
|
}
|
|
|
|
/// `GET /api/schedules` — snapshot of every schedule for the
|
|
/// scheduled-prompts tab. Returns the wire shape directly
|
|
/// so the frontend can render without an extra translation layer.
|
|
async fn api_schedules(State(state): State<AppState>) -> Response {
|
|
match state.coord.scheduled_prompts.list() {
|
|
Ok(rows) => axum::Json(
|
|
rows.into_iter()
|
|
.map(crate::manager_server::schedule_to_wire_public)
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.into_response(),
|
|
Err(e) => error_response(&format!("scheduled_prompts list: {e:#}")),
|
|
}
|
|
}
|
|
|
|
/// `POST /api/schedules` — operator-direct schedule creation
|
|
/// (mara: "user can add them manually"). Accepts the same
|
|
/// `SchedulePromptPayload` shape as the manager request flow but
|
|
/// skips the approval gate — the operator click *is* the
|
|
/// approval. The schedule lands directly with
|
|
/// `source = Operator` and the worker picks it up at fire time.
|
|
async fn post_schedule_new(
|
|
State(state): State<AppState>,
|
|
axum::Json(payload): axum::Json<hive_sh4re::SchedulePromptPayload>,
|
|
) -> Response {
|
|
if payload.targets.is_empty() {
|
|
return error_response("schedule must have at least one target");
|
|
}
|
|
if payload.body.trim().is_empty() {
|
|
return error_response("schedule body must be non-empty");
|
|
}
|
|
if let Some(0) = payload.interval_seconds {
|
|
return error_response("interval_seconds must be > 0 (use None for one-shot)");
|
|
}
|
|
let new = crate::scheduled_prompts::NewSchedule {
|
|
owner: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
|
targets: payload.targets,
|
|
body: payload.body,
|
|
first_fire_at_unix: payload.first_fire_at_unix,
|
|
interval_seconds: payload.interval_seconds,
|
|
description: payload.description,
|
|
source: crate::scheduled_prompts::ScheduleSource::Operator,
|
|
};
|
|
match state.coord.scheduled_prompts.submit(&new) {
|
|
Ok(id) => {
|
|
state.coord.emit_schedules_snapshot();
|
|
axum::Json(serde_json::json!({"id": id})).into_response()
|
|
}
|
|
Err(e) => error_response(&format!("schedule submit: {e:#}")),
|
|
}
|
|
}
|
|
|
|
/// `POST /api/schedules/{id}/fire-now` — operator-initiated
|
|
/// out-of-band fire of a scheduled prompt. Runs the
|
|
/// per-target fan-out once immediately and reports per-target
|
|
/// outcome counts. Does NOT touch `next_fire_at_unix` on
|
|
/// recurring schedules (their cadence stays intact); one-shot
|
|
/// schedules are consumed (cancelled) by a manual fire — the
|
|
/// operator's intent is "send this now, the scheduled time was
|
|
/// wrong."
|
|
async fn post_schedule_fire_now(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<i64>,
|
|
) -> Response {
|
|
match crate::scheduled_prompts_worker::fire_now(&state.coord, id).await {
|
|
Ok(report) => {
|
|
state.coord.emit_schedules_snapshot();
|
|
axum::Json(report).into_response()
|
|
}
|
|
Err(e) => error_response(&format!("fire schedule {id} now: {e:#}")),
|
|
}
|
|
}
|
|
|
|
/// `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry
|
|
/// from the rebuild queue. Refuses `Running` / terminal
|
|
/// entries: an in-flight rebuild owns the agent's nix store +
|
|
/// nixos-container update lock and can't be safely interrupted
|
|
/// from the queue side. Always returns 200; the body is
|
|
/// `{"cancelled": true}` on a successful flip from Queued →
|
|
/// Cancelled, `{"cancelled": false}` when the row was Running /
|
|
/// terminal / gone. On success a fresh `RebuildQueueChanged`
|
|
/// snapshot fires so the row's state flip surfaces live.
|
|
async fn post_rebuild_queue_cancel(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<u64>,
|
|
) -> Response {
|
|
let cancelled = state.coord.rebuild_queue.cancel(id);
|
|
if cancelled {
|
|
state.coord.emit_rebuild_queue_snapshot();
|
|
axum::Json(serde_json::json!({"cancelled": true})).into_response()
|
|
} else {
|
|
axum::Json(serde_json::json!({"cancelled": false})).into_response()
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Deserialize, Default)]
|
|
struct CancelScheduleForm {
|
|
/// `None` / absent / empty array → cancel whole schedule.
|
|
#[serde(default)]
|
|
targets: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize, Default)]
|
|
#[allow(
|
|
clippy::option_option,
|
|
reason = "double-Option carries three-state PATCH semantics on the wire \
|
|
(missing key = leave alone, JSON null = clear, value = set); \
|
|
collapsing to a single Option would lose the 'clear' state"
|
|
)]
|
|
struct EditScheduleForm {
|
|
#[serde(default)]
|
|
body: Option<String>,
|
|
/// Double-`Option` semantics on the wire: missing key = leave
|
|
/// alone, explicit `null` = clear, value = set. serde's
|
|
/// `deserialize_with` trick to distinguish missing from null:
|
|
/// we wrap each editable field in its own helper. Simpler
|
|
/// here — keep them plain `Option<Option<_>>` and document
|
|
/// that the dashboard caller passes JSON `null` to clear.
|
|
#[serde(default, deserialize_with = "deserialize_some")]
|
|
description: Option<Option<String>>,
|
|
#[serde(default, deserialize_with = "deserialize_some")]
|
|
interval_seconds: Option<Option<u64>>,
|
|
#[serde(default)]
|
|
next_fire_at_unix: Option<i64>,
|
|
/// New targets to add. Replace-on-conflict: re-adding a
|
|
/// previously-cancelled target drops the tombstone and the
|
|
/// target starts fresh (operator intent on re-add = "this
|
|
/// target is active again, fresh start").
|
|
#[serde(default)]
|
|
targets_add: Option<Vec<String>>,
|
|
/// Targets to cancel. Same path as `cancel_targets`:
|
|
/// tombstones preserve audit and the parent schedule
|
|
/// auto-cancels when no active targets remain.
|
|
#[serde(default)]
|
|
targets_remove: Option<Vec<String>>,
|
|
}
|
|
|
|
/// serde adaptor: turns missing-key into `None`, explicit-null
|
|
/// into `Some(None)`, value into `Some(Some(v))`. Standard trick
|
|
/// for distinguishing "field absent" from "field set to null" in
|
|
/// JSON PATCH bodies.
|
|
fn deserialize_some<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
|
|
where
|
|
T: serde::Deserialize<'de>,
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
T::deserialize(deserializer).map(Some)
|
|
}
|
|
|
|
/// `PATCH /api/schedules/{id}` — partial update of an existing
|
|
/// schedule. Mutable fields: `body`, `description`,
|
|
/// `interval_seconds`, `next_fire_at_unix`, plus the target set
|
|
/// via `targets_add` / `targets_remove`. Both target lists
|
|
/// are applied in the same transaction as the scalar fields with
|
|
/// removes-before-adds; re-adding a previously-removed target
|
|
/// resets per-target history (fresh start); draining all targets
|
|
/// auto-cancels the parent schedule. JSON body uses missing-key
|
|
/// = "leave alone", explicit null = "clear" for `description` +
|
|
/// `interval_seconds`. Cancelled schedules are refused — submit
|
|
/// a new one instead. Returns the updated `WireSchedule` so the
|
|
/// caller's post-edit refresh has the new state inline.
|
|
async fn patch_schedule(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<i64>,
|
|
axum::Json(form): axum::Json<EditScheduleForm>,
|
|
) -> Response {
|
|
let patch = crate::scheduled_prompts::UpdateSchedule {
|
|
body: form.body,
|
|
description: form.description,
|
|
interval_seconds: form.interval_seconds,
|
|
next_fire_at_unix: form.next_fire_at_unix,
|
|
targets_add: form.targets_add,
|
|
targets_remove: form.targets_remove,
|
|
};
|
|
if let Err(e) = state.coord.scheduled_prompts.update(id, patch) {
|
|
return error_response(&format!("edit schedule {id}: {e:#}"));
|
|
}
|
|
match state.coord.scheduled_prompts.get(id) {
|
|
Ok(Some(s)) => {
|
|
let wire = crate::manager_server::schedule_to_wire_public(s);
|
|
state.coord.emit_schedules_snapshot();
|
|
axum::Json(wire).into_response()
|
|
}
|
|
Ok(None) => error_response(&format!("edit schedule {id}: row vanished post-update")),
|
|
Err(e) => error_response(&format!("re-read schedule {id}: {e:#}")),
|
|
}
|
|
}
|
|
|
|
/// `POST /api/schedules/{id}/cancel` — operator-side cancel
|
|
/// (whole schedule when no `targets` field, partial when one is
|
|
/// provided). Operator bypasses the topology check; the manager
|
|
/// surface enforces it for agent callers.
|
|
async fn post_schedule_cancel(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<i64>,
|
|
body: Option<axum::Json<CancelScheduleForm>>,
|
|
) -> Response {
|
|
let targets = body
|
|
.and_then(|axum::Json(b)| b.targets)
|
|
.filter(|t| !t.is_empty());
|
|
let result = match targets.as_deref() {
|
|
Some(list) => state.coord.scheduled_prompts.cancel_targets(id, list),
|
|
None => state.coord.scheduled_prompts.cancel_all(id),
|
|
};
|
|
match result {
|
|
Ok(()) => {
|
|
state.coord.emit_schedules_snapshot();
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
Err(e) => error_response(&format!("cancel schedule {id}: {e:#}")),
|
|
}
|
|
}
|
|
|
|
async fn post_cancel_reminder(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<i64>,
|
|
) -> Response {
|
|
match state.coord.broker.cancel_reminder(id) {
|
|
Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")),
|
|
Ok(_) => {
|
|
tracing::info!(%id, "operator cancelled reminder");
|
|
state.coord.emit_reminders_snapshot();
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
Err(e) => error_response(&format!("cancel reminder {id} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
/// Reset a pending reminder's failure state so the scheduler
|
|
/// retries it on the next tick. Useful when the failure was
|
|
/// transient (sqlite lock contention, disk full → freed up) and
|
|
/// the operator wants delivery to resume immediately instead of
|
|
/// the row sitting in attempt-count-capped purgatory.
|
|
async fn post_retry_reminder(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<i64>,
|
|
) -> Response {
|
|
match state.coord.broker.reset_reminder_failure(id) {
|
|
Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")),
|
|
Ok(_) => {
|
|
tracing::info!(%id, "operator reset reminder failure for retry");
|
|
state.coord.emit_reminders_snapshot();
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
Err(e) => error_response(&format!("retry reminder {id} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
/// Validate that a path-param agent name conforms to the hyperhive
|
|
/// naming whitelist: 1-63 chars of `[a-z0-9_-]`. Rejects empty,
|
|
/// uppercase, slashes, dots, and any non-ASCII (incl. unicode
|
|
/// homoglyphs of dash/underscore). Returns `None` on accept, `Some(reason)`
|
|
/// on reject — caller wraps the reason in a 400 response. Conservative
|
|
/// whitelist matching `nixos-container` basename rules and the existing
|
|
/// agent-name convention across the codebase.
|
|
fn validate_agent_name(name: &str) -> Option<&'static str> {
|
|
if name.is_empty() {
|
|
return Some("agent name must not be empty");
|
|
}
|
|
if name.len() > 63 {
|
|
return Some("agent name must be 63 characters or fewer");
|
|
}
|
|
if !name
|
|
.bytes()
|
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
|
|
{
|
|
return Some("agent name must contain only [a-z0-9_-]");
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Two-axis path-param guard for write routes. Combines:
|
|
///
|
|
/// 1. **format validation** (`validate_agent_name`) — 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
|
|
/// `validate_agent_name` directly and skip the existence check.
|
|
async fn guard_agent_name(state: &AppState, name: &str) -> Option<Response> {
|
|
if let Some(reason) = validate_agent_name(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
|
|
}
|
|
|
|
/// 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.
|
|
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:#}")),
|
|
}
|
|
}
|
|
|
|
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(", ")))
|
|
}
|
|
}
|
|
|
|
/// 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)]
|
|
struct OpSendForm {
|
|
to: String,
|
|
body: String,
|
|
}
|
|
|
|
/// 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)]
|
|
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.
|
|
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(", ");
|
|
let parent_id = state.coord.rebuild_queue.enqueue_with_inputs(
|
|
crate::rebuild_queue::QueueKind::MetaUpdate,
|
|
"hyperhive".to_owned(),
|
|
crate::rebuild_queue::QueueSource::Manual,
|
|
format!("meta-update via dashboard ({inputs_label})"),
|
|
None,
|
|
inputs.clone(),
|
|
);
|
|
// Pre-enqueue cascade rebuilds NOW so they're visible in the queue
|
|
// alongside the parent. The worker's MetaUpdate arm
|
|
// no longer enqueues children — it just runs the lock bump and
|
|
// (on failure) cancels these pre-queued children.
|
|
let cascade_agents = crate::rebuild_queue::meta_update_cascade_agents(&inputs).await;
|
|
let cascade_reason = format!("meta-update cascade ({inputs_label})");
|
|
for name in cascade_agents {
|
|
state.coord.rebuild_queue.enqueue(
|
|
crate::rebuild_queue::QueueKind::Rebuild,
|
|
name,
|
|
crate::rebuild_queue::QueueSource::MetaUpdate,
|
|
cascade_reason.clone(),
|
|
Some(parent_id),
|
|
);
|
|
}
|
|
state.coord.emit_rebuild_queue_snapshot();
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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)
|
|
{
|
|
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(id, &name, "spawn", None, None, None);
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
/// `POST /api/topology/set-parent` — operator-driven parent move.
|
|
/// Form fields: `child` (required, agent name), `new_parent`
|
|
/// (optional — empty / absent string ⇒ promote to root). Refuses
|
|
/// cycles and unknown agents. The manager is reparentable like any
|
|
/// other agent — its privileges come from the privileged MCP socket,
|
|
/// not its tree position. On success
|
|
/// re-emits container snapshots so the dashboard tree repaints
|
|
/// without a refresh.
|
|
async fn post_set_parent(
|
|
State(state): State<AppState>,
|
|
Form(form): Form<SetParentForm>,
|
|
) -> Response {
|
|
let child = form.child.trim().to_owned();
|
|
if child.is_empty() {
|
|
return error_response("set-parent: `child` required");
|
|
}
|
|
// Empty / whitespace-only `new_parent` ⇒ promote to root. Web
|
|
// forms submit the empty string for a "no value" radio button,
|
|
// so this is the ergonomic encoding.
|
|
let new_parent = form
|
|
.new_parent
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.map(str::to_owned);
|
|
// `reparent_with_notify` wraps `topology::set_parent` with the
|
|
// three notification messages + the ContainerView rescan.
|
|
// Idempotent same-parent calls skip both the messages and the
|
|
// disk write per the topology fast-path.
|
|
match state
|
|
.coord
|
|
.reparent_with_notify(&child, new_parent.as_deref())
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
tracing::info!(
|
|
child = %child,
|
|
new_parent = ?new_parent,
|
|
"operator: set-parent via dashboard"
|
|
);
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
Err(e) => error_response(&format!("set-parent {child} failed: {e}")),
|
|
}
|
|
}
|
|
|
|
/// `POST /api/topology/set-parent-bulk` — move multiple agents in a single
|
|
/// request, producing **one** git commit. JSON body: `[{"child":"name",
|
|
/// "new_parent":"target-or-null"}, ...]`. Empty array is a no-op (200 OK).
|
|
/// First validation error aborts the whole batch.
|
|
async fn post_set_parent_bulk(
|
|
State(state): State<AppState>,
|
|
axum::Json(body): axum::Json<Vec<SetParentBulkEntry>>,
|
|
) -> Response {
|
|
if body.is_empty() {
|
|
return (StatusCode::OK, "ok").into_response();
|
|
}
|
|
// Collect borrows for the coordinator call.
|
|
let moves: Vec<(&str, Option<&str>)> = body
|
|
.iter()
|
|
.map(|e| {
|
|
let child: &str = &e.child;
|
|
let parent: Option<&str> = e.new_parent.as_deref().filter(|s| !s.is_empty());
|
|
(child, parent)
|
|
})
|
|
.collect();
|
|
match state.coord.reparent_bulk_with_notify(&moves).await {
|
|
Ok(()) => {
|
|
let names: Vec<&str> = body.iter().map(|e| e.child.as_str()).collect();
|
|
tracing::info!(agents = ?names, "operator: set-parent-bulk via dashboard");
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
Err(e) => error_response(&format!("set-parent-bulk failed: {e}")),
|
|
}
|
|
}
|
|
|
|
// ── tool-group endpoints ──────────────────────────────────
|
|
|
|
#[derive(Serialize)]
|
|
struct ToolGroupsSnapshot {
|
|
/// Ordered list of all known tool-group names. Drives the column
|
|
/// headers in the capabilities table — the UI does not hard-code them.
|
|
groups: Vec<&'static str>,
|
|
/// Short description for each group name. Keys match `groups` entries.
|
|
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
|
/// Per-agent assignment map. Absent agents use the role default
|
|
/// (agents: messaging+meta+inbox+execution; manager: all groups).
|
|
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
|
}
|
|
|
|
async fn get_tool_groups(State(_state): State<AppState>) -> axum::Json<ToolGroupsSnapshot> {
|
|
let groups = hive_sh4re::ToolGroup::ALL
|
|
.iter()
|
|
.map(|g| g.as_str())
|
|
.collect();
|
|
let descriptions = hive_sh4re::ToolGroup::ALL
|
|
.iter()
|
|
.map(|g| (g.as_str(), g.description()))
|
|
.collect();
|
|
let assignments = crate::tool_groups::read();
|
|
axum::Json(ToolGroupsSnapshot {
|
|
groups,
|
|
descriptions,
|
|
assignments,
|
|
})
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SetToolGroupsBody {
|
|
groups: Vec<String>,
|
|
}
|
|
|
|
async fn post_tool_groups(
|
|
State(state): State<AppState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
axum::Json(body): axum::Json<SetToolGroupsBody>,
|
|
) -> Response {
|
|
let logical = strip_container_prefix(&name);
|
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
|
return reject;
|
|
}
|
|
// Validate group names before queuing — fail fast so the operator
|
|
// sees the error immediately rather than waiting for the worker.
|
|
if let Err(e) = crate::tool_groups::validate_groups(&body.groups) {
|
|
return error_response(&format!("invalid tool-groups for {logical}: {e}"));
|
|
}
|
|
// Enqueue a PermChange so the JSON file write is serialised through
|
|
// the FIFO worker. Prevents concurrent batch-apply actions for
|
|
// different agents from racing on the shared tool-groups.json.
|
|
state.coord.rebuild_queue.enqueue_with_perm(
|
|
logical.clone(),
|
|
crate::rebuild_queue::QueueSource::Manual,
|
|
"tool-group change via permissions UI".to_owned(),
|
|
crate::rebuild_queue::PermPayload::ToolGroups {
|
|
groups: body.groups.clone(),
|
|
},
|
|
);
|
|
state.coord.emit_rebuild_queue_snapshot();
|
|
tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard");
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
// ── capability endpoints ──────────────────────────────────────────────────
|
|
|
|
#[derive(Serialize)]
|
|
struct CapabilitiesSnapshot {
|
|
/// Ordered list of all known capability names. Drives the column
|
|
/// headers in the capabilities table — the UI does not hard-code them.
|
|
caps: Vec<&'static str>,
|
|
/// Short description for each capability name. Keys match `caps` entries.
|
|
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
|
/// Per-agent capability grant map. Absent agents have no extra caps.
|
|
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
|
}
|
|
|
|
async fn get_capabilities(State(_state): State<AppState>) -> axum::Json<CapabilitiesSnapshot> {
|
|
use hive_sh4re::Capability;
|
|
let caps = Capability::ALL.iter().map(|c| c.as_str()).collect();
|
|
let descriptions = Capability::ALL
|
|
.iter()
|
|
.map(|c| (c.as_str(), c.description()))
|
|
.collect();
|
|
let assignments = crate::capabilities::read();
|
|
axum::Json(CapabilitiesSnapshot {
|
|
caps,
|
|
descriptions,
|
|
assignments,
|
|
})
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SetCapabilitiesBody {
|
|
caps: Vec<String>,
|
|
}
|
|
|
|
async fn post_capabilities(
|
|
State(state): State<AppState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
axum::Json(body): axum::Json<SetCapabilitiesBody>,
|
|
) -> Response {
|
|
let logical = strip_container_prefix(&name);
|
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
|
return reject;
|
|
}
|
|
let known: Vec<&str> = hive_sh4re::Capability::ALL
|
|
.iter()
|
|
.map(|c| c.as_str())
|
|
.collect();
|
|
for cap in &body.caps {
|
|
if !known.contains(&cap.as_str()) {
|
|
return error_response(&format!("unknown capability: {cap}"));
|
|
}
|
|
}
|
|
// Enqueue a PermChange so the JSON file write is serialised through
|
|
// the FIFO worker. Prevents concurrent batch-apply actions for
|
|
// different agents from racing on the shared capabilities.json.
|
|
state.coord.rebuild_queue.enqueue_with_perm(
|
|
logical.clone(),
|
|
crate::rebuild_queue::QueueSource::Manual,
|
|
"capability change via dashboard".to_owned(),
|
|
crate::rebuild_queue::PermPayload::Capabilities {
|
|
caps: body.caps.clone(),
|
|
},
|
|
);
|
|
state.coord.emit_rebuild_queue_snapshot();
|
|
tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard");
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
|
let logical = strip_container_prefix(&name);
|
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
|
return reject;
|
|
}
|
|
state.coord.rebuild_queue.enqueue(
|
|
crate::rebuild_queue::QueueKind::Rebuild,
|
|
logical,
|
|
crate::rebuild_queue::QueueSource::Manual,
|
|
"manual via dashboard ↻ R3BU1LD button".to_owned(),
|
|
None,
|
|
);
|
|
state.coord.emit_rebuild_queue_snapshot();
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
/// Common shape for the simple lifecycle action handlers (start /
|
|
/// stop / restart / rebuild): strip the container prefix, mark
|
|
/// transient for the duration so the dashboard can spinner, run the
|
|
/// lifecycle op, clear transient, redirect on success or surface the
|
|
/// error. `verb` only appears in the error message; `extra` runs on
|
|
/// success after `clear_transient` for handlers that need follow-up
|
|
/// (e.g. `kill` also unregisters the agent + fires `HelperEvent`).
|
|
async fn lifecycle_action<F, Fut>(
|
|
state: &AppState,
|
|
name: &str,
|
|
kind: crate::coordinator::TransientKind,
|
|
verb: &str,
|
|
body: F,
|
|
extra: impl FnOnce(&AppState, &str),
|
|
) -> Response
|
|
where
|
|
F: FnOnce(String) -> Fut,
|
|
Fut: std::future::Future<Output = anyhow::Result<()>>,
|
|
{
|
|
let logical = strip_container_prefix(name);
|
|
let guard = state.coord.transient_guard(&logical, kind);
|
|
let result = body(logical.clone()).await;
|
|
drop(guard);
|
|
match result {
|
|
Ok(()) => {
|
|
extra(state, &logical);
|
|
// Rescan so the running/needs_login/needs_update flip on
|
|
// the affected row lands on every dashboard's SSE channel
|
|
// without waiting for a snapshot poll. 200 + matching
|
|
// `data-no-refresh` on the form skip the post-submit
|
|
// /api/state refetch.
|
|
state.coord.rescan_containers_and_emit().await;
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
Err(e) => error_response(&format!("{verb} {logical} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
async fn post_kill(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
|
let logical = strip_container_prefix(&name);
|
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
|
return reject;
|
|
}
|
|
// Manager is stoppable from the dashboard like any other
|
|
// agent. The host's dashboard server keeps running (it's
|
|
// hive-c0re, not the manager container), per-agent approvals
|
|
// submitted by other sub-agents still process through the
|
|
// host-side approval queue without the manager up, and
|
|
// operator-driven meta-input updates work from the dashboard
|
|
// either way. The MCP-surface self-kill guard in
|
|
// `manager_server.rs::ManagerRequest::Kill` stays in place: a
|
|
// manager calling Kill on its own container is self-suicide
|
|
// mid-call, not a legitimate operator action.
|
|
lifecycle_action(
|
|
&state,
|
|
&name,
|
|
crate::coordinator::TransientKind::Stopping,
|
|
"kill",
|
|
|n| async move { lifecycle::kill(&n).await },
|
|
|s, n| {
|
|
s.coord.unregister_agent(n);
|
|
s.coord.notify_manager(&hive_sh4re::HelperEvent::Killed {
|
|
agent: n.to_owned(),
|
|
});
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn post_restart(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
|
let logical = strip_container_prefix(&name);
|
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
|
return reject;
|
|
}
|
|
state.coord.rebuild_queue.enqueue(
|
|
crate::rebuild_queue::QueueKind::Restart,
|
|
logical,
|
|
crate::rebuild_queue::QueueSource::Manual,
|
|
"manual via dashboard ↺ R3START button".to_owned(),
|
|
None,
|
|
);
|
|
state.coord.emit_rebuild_queue_snapshot();
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
async fn post_start(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
|
let logical = strip_container_prefix(&name);
|
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
|
return reject;
|
|
}
|
|
lifecycle_action(
|
|
&state,
|
|
&name,
|
|
crate::coordinator::TransientKind::Starting,
|
|
"start",
|
|
|n| async move { lifecycle::start(&n).await },
|
|
|s, n| s.coord.kick_agent(n, "container started"),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn post_update_all(State(state): State<AppState>) -> Response {
|
|
let containers = lifecycle::list().await.unwrap_or_default();
|
|
for container in containers {
|
|
let Some(logical) = container
|
|
.strip_prefix(lifecycle::AGENT_PREFIX)
|
|
.map(str::to_owned)
|
|
else {
|
|
continue;
|
|
};
|
|
state.coord.rebuild_queue.enqueue(
|
|
crate::rebuild_queue::QueueKind::Rebuild,
|
|
logical,
|
|
crate::rebuild_queue::QueueSource::Manual,
|
|
"manual via dashboard 🌀 UPDATE ALL".to_owned(),
|
|
None,
|
|
);
|
|
}
|
|
state.coord.emit_rebuild_queue_snapshot();
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
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",
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct DestroyForm {
|
|
#[serde(default)]
|
|
purge: Option<String>,
|
|
}
|
|
|
|
async fn post_destroy(
|
|
State(state): State<AppState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
Form(form): Form<DestroyForm>,
|
|
) -> Response {
|
|
if let Some(reject) = guard_agent_name(&state, &name).await {
|
|
return reject;
|
|
}
|
|
// Checkbox semantics: any non-empty value (axum sends "on") = purge.
|
|
let purge = form.purge.as_deref().is_some_and(|v| !v.is_empty());
|
|
// `actions::destroy` rescans the container list on success, so the
|
|
// `ContainerRemoved` event lands before we return 200. The matching
|
|
// form carries `data-no-refresh`.
|
|
match actions::destroy(&state.coord, &name, purge).await {
|
|
Ok(()) => (StatusCode::OK, "ok").into_response(),
|
|
Err(e) => error_response(&format!("destroy {name} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
fn error_response(message: &str) -> Response {
|
|
// Plain text — the JS app surfaces this in an alert(), so HTML
|
|
// wrapping would just clutter the message.
|
|
(StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response()
|
|
}
|
|
|
|
/// Filter out approvals whose agent state dir was wiped out from under us
|
|
/// (e.g. by a test script's cleanup). Marks them failed so they fall out of
|
|
/// `pending` on next render.
|
|
fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
|
|
approvals
|
|
.into_iter()
|
|
.filter(|a| {
|
|
// Spawn and InitConfig approvals are for not-yet-existent agents;
|
|
// the proposed dir is supposed to be missing.
|
|
if matches!(
|
|
a.kind,
|
|
hive_sh4re::ApprovalKind::Spawn | hive_sh4re::ApprovalKind::InitConfig
|
|
) {
|
|
return true;
|
|
}
|
|
if Coordinator::agent_proposed_dir(&a.agent).exists() {
|
|
true
|
|
} else {
|
|
let note = "agent state dir missing";
|
|
let _ = coord.approvals.mark_failed(a.id, note);
|
|
tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval");
|
|
let sha_short = a
|
|
.fetched_sha
|
|
.as_deref()
|
|
.map(|s| s[..s.len().min(12)].to_owned());
|
|
coord.emit_approval_resolved(
|
|
a.id,
|
|
&a.agent,
|
|
"apply_commit",
|
|
sha_short,
|
|
"failed",
|
|
Some(note.to_owned()),
|
|
a.description.clone(),
|
|
);
|
|
false
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Multi-file unified diff between the currently-deployed tree and
|
|
/// the proposal for this approval. Runs against the applied repo
|
|
/// since the canonical proposal commit lives there (manager-side
|
|
/// amendments don't move it). Empty output means proposal == main —
|
|
/// a no-op approval.
|
|
///
|
|
/// `pub(crate)` so the manager-socket handler can pre-compute the
|
|
/// diff once at submission time and embed it in the `ApprovalAdded`
|
|
/// dashboard event (instead of forcing the dashboard to wait a
|
|
/// `/api/state` cycle to see the diff for newly-queued approvals).
|
|
pub(crate) async fn approval_diff(agent: &str, approval_id: i64) -> String {
|
|
let applied = Coordinator::agent_applied_dir(agent);
|
|
if !applied.join(".git").exists() {
|
|
return format!("(no applied git repo at {})", applied.display());
|
|
}
|
|
let proposal_ref = format!("refs/tags/proposal/{approval_id}");
|
|
match git_diff_refs(&applied, "refs/heads/main", &proposal_ref).await {
|
|
Ok(s) if s.is_empty() => "(proposal matches currently-deployed tree)".to_owned(),
|
|
Ok(s) => s,
|
|
Err(e) => format!("(error: {e:#})"),
|
|
}
|
|
}
|
|
|
|
async fn git_diff_refs(applied_dir: &Path, base_ref: &str, target_ref: &str) -> Result<String> {
|
|
let out = lifecycle::git_command()
|
|
.current_dir(applied_dir)
|
|
.args(["diff", &format!("{base_ref}..{target_ref}")])
|
|
.output()
|
|
.await
|
|
.with_context(|| format!("spawn `git diff` in {}", applied_dir.display()))?;
|
|
if !out.status.success() {
|
|
anyhow::bail!(
|
|
"git diff {base_ref}..{target_ref} failed: {}",
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
|
}
|
|
|
|
/// Numeric ids of `<prefix>/<n>` tags in the applied repo (e.g.
|
|
/// `proposal/3` → `3`). Unparseable suffixes are skipped. Used to
|
|
/// resolve the `approved` / `previous` diff bases for an approval.
|
|
async fn tag_ids(applied_dir: &Path, prefix: &str) -> Vec<i64> {
|
|
let Ok(out) = lifecycle::git_command()
|
|
.current_dir(applied_dir)
|
|
.args(["tag", "-l", &format!("{prefix}/*")])
|
|
.output()
|
|
.await
|
|
else {
|
|
return Vec::new();
|
|
};
|
|
if !out.status.success() {
|
|
return Vec::new();
|
|
}
|
|
let strip = format!("{prefix}/");
|
|
String::from_utf8_lossy(&out.stdout)
|
|
.lines()
|
|
.filter_map(|l| l.trim().strip_prefix(&strip))
|
|
.filter_map(|s| s.parse::<i64>().ok())
|
|
.collect()
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct DiffBaseQuery {
|
|
/// `applied` (running tree — default), `approved` (most recent
|
|
/// earlier approved proposal), or `previous` (the prior queued
|
|
/// proposal for this agent).
|
|
base: Option<String>,
|
|
}
|
|
|
|
/// On-demand unified diff for one `ApplyCommit` approval against a
|
|
/// chosen base. `applied` = `applied/main` (what's running);
|
|
/// `approved` = the most recent earlier `approved/<n>` tag (the last
|
|
/// proposal the operator OK'd, even if its build then failed);
|
|
/// `previous` = the prior queued `proposal/<n>` (the incremental
|
|
/// delta when the manager chains proposals). Returns the raw diff
|
|
/// text — the dashboard classifies lines client-side.
|
|
async fn get_approval_diff(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<i64>,
|
|
axum::extract::Query(q): axum::extract::Query<DiffBaseQuery>,
|
|
) -> Response {
|
|
let base = q.base.as_deref().unwrap_or("applied");
|
|
let approval = match state.coord.approvals.get(id) {
|
|
Ok(Some(a)) => a,
|
|
Ok(None) => return error_response(&format!("approval {id} not found")),
|
|
Err(e) => return error_response(&format!("approval {id}: {e:#}")),
|
|
};
|
|
if !matches!(approval.kind, hive_sh4re::ApprovalKind::ApplyCommit) {
|
|
return error_response("spawn approvals carry no commit to diff");
|
|
}
|
|
let applied = Coordinator::agent_applied_dir(&approval.agent);
|
|
if !applied.join(".git").exists() {
|
|
return plain_text(format!("(no applied git repo at {})", applied.display()));
|
|
}
|
|
let target = format!("refs/tags/proposal/{id}");
|
|
let base_ref = match base {
|
|
"applied" => Some("refs/heads/main".to_owned()),
|
|
"approved" => {
|
|
let ids = tag_ids(&applied, "approved").await;
|
|
ids.into_iter()
|
|
.filter(|&n| n != id)
|
|
.max()
|
|
.map(|n| format!("refs/tags/approved/{n}"))
|
|
}
|
|
"previous" => {
|
|
let ids = tag_ids(&applied, "proposal").await;
|
|
ids.into_iter()
|
|
.filter(|&n| n < id)
|
|
.max()
|
|
.map(|n| format!("refs/tags/proposal/{n}"))
|
|
}
|
|
other => return error_response(&format!("unknown diff base {other:?}")),
|
|
};
|
|
let Some(base_ref) = base_ref else {
|
|
return plain_text(match base {
|
|
"approved" => "(no earlier approved proposal to diff against)".to_owned(),
|
|
_ => "(no previous proposal to diff against)".to_owned(),
|
|
});
|
|
};
|
|
match git_diff_refs(&applied, &base_ref, &target).await {
|
|
Ok(s) if s.is_empty() => plain_text("(identical — no changes vs this base)".to_owned()),
|
|
Ok(s) => plain_text(s),
|
|
Err(e) => error_response(&format!("git diff: {e:#}")),
|
|
}
|
|
}
|
|
|
|
fn plain_text(body: String) -> Response {
|
|
(StatusCode::OK, body).into_response()
|
|
}
|
|
|
|
/// Minimal Forgejo push-webhook payload — only the fields we care about.
|
|
#[derive(Deserialize)]
|
|
struct PushWebhookPayload {
|
|
#[serde(rename = "ref")]
|
|
git_ref: Option<String>,
|
|
repository: Option<PushWebhookRepo>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PushWebhookRepo {
|
|
full_name: Option<String>,
|
|
}
|
|
|
|
/// POST `/webhook/knowledge` — Forgejo push webhook for
|
|
/// `internal/knowledge`. Runs `git pull` on the local clone so
|
|
/// agents see up-to-date documents on their next turn.
|
|
///
|
|
/// Expected Forgejo webhook configuration:
|
|
/// - URL: `http://127.0.0.1:<dashboard_port>/webhook/knowledge`
|
|
/// - Event: "Push" (fires on merge commits to main as well)
|
|
///
|
|
/// No signature verification for now; the endpoint is loopback-only
|
|
/// and only triggers a read-only `git pull` on an operator-curated repo.
|
|
async fn post_webhook_knowledge(
|
|
axum::extract::Json(payload): axum::extract::Json<PushWebhookPayload>,
|
|
) -> Response {
|
|
let expected_repo = format!("{}/{}", crate::knowledge::ORG, crate::knowledge::REPO);
|
|
let full_name = payload
|
|
.repository
|
|
.as_ref()
|
|
.and_then(|r| r.full_name.as_deref())
|
|
.unwrap_or("");
|
|
if full_name != expected_repo {
|
|
tracing::debug!(
|
|
full_name,
|
|
"webhook/knowledge: ignoring push from unexpected repo"
|
|
);
|
|
return (StatusCode::OK, "ignored").into_response();
|
|
}
|
|
let git_ref = payload.git_ref.as_deref().unwrap_or("");
|
|
if git_ref != "refs/heads/main" {
|
|
tracing::debug!(git_ref, "webhook/knowledge: ignoring non-main push");
|
|
return (StatusCode::OK, "ignored").into_response();
|
|
}
|
|
tracing::info!("webhook/knowledge: pull triggered by push to {expected_repo}");
|
|
tokio::spawn(async {
|
|
if let Err(e) = crate::knowledge::pull().await {
|
|
tracing::warn!(error = ?e, "webhook/knowledge: pull failed");
|
|
}
|
|
});
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|