fix(#1661): show default-perms agents with effective values in perms tab
This commit is contained in:
parent
24cbafc64a
commit
789ecd86f6
6 changed files with 193 additions and 24 deletions
|
|
@ -208,6 +208,16 @@ permission names fetched from the backend. The column list is
|
|||
authoritative — adding a new tool-group or capability to the backend
|
||||
requires no UI change; the new column appears automatically.
|
||||
|
||||
The snapshot carries `agents` (the full manageable roster — live
|
||||
containers ∪ agents with an explicit entry) and `effective` (per-agent
|
||||
explicit-or-role-default values) alongside the explicit `assignments`
|
||||
map. Rows come from `agents` so **agents on defaults always appear**
|
||||
(not just those with an explicit entry), and checkboxes reflect the
|
||||
`effective` values so a default agent shows the groups it actually runs
|
||||
with rather than blank — which also means saving it won't silently
|
||||
strip those defaults. The `(default)` badge keys off absence from
|
||||
`assignments` (no explicit entry).
|
||||
|
||||
Fetches fire on tab activation (not page-load) to avoid unnecessary
|
||||
work when the operator never visits this tab. Live mutations from the
|
||||
rebuild-queue worker are also pushed via the `capabilities_changed` /
|
||||
|
|
|
|||
|
|
@ -50,17 +50,21 @@ export async function fetchAndRenderCapabilities() {
|
|||
|
||||
function renderCapabilities(root, data) {
|
||||
root.replaceChildren();
|
||||
const { caps, descriptions = {}, assignments } = data;
|
||||
const { caps, descriptions = {}, assignments, effective = {} } = data;
|
||||
if (!caps || !caps.length) {
|
||||
root.append(el('p', { class: 'meta' }, '(no capabilities defined)'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent names: union of live containers + keys already in assignments.
|
||||
const agentNames = [...new Set([
|
||||
...Array.from(containersState.keys()),
|
||||
...Object.keys(assignments),
|
||||
])].sort();
|
||||
// Agent rows: prefer the backend roster (every manageable agent,
|
||||
// default-perms included). Fall back to the live-container ∪ explicit
|
||||
// union for older payloads that don't carry `agents`.
|
||||
const agentNames = (data.agents && data.agents.length)
|
||||
? [...data.agents]
|
||||
: [...new Set([
|
||||
...Array.from(containersState.keys()),
|
||||
...Object.keys(assignments),
|
||||
])].sort();
|
||||
|
||||
if (!agentNames.length) {
|
||||
root.append(el('p', { class: 'meta' }, '(no agents)'));
|
||||
|
|
@ -83,7 +87,9 @@ function renderCapabilities(root, data) {
|
|||
|
||||
const tbody = el('tbody');
|
||||
for (const name of agentNames) {
|
||||
const assigned = assignments[name] || [];
|
||||
// Effective caps (explicit-or-default) drive the checkboxes so
|
||||
// default-perms agents show their real grants, not blank.
|
||||
const assigned = effective[name] || assignments[name] || [];
|
||||
const tr = el('tr', { class: 'cap-row' });
|
||||
|
||||
// Agent name cell.
|
||||
|
|
@ -165,18 +171,20 @@ export async function fetchAndRenderToolGroups() {
|
|||
|
||||
function renderToolGroups(root, data) {
|
||||
root.replaceChildren();
|
||||
const { groups, descriptions = {}, assignments } = data;
|
||||
const { groups, descriptions = {}, assignments, effective = {} } = data;
|
||||
if (!groups || !groups.length) {
|
||||
root.append(el('p', { class: 'meta' }, '(no tool groups defined)'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent names: union of live containers + keys already in assignments,
|
||||
// sorted alphabetically.
|
||||
const agentNames = [...new Set([
|
||||
...Array.from(containersState.keys()),
|
||||
...Object.keys(assignments),
|
||||
])].sort();
|
||||
// Agent rows: prefer the backend roster (default-perms agents included);
|
||||
// fall back to the live-container ∪ explicit union for older payloads.
|
||||
const agentNames = (data.agents && data.agents.length)
|
||||
? [...data.agents]
|
||||
: [...new Set([
|
||||
...Array.from(containersState.keys()),
|
||||
...Object.keys(assignments),
|
||||
])].sort();
|
||||
|
||||
if (!agentNames.length) {
|
||||
root.append(el('p', { class: 'meta' }, '(no agents)'));
|
||||
|
|
@ -199,8 +207,11 @@ function renderToolGroups(root, data) {
|
|||
|
||||
const tbody = el('tbody');
|
||||
for (const name of agentNames) {
|
||||
// Explicit assignment or empty = using role default.
|
||||
const assigned = assignments[name] || [];
|
||||
// Effective groups (explicit-or-role-default) drive the checkboxes so
|
||||
// a default agent shows its real groups, not blank — and saving keeps
|
||||
// them instead of silently stripping the defaults. The "(default)"
|
||||
// badge still keys off explicit-assignment presence.
|
||||
const assigned = effective[name] || assignments[name] || [];
|
||||
const hasExplicit = Object.prototype.hasOwnProperty.call(assignments, name);
|
||||
const tr = el('tr', { class: 'tg-row' });
|
||||
|
||||
|
|
|
|||
|
|
@ -579,11 +579,19 @@ impl Coordinator {
|
|||
.map(|c| (c.as_str(), c.description()))
|
||||
.collect();
|
||||
let assignments = crate::capabilities::read();
|
||||
// Best-effort roster (sync path); on a contended cache miss we
|
||||
// emit explicit keys only — the HTTP refetch fills the rest in.
|
||||
let roster = self.live_container_names_blocking().unwrap_or_default();
|
||||
// Capability default is "no extra caps" — empty default slice.
|
||||
let (agents, effective) =
|
||||
crate::dashboard::permissions::roster_and_effective(roster, &assignments, &[]);
|
||||
self.emit_dashboard_event(DashboardEvent::CapabilitiesChanged {
|
||||
seq: self.next_seq(),
|
||||
caps,
|
||||
descriptions,
|
||||
assignments,
|
||||
agents,
|
||||
effective,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -598,11 +606,19 @@ impl Coordinator {
|
|||
.map(|g| (g.as_str(), g.description()))
|
||||
.collect();
|
||||
let assignments = crate::tool_groups::read();
|
||||
let roster = self.live_container_names_blocking().unwrap_or_default();
|
||||
let (agents, effective) = crate::dashboard::permissions::roster_and_effective(
|
||||
roster,
|
||||
&assignments,
|
||||
&crate::dashboard::permissions::tool_group_default_names(),
|
||||
);
|
||||
self.emit_dashboard_event(DashboardEvent::ToolGroupsChanged {
|
||||
seq: self.next_seq(),
|
||||
groups,
|
||||
descriptions,
|
||||
assignments,
|
||||
agents,
|
||||
effective,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ mod approvals;
|
|||
mod build_logs;
|
||||
mod journal;
|
||||
mod lifecycle_ops;
|
||||
mod permissions;
|
||||
pub(crate) mod permissions;
|
||||
mod questions;
|
||||
mod reminders;
|
||||
mod schedules;
|
||||
|
|
|
|||
|
|
@ -21,13 +21,21 @@ pub(super) struct ToolGroupsSnapshot {
|
|||
groups: Vec<&'static str>,
|
||||
/// Short description for each group name. Keys match `groups` entries.
|
||||
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||||
/// Per-agent assignment map. Absent agents use the role default
|
||||
/// (agents: messaging+meta+inbox+execution; manager: all groups).
|
||||
/// Per-agent *explicit* assignment map. Absent agents use the role
|
||||
/// default — the UI uses presence here to badge an agent "(default)".
|
||||
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||||
/// Every agent the operator can manage (live roster ∪ explicit keys),
|
||||
/// sorted. The UI lists rows from this so default-perms agents always
|
||||
/// appear without depending on a separately-loaded container list.
|
||||
agents: Vec<String>,
|
||||
/// Per-agent *effective* groups: the explicit entry when present, else
|
||||
/// the role default the harness actually applies. Drives the checkbox
|
||||
/// state so default agents show their real groups, not blank.
|
||||
effective: std::collections::BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
pub(super) async fn get_tool_groups(
|
||||
State(_state): State<AppState>,
|
||||
State(state): State<AppState>,
|
||||
) -> axum::Json<ToolGroupsSnapshot> {
|
||||
let groups = hive_sh4re::ToolGroup::ALL
|
||||
.iter()
|
||||
|
|
@ -38,13 +46,63 @@ pub(super) async fn get_tool_groups(
|
|||
.map(|g| (g.as_str(), g.description()))
|
||||
.collect();
|
||||
let assignments = crate::tool_groups::read();
|
||||
let roster = state
|
||||
.coord
|
||||
.containers_snapshot()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|c| c.name);
|
||||
let (agents, effective) =
|
||||
roster_and_effective(roster, &assignments, &tool_group_default_names());
|
||||
axum::Json(ToolGroupsSnapshot {
|
||||
groups,
|
||||
descriptions,
|
||||
assignments,
|
||||
agents,
|
||||
effective,
|
||||
})
|
||||
}
|
||||
|
||||
/// The role-default tool-group names the harness falls back to for an
|
||||
/// agent with no explicit entry (`ToolGroup::AGENT_DEFAULT`). Shared by
|
||||
/// the HTTP snapshot and the SSE emit so the effective values match what
|
||||
/// the container actually runs with.
|
||||
#[must_use]
|
||||
pub(crate) fn tool_group_default_names() -> Vec<&'static str> {
|
||||
hive_sh4re::ToolGroup::AGENT_DEFAULT
|
||||
.iter()
|
||||
.map(|g| g.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the `(agents, effective)` pair for a permissions snapshot.
|
||||
/// `agents` is the sorted union of the live roster and any agent that
|
||||
/// already has an explicit entry; `effective[agent]` is the explicit
|
||||
/// assignment when present, else `default` (the role fallback). Shared by
|
||||
/// the capabilities + tool-groups snapshots on both the HTTP and SSE
|
||||
/// paths so the two surfaces never drift.
|
||||
#[must_use]
|
||||
pub(crate) fn roster_and_effective(
|
||||
roster: impl IntoIterator<Item = String>,
|
||||
explicit: &std::collections::BTreeMap<String, Vec<String>>,
|
||||
default: &[&'static str],
|
||||
) -> (Vec<String>, std::collections::BTreeMap<String, Vec<String>>) {
|
||||
let mut names: std::collections::BTreeSet<String> = roster.into_iter().collect();
|
||||
names.extend(explicit.keys().cloned());
|
||||
let agents: Vec<String> = names.into_iter().collect();
|
||||
let effective = agents
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let v = explicit
|
||||
.get(a)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| default.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>());
|
||||
(a.clone(), v)
|
||||
})
|
||||
.collect();
|
||||
(agents, effective)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct SetToolGroupsBody {
|
||||
groups: Vec<String>,
|
||||
|
|
@ -87,12 +145,20 @@ pub(super) struct CapabilitiesSnapshot {
|
|||
caps: Vec<&'static str>,
|
||||
/// Short description for each capability name. Keys match `caps` entries.
|
||||
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||||
/// Per-agent capability grant map. Absent agents have no extra caps.
|
||||
/// Per-agent *explicit* capability grant map. Absent agents have no
|
||||
/// extra caps — the UI uses presence here to badge "(default)".
|
||||
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||||
/// Every agent the operator can manage (live roster ∪ explicit keys),
|
||||
/// sorted — so default agents always list.
|
||||
agents: Vec<String>,
|
||||
/// Per-agent *effective* caps: explicit entry when present, else the
|
||||
/// default (no caps). Keeps the snapshot shape symmetric with
|
||||
/// tool-groups; the default here is always empty.
|
||||
effective: std::collections::BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
pub(super) async fn get_capabilities(
|
||||
State(_state): State<AppState>,
|
||||
State(state): State<AppState>,
|
||||
) -> axum::Json<CapabilitiesSnapshot> {
|
||||
use hive_sh4re::Capability;
|
||||
let caps = Capability::ALL.iter().map(|c| c.as_str()).collect();
|
||||
|
|
@ -101,10 +167,20 @@ pub(super) async fn get_capabilities(
|
|||
.map(|c| (c.as_str(), c.description()))
|
||||
.collect();
|
||||
let assignments = crate::capabilities::read();
|
||||
let roster = state
|
||||
.coord
|
||||
.containers_snapshot()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|c| c.name);
|
||||
// Capability default is "no extra caps" — empty default slice.
|
||||
let (agents, effective) = roster_and_effective(roster, &assignments, &[]);
|
||||
axum::Json(CapabilitiesSnapshot {
|
||||
caps,
|
||||
descriptions,
|
||||
assignments,
|
||||
agents,
|
||||
effective,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -146,3 +222,45 @@ pub(super) async fn post_capabilities(
|
|||
tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard");
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::roster_and_effective;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn effective_unions_roster_and_explicit_and_applies_default() {
|
||||
let mut explicit: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
explicit.insert("iris".to_owned(), vec!["messaging".to_owned()]);
|
||||
// `ruth` has an explicit entry but isn't in the live roster (e.g.
|
||||
// momentarily not listed) — it must still appear and keep its
|
||||
// explicit value.
|
||||
explicit.insert(
|
||||
"ruth".to_owned(),
|
||||
vec!["messaging".to_owned(), "lifecycle".to_owned()],
|
||||
);
|
||||
let roster = ["damocles".to_owned(), "iris".to_owned()];
|
||||
let default = ["messaging", "meta", "inbox", "execution"];
|
||||
|
||||
let (agents, effective) = roster_and_effective(roster, &explicit, &default);
|
||||
|
||||
// Sorted union of roster + explicit keys.
|
||||
assert_eq!(agents, vec!["damocles", "iris", "ruth"]);
|
||||
// Entry-less agent (damocles) → role default.
|
||||
assert_eq!(
|
||||
effective["damocles"],
|
||||
vec!["messaging", "meta", "inbox", "execution"]
|
||||
);
|
||||
// Explicit entries kept verbatim.
|
||||
assert_eq!(effective["iris"], vec!["messaging"]);
|
||||
assert_eq!(effective["ruth"], vec!["messaging", "lifecycle"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_default_yields_empty_effective_for_entryless() {
|
||||
let explicit: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
let (agents, effective) = roster_and_effective(["atlas".to_owned()], &explicit, &[]);
|
||||
assert_eq!(agents, vec!["atlas"]);
|
||||
assert!(effective["atlas"].is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,8 +231,13 @@ pub enum DashboardEvent {
|
|||
caps: Vec<&'static str>,
|
||||
/// Short description for each capability name (tooltip).
|
||||
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||||
/// Per-agent capability grant map; absent agents have no extra caps.
|
||||
/// Per-agent *explicit* capability grant map; absent agents have
|
||||
/// no extra caps (the UI badges those "(default)").
|
||||
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||||
/// Sorted roster the operator can manage (live ∪ explicit keys).
|
||||
agents: Vec<String>,
|
||||
/// Per-agent *effective* caps (explicit-or-default; default empty).
|
||||
effective: std::collections::BTreeMap<String, Vec<String>>,
|
||||
},
|
||||
/// Full snapshot of tool-group assignments (per-agent `Vec<group_name>`).
|
||||
/// Emitted from the rebuild-queue worker after a `PermChange`
|
||||
|
|
@ -244,8 +249,13 @@ pub enum DashboardEvent {
|
|||
groups: Vec<&'static str>,
|
||||
/// Short description for each group name (tooltip).
|
||||
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||||
/// Per-agent assignment map; absent agents use the role default.
|
||||
/// Per-agent *explicit* assignment map; absent agents use the role
|
||||
/// default (the UI badges those "(default)").
|
||||
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||||
/// Sorted roster the operator can manage (live ∪ explicit keys).
|
||||
agents: Vec<String>,
|
||||
/// Per-agent *effective* groups (explicit-or-`AGENT_DEFAULT`).
|
||||
effective: std::collections::BTreeMap<String, Vec<String>>,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -413,12 +423,16 @@ mod tests {
|
|||
caps: Vec::new(),
|
||||
descriptions: std::collections::BTreeMap::new(),
|
||||
assignments: std::collections::BTreeMap::new(),
|
||||
agents: Vec::new(),
|
||||
effective: std::collections::BTreeMap::new(),
|
||||
},
|
||||
DashboardEvent::ToolGroupsChanged {
|
||||
seq: 1,
|
||||
groups: Vec::new(),
|
||||
descriptions: std::collections::BTreeMap::new(),
|
||||
assignments: std::collections::BTreeMap::new(),
|
||||
agents: Vec::new(),
|
||||
effective: std::collections::BTreeMap::new(),
|
||||
},
|
||||
DashboardEvent::AuditEntryAdded {
|
||||
seq: 1,
|
||||
|
|
|
|||
Loading…
Reference in a new issue