From c32ce01e4cb59d9967410c8dc51e2c8023ae13e8 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Tue, 7 Apr 2026 12:05:01 +0800 Subject: [PATCH] Guard graph runtime against stale persistence reloads --- index.js | 275 +++++++++++++++++++++++++++++++++++- tests/graph-persistence.mjs | 185 ++++++++++++++++++++++++ 2 files changed, 459 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index bd3da9d..93dead7 100644 --- a/index.js +++ b/index.js @@ -3589,6 +3589,160 @@ function doesChatIdMatchResolvedGraphIdentity( return knownChatIds.has(normalizedCandidate); } +function areChatIdsEquivalentForResolvedIdentity( + candidateChatId, + referenceChatId, + identity = resolveCurrentChatIdentity(getContext()), +) { + const normalizedCandidate = normalizeChatIdCandidate(candidateChatId); + const normalizedReference = normalizeChatIdCandidate(referenceChatId); + if (!normalizedCandidate || !normalizedReference) { + return normalizedCandidate === normalizedReference; + } + if (normalizedCandidate === normalizedReference) { + return true; + } + return ( + doesChatIdMatchResolvedGraphIdentity(normalizedCandidate, identity) && + doesChatIdMatchResolvedGraphIdentity(normalizedReference, identity) + ); +} + +function getIndexedDbSnapshotHistoryState(snapshot = null) { + const snapshotState = + snapshot?.meta?.runtimeHistoryState && + typeof snapshot.meta.runtimeHistoryState === "object" && + !Array.isArray(snapshot.meta.runtimeHistoryState) + ? snapshot.meta.runtimeHistoryState + : null; + + return { + lastProcessedAssistantFloor: Number.isFinite( + Number(snapshot?.state?.lastProcessedFloor), + ) + ? Number(snapshot.state.lastProcessedFloor) + : Number.isFinite(Number(snapshotState?.lastProcessedAssistantFloor)) + ? Number(snapshotState.lastProcessedAssistantFloor) + : -1, + extractionCount: Number.isFinite(Number(snapshot?.state?.extractionCount)) + ? Number(snapshot.state.extractionCount) + : Number.isFinite(Number(snapshotState?.extractionCount)) + ? Number(snapshotState.extractionCount) + : 0, + }; +} + +function detectStaleIndexedDbSnapshotAgainstRuntime( + chatId, + snapshot, + { identity = resolveCurrentChatIdentity(getContext()) } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId || !isIndexedDbSnapshotMeaningful(snapshot) || !currentGraph) { + return { + stale: false, + reason: "", + }; + } + + const runtimeChatId = normalizeChatIdCandidate( + currentGraph?.historyState?.chatId || + getGraphPersistenceMeta(currentGraph)?.chatId || + graphPersistenceState.chatId, + ); + if ( + !runtimeChatId || + !areChatIdsEquivalentForResolvedIdentity( + normalizedChatId, + runtimeChatId, + identity, + ) + ) { + return { + stale: false, + reason: "", + }; + } + + const runtimeRevision = Math.max( + normalizeIndexedDbRevision(graphPersistenceState.revision), + normalizeIndexedDbRevision(graphPersistenceState.lastPersistedRevision), + normalizeIndexedDbRevision(graphPersistenceState.queuedPersistRevision), + getGraphPersistedRevision(currentGraph), + ); + const snapshotRevision = normalizeIndexedDbRevision(snapshot?.meta?.revision); + if (runtimeRevision > snapshotRevision) { + return { + stale: true, + reason: "runtime-revision-newer", + runtimeRevision, + snapshotRevision, + }; + } + + if (runtimeRevision < snapshotRevision) { + return { + stale: false, + reason: "", + runtimeRevision, + snapshotRevision, + }; + } + + const runtimeLastProcessedFloor = Number.isFinite( + Number(currentGraph?.historyState?.lastProcessedAssistantFloor), + ) + ? Number(currentGraph.historyState.lastProcessedAssistantFloor) + : Number.isFinite(Number(currentGraph?.lastProcessedSeq)) + ? Number(currentGraph.lastProcessedSeq) + : -1; + const runtimeExtractionCount = Number.isFinite( + Number(currentGraph?.historyState?.extractionCount), + ) + ? Number(currentGraph.historyState.extractionCount) + : Number.isFinite(Number(extractionCount)) + ? Number(extractionCount) + : 0; + const snapshotHistoryState = getIndexedDbSnapshotHistoryState(snapshot); + + if (runtimeLastProcessedFloor > snapshotHistoryState.lastProcessedAssistantFloor) { + return { + stale: true, + reason: "runtime-last-processed-newer", + runtimeRevision, + snapshotRevision, + runtimeLastProcessedFloor, + snapshotLastProcessedFloor: snapshotHistoryState.lastProcessedAssistantFloor, + runtimeExtractionCount, + snapshotExtractionCount: snapshotHistoryState.extractionCount, + }; + } + + if (runtimeExtractionCount > snapshotHistoryState.extractionCount) { + return { + stale: true, + reason: "runtime-extraction-count-newer", + runtimeRevision, + snapshotRevision, + runtimeLastProcessedFloor, + snapshotLastProcessedFloor: snapshotHistoryState.lastProcessedAssistantFloor, + runtimeExtractionCount, + snapshotExtractionCount: snapshotHistoryState.extractionCount, + }; + } + + return { + stale: false, + reason: "", + runtimeRevision, + snapshotRevision, + runtimeLastProcessedFloor, + snapshotLastProcessedFloor: snapshotHistoryState.lastProcessedAssistantFloor, + runtimeExtractionCount, + snapshotExtractionCount: snapshotHistoryState.extractionCount, + }; +} + function resolveCompatibleGraphShadowSnapshot( identity = resolveCurrentChatIdentity(getContext()), ) { @@ -4454,6 +4608,50 @@ function applyIndexedDbSnapshotToRuntime( 1, normalizeIndexedDbRevision(snapshot?.meta?.revision), ); + const staleDecision = detectStaleIndexedDbSnapshotAgainstRuntime( + normalizedChatId, + snapshot, + ); + if (staleDecision.stale) { + updateGraphPersistenceState({ + storagePrimary: + graphPersistenceState.storagePrimary || "indexeddb", + storageMode: graphPersistenceState.storageMode || "indexeddb", + indexedDbRevision: Math.max( + graphPersistenceState.indexedDbRevision || 0, + revision, + ), + metadataIntegrity: + getChatMetadataIntegrity(getContext()) || + graphPersistenceState.metadataIntegrity, + indexedDbLastError: "", + dualWriteLastResult: { + action: "load", + source: String(source || "indexeddb"), + success: false, + rejected: true, + reason: "indexeddb-stale-runtime", + revision, + staleDetail: cloneRuntimeDebugValue(staleDecision, null), + at: Date.now(), + }, + }); + debugDebug("[ST-BME] 已拒绝用较旧 IndexedDB 快照覆盖当前运行时图谱", { + chatId: normalizedChatId, + source, + revision, + staleDetail: staleDecision, + }); + return { + success: false, + loaded: false, + reason: "indexeddb-stale-runtime", + chatId: normalizedChatId, + attemptIndex, + revision, + staleDetail: cloneRuntimeDebugValue(staleDecision, null), + }; + } let graphFromSnapshot = null; try { graphFromSnapshot = buildGraphFromSnapshot(snapshot, { @@ -5449,7 +5647,15 @@ function shouldSyncGraphLoadFromLiveContext( const liveChatId = chatIdentity.chatId; const stateChatId = normalizeChatIdCandidate(graphPersistenceState.chatId); - if (liveChatId !== stateChatId) return true; + if ( + !areChatIdsEquivalentForResolvedIdentity( + liveChatId, + stateChatId, + chatIdentity, + ) + ) { + return true; + } if ( !liveChatId && @@ -5493,6 +5699,15 @@ function syncGraphLoadFromLiveContext(options = {}) { source: `${source}:indexeddb-cache`, attemptIndex: 0, }); + if (result?.reason === "indexeddb-stale-runtime") { + return { + synced: false, + reason: "cached-indexeddb-stale-runtime", + loadState: graphPersistenceState.loadState, + chatId: graphPersistenceState.chatId, + staleDetail: cloneRuntimeDebugValue(result?.staleDetail, null), + }; + } return { synced: true, ...result, @@ -6514,6 +6729,19 @@ function loadGraphFromChat(options = {}) { attemptIndex, }, ); + if (cachedResult?.reason === "indexeddb-stale-runtime") { + clearPendingGraphLoadRetry(); + refreshPanelLiveState(); + return { + success: false, + loaded: false, + loadState: graphPersistenceState.loadState, + reason: "indexeddb-cache-stale-runtime", + chatId, + attemptIndex, + staleDetail: cloneRuntimeDebugValue(cachedResult?.staleDetail, null), + }; + } if (cachedResult?.loaded) { clearPendingGraphLoadRetry(); return cachedResult; @@ -6538,6 +6766,51 @@ function loadGraphFromChat(options = {}) { 1, getGraphPersistedRevision(officialGraph), ); + const officialRuntimeStaleDecision = + detectStaleIndexedDbSnapshotAgainstRuntime( + chatId, + buildSnapshotFromGraph(officialGraph, { + chatId, + revision: officialRevision, + }), + { + identity: chatIdentity, + }, + ); + + if (officialRuntimeStaleDecision.stale) { + clearPendingGraphLoadRetry(); + updateGraphPersistenceState({ + metadataIntegrity: getChatMetadataIntegrity(context), + dualWriteLastResult: { + action: "load", + source: `${source}:metadata-compat`, + success: false, + provisional: true, + rejected: true, + reason: "metadata-compat-stale-runtime", + revision: officialRevision, + staleDetail: cloneRuntimeDebugValue( + officialRuntimeStaleDecision, + null, + ), + at: Date.now(), + }, + }); + refreshPanelLiveState(); + return { + success: false, + loaded: false, + loadState: graphPersistenceState.loadState, + reason: "metadata-compat-stale-runtime", + chatId, + attemptIndex, + staleDetail: cloneRuntimeDebugValue( + officialRuntimeStaleDecision, + null, + ), + }; + } if (shadowSnapshot && shadowDecision?.reason) { updateGraphPersistenceState({ diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index e106086..98417b4 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -815,6 +815,7 @@ result = { removeGraphShadowSnapshot, maybeCaptureGraphShadowSnapshot, loadGraphFromChat, + loadGraphFromIndexedDb, saveGraphToChat, syncGraphLoadFromLiveContext, buildBmeSyncRuntimeOptions, @@ -1433,6 +1434,190 @@ result = { ); } +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-panel-host", + globalChatId: "chat-panel-host", + chatMetadata: { + integrity: "chat-panel-integrity", + }, + }); + harness.api.setCurrentGraph( + normalizeGraphRuntimeState( + createMeaningfulGraph("chat-panel-host", "runtime-host"), + "chat-panel-host", + ), + ); + harness.api.setGraphPersistenceState({ + loadState: "loaded", + chatId: "chat-panel-host", + reason: "runtime-host-loaded", + revision: 6, + lastPersistedRevision: 6, + dbReady: true, + writesBlocked: false, + }); + + const result = harness.api.syncGraphLoadFromLiveContext({ + source: "panel-open-sync", + }); + + assert.equal( + result.synced, + false, + "hostChatId 与 integrity 只是同一聊天的不同身份时,不应误判为需要重新加载", + ); + assert.equal(result.reason, "no-sync-needed"); +} + +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-stale-cache", + globalChatId: "chat-stale-cache", + chatMetadata: { + integrity: "chat-stale-cache-integrity", + }, + }); + harness.api.setCurrentGraph( + normalizeGraphRuntimeState( + createMeaningfulGraph("chat-stale-cache", "runtime-newer"), + "chat-stale-cache", + ), + ); + harness.api.setGraphPersistenceState({ + loadState: "loaded", + chatId: "chat-stale-cache", + reason: "runtime-newer", + revision: 9, + lastPersistedRevision: 9, + queuedPersistRevision: 9, + dbReady: true, + writesBlocked: false, + }); + harness.api.setIndexedDbSnapshotForChat( + "chat-stale-cache-integrity", + buildSnapshotFromGraph( + createMeaningfulGraph("chat-stale-cache", "indexeddb-older"), + { + chatId: "chat-stale-cache-integrity", + revision: 4, + }, + ), + ); + + const result = await harness.api.loadGraphFromIndexedDb( + "chat-stale-cache-integrity", + { + source: "sync-post-refresh:download", + allowOverride: true, + applyEmptyState: true, + }, + ); + + assert.equal(result.success, false); + assert.equal(result.loaded, false); + assert.equal(result.reason, "indexeddb-stale-runtime"); + assert.equal( + result.staleDetail?.reason, + "runtime-revision-newer", + "同聊天较旧的 IndexedDB 快照应被识别为过期", + ); + assert.equal( + harness.api.getCurrentGraph().nodes[0]?.fields?.title, + "事件-runtime-newer", + "较旧的 IndexedDB 快照不得覆盖当前更近的运行时图谱", + ); + assert.equal( + harness.api.getGraphPersistenceLiveState().loadState, + "loaded", + "拒绝旧快照后不应把当前图谱重新打回 loading", + ); +} + +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-stale-cache-panel", + globalChatId: "chat-stale-cache-panel", + chatMetadata: { + integrity: "chat-stale-cache-panel-integrity", + }, + }); + harness.api.setCurrentGraph( + normalizeGraphRuntimeState( + createMeaningfulGraph("chat-stale-cache-panel", "runtime-newer"), + "chat-stale-cache-panel", + ), + ); + harness.api.setGraphPersistenceState({ + loadState: "loaded", + chatId: "chat-stale-cache-panel", + reason: "runtime-newer", + revision: 9, + lastPersistedRevision: 9, + queuedPersistRevision: 9, + dbReady: true, + writesBlocked: false, + }); + + const result = harness.api.syncGraphLoadFromLiveContext({ + source: "panel-open-sync", + }); + + assert.equal( + result.synced, + false, + "hostChatId 与 integrity 只是同一聊天的不同身份时,面板打开不应误判成要重新同步", + ); + assert.equal(result.reason, "no-sync-needed"); +} + +{ + const metadataGraph = stampPersistedGraph( + createMeaningfulGraph("chat-stale-metadata", "metadata-older"), + { + revision: 3, + integrity: "chat-stale-metadata-integrity", + chatId: "chat-stale-metadata", + reason: "metadata-older", + }, + ); + const harness = await createGraphPersistenceHarness({ + chatId: "chat-stale-metadata", + globalChatId: "chat-stale-metadata", + chatMetadata: { + integrity: "chat-stale-metadata-integrity", + st_bme_graph: metadataGraph, + }, + }); + harness.api.setCurrentGraph( + normalizeGraphRuntimeState( + createMeaningfulGraph("chat-stale-metadata", "runtime-newer"), + "chat-stale-metadata", + ), + ); + harness.api.setGraphPersistenceState({ + loadState: "loaded", + chatId: "chat-stale-metadata", + reason: "runtime-newer", + revision: 8, + lastPersistedRevision: 8, + dbReady: true, + writesBlocked: false, + }); + + const result = harness.api.loadGraphFromChat({ + attemptIndex: 0, + source: "stale-metadata-runtime-guard", + }); + + assert.equal(result.reason, "metadata-compat-stale-runtime"); + assert.equal( + harness.api.getCurrentGraph().nodes[0]?.fields?.title, + "事件-runtime-newer", + "较旧的 metadata 兼容图不得把当前运行时图谱盖回去", + ); +} + { const sharedSession = new Map(); const writer = await createGraphPersistenceHarness({