diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 002bd8ec..6b13add1 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -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` / diff --git a/frontend/packages/dashboard/src/permissions.js b/frontend/packages/dashboard/src/permissions.js index cff5658d..ae781275 100644 --- a/frontend/packages/dashboard/src/permissions.js +++ b/frontend/packages/dashboard/src/permissions.js @@ -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' }); diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 8e632edf..cff91a2f 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -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, }); } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index be68a6db..187c93a4 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -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; diff --git a/hive-c0re/src/dashboard/permissions.rs b/hive-c0re/src/dashboard/permissions.rs index b2923878..3f07a6e5 100644 --- a/hive-c0re/src/dashboard/permissions.rs +++ b/hive-c0re/src/dashboard/permissions.rs @@ -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>, + /// 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, + /// 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>, } pub(super) async fn get_tool_groups( - State(_state): State, + State(state): State, ) -> axum::Json { 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, + explicit: &std::collections::BTreeMap>, + default: &[&'static str], +) -> (Vec, std::collections::BTreeMap>) { + let mut names: std::collections::BTreeSet = roster.into_iter().collect(); + names.extend(explicit.keys().cloned()); + let agents: Vec = 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::>()); + (a.clone(), v) + }) + .collect(); + (agents, effective) +} + #[derive(Deserialize)] pub(super) struct SetToolGroupsBody { groups: Vec, @@ -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>, + /// Every agent the operator can manage (live roster ∪ explicit keys), + /// sorted — so default agents always list. + agents: Vec, + /// 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>, } pub(super) async fn get_capabilities( - State(_state): State, + State(state): State, ) -> axum::Json { 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> = 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> = BTreeMap::new(); + let (agents, effective) = roster_and_effective(["atlas".to_owned()], &explicit, &[]); + assert_eq!(agents, vec!["atlas"]); + assert!(effective["atlas"].is_empty()); + } +} diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index d13f5a77..7cf02437 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -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>, + /// Sorted roster the operator can manage (live ∪ explicit keys). + agents: Vec, + /// Per-agent *effective* caps (explicit-or-default; default empty). + effective: std::collections::BTreeMap>, }, /// Full snapshot of tool-group assignments (per-agent `Vec`). /// 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>, + /// Sorted roster the operator can manage (live ∪ explicit keys). + agents: Vec, + /// Per-agent *effective* groups (explicit-or-`AGENT_DEFAULT`). + effective: std::collections::BTreeMap>, }, } @@ -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,