drops the #[allow(clippy::too_many_lines)] on api_state by extracting four pure helpers: - build_container_views — live containers + any_stale flag - build_transient_views — agents in pre-creation Spawning state only - build_approval_views — pending approvals with diff html - build_tombstone_views — destroyed-but-kept state dirs api_state itself is now ~30 lines of orchestration. zero behavior change. each helper is independently readable + testable.
775 lines
26 KiB
Rust
775 lines
26 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::fmt::Write as _;
|
|
use std::net::SocketAddr;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Context, Result};
|
|
use axum::extract::Form;
|
|
use axum::{
|
|
Router,
|
|
extract::{Path as AxumPath, State},
|
|
http::{HeaderMap, StatusCode},
|
|
response::{
|
|
Html, IntoResponse, Redirect, Response,
|
|
sse::{Event, KeepAlive, Sse},
|
|
},
|
|
routing::{get, post},
|
|
};
|
|
use hive_sh4re::Approval;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio_stream::wrappers::BroadcastStream;
|
|
use tokio_stream::{Stream, StreamExt};
|
|
|
|
use crate::actions;
|
|
use crate::coordinator::Coordinator;
|
|
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
|
|
|
|
const MANAGER_PORT: u16 = 8000;
|
|
|
|
#[derive(Clone)]
|
|
struct AppState {
|
|
coord: Arc<Coordinator>,
|
|
}
|
|
|
|
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
|
let app = Router::new()
|
|
.route("/", get(serve_index))
|
|
.route("/static/dashboard.css", get(serve_css))
|
|
.route("/static/app.js", get(serve_app_js))
|
|
.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("/purge-tombstone/{name}", post(post_purge_tombstone))
|
|
.route("/request-spawn", post(post_request_spawn))
|
|
.route("/messages/stream", get(messages_stream))
|
|
.with_state(AppState { coord });
|
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
|
let listener = tokio::net::TcpListener::bind(addr)
|
|
.await
|
|
.with_context(|| format!("bind dashboard on port {port}"))?;
|
|
tracing::info!(%port, "dashboard listening");
|
|
axum::serve(listener, app).await?;
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Static asset handlers: the dashboard is an SPA. `GET /` returns the
|
|
// (static) shell; `GET /static/*` serves the CSS + JS app; `GET /api/state`
|
|
// returns the current snapshot as JSON. The JS app fetches state on load,
|
|
// re-fetches after every async-form submit, and listens on
|
|
// `/messages/stream` for broker traffic.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn serve_index() -> impl IntoResponse {
|
|
Html(include_str!("../assets/index.html"))
|
|
}
|
|
|
|
async fn serve_css() -> impl IntoResponse {
|
|
(
|
|
[("content-type", "text/css")],
|
|
include_str!("../assets/dashboard.css"),
|
|
)
|
|
}
|
|
|
|
async fn serve_app_js() -> impl IntoResponse {
|
|
(
|
|
[("content-type", "application/javascript")],
|
|
include_str!("../assets/app.js"),
|
|
)
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct StateSnapshot {
|
|
hostname: String,
|
|
manager_port: u16,
|
|
any_stale: bool,
|
|
containers: Vec<ContainerView>,
|
|
transients: Vec<TransientView>,
|
|
approvals: Vec<ApprovalView>,
|
|
/// Latest messages addressed to `operator` — surfaces agent replies
|
|
/// asynchronously so the operator can see them without watching the
|
|
/// live panel during a turn.
|
|
operator_inbox: Vec<crate::broker::InboxRow>,
|
|
/// Pending operator questions (currently only from the manager).
|
|
/// `ask_operator` returns immediately with the id; on `/answer-question`
|
|
/// we mark the row answered and fire `HelperEvent::OperatorAnswered`
|
|
/// into the manager's inbox.
|
|
questions: Vec<crate::operator_questions::OpQuestion>,
|
|
/// 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>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct TombstoneView {
|
|
name: String,
|
|
/// Bytes used by the state dir tree. Cheap-ish to compute; let the
|
|
/// operator know how much they're holding onto.
|
|
state_bytes: u64,
|
|
/// Mtime (unix seconds) of the state dir; rough "last seen".
|
|
last_seen: i64,
|
|
has_creds: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[allow(clippy::struct_excessive_bools)]
|
|
struct ContainerView {
|
|
/// Logical agent name (no `h-` prefix). Used in action URLs.
|
|
name: String,
|
|
/// Container name as nixos-container sees it (`h-foo`, `hm1nd`).
|
|
container: String,
|
|
is_manager: bool,
|
|
port: u16,
|
|
running: bool,
|
|
needs_update: bool,
|
|
needs_login: bool,
|
|
/// When a lifecycle action is in flight on this container, the kind
|
|
/// (`starting`, `stopping`, etc.) so the JS can render a spinner +
|
|
/// disable other buttons.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pending: Option<&'static str>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct TransientView {
|
|
name: String,
|
|
kind: &'static str,
|
|
secs: u64,
|
|
}
|
|
|
|
#[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>,
|
|
/// Pre-rendered syntax-coloured diff HTML, for `ApplyCommit` only.
|
|
diff_html: Option<String>,
|
|
}
|
|
|
|
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();
|
|
|
|
let raw_containers = lifecycle::list().await.unwrap_or_default();
|
|
let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake);
|
|
let transient_snapshot = state.coord.transient_snapshot();
|
|
let pending_approvals = gc_orphans(
|
|
&state.coord,
|
|
state.coord.approvals.pending().unwrap_or_default(),
|
|
);
|
|
|
|
let (containers, any_stale) =
|
|
build_container_views(&raw_containers, current_rev.as_deref(), &transient_snapshot).await;
|
|
let transients = build_transient_views(&raw_containers, &transient_snapshot);
|
|
let approvals = build_approval_views(pending_approvals).await;
|
|
let tombstones = build_tombstone_views(&state.coord, &containers, &transient_snapshot);
|
|
|
|
let operator_inbox = state
|
|
.coord
|
|
.broker
|
|
.recent_for(hive_sh4re::OPERATOR_RECIPIENT, 50)
|
|
.unwrap_or_default();
|
|
let questions = state.coord.questions.pending().unwrap_or_default();
|
|
|
|
axum::Json(StateSnapshot {
|
|
hostname,
|
|
manager_port: MANAGER_PORT,
|
|
any_stale,
|
|
containers,
|
|
transients,
|
|
approvals,
|
|
operator_inbox,
|
|
questions,
|
|
tombstones,
|
|
})
|
|
}
|
|
|
|
/// Build `ContainerView`s for every live nixos-container. Returns the
|
|
/// list and whether any container is stale (drives the "↻ UPD4TE 4LL"
|
|
/// banner).
|
|
async fn build_container_views(
|
|
raw_containers: &[String],
|
|
current_rev: Option<&str>,
|
|
transient_snapshot: &std::collections::HashMap<String, crate::coordinator::TransientState>,
|
|
) -> (Vec<ContainerView>, bool) {
|
|
let mut out = Vec::new();
|
|
let mut any_stale = false;
|
|
for c in raw_containers {
|
|
let (logical, is_manager) = if c == MANAGER_NAME {
|
|
(MANAGER_NAME.to_owned(), true)
|
|
} else if let Some(n) = c.strip_prefix(AGENT_PREFIX) {
|
|
(n.to_owned(), false)
|
|
} else {
|
|
continue;
|
|
};
|
|
let needs_update =
|
|
current_rev.is_some_and(|rev| crate::auto_update::agent_needs_update(&logical, rev));
|
|
if needs_update {
|
|
any_stale = true;
|
|
}
|
|
let needs_login = !is_manager && !claude_has_session(&Coordinator::agent_claude_dir(&logical));
|
|
let pending = transient_snapshot
|
|
.get(&logical)
|
|
.map(|st| transient_label(st.kind));
|
|
out.push(ContainerView {
|
|
port: lifecycle::agent_web_port(&logical),
|
|
running: lifecycle::is_running(&logical).await,
|
|
container: c.clone(),
|
|
name: logical,
|
|
is_manager,
|
|
needs_update,
|
|
needs_login,
|
|
pending,
|
|
});
|
|
}
|
|
(out, any_stale)
|
|
}
|
|
|
|
/// 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(
|
|
raw_containers: &[String],
|
|
transient_snapshot: &std::collections::HashMap<String, crate::coordinator::TransientState>,
|
|
) -> Vec<TransientView> {
|
|
transient_snapshot
|
|
.iter()
|
|
.filter(|(name, _)| {
|
|
!raw_containers
|
|
.iter()
|
|
.any(|c| c == &format!("{AGENT_PREFIX}{name}") || c == *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`).
|
|
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 => {
|
|
let sha = a.commit_ref[..a.commit_ref.len().min(12)].to_owned();
|
|
let diff = approval_diff(&a.agent, &a.commit_ref).await;
|
|
ApprovalView {
|
|
id: a.id,
|
|
agent: a.agent,
|
|
kind: "apply_commit",
|
|
sha_short: Some(sha),
|
|
diff_html: Some(render_diff_lines(&diff)),
|
|
}
|
|
}
|
|
hive_sh4re::ApprovalKind::Spawn => ApprovalView {
|
|
id: a.id,
|
|
agent: a.agent,
|
|
kind: "spawn",
|
|
sha_short: None,
|
|
diff_html: None,
|
|
},
|
|
});
|
|
}
|
|
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 messages_stream(
|
|
State(state): State<AppState>,
|
|
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
|
let rx = state.coord.broker.subscribe();
|
|
let stream = BroadcastStream::new(rx).filter_map(|res| {
|
|
// Drop lagged events. Browsers reconnect; nothing to do here.
|
|
let event = res.ok()?;
|
|
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 {
|
|
Ok(()) => Redirect::to("/").into_response(),
|
|
Err(e) => error_response(&format!("approve {id} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
async fn post_deny(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response {
|
|
match actions::deny(&state.coord, id) {
|
|
Ok(()) => Redirect::to("/").into_response(),
|
|
Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RequestSpawnForm {
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct AnswerForm {
|
|
answer: String,
|
|
}
|
|
|
|
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 error_response("answer: required");
|
|
}
|
|
match state.coord.questions.answer(id, answer) {
|
|
Ok(question) => {
|
|
tracing::info!(%id, "operator answered question");
|
|
state
|
|
.coord
|
|
.notify_manager(&hive_sh4re::HelperEvent::OperatorAnswered {
|
|
id,
|
|
question,
|
|
answer: answer.to_owned(),
|
|
});
|
|
Redirect::to("/").into_response()
|
|
}
|
|
Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
async fn post_purge_tombstone(
|
|
State(state): State<AppState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
) -> Response {
|
|
if name == lifecycle::MANAGER_NAME {
|
|
return error_response("refusing to purge the manager's state");
|
|
}
|
|
// 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");
|
|
Redirect::to("/").into_response()
|
|
} else {
|
|
error_response(&format!("purge {name} partial: {}", errors.join(", ")))
|
|
}
|
|
}
|
|
|
|
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, "")
|
|
{
|
|
Ok(id) => {
|
|
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard");
|
|
Redirect::to("/").into_response()
|
|
}
|
|
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
|
let Some(current_rev) = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake)
|
|
else {
|
|
return error_response(
|
|
"rebuild: hyperhive_flake has no canonical path; manual rebuild only via `hive-c0re rebuild`",
|
|
);
|
|
};
|
|
let coord = state.coord.clone();
|
|
lifecycle_action(
|
|
&state,
|
|
&name,
|
|
crate::coordinator::TransientKind::Rebuilding,
|
|
"rebuild",
|
|
move |n| {
|
|
let coord = coord.clone();
|
|
let rev = current_rev.clone();
|
|
async move { crate::auto_update::rebuild_agent(&coord, &n, &rev).await }
|
|
},
|
|
|_, _| {},
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// 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);
|
|
state.coord.set_transient(&logical, kind);
|
|
let result = body(logical.clone()).await;
|
|
state.coord.clear_transient(&logical);
|
|
match result {
|
|
Ok(()) => {
|
|
extra(state, &logical);
|
|
Redirect::to("/").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 logical == lifecycle::MANAGER_NAME {
|
|
return error_response("kill: refusing to stop the manager");
|
|
}
|
|
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 {
|
|
lifecycle_action(
|
|
&state,
|
|
&name,
|
|
crate::coordinator::TransientKind::Restarting,
|
|
"restart",
|
|
|n| async move { lifecycle::restart(&n).await },
|
|
|_, _| {},
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn post_start(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
|
lifecycle_action(
|
|
&state,
|
|
&name,
|
|
crate::coordinator::TransientKind::Starting,
|
|
"start",
|
|
|n| async move { lifecycle::start(&n).await },
|
|
|_, _| {},
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn post_update_all(State(state): State<AppState>) -> Response {
|
|
let Some(current_rev) = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake)
|
|
else {
|
|
return error_response("update-all: hyperhive_flake has no canonical path");
|
|
};
|
|
let containers = lifecycle::list().await.unwrap_or_default();
|
|
let mut errors = Vec::new();
|
|
for container in containers {
|
|
let logical = if container == lifecycle::MANAGER_NAME {
|
|
lifecycle::MANAGER_NAME.to_owned()
|
|
} else if let Some(n) = container.strip_prefix(lifecycle::AGENT_PREFIX) {
|
|
n.to_owned()
|
|
} else {
|
|
continue;
|
|
};
|
|
if !crate::auto_update::agent_needs_update(&logical, ¤t_rev) {
|
|
continue;
|
|
}
|
|
if let Err(e) =
|
|
crate::auto_update::rebuild_agent(&state.coord, &logical, ¤t_rev).await
|
|
{
|
|
errors.push(format!("{logical}: {e:#}"));
|
|
}
|
|
}
|
|
if errors.is_empty() {
|
|
Redirect::to("/").into_response()
|
|
} else {
|
|
error_response(&format!(
|
|
"update-all partial failure:\n{}",
|
|
errors.join("\n")
|
|
))
|
|
}
|
|
}
|
|
|
|
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 `hm1nd`.
|
|
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 {
|
|
// Checkbox semantics: any non-empty value (axum sends "on") = purge.
|
|
let purge = form.purge.as_deref().is_some_and(|v| !v.is_empty());
|
|
match actions::destroy(&state.coord, &name, purge).await {
|
|
Ok(()) => Redirect::to("/").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 approvals are for not-yet-existent agents; the proposed
|
|
// dir is supposed to be missing.
|
|
if matches!(a.kind, hive_sh4re::ApprovalKind::Spawn) {
|
|
return true;
|
|
}
|
|
if Coordinator::agent_proposed_dir(&a.agent).exists() {
|
|
true
|
|
} else {
|
|
let _ = coord.approvals.mark_failed(a.id, "agent state dir missing");
|
|
tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval");
|
|
false
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Render a unified diff with per-line CSS classes so the dashboard can
|
|
/// colour adds / dels / hunk headers / context. Each line becomes a
|
|
/// `<span>` tagged by its leading character; the wrapping `<pre>` keeps
|
|
/// whitespace intact.
|
|
fn render_diff_lines(diff: &str) -> String {
|
|
let mut out = String::new();
|
|
for raw in diff.lines() {
|
|
let cls = match raw.as_bytes().first() {
|
|
// file headers (`--- a/...` / `+++ b/...`) come before any
|
|
// line starting with a single `+`/`-`. similar-rs emits them
|
|
// with the doubled prefix.
|
|
_ if raw.starts_with("--- ") => "diff-file",
|
|
_ if raw.starts_with("+++ ") => "diff-file",
|
|
Some(b'@') => "diff-hunk",
|
|
Some(b'+') => "diff-add",
|
|
Some(b'-') => "diff-del",
|
|
_ => "diff-ctx",
|
|
};
|
|
let _ = writeln!(out, "<span class=\"{cls}\">{}</span>", html_escape(raw),);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true if the
|
|
/// agent's bound `~/.claude/` dir on disk contains any regular file. The
|
|
/// dashboard reads this each render so logins driven from the agent web UI
|
|
/// (Phase 8 step 4) reflect within one auto-refresh cycle.
|
|
fn claude_has_session(dir: &Path) -> bool {
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return false;
|
|
};
|
|
entries
|
|
.flatten()
|
|
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
|
}
|
|
|
|
async fn approval_diff(agent: &str, commit_ref: &str) -> String {
|
|
let proposed = Coordinator::agent_proposed_dir(agent);
|
|
if !proposed.exists() {
|
|
return format!(
|
|
"(proposed dir {} does not exist — agent destroyed?)",
|
|
proposed.display()
|
|
);
|
|
}
|
|
if !proposed.join(".git").exists() {
|
|
return format!("(no git repo at {})", proposed.display());
|
|
}
|
|
let applied = Coordinator::agent_applied_dir(agent).join("agent.nix");
|
|
let applied_text = std::fs::read_to_string(&applied).unwrap_or_default();
|
|
match git_show(&proposed, commit_ref).await {
|
|
Ok(s) => unified_diff(&applied_text, &s),
|
|
Err(e) => format!("(error: {e:#})"),
|
|
}
|
|
}
|
|
|
|
async fn git_show(proposed_dir: &Path, commit_ref: &str) -> Result<String> {
|
|
let out = lifecycle::git_command()
|
|
.current_dir(proposed_dir)
|
|
.args(["show", &format!("{commit_ref}:agent.nix")])
|
|
.output()
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"spawn `git show` in {} (HYPERHIVE_GIT={})",
|
|
proposed_dir.display(),
|
|
std::env::var("HYPERHIVE_GIT").unwrap_or_else(|_| "<unset>".into()),
|
|
)
|
|
})?;
|
|
if !out.status.success() {
|
|
anyhow::bail!(
|
|
"git show {commit_ref}:agent.nix failed: {}",
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
|
}
|
|
|
|
fn unified_diff(applied: &str, proposed: &str) -> String {
|
|
let diff = similar::TextDiff::from_lines(applied, proposed);
|
|
diff.unified_diff()
|
|
.context_radius(3)
|
|
.header("applied", "proposed")
|
|
.to_string()
|
|
}
|
|
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
}
|