diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 736bdde..31d4758 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -634,7 +634,51 @@ } const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab'; - function renderApprovals(s) { + // Derived approval state — cold-loaded from /api/state, then mutated + // live by `approval_added` / `approval_resolved` dashboard events. + // `pending` is the open queue (newest-first); `history` is the last + // 30 resolved rows. + const APPROVAL_HISTORY_LIMIT = 30; + const approvalsState = { pending: [], history: [] }; + function syncApprovalsFromSnapshot(s) { + approvalsState.pending = (s.approvals || []).slice(); + approvalsState.history = (s.approval_history || []).slice(); + } + function applyApprovalAdded(ev) { + // Upsert by id so a snapshot that already included the row (cold + // load + event lands at the same tick) doesn't double it. + const existing = approvalsState.pending.findIndex((a) => a.id === ev.id); + const row = { + id: ev.id, + agent: ev.agent, + kind: ev.approval_kind, + sha_short: ev.sha_short || null, + diff: ev.diff || null, + description: ev.description || null, + }; + if (existing >= 0) approvalsState.pending[existing] = row; + else approvalsState.pending.push(row); + renderApprovals(); + } + function applyApprovalResolved(ev) { + // Drop from pending; prepend to history (newest-first), cap at 30. + approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id); + approvalsState.history.unshift({ + id: ev.id, + agent: ev.agent, + kind: ev.approval_kind, + sha_short: ev.sha_short || null, + status: ev.status, + resolved_at: ev.resolved_at, + note: ev.note || null, + description: ev.description || null, + }); + if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) { + approvalsState.history.length = APPROVAL_HISTORY_LIMIT; + } + renderApprovals(); + } + function renderApprovals() { const root = $('approvals-section'); root.innerHTML = ''; @@ -655,7 +699,8 @@ ); root.append(spawn); - const history = s.approval_history || []; + const pending = approvalsState.pending; + const history = approvalsState.history; const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending'; const tabs = el('div', { class: 'approval-tabs' }); const pendingTab = el( @@ -664,7 +709,7 @@ type: 'button', class: 'approval-tab' + (active === 'pending' ? ' active' : ''), }, - `pending · ${s.approvals.length}`, + `pending · ${pending.length}`, ); const historyTab = el( 'button', @@ -676,11 +721,11 @@ ); pendingTab.addEventListener('click', () => { localStorage.setItem(APPROVAL_TAB_KEY, 'pending'); - renderApprovals(s); + renderApprovals(); }); historyTab.addEventListener('click', () => { localStorage.setItem(APPROVAL_TAB_KEY, 'history'); - renderApprovals(s); + renderApprovals(); }); tabs.append(pendingTab, historyTab); root.append(tabs); @@ -690,12 +735,12 @@ return; } - if (!s.approvals.length) { + if (!pending.length) { root.append(el('p', { class: 'empty' }, 'queue empty')); return; } const ul = el('ul', { class: 'approvals' }); - for (const a of s.approvals) { + for (const a of pending) { const li = el('li'); const row = el('div', { class: 'row' }); if (a.kind === 'apply_commit') { @@ -954,7 +999,12 @@ renderTombstones(s); renderQuestions(s); renderInbox(); - renderApprovals(s); + // Sync the derived approvals store from the snapshot, then + // render. Live `approval_added` / `approval_resolved` events + // mutate the store directly and call renderApprovals() without + // a snapshot refetch. + syncApprovalsFromSnapshot(s); + renderApprovals(); renderMetaInputs(s); restoreOpenDetails(openDetails); notifyDeltas(s); @@ -1014,6 +1064,11 @@ renderers: { sent: (ev, api) => renderMsg(ev, api, '→'), delivered: (ev, api) => renderMsg(ev, api, '✓'), + // Mutation events update derived state and trigger a + // section re-render — no terminal log row (the terminal is + // for broker traffic, not state-change chatter). + approval_added: (ev) => { applyApprovalAdded(ev); }, + approval_resolved: (ev) => { applyApprovalResolved(ev); }, }, // Both history backfill and live frames flow through here, so the // inbox section ends up populated correctly on first paint and diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index c3d7015..8dbbfdf 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -114,6 +114,29 @@ fn finish_approval( sha: approval.fetched_sha.clone(), tag: terminal_tag.clone(), }); + // Phase 5b: also fire on the dashboard event channel so the + // browser moves the row out of pending into history without a + // snapshot refetch. `approved` rows that succeed get the + // approval's logged resolved_at indirectly via `now_unix()`; + // failures already wrote it via mark_failed above. + let approval_kind = match approval.kind { + ApprovalKind::Spawn => "spawn", + ApprovalKind::ApplyCommit => "apply_commit", + }; + let sha_short = approval + .fetched_sha + .as_deref() + .map(|s| s[..s.len().min(12)].to_owned()); + let status_str = if ok { "approved" } else { "failed" }; + coord.emit_approval_resolved( + approval.id, + &approval.agent, + approval_kind, + sha_short, + status_str, + note.clone(), + approval.description.clone(), + ); // For spawn/rebuild approvals, also surface the underlying action so // the manager knows whether the container actually came up. The // ApprovalResolved event already carries the same `ok` signal but @@ -381,6 +404,13 @@ pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<() } } } + let approval_kind = match a.kind { + ApprovalKind::Spawn => "spawn", + ApprovalKind::ApplyCommit => "apply_commit", + }; + let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned()); + let description = a.description.clone(); + let agent_owned = a.agent.clone(); coord.notify_manager(&HelperEvent::ApprovalResolved { id: a.id, agent: a.agent, @@ -390,6 +420,15 @@ pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<() sha, tag, }); + coord.emit_approval_resolved( + id, + &agent_owned, + approval_kind, + sha_short, + "denied", + note.map(String::from), + description, + ); } Ok(()) } diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 0718924..9ace31c 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -161,6 +161,63 @@ impl Coordinator { let _ = self.dashboard_events.send(event); } + /// Emit `ApprovalAdded` immediately after the row is inserted in + /// sqlite. Caller passes the diff text it already computed (or + /// `None` for spawn approvals which carry no diff). + pub fn emit_approval_added( + &self, + id: i64, + agent: &str, + approval_kind: &'static str, + sha_short: Option, + diff: Option, + description: Option, + ) { + self.emit_dashboard_event(DashboardEvent::ApprovalAdded { + seq: self.next_seq(), + id, + agent: agent.to_owned(), + approval_kind, + sha_short, + diff, + description, + }); + } + + /// Emit `ApprovalResolved` after `mark_approved` / `mark_denied` / + /// `mark_failed` lands. `resolved_at` is stamped from the system + /// clock here so call sites don't repeat the conversion; if you + /// already have an authoritative timestamp from the db update, + /// the tiny skew between "row updated" and "event emitted" is + /// presentation-only and doesn't matter to clients. + pub fn emit_approval_resolved( + &self, + id: i64, + agent: &str, + approval_kind: &'static str, + sha_short: Option, + status: &'static str, + note: Option, + description: Option, + ) { + let resolved_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|d| i64::try_from(d.as_secs()).ok()) + .unwrap_or(0); + self.emit_dashboard_event(DashboardEvent::ApprovalResolved { + seq: self.next_seq(), + id, + agent: agent.to_owned(), + approval_kind, + sha_short, + status, + resolved_at, + note, + description, + }); + } + pub fn register_agent(self: &Arc, name: &str) -> Result { // Idempotent: drop any existing listener so re-registration (e.g. on rebuild, // or after a hive-c0re restart cleared /run/hyperhive) gets a fresh socket. diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 868d1d3..682e0ab 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -1177,6 +1177,12 @@ async fn post_request_spawn( { Ok(id) => { tracing::info!(%id, %name, "operator: spawn approval queued via dashboard"); + // Phase 5b: notify the dashboard event channel so live + // subscribers can append the row without a snapshot + // refetch. Spawn approvals carry no diff/sha. + state + .coord + .emit_approval_added(id, &name, "spawn", None, None, None); Redirect::to("/").into_response() } Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")), @@ -1381,8 +1387,22 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { if Coordinator::agent_proposed_dir(&a.agent).exists() { true } else { - let _ = coord.approvals.mark_failed(a.id, "agent state dir missing"); + let note = "agent state dir missing"; + let _ = coord.approvals.mark_failed(a.id, note); tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval"); + let sha_short = a + .fetched_sha + .as_deref() + .map(|s| s[..s.len().min(12)].to_owned()); + coord.emit_approval_resolved( + a.id, + &a.agent, + "apply_commit", + sha_short, + "failed", + Some(note.to_owned()), + a.description.clone(), + ); false } }) @@ -1407,7 +1427,12 @@ fn claude_has_session(dir: &Path) -> bool { /// since the canonical proposal commit lives there (manager-side /// amendments don't move it). Empty output means proposal == main — /// a no-op approval. -async fn approval_diff(agent: &str, approval_id: i64) -> String { +/// +/// `pub(crate)` so the manager-socket handler can pre-compute the +/// diff once at submission time and embed it in the `ApprovalAdded` +/// dashboard event (instead of forcing the dashboard to wait a +/// `/api/state` cycle to see the diff for newly-queued approvals). +pub(crate) async fn approval_diff(agent: &str, approval_id: i64) -> String { let applied = Coordinator::agent_applied_dir(agent); if !applied.join(".git").exists() { return format!("(no applied git repo at {})", applied.display()); diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index fc8305f..f5cdf66 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -44,4 +44,37 @@ pub enum DashboardEvent { body: String, at: i64, }, + /// A new approval landed in the pending queue. Payload carries + /// enough to render the dashboard row without a `/api/state` + /// refetch (`diff` is the raw unified diff text, same shape the + /// snapshot ships). + /// + /// The approval's own kind (`"apply_commit"` / `"spawn"`) lives on + /// `approval_kind` rather than `kind` because the latter is taken + /// by the serde tag identifying which `DashboardEvent` variant + /// this is. + ApprovalAdded { + seq: u64, + id: i64, + agent: String, + approval_kind: &'static str, + sha_short: Option, + diff: Option, + description: Option, + }, + /// A pending approval transitioned to a terminal state + /// (approved / denied / failed). Clients move the row out of the + /// pending list and into history. + ApprovalResolved { + seq: u64, + id: i64, + agent: String, + approval_kind: &'static str, + sha_short: Option, + /// `"approved"` / `"denied"` / `"failed"`. + status: &'static str, + resolved_at: i64, + note: Option, + description: Option, + }, } diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 2959c64..a9786ad 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -157,6 +157,7 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp ) { Ok(id) => { tracing::info!(%id, %name, "spawn approval queued"); + coord.emit_approval_added(id, name, "spawn", None, None, description.clone()); ManagerResponse::Ok } Err(e) => ManagerResponse::Err { @@ -382,7 +383,17 @@ async fn submit_apply_commit( // dashboard reflects it instead of leaving a phantom // pending entry. The note doubles as the operator-visible // explanation of why the approval can't be approved. - let _ = coord.approvals.mark_failed(id, &format!("{e:#}")); + let note = format!("{e:#}"); + let _ = coord.approvals.mark_failed(id, ¬e); + coord.emit_approval_resolved( + id, + agent, + "apply_commit", + None, + "failed", + Some(note), + description.map(str::to_owned), + ); return Err(anyhow::anyhow!("git_fetch_to_tag: {e:#}")); } }; @@ -390,6 +401,19 @@ async fn submit_apply_commit( .approvals .set_fetched_sha(id, &sha) .map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?; + // Phase 5b: surface the new pending approval on the dashboard + // event channel. Compute the diff once here so live subscribers + // get a fully-formed row without a snapshot refetch. + let sha_short = sha[..sha.len().min(12)].to_owned(); + let diff = crate::dashboard::approval_diff(agent, id).await; + coord.emit_approval_added( + id, + agent, + "apply_commit", + Some(sha_short), + Some(diff), + description.map(str::to_owned), + ); Ok((id, sha)) }