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:
parent
6ab0757cc6
commit
4df286345a
3 changed files with 65 additions and 20 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,10 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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),
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
pub(super) async fn get_stale_permissions(
|
||||
State(state): State<AppState>,
|
||||
) -> axum::Json<StalePermsResponse> {
|
||||
// Live container names — includes stopped-but-configured containers.
|
||||
let live: std::collections::HashSet<String> = 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<String> =
|
||||
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<String> = caps
|
||||
.keys()
|
||||
.chain(tgs.keys())
|
||||
.filter(|n| !known.contains(n))
|
||||
.cloned()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.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
|
||||
|
|
|
|||
Loading…
Reference in a new issue