//! 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, } /// 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, } /// `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, Form(form): Form, ) -> Result { 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, axum::Json(body): axum::Json>, ) -> Result { 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::, 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()) }