diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index d28e0568..9d3c0733 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -976,10 +976,9 @@ window.marked = marked; } sel.disabled = true; const failures = []; - if (names.length === 1) { - // Single agent — use the form-encoded endpoint (backwards compat). + for (const name of names) { try { - const body = new URLSearchParams({ child: names[0], new_parent: newParent }); + const body = new URLSearchParams({ child: name, new_parent: newParent }); const resp = await fetch('/api/topology/set-parent', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -990,30 +989,10 @@ window.marked = marked; || (resp.status >= 200 && resp.status < 400); if (!ok) { const text = await resp.text().catch(() => ''); - failures.push(`${names[0]}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`); + failures.push(`${name}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`); } } catch (err) { - failures.push(`${names[0]}: ${err}`); - } - } else { - // Multiple agents — use the bulk endpoint so all moves land in - // a single git commit instead of one per agent. - try { - const payload = names.map((n) => ({ child: n, new_parent: newParent || null })); - const resp = await fetch('/api/topology/set-parent-bulk', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - redirect: 'manual', - }); - const ok = resp.ok || resp.type === 'opaqueredirect' - || (resp.status >= 200 && resp.status < 400); - if (!ok) { - const text = await resp.text().catch(() => ''); - failures.push(`bulk: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`); - } - } catch (err) { - failures.push(`bulk: ${err}`); + failures.push(`${name}: ${err}`); } } sel.disabled = false; diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index ee0b8440..4e70f5da 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -716,62 +716,6 @@ impl Coordinator { Ok(()) } - /// Batch version of [`reparent_with_notify`]: applies all moves under a - /// single `META_LOCK` acquisition (one git commit) then sends per-agent - /// notifications for each move that actually changed topology. - /// First validation failure aborts the whole batch with no disk writes. - /// - /// # Errors - /// - /// Propagates any error returned by [`crate::meta::bulk_commit_topology`] - /// (validation failure or topology-file write error). - pub async fn reparent_bulk_with_notify( - self: &Arc, - moves: &[(&str, Option<&str>)], - ) -> std::result::Result<(), String> { - if moves.is_empty() { - return Ok(()); - } - // bulk_commit_topology applies all set_parent calls under one lock - // and returns (child, old_parent) for every move that changed. - let changed = crate::meta::bulk_commit_topology(moves).await?; - - // Send per-agent notifications for each changed move. - for (child, old_parent) in &changed { - // Find the new parent from the moves slice. - let new_parent = moves - .iter() - .find(|(c, _)| *c == child) - .and_then(|(_, np)| *np); - let old_label = old_parent.as_deref().unwrap_or(""); - let new_label = new_parent.unwrap_or(""); - if let Some(op) = old_parent.as_deref() { - let _ = self.broker.send(&hive_sh4re::Message { - from: hive_sh4re::SYSTEM_SENDER.to_owned(), - to: op.to_owned(), - body: format!("{child} moved out of your subtree to {new_label}"), - in_reply_to: None, - }); - } - if let Some(np) = new_parent { - let _ = self.broker.send(&hive_sh4re::Message { - from: hive_sh4re::SYSTEM_SENDER.to_owned(), - to: np.to_owned(), - body: format!( - "{child} just moved into your subtree (was previously under {old_label})" - ), - in_reply_to: None, - }); - } - let _ = self - .broker - .send_coalescing_reparent(child, old_label, new_label); - } - - self.rescan_containers_and_emit().await; - Ok(()) - } - /// Read-only snapshot of the last cached container view. Used by /// `/api/state` to cold-load page-open clients without re-running /// `nixos-container list` themselves; the diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 1b67bc95..d5ca2c66 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -78,7 +78,6 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/retry-reminder/{id}", post(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/tool-groups", get(get_tool_groups)) .route("/api/tool-groups/{agent}", post(post_tool_groups)) .route("/api/capabilities", get(get_capabilities)) @@ -1055,15 +1054,6 @@ struct SetParentForm { 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 AnswerForm { answer: String, @@ -2486,36 +2476,6 @@ async fn post_set_parent( } } -/// `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}")), - } -} - // ── tool-group endpoints ────────────────────────────────── #[derive(Serialize)] diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index f4653462..d5795b79 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -397,88 +397,6 @@ pub async fn commit_topology( Ok(()) } -/// Batch variant of [`commit_topology`]: applies every `(child, new_parent)` -/// move under a single `META_LOCK` acquisition and creates **one** git commit -/// for all of them. Moves are applied in the order given; the first -/// validation error short-circuits the whole batch. True atomic write: all -/// moves are pre-validated against a cumulative in-memory state with -/// [`crate::topology::apply_set_parent`] before anything touches disk, then -/// [`crate::topology::write`] is called exactly once. If any move fails -/// validation the topology file is never modified. -/// -/// The multi-move commit message uses `moves[0].1` as the destination label. -/// This is intentional: the dashboard bulk-move UI always sends a single -/// destination for all selected agents, so the message is always accurate in -/// practice. -/// -/// Returns a `Vec` of `(child, old_parent)` pairs for every move that -/// actually changed the topology (idempotent same-parent moves are skipped), -/// so the caller can send targeted notifications. -/// -/// # Errors -/// -/// Returns a `String` error if any move fails validation (cycle, unknown -/// agent, etc.) or if the topology file cannot be written. Git-commit failure -/// is logged as a warning and does not propagate — `sync_agents` will recover. -pub async fn bulk_commit_topology( - moves: &[(&str, Option<&str>)], -) -> std::result::Result)>, String> { - if moves.is_empty() { - return Ok(vec![]); - } - let _guard = META_LOCK.lock().await; - // Snapshot parents before any writes so we can compute the diff. - let topo_before = crate::topology::read(); - // Validate all moves against a cumulative in-memory state -- no disk - // writes yet; first error aborts with the topology file untouched. - let mut next = topo_before.clone(); - for (child, new_parent) in moves { - next = crate::topology::apply_set_parent(&next, child, *new_parent)?; - } - // Only flush to disk if something actually changed. - if next != topo_before { - crate::topology::write(&next).map_err(|e| format!("{e:#}"))?; - } - // Commit the whole batch as one git operation. - let dir = meta_dir(); - let commit_msg = if moves.len() == 1 { - let (child, new_parent) = moves[0]; - format!("topology: {} → {}", child, new_parent.unwrap_or("")) - } else { - let names: Vec<&str> = moves.iter().map(|(c, _)| *c).collect(); - let dest = moves[0].1.unwrap_or(""); - format!( - "topology: move {} agents → {} ({})", - moves.len(), - dest, - names.join(", ") - ) - }; - let stage = async { - git(&dir, &["add", "topology.json"]).await?; - if has_staged_changes(&dir).await? { - git_commit(&dir, &commit_msg).await?; - } - Ok::<_, anyhow::Error>(()) - }; - if let Err(e) = stage.await { - tracing::warn!(error = ?e, "bulk_commit_topology: topology written but git commit failed (sync_agents will recover)"); - } - // Return (child, old_parent) for each move that changed state. - let changed = moves - .iter() - .filter_map(|(child, new_parent)| { - let old = topo_before.get(*child).cloned().flatten(); - if old.as_deref() != *new_parent { - Some((child.to_string(), old)) - } else { - None - } - }) - .collect(); - Ok(changed) -} - fn render_flake( hyperhive_flake: &str, nixpkgs_flake: &str,