From 4df286345a4e40df8fc544f4b6024b0f296b5625 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 27 Jun 2026 17:55:47 +0200 Subject: [PATCH] refactor(permissions): move ghost-perm detection server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /api/permissions/stale endpoint that returns agent names with explicit capability/tool-group JSON entries but no live container AND no kept-state tombstone. Ghost detection is now entirely server-side — one authoritative call, no client-side roster cache, no staleness window. The previous client-side approach in core.js made three parallel API calls (GET /api/capabilities, GET /api/tool-groups, GET /api/state) and filtered the result against a module-level `liveContainerNames` Set populated only on cold load and form submits. Any container lifecycle event (spawn, destroy) while core.html was open left `liveContainerNames` stale, risking a false-positive ghost entry for a live container. Changes: - permissions.rs: add `get_stale_permissions` handler + `StalePermsResponse` struct. Computes live roster (containers_snapshot), tombstone set (Coordinator::kept_state_names), explicit perm names (capabilities::read + tool_groups::read), then returns the difference sorted. - dashboard.rs: register GET /api/permissions/stale. - core.js: replace the three-call client-side logic in `fetchAndRenderStalePerms` with a single fetch to /api/permissions/stale. Remove `liveContainerNames` state + its syncFromSnapshot population. --- frontend/packages/dashboard/src/core.js | 35 ++++++++----------- hive-c0re/src/dashboard.rs | 4 +++ hive-c0re/src/dashboard/permissions.rs | 46 +++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 20 deletions(-) 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