From aa8bf11c8bcedf7632ada268728263d0af769177 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 22:56:03 +0200 Subject: [PATCH] refactor(#1456): extract dashboard topology set-parent endpoints into dashboard/topology.rs --- hive-c0re/src/dashboard.rs | 115 ++++----------------------- hive-c0re/src/dashboard/topology.rs | 116 ++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 102 deletions(-) create mode 100644 hive-c0re/src/dashboard/topology.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 4bef6606..def9c56d 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -35,6 +35,7 @@ mod permissions; mod questions; mod reminders; mod schedules; +mod topology; mod webhook; #[derive(Clone)] @@ -42,6 +43,13 @@ struct AppState { coord: Arc, } +#[allow( + clippy::too_many_lines, + reason = "the body is dominated by the flat axum route table — one line \ + per endpoint mapping a URL to its (now per-concern submodule) \ + handler; splitting that exhaustive list across helpers would \ + obscure the route map for no readability gain" +)] pub async fn serve(port: u16, coord: Arc) -> Result<()> { let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR") .map(PathBuf::from) @@ -95,8 +103,11 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { ) .route("/retry-reminder/{id}", post(reminders::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/topology/set-parent", post(topology::post_set_parent)) + .route( + "/api/topology/set-parent-bulk", + post(topology::post_set_parent_bulk), + ) .route("/api/tool-groups", get(permissions::get_tool_groups)) .route( "/api/tool-groups/{agent}", @@ -1083,30 +1094,6 @@ 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, -} - -/// 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, -} - #[derive(Deserialize)] struct BuildLogsAllQuery { /// Max rows to return. Capped at 100. Default 30. @@ -2068,82 +2055,6 @@ async fn post_request_spawn( } } -/// `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, - Form(form): Form, -) -> 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, - axum::Json(body): axum::Json>, -) -> 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}")), - } -} - async fn post_rebuild(State(state): State, AxumPath(name): AxumPath) -> Response { let logical = strip_container_prefix(&name); if let Some(reject) = guard_agent_name(&state, &logical).await { diff --git a/hive-c0re/src/dashboard/topology.rs b/hive-c0re/src/dashboard/topology.rs new file mode 100644 index 00000000..7482a846 --- /dev/null +++ b/hive-c0re/src/dashboard/topology.rs @@ -0,0 +1,116 @@ +//! 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 go through `Coordinator::reparent*_with_notify`, +//! which wraps `topology::set_parent` with the move-notification messages +//! and the `ContainerView` rescan. + +use axum::{ + extract::{Form, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +use super::{AppState, error_response}; + +/// `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)] +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)] +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. 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. +pub(super) async fn post_set_parent( + State(state): State, + Form(form): Form, +) -> 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. +pub(super) async fn post_set_parent_bulk( + State(state): State, + axum::Json(body): axum::Json>, +) -> 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}")), + } +}