dashboard: re-sync /api/state on SSE (re)connect

The dashboard cold-loaded its derived stores (approvals, questions,
containers, …) from /api/state once, then relied solely on live SSE
events. Events that fired during a disconnect window (reconnect,
hive-c0re restart) are never replayed, so the dashboard drifted stale
until a manual reload.

- terminal.js: add onStreamOpen, fired on every EventSource open
  (initial + reconnect); the dashboard wires it to refreshState() so
  every connection epoch re-syncs the authoritative snapshot.
- terminal.js: seq-dedupe only event kinds that actually appeared in
  the history replay. Mutation events are never in /dashboard/history,
  so deduping them against the broker-history seq wrongly dropped ones
  that fired between the /api/state snapshot and the history fetch.
- app.js: make applyApprovalResolved / applyQuestionResolved
  idempotent (guard the history unshift by id) so a re-sync
  overlapping a live event can't double a history row.

closes #163
This commit is contained in:
iris 2026-05-21 18:25:42 +02:00
parent fefa91a39e
commit 32f4796a7f
2 changed files with 75 additions and 34 deletions

View file

@ -789,22 +789,27 @@
const idx = questionsState.pending.findIndex((q) => q.id === ev.id);
const existing = idx >= 0 ? questionsState.pending[idx] : null;
if (idx >= 0) questionsState.pending.splice(idx, 1);
questionsState.history.unshift({
id: ev.id,
asker: existing?.asker || '?',
question: existing?.question || '',
options: existing?.options || [],
multi: existing?.multi || false,
asked_at: existing?.asked_at || ev.answered_at,
answered_at: ev.answered_at,
answer: ev.answer,
answerer: ev.answerer,
target: existing?.target ?? ev.target ?? null,
question_refs: existing?.question_refs || [],
answer_refs: ev.answer_refs || [],
});
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
questionsState.history.length = QUESTION_HISTORY_LIMIT;
// Idempotent: a snapshot re-sync (issue #163) can carry this same
// answered row in `question_history` while a live event also
// delivers it — guard the unshift so history can't double a row.
if (!questionsState.history.some((h) => h.id === ev.id)) {
questionsState.history.unshift({
id: ev.id,
asker: existing?.asker || '?',
question: existing?.question || '',
options: existing?.options || [],
multi: existing?.multi || false,
asked_at: existing?.asked_at || ev.answered_at,
answered_at: ev.answered_at,
answer: ev.answer,
answerer: ev.answerer,
target: existing?.target ?? ev.target ?? null,
question_refs: existing?.question_refs || [],
answer_refs: ev.answer_refs || [],
});
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
questionsState.history.length = QUESTION_HISTORY_LIMIT;
}
}
renderQuestions();
}
@ -1083,18 +1088,23 @@
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;
// Idempotent: a snapshot re-sync (issue #163) can carry this same
// resolved row in `approval_history` while a live event also
// delivers it — guard the unshift so history can't double a row.
if (!approvalsState.history.some((h) => h.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();
}
@ -1727,6 +1737,13 @@
onAnyEvent: (ev /* , { fromHistory } */) => {
if (inboxAppendFromEvent(ev)) renderInbox();
},
// Re-sync the full /api/state snapshot on every SSE (re)connect.
// Live mutation events that fired during a disconnect window are
// never replayed, so without this the derived stores (approvals,
// questions, containers, …) would drift stale until a manual
// reload (issue #163). refreshState() replaces every store from
// the snapshot, so a missed event self-heals on reconnect.
onStreamOpen: () => { refreshState(); },
onLiveEvent: (ev) => {
pulseBanner();
if (ev.kind === 'sent' && ev.to === 'operator') {