hyperhive/hive-c0re/src/dashboard/topology.rs

158 lines
6.1 KiB
Rust

//! Topology (set-parent) endpoints for the dashboard.
//!
//! Operator-driven agent reparenting — single (`/api/topology/set-parent`,
//! form-encoded) and bulk (`/api/topology/set-parent-bulk`, JSON array →
//! one git commit). Both submit a `NodeKind::Reparent` DAG to the job
//! queue (fire-and-forget, like every other queue-backed op — the
//! dashboard tree repaints off the queue's own snapshot/rescan once the
//! commit lands, same as a rebuild or restart). The executor delegates to
//! `Coordinator::reparent_bulk_with_notify`, which wraps
//! `crate::meta::bulk_commit_topology` with the move-notification messages
//! and the `ContainerView` rescan.
use axum::{
extract::{Form, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use utoipa::ToSchema;
use problem_details::ProblemDetails;
use super::{AppState, error_problem};
use crate::job_queue::{Source, submit};
/// `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, ToSchema)]
pub(super) 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, ToSchema)]
pub(super) struct SetParentBulkEntry {
child: String,
#[serde(default)]
new_parent: Option<String>,
}
/// `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 (surfaced async on the job view — this
/// handler only validates the identifiers, not the move itself). The
/// manager is reparentable like any other agent — its privileges come
/// from the privileged MCP socket, not its tree position. Submitting
/// re-emits the queue snapshot immediately so the dashboard shows the
/// queued move without a refresh; the tree itself repaints once the
/// commit lands.
#[utoipa::path(
post,
path = "/api/topology/set-parent",
responses(
(status = 200, description = "reparent queued", body = String),
(status = 400, description = "missing/invalid child or new_parent identifier"),
),
tag = "topology"
)]
pub(super) async fn post_set_parent(
State(state): State<AppState>,
Form(form): Form<SetParentForm>,
) -> Result<Response, ProblemDetails> {
let child = form.child.trim().to_owned();
if child.is_empty() {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail("set-parent: `child` required"));
}
let child = hive_types::Ident::parse(&child)
.map_err(|e| error_problem(&format!("set-parent: `child` {e}")))?;
// 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(hive_types::Ident::parse)
.transpose()
.map_err(|e| error_problem(&format!("set-parent: `new_parent` {e}")))?;
tracing::info!(
child = %child,
new_parent = ?new_parent,
"operator: set-parent via dashboard"
);
submit::reparent(
&state.coord,
vec![(child, new_parent)],
Source::Manual,
"manual set-parent via dashboard".to_owned(),
);
Ok((StatusCode::OK, "ok").into_response())
}
/// `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 identifier that fails to parse aborts the whole batch before
/// anything is submitted — a partially-invalid bulk move never reaches
/// the queue.
#[utoipa::path(
post,
path = "/api/topology/set-parent-bulk",
responses(
(status = 200, description = "reparents queued", body = String),
(status = 400, description = "an invalid child identifier in the batch"),
),
tag = "topology"
)]
pub(super) async fn post_set_parent_bulk(
State(state): State<AppState>,
axum::Json(body): axum::Json<Vec<SetParentBulkEntry>>,
) -> Result<Response, ProblemDetails> {
if body.is_empty() {
return Ok((StatusCode::OK, "ok").into_response());
}
// Collect into `Result<_, String>` first, not `ProblemDetails` directly —
// clippy::result_large_err flags a ~232-byte `Err` variant threaded
// through this closure's `?`. `String` is small enough to satisfy the
// lint; the single `map_err` below promotes it to a `ProblemDetails`
// once, after the fallible collect.
let moves = body
.iter()
.map(|e| {
let child = hive_types::Ident::parse(e.child.trim())
.map_err(|err| format!("set-parent-bulk: `{}` {err}", e.child))?;
let new_parent = e
.new_parent
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(hive_types::Ident::parse)
.transpose()
.map_err(|err| format!("set-parent-bulk: `{}` {err}", e.child))?;
Ok((child, new_parent))
})
.collect::<Result<Vec<_>, String>>()
.map_err(|e| error_problem(&e))?;
let names: Vec<&str> = body.iter().map(|e| e.child.as_str()).collect();
tracing::info!(agents = ?names, "operator: set-parent-bulk via dashboard");
submit::reparent(
&state.coord,
moves,
Source::Manual,
"manual set-parent-bulk via dashboard".to_owned(),
);
Ok((StatusCode::OK, "ok").into_response())
}