feat(dashboard): batch POST /api/permissions for save-all perms (#1719)
This commit is contained in:
parent
75dbcc2aa2
commit
8e24814efe
5 changed files with 163 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue