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;
|
`HIVE_CAPABILITIES` takes effect. Agent name validated;
|
||||||
unknown capability strings are rejected (400). `guard_agent_name`
|
unknown capability strings are rejected (400). `guard_agent_name`
|
||||||
applied.
|
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
|
- `GET /api/schedules` — list all schedules (active and
|
||||||
recently cancelled) for the SCH3DUL3S scheduled-prompts panel.
|
recently cancelled) for the SCH3DUL3S scheduled-prompts panel.
|
||||||
- `POST /api/schedules` — operator-direct schedule create:
|
- `POST /api/schedules` — operator-direct schedule create:
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
"/api/capabilities/{agent}",
|
"/api/capabilities/{agent}",
|
||||||
post(permissions::post_capabilities),
|
post(permissions::post_capabilities),
|
||||||
)
|
)
|
||||||
|
.route("/api/permissions", post(permissions::post_permissions))
|
||||||
.route("/op-send", post(post_op_send))
|
.route("/op-send", post(post_op_send))
|
||||||
.route("/meta-update", post(post_meta_update))
|
.route("/meta-update", post(post_meta_update))
|
||||||
.route(
|
.route(
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,86 @@ pub(super) async fn post_capabilities(
|
||||||
(StatusCode::OK, "ok").into_response()
|
(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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::roster_and_effective;
|
use super::roster_and_effective;
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,47 @@ pub async fn commit_capabilities(agent: &str, caps: &[String]) -> Result<()> {
|
||||||
Ok(())
|
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`.
|
/// Write the topology file and commit it atomically under `META_LOCK`.
|
||||||
/// Returns `Err(String)` on validation failure (unknown agent, cycle,
|
/// Returns `Err(String)` on validation failure (unknown agent, cycle,
|
||||||
/// etc.) — same shape as `topology::set_parent` — so callers can
|
/// etc.) — same shape as `topology::set_parent` — so callers can
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,16 @@ pub enum PermPayload {
|
||||||
ToolGroups { groups: Vec<String> },
|
ToolGroups { groups: Vec<String> },
|
||||||
/// Set the capabilities for one agent (`capabilities.json`).
|
/// Set the capabilities for one agent (`capabilities.json`).
|
||||||
Capabilities { caps: Vec<String> },
|
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
|
/// 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::Capabilities { .. })
|
Some(PermPayload::Capabilities { .. })
|
||||||
|
) | (
|
||||||
|
Some(PermPayload::Combined { .. }),
|
||||||
|
Some(PermPayload::Combined { .. })
|
||||||
) | (None, None)
|
) | (None, None)
|
||||||
);
|
);
|
||||||
if entry.state == QueueState::Queued
|
if entry.state == QueueState::Queued
|
||||||
|
|
@ -791,6 +804,21 @@ async fn dispatch(
|
||||||
.with_context(|| format!("commit capabilities for {name}"))?;
|
.with_context(|| format!("commit capabilities for {name}"))?;
|
||||||
coord.emit_capabilities_snapshot();
|
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 => {
|
None => {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"PermChange entry id={} agent={} is missing perm_payload",
|
"PermChange entry id={} agent={} is missing perm_payload",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue