dashboard: approval_added / approval_resolved mutation events + client derived state

This commit is contained in:
müde 2026-05-17 13:30:25 +02:00
parent 291f1fce42
commit 56d615b51f
6 changed files with 244 additions and 11 deletions

View file

@ -634,7 +634,51 @@
} }
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab'; 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'); const root = $('approvals-section');
root.innerHTML = ''; root.innerHTML = '';
@ -655,7 +699,8 @@
); );
root.append(spawn); 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 active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
const tabs = el('div', { class: 'approval-tabs' }); const tabs = el('div', { class: 'approval-tabs' });
const pendingTab = el( const pendingTab = el(
@ -664,7 +709,7 @@
type: 'button', type: 'button',
class: 'approval-tab' + (active === 'pending' ? ' active' : ''), class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
}, },
`pending · ${s.approvals.length}`, `pending · ${pending.length}`,
); );
const historyTab = el( const historyTab = el(
'button', 'button',
@ -676,11 +721,11 @@
); );
pendingTab.addEventListener('click', () => { pendingTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'pending'); localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
renderApprovals(s); renderApprovals();
}); });
historyTab.addEventListener('click', () => { historyTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'history'); localStorage.setItem(APPROVAL_TAB_KEY, 'history');
renderApprovals(s); renderApprovals();
}); });
tabs.append(pendingTab, historyTab); tabs.append(pendingTab, historyTab);
root.append(tabs); root.append(tabs);
@ -690,12 +735,12 @@
return; return;
} }
if (!s.approvals.length) { if (!pending.length) {
root.append(el('p', { class: 'empty' }, 'queue empty')); root.append(el('p', { class: 'empty' }, 'queue empty'));
return; return;
} }
const ul = el('ul', { class: 'approvals' }); const ul = el('ul', { class: 'approvals' });
for (const a of s.approvals) { for (const a of pending) {
const li = el('li'); const li = el('li');
const row = el('div', { class: 'row' }); const row = el('div', { class: 'row' });
if (a.kind === 'apply_commit') { if (a.kind === 'apply_commit') {
@ -954,7 +999,12 @@
renderTombstones(s); renderTombstones(s);
renderQuestions(s); renderQuestions(s);
renderInbox(); 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); renderMetaInputs(s);
restoreOpenDetails(openDetails); restoreOpenDetails(openDetails);
notifyDeltas(s); notifyDeltas(s);
@ -1014,6 +1064,11 @@
renderers: { renderers: {
sent: (ev, api) => renderMsg(ev, api, '→'), sent: (ev, api) => renderMsg(ev, api, '→'),
delivered: (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 // Both history backfill and live frames flow through here, so the
// inbox section ends up populated correctly on first paint and // inbox section ends up populated correctly on first paint and

View file

@ -114,6 +114,29 @@ fn finish_approval(
sha: approval.fetched_sha.clone(), sha: approval.fetched_sha.clone(),
tag: terminal_tag.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 // For spawn/rebuild approvals, also surface the underlying action so
// the manager knows whether the container actually came up. The // the manager knows whether the container actually came up. The
// ApprovalResolved event already carries the same `ok` signal but // 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 { coord.notify_manager(&HelperEvent::ApprovalResolved {
id: a.id, id: a.id,
agent: a.agent, agent: a.agent,
@ -390,6 +420,15 @@ pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()
sha, sha,
tag, tag,
}); });
coord.emit_approval_resolved(
id,
&agent_owned,
approval_kind,
sha_short,
"denied",
note.map(String::from),
description,
);
} }
Ok(()) Ok(())
} }

View file

@ -161,6 +161,63 @@ impl Coordinator {
let _ = self.dashboard_events.send(event); 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<String>,
diff: Option<String>,
description: Option<String>,
) {
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<String>,
status: &'static str,
note: Option<String>,
description: Option<String>,
) {
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<Self>, name: &str) -> Result<PathBuf> { pub fn register_agent(self: &Arc<Self>, name: &str) -> Result<PathBuf> {
// Idempotent: drop any existing listener so re-registration (e.g. on rebuild, // 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. // or after a hive-c0re restart cleared /run/hyperhive) gets a fresh socket.

View file

@ -1177,6 +1177,12 @@ async fn post_request_spawn(
{ {
Ok(id) => { Ok(id) => {
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard"); 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() Redirect::to("/").into_response()
} }
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")), Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),
@ -1381,8 +1387,22 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
if Coordinator::agent_proposed_dir(&a.agent).exists() { if Coordinator::agent_proposed_dir(&a.agent).exists() {
true true
} else { } 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"); 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 false
} }
}) })
@ -1407,7 +1427,12 @@ fn claude_has_session(dir: &Path) -> bool {
/// since the canonical proposal commit lives there (manager-side /// since the canonical proposal commit lives there (manager-side
/// amendments don't move it). Empty output means proposal == main — /// amendments don't move it). Empty output means proposal == main —
/// a no-op approval. /// 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); let applied = Coordinator::agent_applied_dir(agent);
if !applied.join(".git").exists() { if !applied.join(".git").exists() {
return format!("(no applied git repo at {})", applied.display()); return format!("(no applied git repo at {})", applied.display());

View file

@ -44,4 +44,37 @@ pub enum DashboardEvent {
body: String, body: String,
at: i64, 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<String>,
diff: Option<String>,
description: Option<String>,
},
/// 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<String>,
/// `"approved"` / `"denied"` / `"failed"`.
status: &'static str,
resolved_at: i64,
note: Option<String>,
description: Option<String>,
},
} }

View file

@ -157,6 +157,7 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
) { ) {
Ok(id) => { Ok(id) => {
tracing::info!(%id, %name, "spawn approval queued"); tracing::info!(%id, %name, "spawn approval queued");
coord.emit_approval_added(id, name, "spawn", None, None, description.clone());
ManagerResponse::Ok ManagerResponse::Ok
} }
Err(e) => ManagerResponse::Err { Err(e) => ManagerResponse::Err {
@ -382,7 +383,17 @@ async fn submit_apply_commit(
// dashboard reflects it instead of leaving a phantom // dashboard reflects it instead of leaving a phantom
// pending entry. The note doubles as the operator-visible // pending entry. The note doubles as the operator-visible
// explanation of why the approval can't be approved. // 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, &note);
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:#}")); return Err(anyhow::anyhow!("git_fetch_to_tag: {e:#}"));
} }
}; };
@ -390,6 +401,19 @@ async fn submit_apply_commit(
.approvals .approvals
.set_fetched_sha(id, &sha) .set_fetched_sha(id, &sha)
.map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?; .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)) Ok((id, sha))
} }