topology: drop the parent field and the hierarchy it fed
`topology.json` was a map of `name -> parent | null`, and that value fed the whole agent hierarchy: `<parent>` / `<children>` recipient sentinels, the reparenting API (CLI verb, wire verb, dashboard endpoints, DAG node), the dashboard tree, the rebuild depth sort, and an unconditional bind-mount grant giving every agent RW on its direct children's state. Per the operator's ruling the field goes, and with it all of the above. The file survives as what remains once the value is gone: the roster of agent names, which is the set `ManageRootAgent` grants mounts over. It is now a JSON array; `read` still accepts the old map shape and keeps its keys, so a hive that upgrades across this does not blank its roster (and so no capability holder loses its mounts for the length of that window). Two sites kept their behaviour under a different recipient rather than losing it. Both addressed `<parent>`, which the broker already resolved to `operator` for a root agent, and every agent is now what that fallback called a root: - the harness's turn-failure / plugin-failure notification (`Surface::send_to_parent` -> `send_to_operator`), and - the send allow-list's always-permitted escape hatch, so an agent with a restrictive allow-list still has a way to say it is stuck. What is NOT preserved, deliberately: an agent with no capability no longer sees any other agent's dirs. `ManageRootAgent`'s own grant is unchanged -- still every agent in the roster, still state RW + config RO, still no `harness`. The dashboard's reparenting control (the M0V3 picker) is deleted with its CSS. The tree rendering that reads `ContainerView.parent` is left for the frontend owner -- it degrades to a flat list with the field gone.
This commit is contained in:
parent
392f16cbc0
commit
d94bc2188d
28 changed files with 236 additions and 1513 deletions
|
|
@ -47,7 +47,6 @@ use crate::lifecycle;
|
|||
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
||||
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
||||
(name = "tombstones", description = "purge of retained state for destroyed agents"),
|
||||
(name = "topology", description = "operator-driven agent reparenting"),
|
||||
(name = "webhook", description = "forgejo webhook receivers"),
|
||||
)
|
||||
)]
|
||||
|
|
@ -72,7 +71,6 @@ mod schedules;
|
|||
mod state_files;
|
||||
mod state_snapshot;
|
||||
mod tombstones;
|
||||
mod topology;
|
||||
mod webhook;
|
||||
|
||||
// Run after lock bumps by the job queue (`job_queue/exec.rs`); the view
|
||||
|
|
@ -161,8 +159,6 @@ pub async fn serve(
|
|||
.routes(routes!(build_logs::get_build_logs_agent))
|
||||
.routes(routes!(build_logs::get_build_log_full))
|
||||
.routes(routes!(build_logs::get_build_log_raw))
|
||||
.routes(routes!(topology::post_set_parent))
|
||||
.routes(routes!(topology::post_set_parent_bulk))
|
||||
.routes(routes!(permissions::get_tool_groups))
|
||||
.routes(routes!(permissions::post_tool_groups))
|
||||
.routes(routes!(permissions::get_capabilities))
|
||||
|
|
@ -402,8 +398,6 @@ mod router_build_probe {
|
|||
.routes(routes!(build_logs::get_build_logs_agent))
|
||||
.routes(routes!(build_logs::get_build_log_full))
|
||||
.routes(routes!(build_logs::get_build_log_raw))
|
||||
.routes(routes!(topology::post_set_parent))
|
||||
.routes(routes!(topology::post_set_parent_bulk))
|
||||
.routes(routes!(permissions::get_tool_groups))
|
||||
.routes(routes!(permissions::post_tool_groups))
|
||||
.routes(routes!(permissions::get_capabilities))
|
||||
|
|
|
|||
|
|
@ -805,7 +805,6 @@ mod tests {
|
|||
needs_update: false,
|
||||
needs_login: false,
|
||||
deployed_sha: None,
|
||||
parent: None,
|
||||
active_model: None,
|
||||
status_text: None,
|
||||
status_set_at: None,
|
||||
|
|
|
|||
|
|
@ -1,165 +0,0 @@
|
|||
//! 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};
|
||||
|
||||
/// `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>,
|
||||
}
|
||||
|
||||
/// 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"
|
||||
);
|
||||
state
|
||||
.coord
|
||||
.job_queue
|
||||
.insert_job(|b| {
|
||||
crate::job_queue::templates::reparent(b, vec![(child, new_parent)]);
|
||||
Vec::new()
|
||||
})
|
||||
.expect("template-declared shapes are acyclic");
|
||||
state.coord.emit_rebuild_queue_snapshot();
|
||||
Ok((StatusCode::OK, "ok").into_response())
|
||||
}
|
||||
|
||||
/// 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");
|
||||
state
|
||||
.coord
|
||||
.job_queue
|
||||
.insert_job(|b| {
|
||||
crate::job_queue::templates::reparent(b, moves);
|
||||
Vec::new()
|
||||
})
|
||||
.expect("template-declared shapes are acyclic");
|
||||
state.coord.emit_rebuild_queue_snapshot();
|
||||
Ok((StatusCode::OK, "ok").into_response())
|
||||
}
|
||||
Loading…
Reference in a new issue