feat(dashboard): batch POST /api/permissions for save-all perms (#1719)

This commit is contained in:
damocles 2026-06-17 18:08:19 +02:00
commit 8e24814efe
5 changed files with 163 additions and 0 deletions

View file

@ -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:

View file

@ -153,6 +153,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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(

View file

@ -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<Vec<String>>,
#[serde(default)]
capabilities: Option<Vec<String>>,
}
#[derive(Deserialize)]
pub(super) struct BatchPermsBody {
changes: Vec<PermChangeBody>,
}
/// A validated, non-empty change staged for enqueue:
/// `(logical agent, new groups?, new caps?)`.
type StagedPerm = (String, Option<Vec<String>>, Option<Vec<String>>);
/// 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<AppState>,
axum::Json(body): axum::Json<BatchPermsBody>,
) -> 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<StagedPerm> = 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;

View file

@ -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

View file

@ -69,6 +69,16 @@ pub enum PermPayload {
ToolGroups { groups: Vec<String> },
/// Set the capabilities for one agent (`capabilities.json`).
Capabilities { caps: Vec<String> },
/// 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<Vec<String>>,
caps: Option<Vec<String>>,
},
}
/// 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",