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:
parent
fefa91a39e
commit
32f4796a7f
2 changed files with 75 additions and 34 deletions
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue