From 5ee65d2f15e9da2b7d7bed25cfa121948744abcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 19:55:27 +0200 Subject: [PATCH] dashboard: K3PT ST4T3 section + agent links open in new tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new section between containers and questions: lists every name with a state dir under /var/lib/hyperhive/agents/ that doesn't correspond to a live container. shows state size + last-modified age + whether claude creds are kept. two actions per row: - R3V1V3 — queues a spawn approval with the same name (operator approves to recreate; spawn flow reuses prior config + claude creds, no re-login needed) - PURG3 — wipes the agent's state + applied dirs (post /purge-tombstone/ endpoint; refuses if a live container with that name still exists) dashboard also opens agent links in new tabs now (target=_blank + rel=noopener) so the operator's overview tab stays put when they dive into an agent. --- hive-c0re/assets/app.js | 66 +++++++++++++++- hive-c0re/assets/dashboard.css | 10 +++ hive-c0re/assets/index.html | 6 ++ hive-c0re/src/coordinator.rs | 19 +++++ hive-c0re/src/dashboard.rs | 112 ++++++++++++++++++++++++++++ hive-c0re/src/operator_questions.rs | 2 +- 6 files changed, 212 insertions(+), 3 deletions(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 33ed880..0596b6b 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -126,7 +126,7 @@ // ── line 1: identity ───────────────────────────────────────── const head = el('div', { class: 'head' }); head.append( - el('a', { class: 'name', href: url }, c.name), + el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name), el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, c.is_manager ? 'm1nd' : 'ag3nt'), ); @@ -135,7 +135,8 @@ el('span', { class: 'spinner' }, '◐'), ' ', c.pending + '…')); } else if (c.needs_login) { head.append(el('a', - { class: 'badge badge-warn', href: url }, 'needs login →')); + { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' }, + 'needs login →')); } if (c.needs_update) { head.append(form( @@ -182,6 +183,66 @@ root.append(ul); } + function renderTombstones(s) { + const root = $('tombstones-section'); + root.innerHTML = ''; + if (!s.tombstones || !s.tombstones.length) { + root.append(el('p', { class: 'empty' }, 'no kept state — clean')); + return; + } + const fmtBytes = (n) => { + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB'; + return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; + }; + const fmtAge = (ts) => { + if (!ts) return '?'; + const d = Math.floor((Date.now() / 1000 - ts) / 86400); + if (d <= 0) return 'today'; + if (d === 1) return '1 day ago'; + return d + ' days ago'; + }; + const ul = el('ul', { class: 'containers' }); + for (const t of s.tombstones) { + const li = el('li', { class: 'container-row tombstone' }); + const head = el('div', { class: 'head' }); + head.append( + el('span', { class: 'name' }, t.name), + el('span', { class: 'badge badge-muted' }, 'destroyed'), + ); + if (t.has_creds) { + head.append(el('span', { class: 'badge badge-muted' }, 'creds kept')); + } + head.append(el('span', { class: 'meta' }, + `${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`)); + li.append(head); + + const actions = el('div', { class: 'actions' }); + // Reuse the existing spawn form pattern via /request-spawn — operator + // can queue an approval that recreates the agent with the same name + // and reuses the kept state. + const respawn = el('form', { + method: 'POST', action: '/request-spawn', + class: 'inline', 'data-async': '', + 'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.', + }); + respawn.append( + el('input', { type: 'hidden', name: 'name', value: t.name }), + el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'), + ); + actions.append(respawn); + actions.append(form( + '/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3', + 'PURGE ' + t.name + '? config history, claude creds, /state/ notes ' + + 'are all WIPED. no undo.', + )); + li.append(actions); + ul.append(li); + } + root.append(ul); + } + function renderQuestions(s) { const root = $('questions-section'); root.innerHTML = ''; @@ -331,6 +392,7 @@ if (!resp.ok) throw new Error('http ' + resp.status); const s = await resp.json(); renderContainers(s); + renderTombstones(s); renderQuestions(s); renderInbox(s); renderApprovals(s); diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 08479d8..1e0792a 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -133,6 +133,16 @@ a:hover { color: var(--amber); border-color: var(--amber); text-shadow: 0 0 6px rgba(250, 179, 135, 0.5); } +.badge-muted { + color: var(--muted); border-color: var(--purple-dim); + background: rgba(127, 132, 156, 0.08); +} +.container-row.tombstone { + border-style: dashed; + background: rgba(24, 24, 37, 0.35); + opacity: 0.85; +} +.container-row.tombstone .name { color: var(--muted); } .pending-state { color: var(--amber); font-size: 0.85em; diff --git a/hive-c0re/assets/index.html b/hive-c0re/assets/index.html index 838591a..26e7513 100644 --- a/hive-c0re/assets/index.html +++ b/hive-c0re/assets/index.html @@ -16,6 +16,12 @@

loading…

+

◆ K3PT ST4T3 ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
+

◆ M1ND H4S QU3STI0NS ◆

══════════════════════════════════════════════════════════════
diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index ed44bea..b6df8ac 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -204,4 +204,23 @@ impl Coordinator { pub fn agent_applied_dir(name: &str) -> PathBuf { PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}")) } + + /// Enumerate names that have a persistent state dir under + /// `/var/lib/hyperhive/agents/` (i.e. config / claude creds / + /// notes survive). Includes both currently-existing containers and + /// destroyed-but-kept tombstones; callers filter the latter by + /// subtracting `lifecycle::list()`. + #[must_use] + pub fn kept_state_names() -> Vec { + let Ok(rd) = std::fs::read_dir(AGENT_STATE_ROOT) else { + return Vec::new(); + }; + let mut out: Vec = rd + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + out.sort(); + out + } } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index c5f9334..2a540e3 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -51,6 +51,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/rebuild/{name}", post(post_rebuild)) .route("/update-all", post(post_update_all)) .route("/answer-question/{id}", post(post_answer_question)) + .route("/purge-tombstone/{name}", post(post_purge_tombstone)) .route("/request-spawn", post(post_request_spawn)) .route("/messages/stream", get(messages_stream)) .with_state(AppState { coord }); @@ -106,6 +107,21 @@ struct StateSnapshot { /// we mark the row answered and fire `HelperEvent::OperatorAnswered` /// into the manager's inbox. questions: Vec, + /// State dirs (config history + claude creds + /state/ notes) that + /// survive after a destroy-without-purge. The operator can re-spawn + /// with the same name to resume, or PURG3 to wipe them. + tombstones: Vec, +} + +#[derive(Serialize)] +struct TombstoneView { + name: String, + /// Bytes used by the state dir tree. Cheap-ish to compute; let the + /// operator know how much they're holding onto. + state_bytes: u64, + /// Mtime (unix seconds) of the state dir; rough "last seen". + last_seen: i64, + has_creds: bool, } #[derive(Serialize)] @@ -145,6 +161,7 @@ struct ApprovalView { diff_html: Option, } +#[allow(clippy::too_many_lines)] async fn api_state(headers: HeaderMap, State(state): State) -> axum::Json { let host = headers .get("host") @@ -242,6 +259,35 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J .unwrap_or_default(); let questions = state.coord.questions.pending().unwrap_or_default(); + // Tombstones: state-dir names that don't appear in the live container + // list (and aren't the manager). Operator can re-spawn or PURG3. + let live: std::collections::HashSet = containers + .iter() + .map(|c| c.name.clone()) + .chain(state.coord.transient_snapshot().into_keys()) + .collect(); + let tombstones: Vec = Coordinator::kept_state_names() + .into_iter() + .filter(|name| name != MANAGER_NAME && !live.contains(name)) + .map(|name| { + let root = Coordinator::agent_state_root(&name); + let state_bytes = dir_size_bytes(&root); + let last_seen = std::fs::metadata(&root) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .and_then(|d| i64::try_from(d.as_secs()).ok()) + .unwrap_or(0); + let has_creds = claude_has_session(&Coordinator::agent_claude_dir(&name)); + TombstoneView { + name, + state_bytes, + last_seen, + has_creds, + } + }) + .collect(); + axum::Json(StateSnapshot { hostname, manager_port: MANAGER_PORT, @@ -251,9 +297,33 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J approvals: approval_views, operator_inbox, questions, + tombstones, }) } +/// Sum the byte size of every regular file under `root`. Cheap to compute +/// for typical agent state (config repo + claude creds + notes file — +/// usually a few MB); fine to do inline on each /api/state. Returns 0 on +/// any error. +fn dir_size_bytes(root: &Path) -> u64 { + fn walk(p: &Path, acc: &mut u64) { + let Ok(rd) = std::fs::read_dir(p) else { return }; + for entry in rd.flatten() { + let Ok(ft) = entry.file_type() else { continue }; + if ft.is_dir() { + walk(&entry.path(), acc); + } else if ft.is_file() + && let Ok(meta) = entry.metadata() + { + *acc += meta.len(); + } + } + } + let mut total = 0u64; + walk(root, &mut total); + total +} + async fn messages_stream( State(state): State, ) -> Sse>> { @@ -316,6 +386,48 @@ async fn post_answer_question( } } +async fn post_purge_tombstone( + State(state): State, + AxumPath(name): AxumPath, +) -> Response { + if name == lifecycle::MANAGER_NAME { + return error_response("refusing to purge the manager's state"); + } + // Sanity: refuse to purge if a live container still exists with this + // name. The dashboard already filters tombstones to non-live names, + // but the operator could send a stale POST. + let live = lifecycle::list().await.unwrap_or_default(); + if live + .iter() + .any(|c| c == &format!("{}{name}", lifecycle::AGENT_PREFIX) || c == &name) + { + return error_response(&format!( + "refusing to purge {name}: container still exists — use DESTR0Y first" + )); + } + let mut errors = Vec::new(); + for dir in [ + Coordinator::agent_state_root(&name), + Coordinator::agent_applied_dir(&name), + ] { + if dir.exists() + && let Err(e) = std::fs::remove_dir_all(&dir) + { + errors.push(format!("{}: {e}", dir.display())); + } + } + let _ = state + .coord + .approvals + .fail_pending_for_agent(&name, "agent state purged"); + if errors.is_empty() { + tracing::info!(%name, "tombstone purged"); + Redirect::to("/").into_response() + } else { + error_response(&format!("purge {name} partial: {}", errors.join(", "))) + } +} + async fn post_request_spawn( State(state): State, Form(form): Form, diff --git a/hive-c0re/src/operator_questions.rs b/hive-c0re/src/operator_questions.rs index cfb8b4f..7ece4fc 100644 --- a/hive-c0re/src/operator_questions.rs +++ b/hive-c0re/src/operator_questions.rs @@ -26,7 +26,7 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending "; /// Add the `multi` column to pre-existing databases. `ALTER TABLE ADD COLUMN` -/// has no `IF NOT EXISTS` form in sqlite, so we check pragma_table_info first. +/// has no `IF NOT EXISTS` form in sqlite, so we check `pragma_table_info` first. fn ensure_multi_column(conn: &Connection) -> Result<()> { let has: bool = conn .prepare("SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = 'multi'")?