From 8e24814efea7e948fa19827607967588f477dd74 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 17 Jun 2026 18:08:19 +0200 Subject: [PATCH] feat(dashboard): batch POST /api/permissions for save-all perms (#1719) --- docs/web-ui/dashboard.md | 13 +++++ hive-c0re/src/dashboard.rs | 1 + hive-c0re/src/dashboard/permissions.rs | 80 ++++++++++++++++++++++++++ hive-c0re/src/meta.rs | 41 +++++++++++++ hive-c0re/src/rebuild_queue.rs | 28 +++++++++ 5 files changed, 163 insertions(+) diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index f44c7bad..e43f8a92 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -1026,6 +1026,19 @@ that's a browser-level decision, not ours. `HIVE_CAPABILITIES` takes effect. Agent name validated; unknown capability strings are rejected (400). `guard_agent_name` applied. +- `POST /api/permissions` — batch perm apply for the save-all + permissions button. Body + `{ changes: [{ agent, tool_groups?: ["name", …], capabilities?: ["name", …] }] }`. + Sparse per agent: an omitted field leaves that perm-type untouched, + an empty array clears it, a populated array fully replaces it (same + replace semantics as the per-agent endpoints above). Each affected + agent gets ONE combined `PermChange` queue entry, so changing both + an agent's tool-groups and capabilities triggers a single rebuild, + not two. **Atomic**: every change is validated first (agent names via + `guard_agent_name`, group + capability names) and on any validation + error nothing is written or enqueued (non-2xx `{ error }`); rows with + both fields omitted are skipped, not errors. Returns `200 "ok"` on + success. - `GET /api/schedules` — list all schedules (active and recently cancelled) for the SCH3DUL3S scheduled-prompts panel. - `POST /api/schedules` — operator-direct schedule create: diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index aa660640..3790409c 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -153,6 +153,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { "/api/capabilities/{agent}", post(permissions::post_capabilities), ) + .route("/api/permissions", post(permissions::post_permissions)) .route("/op-send", post(post_op_send)) .route("/meta-update", post(post_meta_update)) .route( diff --git a/hive-c0re/src/dashboard/permissions.rs b/hive-c0re/src/dashboard/permissions.rs index 3f07a6e5..60044b4b 100644 --- a/hive-c0re/src/dashboard/permissions.rs +++ b/hive-c0re/src/dashboard/permissions.rs @@ -223,6 +223,86 @@ pub(super) async fn post_capabilities( (StatusCode::OK, "ok").into_response() } +/// One agent's slice of a batch permission change. Sparse: an omitted +/// field leaves that perm-type untouched, an empty array clears it, a +/// populated array fully replaces it (same replace semantics as the +/// per-agent endpoints). +#[derive(Deserialize)] +pub(super) struct PermChangeBody { + agent: String, + #[serde(default)] + tool_groups: Option>, + #[serde(default)] + capabilities: Option>, +} + +#[derive(Deserialize)] +pub(super) struct BatchPermsBody { + changes: Vec, +} + +/// A validated, non-empty change staged for enqueue: +/// `(logical agent, new groups?, new caps?)`. +type StagedPerm = (String, Option>, Option>); + +/// Batch permission apply — `POST /api/permissions`. The save-all +/// permissions UI sends only the perm-types that actually changed per +/// agent; each affected agent gets ONE combined `PermChange`, so the +/// dedup key collapses to `(kind, agent)` and an agent whose caps AND +/// groups both changed rebuilds once, not twice. The whole batch is +/// atomic: every change is validated up front and on any validation +/// error nothing is written or enqueued. +pub(super) async fn post_permissions( + State(state): State, + axum::Json(body): axum::Json, +) -> Response { + let known_caps: Vec<&str> = hive_sh4re::Capability::ALL + .iter() + .map(|c| c.as_str()) + .collect(); + // Phase 1 — validate everything before touching any file or the + // queue, so a bad entry fails the whole POST with zero side effects. + // No-op rows (both fields omitted) are skipped, not errors. + let mut staged: Vec = Vec::new(); + for change in &body.changes { + let logical = strip_container_prefix(&change.agent); + if let Some(reject) = guard_agent_name(&state, &logical).await { + return reject; + } + if let Some(groups) = &change.tool_groups + && let Err(e) = crate::tool_groups::validate_groups(groups) + { + return error_response(&format!("invalid tool-groups for {logical}: {e}")); + } + if let Some(caps) = &change.capabilities { + for cap in caps { + if !known_caps.contains(&cap.as_str()) { + return error_response(&format!("unknown capability for {logical}: {cap}")); + } + } + } + if change.tool_groups.is_some() || change.capabilities.is_some() { + staged.push(( + logical, + change.tool_groups.clone(), + change.capabilities.clone(), + )); + } + } + // Phase 2 — enqueue one combined PermChange per affected agent. + for (logical, groups, caps) in staged { + state.coord.rebuild_queue.enqueue_with_perm( + logical.clone(), + crate::rebuild_queue::QueueSource::Manual, + "batch permission change via permissions UI".to_owned(), + crate::rebuild_queue::PermPayload::Combined { groups, caps }, + ); + tracing::info!(agent = %logical, "operator: batch perm change via dashboard"); + } + state.coord.emit_rebuild_queue_snapshot(); + (StatusCode::OK, "ok").into_response() +} + #[cfg(test)] mod tests { use super::roster_and_effective; diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 51abe78e..401287a0 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -357,6 +357,47 @@ pub async fn commit_capabilities(agent: &str, caps: &[String]) -> Result<()> { Ok(()) } +/// Write both perm files for `agent` (whichever are `Some`) and commit +/// them in a SINGLE git commit under `META_LOCK` — the batch +/// `POST /api/permissions` path. A `None` field leaves that file +/// untouched. One commit + (caller does) one rebuild means changing an +/// agent's caps and tool-groups together no longer triggers two +/// rebuilds. Mirrors the staging discipline of `commit_tool_groups` / +/// `commit_capabilities`. +/// +/// # Errors +/// +/// Returns an error if writing either JSON file fails, a capability name +/// is invalid (`set_caps`), or a git stage/commit step fails. +pub async fn commit_perms( + agent: &str, + groups: Option<&[String]>, + caps: Option<&[String]>, +) -> Result<()> { + let _guard = META_LOCK.lock().await; + let dir = meta_dir(); + let mut parts: Vec<&str> = Vec::new(); + if let Some(groups) = groups { + crate::tool_groups::set_groups(agent, groups)?; + if crate::tool_groups::tool_groups_path().exists() { + git(&dir, &["add", "tool-groups.json"]).await?; + } + parts.push("tool-groups"); + } + if let Some(caps) = caps { + crate::capabilities::set_caps(agent, caps) + .map_err(|e| anyhow::anyhow!("set capabilities for {agent}: {e}"))?; + if crate::capabilities::capabilities_path().exists() { + git(&dir, &["add", "capabilities.json"]).await?; + } + parts.push("capabilities"); + } + if has_staged_changes(&dir).await? { + git_commit(&dir, &format!("set {} for {agent}", parts.join(" + "))).await?; + } + Ok(()) +} + /// Write the topology file and commit it atomically under `META_LOCK`. /// Returns `Err(String)` on validation failure (unknown agent, cycle, /// etc.) — same shape as `topology::set_parent` — so callers can diff --git a/hive-c0re/src/rebuild_queue.rs b/hive-c0re/src/rebuild_queue.rs index 394d515f..8b503484 100644 --- a/hive-c0re/src/rebuild_queue.rs +++ b/hive-c0re/src/rebuild_queue.rs @@ -69,6 +69,16 @@ pub enum PermPayload { ToolGroups { groups: Vec }, /// Set the capabilities for one agent (`capabilities.json`). Capabilities { caps: Vec }, + /// Set both perm-types for one agent in a single entry — the batch + /// `POST /api/permissions` path. Either field `None` leaves that + /// file untouched (no write, no commit); the worker commits whichever + /// are present in one git commit, then rebuilds once. Collapses the + /// dedup key to `(kind, agent)` so caps + groups for one agent + /// produce a single rebuild rather than two. + Combined { + groups: Option>, + caps: Option>, + }, } /// Where the enqueue request originated. Drives the "why" chip on the @@ -411,6 +421,9 @@ impl RebuildQueue { ) | ( Some(PermPayload::Capabilities { .. }), Some(PermPayload::Capabilities { .. }) + ) | ( + Some(PermPayload::Combined { .. }), + Some(PermPayload::Combined { .. }) ) | (None, None) ); if entry.state == QueueState::Queued @@ -791,6 +804,21 @@ async fn dispatch( .with_context(|| format!("commit capabilities for {name}"))?; coord.emit_capabilities_snapshot(); } + Some(PermPayload::Combined { groups, caps }) => { + // Batch perm change: commit whichever file(s) are + // present in a single git commit, then the rebuild + // below runs once — no double-rebuild for an agent + // whose caps AND groups both changed. + crate::meta::commit_perms(name, groups.as_deref(), caps.as_deref()) + .await + .with_context(|| format!("commit perms for {name}"))?; + if groups.is_some() { + coord.emit_tool_groups_snapshot(); + } + if caps.is_some() { + coord.emit_capabilities_snapshot(); + } + } None => { anyhow::bail!( "PermChange entry id={} agent={} is missing perm_payload",