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

@ -20,6 +20,8 @@
// the full picture (e.g. a per-recipient inbox built from broker
// events) */ },
// onBackfillDone: (count) => { /* one-shot after history replay */ },
// onStreamOpen: () => { /* fires on every EventSource (re)connect —
// use to re-sync snapshot-derived state after a reconnect gap */ },
// pillAnchor: document.getElementById('msgflow').parentElement,
// });
//
@ -213,16 +215,35 @@
if (es.readyState === EventSource.CONNECTING) row('note', '[reconnecting…]');
else row('note', '[disconnected]');
};
es.onopen = () => {
// Fires on the initial connect and on every automatic
// reconnect. EventSource never replays events that fired
// during a disconnect window, so a consumer with
// snapshot-derived state (the dashboard's /api/state stores)
// must re-sync here or it shows stale state until a manual
// reload (issue #163).
if (opts.onStreamOpen) {
try { opts.onStreamOpen(); }
catch (err) { console.error('onStreamOpen threw', err); }
}
};
function flushBuffered(boundarySeq) {
function flushBuffered(boundarySeq, historyKinds) {
const drained = buffered;
buffered = [];
live = true;
for (const ev of drained) {
// ev.seq is set by the server on live frames; absent/0 means
// "no dedupe possible, apply." Historical replays via the
// history endpoint carry no seq either way.
if (boundarySeq != null && typeof ev.seq === 'number' && ev.seq <= boundarySeq) {
// Seq-dedupe only events of a kind that actually appeared in
// the history replay — those are the only ones that could
// double (once via history, once via the live buffer).
// Mutation events (approval/question/container/…) are never
// carried by the history endpoint; deduping them against the
// broker-history seq would wrongly drop ones that fired
// between a consumer's own snapshot read and this history
// fetch (issue #163). ev.seq absent/0 → no dedupe possible.
if (boundarySeq != null
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
&& historyKinds && historyKinds.has(ev.kind)) {
continue;
}
dispatch(ev, false);
@ -252,12 +273,15 @@
// it as "no dedupe possible."
const events = Array.isArray(body) ? body : (body.events || []);
const boundarySeq = Array.isArray(body) ? null : (body.seq ?? null);
// Kinds present in the history replay — the only kinds that
// can double and therefore the only ones to seq-dedupe.
const historyKinds = new Set(events.map((ev) => ev.kind));
currentNoAnim = true;
for (const ev of events) dispatch(ev, true);
currentNoAnim = false;
if (events.length) row('note', '─── live (older above) ───');
else placeholder('(connected — waiting for events)');
flushBuffered(boundarySeq);
flushBuffered(boundarySeq, historyKinds);
if (opts.onBackfillDone) opts.onBackfillDone(events.length);
} catch (err) {
console.warn('history backfill failed', err);