refactor(permissions): move ghost-perm detection server-side

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.
This commit is contained in:
iris 2026-06-27 17:55:47 +02:00
commit 4df286345a
3 changed files with 65 additions and 20 deletions

View file

@ -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;
}