From 189fc587a4952426b35ceff4c42851c115e88384 Mon Sep 17 00:00:00 2001 From: iris Date: Thu, 21 May 2026 18:14:53 +0200 Subject: [PATCH 1/2] fix: handle init_config approval kind in row deserializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit row_to_approval matched only apply_commit + spawn, so any approvals row with kind=init_config (added by 80dd5bb's two-step spawn) failed to deserialize. pending() / recent_resolved() collect all-or-nothing via collect::>(), so one bad row errored the whole query; api_state's log_default then swallowed the error and returned an empty list — every pending approval vanished from the dashboard (issue #160). - add the missing init_config arm to row_to_approval - collect_lenient(): skip + log unparseable rows so a single bad row can never blank the whole approvals list again - dashboard: label init_config approvals 'init' (was mislabeled 'spawn' by the apply-vs-other fallthrough) closes #160 --- hive-c0re/assets/app.js | 11 ++++++++--- hive-c0re/src/approvals.rs | 26 ++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 664eb692..96d94140 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -273,7 +273,9 @@ for (const a of approvals) { if (seenApprovals.has(a.id)) continue; seenApprovals.add(a.id); - const verb = a.kind === 'spawn' ? 'spawn approval' : 'config commit'; + const verb = a.kind === 'spawn' ? 'spawn approval' + : a.kind === 'init_config' ? 'config-init approval' + : 'config commit'; NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`, 'hyperhive:approval:' + a.id); } @@ -1227,6 +1229,7 @@ const ul = el('ul', { class: 'approvals' }); for (const a of pending) { const isApply = a.kind === 'apply_commit'; + const isInit = a.kind === 'init_config'; const li = el('li', { class: 'approval-card' }); // ── identity header ────────────────────────────────────────── @@ -1235,7 +1238,7 @@ el('span', { class: 'id' }, '#' + a.id), el('span', { class: 'agent' }, a.agent), el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') }, - isApply ? 'apply' : 'spawn'), + isApply ? 'apply' : isInit ? 'init' : 'spawn'), ); if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short)); li.append(head); @@ -1261,7 +1264,9 @@ body.append(drill); } else { body.append(el('span', { class: 'meta' }, - 'new sub-agent — container will be created on approve')); + isInit + ? 'scaffold proposed config repo — manager customises agent.nix before spawn' + : 'new sub-agent — container will be created on approve')); } li.append(body); diff --git a/hive-c0re/src/approvals.rs b/hive-c0re/src/approvals.rs index 9b696c5c..815ecf7b 100644 --- a/hive-c0re/src/approvals.rs +++ b/hive-c0re/src/approvals.rs @@ -137,8 +137,7 @@ impl Approvals { LIMIT ?1", )?; let rows = stmt.query_map([limit], row_to_approval)?; - rows.collect::>>() - .map_err(Into::into) + Ok(collect_lenient(rows)) } pub fn pending(&self) -> Result> { @@ -150,8 +149,7 @@ impl Approvals { ORDER BY id ASC", )?; let rows = stmt.query_map([], row_to_approval)?; - rows.collect::>>() - .map_err(Into::into) + Ok(collect_lenient(rows)) } pub fn get(&self, id: i64) -> Result> { @@ -261,12 +259,32 @@ impl Approvals { } } +/// Collect approval rows, dropping (and logging) any that fail to +/// deserialize. A single malformed / unknown-kind row must never blank +/// the whole list: `collect::>()` is all-or-nothing, so one +/// bad row used to make `pending()` / `recent_resolved()` error out +/// wholesale — the dashboard then rendered an empty approvals queue +/// (issue #160, an unhandled `init_config` kind poisoning every read). +fn collect_lenient( + rows: impl Iterator>, +) -> Vec { + rows.filter_map(|r| match r { + Ok(a) => Some(a), + Err(e) => { + tracing::warn!(error = ?e, "skipping unparseable approval row"); + None + } + }) + .collect() +} + fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { // Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description. let kind: String = row.get(2)?; let kind = match kind.as_str() { "apply_commit" => ApprovalKind::ApplyCommit, "spawn" => ApprovalKind::Spawn, + "init_config" => ApprovalKind::InitConfig, other => { return Err(rusqlite::Error::FromSqlConversionFailure( 2, From fefa91a39e95f35ce5d4eca494d4e44d5bdf74e1 Mon Sep 17 00:00:00 2001 From: iris Date: Thu, 21 May 2026 18:20:15 +0200 Subject: [PATCH 2/2] test: cover init_config approval deser + lenient row collection --- hive-c0re/src/approvals.rs | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/hive-c0re/src/approvals.rs b/hive-c0re/src/approvals.rs index 815ecf7b..ba9f9f2b 100644 --- a/hive-c0re/src/approvals.rs +++ b/hive-c0re/src/approvals.rs @@ -345,3 +345,68 @@ fn now_unix() -> i64 { .and_then(|d| i64::try_from(d.as_secs()).ok()) .unwrap_or(0) } + +#[cfg(test)] +mod tests { + use super::*; + use hive_sh4re::ApprovalKind; + + fn open_temp() -> (tempfile::TempDir, std::path::PathBuf, Approvals) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("approvals.sqlite"); + let db = Approvals::open(&path).expect("open approvals db"); + (dir, path, db) + } + + #[test] + fn init_config_approval_round_trips() { + // Regression for #160: an `init_config` row used to fail + // deserialization (row_to_approval matched only apply_commit + + // spawn), erroring out the whole `pending()` query — every + // approval then vanished from the dashboard. + let (_dir, _path, db) = open_temp(); + let id = db + .submit_kind("bitburner", ApprovalKind::InitConfig, "", Some("scaffold")) + .expect("submit init_config"); + let pending = db + .pending() + .expect("pending() must not error on an init_config row"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, id); + assert!(matches!(pending[0].kind, ApprovalKind::InitConfig)); + } + + #[test] + fn mixed_kinds_all_listed() { + let (_dir, _path, db) = open_temp(); + db.submit_kind("a", ApprovalKind::ApplyCommit, "deadbeef", None) + .unwrap(); + db.submit_kind("b", ApprovalKind::Spawn, "", None).unwrap(); + db.submit_kind("c", ApprovalKind::InitConfig, "", None) + .unwrap(); + let pending = db.pending().expect("pending"); + assert_eq!(pending.len(), 3, "all three kinds must be visible"); + } + + #[test] + fn unknown_kind_row_is_skipped_not_fatal() { + // A single malformed / future-kind row must not blank the + // whole list — collect_lenient skips it instead of failing. + let (_dir, path, db) = open_temp(); + let good = db + .submit_kind("good", ApprovalKind::ApplyCommit, "cafe", None) + .unwrap(); + let raw = Connection::open(&path).unwrap(); + raw.execute( + "INSERT INTO approvals (agent, kind, commit_ref, requested_at, status) + VALUES ('weird', 'from_the_future', '', 0, 'pending')", + [], + ) + .unwrap(); + let pending = db + .pending() + .expect("pending() must survive an unparseable row"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, good); + } +}