diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js index 41f9d9f0..bcd74870 100644 --- a/frontend/packages/dashboard/src/core.js +++ b/frontend/packages/dashboard/src/core.js @@ -20,15 +20,11 @@ let metaInputsState = []; let metaUpdateRunning = false; let tombstonesState = []; let rebuildQueueState = []; -// Live container names — used by the stale-perms ghost filter. -let liveContainerNames = new Set(); - function syncFromSnapshot(s) { metaInputsState = (s.meta_inputs || []).slice(); metaUpdateRunning = !!s.meta_update_running; tombstonesState = (s.tombstones || []).slice(); rebuildQueueState = (s.rebuild_queue || []).slice(); - liveContainerNames = new Set((s.containers || []).map((c) => c.name)); } // ─── meta inputs ────────────────────────────────────────────────────────── @@ -452,8 +448,11 @@ function renderTombstones(s) { // ─── stale permission entries (K3PT ST4T3 pane sub-section) ────────────── // Agents with explicit capability / tool-group JSON entries but no live -// container (e.g. renamed agents like the old "root" manager name). Lazy- -// loaded on first "kept" tab activation; refreshed when perm data changes. +// container AND no kept-state tombstone (e.g. renamed agents like the old +// "root" manager name). Ghost detection is server-side via +// GET /api/permissions/stale so the client doesn't need to maintain a +// container-roster cache or perform set arithmetic. Lazy-loaded on first +// "kept" tab activation; refreshed when perm data changes via SSE. let stalePermsLoaded = false; function renderStalePerms(root, ghosts) { @@ -462,7 +461,7 @@ function renderStalePerms(root, ghosts) { root.append(el('p', { class: 'tombstones-stale-heading' }, 'stale permission entries')); root.append(el('p', { class: 'meta' }, 'agents with explicit capability or tool-group entries but no live container ' - + '(typically renamed or manually-deleted agents whose JSON entries persisted).')); + + 'or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).')); const errP = el('p', { class: 'tombstones-stale-err', hidden: true }); const ul = el('ul', { class: 'tombstones-stale-list' }); for (const name of ghosts) { @@ -500,25 +499,21 @@ function renderStalePerms(root, ghosts) { root.append(ul, errP); } +// Ghost detection is entirely server-side: GET /api/permissions/stale +// returns the computed list of agent names that have explicit JSON entries +// but are absent from both the live roster and the kept-state tombstones. +// One call, no client-side roster cache, always authoritative. async function fetchAndRenderStalePerms() { const root = $('tombstones-stale-perms'); if (!root) return; try { - const [capsResp, tgResp] = await Promise.all([ - fetch('/api/capabilities'), - fetch('/api/tool-groups'), - ]); - const capsData = capsResp.ok ? await capsResp.json() : { assignments: {} }; - const tgData = tgResp.ok ? await tgResp.json() : { assignments: {} }; - const explicit = new Set([ - ...Object.keys(capsData.assignments || {}), - ...Object.keys(tgData.assignments || {}), - ]); - const ghosts = [...explicit].filter((n) => !liveContainerNames.has(n)).sort(); - renderStalePerms(root, ghosts); + const resp = await fetch('/api/permissions/stale'); + if (!resp.ok) throw new Error('http ' + resp.status); + const data = await resp.json(); + renderStalePerms(root, data.stale || []); } catch (err) { root.replaceChildren(); - root.append(el('p', { class: 'meta' }, 'failed to load perm data: ' + err)); + root.append(el('p', { class: 'meta' }, 'failed to load stale perm data: ' + err)); } stalePermsLoaded = true; } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 693c517a..c6da06a8 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -115,6 +115,10 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { post(permissions::post_capabilities), ) .route("/api/permissions", post(permissions::post_permissions)) + .route( + "/api/permissions/stale", + get(permissions::get_stale_permissions), + ) .route( "/api/permissions/{agent}", axum::routing::delete(permissions::delete_agent_permissions), diff --git a/hive-c0re/src/dashboard/permissions.rs b/hive-c0re/src/dashboard/permissions.rs index 87031e5d..1b62dd20 100644 --- a/hive-c0re/src/dashboard/permissions.rs +++ b/hive-c0re/src/dashboard/permissions.rs @@ -312,6 +312,52 @@ pub(super) async fn post_permissions( Ok((StatusCode::OK, "ok").into_response()) } +/// Agent names that have explicit capability/tool-group entries but are +/// not in the live container roster AND not in the kept-state tombstone +/// list (i.e. truly gone — renamed or destroyed agents whose JSON entries +/// persisted). The client uses this to drive the "stale permission entries" +/// sub-section in K3PT ST4T3 without having to fetch three separate +/// endpoints and perform set arithmetic on the client side. +#[derive(Serialize)] +pub(super) struct StalePermsResponse { + /// Ghost agent names, sorted. Empty list → no stale entries. + stale: Vec, +} + +pub(super) async fn get_stale_permissions( + State(state): State, +) -> axum::Json { + // Live container names — includes stopped-but-configured containers. + let live: std::collections::HashSet = state + .coord + .containers_snapshot() + .await + .into_iter() + .map(|c| c.name) + .collect(); + // Kept-state directories — any name here is a tombstone, not a ghost. + let tombstones: std::collections::HashSet = + crate::coordinator::Coordinator::kept_state_names() + .into_iter() + .collect(); + // Known = live roster ∪ tombstones. + let known: std::collections::HashSet<&String> = + live.iter().chain(tombstones.iter()).collect(); + // Explicit entries in either JSON file. + let caps = crate::capabilities::read(); + let tgs = crate::tool_groups::read(); + let mut ghost_names: Vec = caps + .keys() + .chain(tgs.keys()) + .filter(|n| !known.contains(n)) + .cloned() + .collect::>() + .into_iter() + .collect(); + ghost_names.sort(); + axum::Json(StalePermsResponse { stale: ghost_names }) +} + /// Clear all explicit permission entries for a named agent without /// requiring it to exist in the live roster. Used by the P3RM1SS10NS /// tab's "remove" button for agents that have stale explicit entries