From 1834bc1d24f329312213fc497171370eebc68b12 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 13:57:03 +0800 Subject: [PATCH] refactor: stabilize persistence delta commit flow --- index.js | 1349 +++++++++++-------- maintenance/extraction-controller.js | 8 +- runtime/runtime-state.js | 2 + sync/bme-db.js | 257 ++++ tests/graph-persistence.mjs | 115 +- tests/helpers/generation-recall-harness.mjs | 1 + tests/mobile-status-regressions.mjs | 3 + tests/p0-regressions.mjs | 6 + ui/panel.js | 21 +- ui/ui-status.js | 9 + 10 files changed, 1182 insertions(+), 589 deletions(-) diff --git a/index.js b/index.js index a0de6ea..58609ea 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ -// ST-BME: 主入口 -// 事件钩子、设置管理、流程调度 +// ST-BME: 主入? +// 事件钩子、设置管理、流程调? import { eventSource, @@ -20,6 +20,7 @@ import { BmeChatManager } from "./sync/bme-chat-manager.js"; import { BmeDatabase, buildBmeDbName, + buildPersistDelta, buildGraphFromSnapshot, buildSnapshotFromGraph, ensureDexieLoaded, @@ -296,7 +297,7 @@ import { export { DEFAULT_TRIGGER_KEYWORDS, getSmartTriggerDecision }; -// 操控面板模块(动态加载,防止加载失败崩溃整个扩展) +// 操控面板模块(动态加载,防止加载失败崩溃整个扩展? let _panelModule = null; let _themesModule = null; @@ -315,14 +316,143 @@ function syncCommitMarkerToPersistenceState(context = getContext()) { const marker = getChatCommitMarker(context); updateGraphPersistenceState({ commitMarker: cloneRuntimeDebugValue(marker, null), - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - getAcceptedCommitMarkerRevision(marker), - ), }); return marker; } +function isAcceptedPersistTier(storageTier = "none") { + const normalizedTier = String(storageTier || "none").trim().toLowerCase(); + return normalizedTier === "indexeddb" || normalizedTier === "chat-state"; +} + +function isRecoveryOnlyPersistTier(storageTier = "none") { + const normalizedTier = String(storageTier || "none").trim().toLowerCase(); + return normalizedTier === "shadow" || normalizedTier === "metadata-full"; +} + +function resolvePersistRevisionFloor( + requestedRevision = 0, + graph = currentGraph, +) { + return Math.max( + normalizeIndexedDbRevision(requestedRevision), + normalizeIndexedDbRevision(graphPersistenceState.revision), + normalizeIndexedDbRevision(graphPersistenceState.lastPersistedRevision), + normalizeIndexedDbRevision(graphPersistenceState.queuedPersistRevision), + normalizeIndexedDbRevision(graph ? getGraphPersistedRevision(graph) : 0), + ); +} + +function allocateRequestedPersistRevision( + requestedRevision = 0, + graph = currentGraph, +) { + return Math.max(1, resolvePersistRevisionFloor(requestedRevision, graph) + 1); +} + +function normalizeRestoreLockState(lock = null) { + const source = String(lock?.source || "").trim(); + const reason = String(lock?.reason || "").trim(); + const startedAt = Number(lock?.startedAt); + const depth = Math.max(0, Math.floor(Number(lock?.depth) || 0)); + const active = lock?.active === true || depth > 0; + return { + active, + depth: active ? Math.max(1, depth || 1) : 0, + source, + reason, + startedAt: Number.isFinite(startedAt) && startedAt > 0 ? startedAt : 0, + }; +} + +function isRestoreLockActive() { + return normalizeRestoreLockState(graphPersistenceState.restoreLock).active; +} + +function getRestoreLockMessage(operationLabel = "当前操作") { + const lock = normalizeRestoreLockState(graphPersistenceState.restoreLock); + if (!lock.active) return ""; + const details = [lock.reason, lock.source].filter(Boolean).join(" / "); + return `${operationLabel}已暂停:当前处于恢复锁${details ? `(${details})` : ""}`; +} + +function enterRestoreLock(source = "runtime", reason = "") { + const currentLock = normalizeRestoreLockState(graphPersistenceState.restoreLock); + const nextLock = { + active: true, + depth: currentLock.depth + 1, + source: String(source || currentLock.source || "runtime"), + reason: String(reason || currentLock.reason || ""), + startedAt: currentLock.startedAt || Date.now(), + }; + updateGraphPersistenceState({ + restoreLock: nextLock, + }); + return cloneRuntimeDebugValue(nextLock, nextLock); +} + +function leaveRestoreLock(source = "runtime") { + const currentLock = normalizeRestoreLockState(graphPersistenceState.restoreLock); + if (!currentLock.active) { + return currentLock; + } + const nextDepth = Math.max(0, currentLock.depth - 1); + const nextLock = + nextDepth > 0 + ? { + ...currentLock, + depth: nextDepth, + source: String(source || currentLock.source || ""), + } + : { + active: false, + depth: 0, + source: "", + reason: "", + startedAt: 0, + }; + updateGraphPersistenceState({ + restoreLock: nextLock, + }); + return cloneRuntimeDebugValue(nextLock, nextLock); +} + +async function runWithRestoreLock(source, reason, task) { + enterRestoreLock(source, reason); + try { + return await task(); + } finally { + leaveRestoreLock(source); + } +} + +function recordPersistMismatchDiagnostic( + mismatch = null, + { source = "persist-mismatch", resolvedBy = "" } = {}, +) { + const normalizedReason = String(mismatch?.reason || "").trim(); + const marker = cloneRuntimeDebugValue(mismatch?.marker, null) || getChatCommitMarker(); + updateGraphPersistenceState({ + persistMismatchReason: normalizedReason, + commitMarker: marker, + dualWriteLastResult: { + action: "load", + source: String(source || "persist-mismatch"), + success: false, + diagnostic: true, + reason: normalizedReason, + markerRevision: Number(mismatch?.markerRevision || 0), + snapshotRevision: Number(mismatch?.snapshotRevision || 0), + resolvedBy: String(resolvedBy || ""), + at: Date.now(), + }, + }); + return { + reason: normalizedReason, + marker, + }; +} + function persistGraphCommitMarker( context = getContext(), { @@ -385,12 +515,6 @@ function persistGraphCommitMarker( const saveMode = triggerChatMetadataSave(context, { immediate }); updateGraphPersistenceState({ commitMarker: cloneRuntimeDebugValue(marker, null), - lastAcceptedRevision: accepted - ? Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - Number(marker.revision || 0), - ) - : Number(graphPersistenceState.lastAcceptedRevision || 0), lastPersistReason: String(reason || ""), lastPersistMode: `commit-marker:${saveMode}`, }); @@ -408,53 +532,37 @@ function persistGraphCommitMarker( function applyPersistMismatchBlockedState( chatId, mismatch = null, - { source = "persist-mismatch", attemptIndex = 0 } = {}, + { source = "persist-mismatch", attemptIndex = 0, resolvedBy = "" } = {}, ) { const marker = cloneRuntimeDebugValue(mismatch?.marker, null) || getChatCommitMarker(); const markerRevision = Number(mismatch?.markerRevision || 0); const snapshotRevision = Number(mismatch?.snapshotRevision || 0); - const reason = String(mismatch?.reason || "persist-mismatch:indexeddb-behind-commit-marker"); - applyGraphLoadState(GRAPH_LOAD_STATES.BLOCKED, { - chatId, - reason: `${source}:${reason}`, - attemptIndex, - revision: Math.max(Number(graphPersistenceState.revision || 0), markerRevision), - lastPersistedRevision: Math.max( - Number(graphPersistenceState.lastPersistedRevision || 0), - snapshotRevision, - ), - pendingPersist: false, - dbReady: true, - writesBlocked: true, - }); - updateGraphPersistenceState({ - persistMismatchReason: reason, - commitMarker: cloneRuntimeDebugValue(marker, null), - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - markerRevision, - ), - dualWriteLastResult: { - action: "load", - source: String(source || "persist-mismatch"), - success: false, - rejected: true, - reason, - markerRevision, - snapshotRevision, - at: Date.now(), + const diagnostic = recordPersistMismatchDiagnostic( + { + ...(mismatch || {}), + marker, }, - }); + { + source, + resolvedBy, + }, + ); refreshPanelLiveState(); return { success: false, loaded: false, - loadState: GRAPH_LOAD_STATES.BLOCKED, - reason, + loadState: graphPersistenceState.loadState, + reason: + diagnostic.reason || + String( + mismatch?.reason || + "persist-mismatch:indexeddb-behind-commit-marker", + ), chatId, attemptIndex, markerRevision, snapshotRevision, + diagnosticOnly: true, }; } @@ -594,7 +702,7 @@ function readRuntimeDebugSnapshot() { ); } -// ==================== 状态 ==================== +// ==================== 状?==================== let currentGraph = null; let isExtracting = false; @@ -602,8 +710,8 @@ let isRecalling = false; let activeRecallPromise = null; let recallRunSequence = 0; let lastInjectionContent = ""; -let lastExtractedItems = []; // 最近提取的节点(面板展示用) -let lastRecalledItems = []; // 最近召回的节点(面板展示用) +let lastExtractedItems = []; // 最近提取的节点(面板展示用? +let lastRecalledItems = []; // 最近召回的节点(面板展示用? let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗忘/反思) let serverSettingsSaveTimer = null; let isRecoveringHistory = false; @@ -732,10 +840,7 @@ function getGraphPersistenceLiveState() { const liveCommitMarker = cloneRuntimeDebugValue(graphPersistenceState.commitMarker, null) || readGraphCommitMarker(getContext()); - const lastAcceptedRevision = Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - getAcceptedCommitMarkerRevision(liveCommitMarker), - ); + const restoreLock = normalizeRestoreLockState(graphPersistenceState.restoreLock); const snapshot = { loadState: graphPersistenceState.loadState, chatId: graphPersistenceState.chatId, @@ -754,9 +859,14 @@ function getGraphPersistenceLiveState() { metadataIntegrity: graphPersistenceState.metadataIntegrity, writesBlocked: graphPersistenceState.writesBlocked, pendingPersist: graphPersistenceState.pendingPersist, - lastAcceptedRevision, + lastAcceptedRevision: Number(graphPersistenceState.lastAcceptedRevision || 0), + acceptedStorageTier: String(graphPersistenceState.acceptedStorageTier || "none"), + lastRecoverableStorageTier: String( + graphPersistenceState.lastRecoverableStorageTier || "none", + ), persistMismatchReason: String(graphPersistenceState.persistMismatchReason || ""), commitMarker: cloneRuntimeDebugValue(liveCommitMarker, null), + restoreLock, queuedPersistMode: graphPersistenceState.queuedPersistMode, queuedPersistRotateIntegrity: graphPersistenceState.queuedPersistRotateIntegrity, @@ -863,14 +973,14 @@ function hasReadableRuntimeGraphForRecall(chatId = getCurrentChatId()) { currentGraph.historyState.chatId, ); - // chatId 匹配验证:如果两者都有,必须一致 + // chatId 匹配验证:如果两者都有,必须一? if (activeChatId && runtimeChatId) { return runtimeChatId === activeChatId; } // 兜底:chatId 不可用(ST 插件环境可能无法获取 chatId), - // 但 currentGraph 结构完整且有节点数据 → 允许召回。 - // 这对应用户能在 UI 看到图谱但 getCurrentChatId() 返回空的场景。 + // ?currentGraph 结构完整且有节点数据 ?允许召回? + // 这对应用户能?UI 看到图谱?getCurrentChatId() 返回空的场景? return currentGraph.nodes.length > 0 || currentGraph.edges.length > 0; } @@ -882,10 +992,10 @@ function isGraphReadableForRecall( return true; } - // 当 loadState 不在正常可读状态时(如 NO_CHAT、LOADING), - // 仍检查运行时图谱的实际结构。持久化状态机可能失同步 - // (如 getCurrentChatId 在某些 ST 环境下返回空导致 loadState 卡在 NO_CHAT), - // 但 currentGraph 已经通过其他路径(IndexedDB probe / metadata fallback)加载了数据。 + // ?loadState 不在正常可读状态时(如 NO_CHAT、LOADING), + // 仍检查运行时图谱的实际结构。持久化状态机可能失同? + // (如 getCurrentChatId 在某?ST 环境下返回空导致 loadState 卡在 NO_CHAT), + // ?currentGraph 已经通过其他路径(IndexedDB probe / metadata fallback)加载了数据? return hasReadableRuntimeGraphForRecall(chatId); } @@ -918,7 +1028,7 @@ function createGraphLoadUiStatus() { case GRAPH_LOAD_STATES.BLOCKED: return createUiStatus( "图谱加载受阻", - "当前图谱尚未完成 IndexedDB 初始化", + "当前图谱尚未完成 IndexedDB 初始加载", "warning", ); case GRAPH_LOAD_STATES.LOADED: @@ -942,6 +1052,9 @@ function getPanelRuntimeStatus() { } function getGraphMutationBlockReason(operationLabel = "当前操作") { + if (isRestoreLockActive()) { + return getRestoreLockMessage(operationLabel); + } const loadState = graphPersistenceState.loadState; if (!getCurrentChatId()) { return `${operationLabel}已暂停:当前尚未进入聊天。`; @@ -967,8 +1080,14 @@ function getGraphMutationBlockReason(operationLabel = "当前操作") { function ensureGraphMutationReady( operationLabel = "当前操作", - { notify = true } = {}, + { notify = true, ignoreRestoreLock = false } = {}, ) { + if (!ignoreRestoreLock && isRestoreLockActive()) { + if (notify) { + toastr.info(getRestoreLockMessage(operationLabel), "ST-BME"); + } + return false; + } if (graphPersistenceState.dbReady || isGraphLoadStateDbReady()) return true; if (notify) { toastr.info(getGraphMutationBlockReason(operationLabel), "ST-BME"); @@ -1603,10 +1722,10 @@ function recordRecallSentUserMessage(messageId, text, source = "message-sent") { } // 注意:不再在 MESSAGE_SENT 阶段清空 pendingRecallSendIntent / - // pendingHostGenerationInputSnapshot / transactions。 - // 这些数据在 GENERATION_AFTER_COMMANDS 中被消费;MESSAGE_SENT 先于 - // GENERATION_AFTER_COMMANDS 触发,提前清空会导致召回拿不到用户输入。 - // 真正的消费发生在 recall 执行后(runRecallController 内部)。 + // pendingHostGenerationInputSnapshot / transactions? + // 这些数据?GENERATION_AFTER_COMMANDS 中被消费;MESSAGE_SENT 先于 + // GENERATION_AFTER_COMMANDS 触发,提前清空会导致召回拿不到用户输入? + // 真正的消费发生在 recall 执行后(runRecallController 内部)? return lastRecallSentUserMessage; } @@ -1781,9 +1900,9 @@ function resolveRecallPersistenceTargetUserMessageIndex( } // 正常生成阶段里,ST 可能会在真正发送前改写用户文本 - // (命令展开、包装显示、助手 UI 处理等),导致 hash 已无法精确匹配。 - // 这时仍应优先回绑到“当前最新 user 楼层”,否则召回记录虽然生成了, - // 但 Recall Card 会因为找不到目标楼层而消失。 + // (命令展开、包装显示、助?UI 处理等),导?hash 已无法精确匹配? + // 这时仍应优先回绑到“当前最?user 楼层”,否则召回记录虽然生成了, + // ?Recall Card 会因为找不到目标楼层而消失? if ( normalizedGenerationType === "normal" && Number.isFinite(latestUserIndex) && @@ -2945,8 +3064,8 @@ function schedulePersistedRecallMessageUiRefresh(delayMs = 0) { summary.status === "missing_message_anchor") && attemptIndex < retryDelays.length - 1; - // 勿在「已成功渲染」时长期挂 MutationObserver:#chat 上 class/流式更新会疯狂触发 - // runAttempt,造成满屏刷新与日志;显式事件(USER_MESSAGE_RENDERED 等)仍会 schedule 刷新。 + // 勿在「已成功渲染」时长期?MutationObserver?chat ?class/流式更新会疯狂触? + // runAttempt,造成满屏刷新与日志;显式事件(USER_MESSAGE_RENDERED 等)仍会 schedule 刷新? const shouldWatchForRepaint = false; if (!shouldRetryForPending && !shouldWatchForRepaint) { @@ -3182,7 +3301,7 @@ async function runIncrementalMessageHide(reason = "incremental") { function clearMessageHideState(reason = "reset") { try { resetHideState(getHideRuntimeAdapters()); - debugLog("[ST-BME] 已重置旧楼层隐藏状态:", reason); + debugLog("[ST-BME] 已重置旧楼层隐藏状态", reason); } catch (error) { console.warn("[ST-BME] 重置旧楼层隐藏状态失败:", reason, error); } @@ -3396,7 +3515,7 @@ function isHostChatMetadataReady(context = getContext()) { } const metadata = context.chatMetadata; - // 仅接受宿主“强信号”,避免把中间态/占位 metadata 误判为 ready。 + // 仅接受宿主“强信号”,避免把中间?占位 metadata 误判?ready? if (hasHostMetadataReadySignal(metadata)) return true; return false; @@ -3991,11 +4110,8 @@ function applyShadowSnapshotToRuntime( storageMode: "indexeddb", dbReady: true, indexedDbLastError: "", - persistMismatchReason: "", - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - shadowRevision, - ), + pendingPersist: Boolean(promoteToIndexedDb), + lastRecoverableStorageTier: "shadow", metadataIntegrity: getChatMetadataIntegrity(getContext()) || graphPersistenceState.metadataIntegrity, @@ -4668,28 +4784,33 @@ async function loadGraphFromChatState( snapshot, effectiveCommitMarker, ); + let commitMarkerDiagnostic = null; if (commitMarkerMismatch.mismatched) { - if ( - shadowSnapshot && - Number(shadowSnapshot.revision || 0) >= - Number(commitMarkerMismatch.markerRevision || 0) - ) { - return applyShadowSnapshotToRuntime(normalizedChatId, shadowSnapshot, { - source: `${source}:shadow-beats-chat-state-marker`, - attemptIndex, - }); - } - return applyPersistMismatchBlockedState( - normalizedChatId, + commitMarkerDiagnostic = recordPersistMismatchDiagnostic( { ...commitMarkerMismatch, marker: commitMarkerMismatch.marker || effectiveCommitMarker, }, { source: `${source}:chat-state-marker`, - attemptIndex, }, ); + if ( + shadowSnapshot && + Number(shadowSnapshot.revision || 0) >= + Number(commitMarkerMismatch.markerRevision || 0) + ) { + const shadowResult = applyShadowSnapshotToRuntime(normalizedChatId, shadowSnapshot, { + source: `${source}:shadow-beats-chat-state-marker`, + attemptIndex, + }); + if (shadowResult?.loaded && commitMarkerDiagnostic?.reason) { + updateGraphPersistenceState({ + persistMismatchReason: commitMarkerDiagnostic.reason, + }); + } + return shadowResult; + } } const shouldAllowOverride = @@ -4719,7 +4840,7 @@ async function loadGraphFromChatState( }; } - return applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, { + const loadResult = applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, { source, attemptIndex, storagePrimary: "chat-state", @@ -4727,6 +4848,12 @@ async function loadGraphFromChatState( statusLabel: "聊天侧车", reasonPrefix: "chat-state", }); + if (commitMarkerDiagnostic?.reason && loadResult?.loaded) { + updateGraphPersistenceState({ + persistMismatchReason: commitMarkerDiagnostic.reason, + }); + } + return loadResult; } function scheduleGraphChatStateProbe(chatId, options = {}) { @@ -5610,8 +5737,15 @@ async function loadGraphFromIndexedDb( snapshot, commitMarker, ); + let commitMarkerDiagnostic = null; if (!isIndexedDbSnapshotMeaningful(snapshot)) { if (commitMarkerMismatch.mismatched) { + commitMarkerDiagnostic = recordPersistMismatchDiagnostic( + commitMarkerMismatch, + { + source: `${source}:indexeddb-empty`, + }, + ); if ( shadowSnapshot && Number(shadowSnapshot.revision || 0) >= @@ -5626,17 +5760,12 @@ async function loadGraphFromIndexedDb( }, ); if (shadowRestoreResult?.loaded) { + updateGraphPersistenceState({ + persistMismatchReason: commitMarkerDiagnostic.reason, + }); return shadowRestoreResult; } } - return applyPersistMismatchBlockedState( - normalizedChatId, - commitMarkerMismatch, - { - source: `${source}:indexeddb-empty`, - attemptIndex, - }, - ); } if (shadowSnapshot) { const shadowRestoreResult = applyShadowSnapshotToRuntime( @@ -5651,7 +5780,11 @@ async function loadGraphFromIndexedDb( return shadowRestoreResult; } } - if (applyEmptyState && getCurrentChatId() === normalizedChatId) { + if ( + applyEmptyState && + !commitMarkerDiagnostic?.reason && + getCurrentChatId() === normalizedChatId + ) { return applyIndexedDbEmptyToRuntime(normalizedChatId, { source, attemptIndex, @@ -5660,7 +5793,7 @@ async function loadGraphFromIndexedDb( return { success: false, loaded: false, - reason: "indexeddb-empty", + reason: commitMarkerDiagnostic?.reason || "indexeddb-empty", chatId: normalizedChatId, attemptIndex, }; @@ -5703,12 +5836,21 @@ async function loadGraphFromIndexedDb( ); } if (commitMarkerMismatch.mismatched) { + commitMarkerDiagnostic = recordPersistMismatchDiagnostic( + { + ...commitMarkerMismatch, + marker: commitMarkerMismatch.marker || commitMarker, + }, + { + source: `${source}:indexeddb-commit-marker`, + }, + ); if ( shadowSnapshot && Number(shadowSnapshot.revision || 0) >= Number(commitMarkerMismatch.markerRevision || 0) ) { - return applyShadowSnapshotToRuntime( + const shadowResult = applyShadowSnapshotToRuntime( normalizedChatId, shadowSnapshot, { @@ -5716,18 +5858,13 @@ async function loadGraphFromIndexedDb( attemptIndex, }, ); + if (shadowResult?.loaded && commitMarkerDiagnostic?.reason) { + updateGraphPersistenceState({ + persistMismatchReason: commitMarkerDiagnostic.reason, + }); + } + return shadowResult; } - return applyPersistMismatchBlockedState( - normalizedChatId, - { - ...commitMarkerMismatch, - marker: commitMarkerMismatch.marker || commitMarker, - }, - { - source: `${source}:indexeddb-commit-marker`, - attemptIndex, - }, - ); } const shouldAllowOverride = allowOverride || @@ -5760,10 +5897,16 @@ async function loadGraphFromIndexedDb( }; } - return applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, { + const loadResult = applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, { source, attemptIndex, }); + if (commitMarkerDiagnostic?.reason && loadResult?.loaded) { + updateGraphPersistenceState({ + persistMismatchReason: commitMarkerDiagnostic.reason, + }); + } + return loadResult; } catch (error) { console.warn("[ST-BME] IndexedDB 读取失败,回退 metadata:", error); updateGraphPersistenceState({ @@ -6091,6 +6234,17 @@ function maybeResumePendingAutoExtraction(source = "auto-extraction-resume") { }; } + if (isRestoreLockActive()) { + return { + resumed: false, + reason: "restore-lock-active", + restoreLock: cloneRuntimeDebugValue( + normalizeRestoreLockState(graphPersistenceState.restoreLock), + null, + ), + }; + } + const currentChatId = normalizeChatIdCandidate(getCurrentChatId()); if (!currentChatId || currentChatId !== pendingChatId) { clearPendingAutoExtraction(); @@ -6325,6 +6479,7 @@ function buildGraphPersistResult({ queued = false, blocked = false, accepted = false, + recoverable = false, storageTier = "none", reason = "", loadState = graphPersistenceState.loadState, @@ -6336,6 +6491,7 @@ function buildGraphPersistResult({ queued, blocked, accepted, + recoverable, storageTier: String(storageTier || "none"), reason: String(reason || ""), loadState, @@ -6431,6 +6587,7 @@ function buildBatchPersistenceRecordFromPersistResult(persistResult = null) { const accepted = persistResult?.accepted === true; const queued = persistResult?.queued === true; const blocked = persistResult?.blocked === true; + const recoverable = persistResult?.recoverable === true; let outcome = "failed"; if (accepted && String(persistResult?.storageTier || "") === "indexeddb") { @@ -6439,6 +6596,8 @@ function buildBatchPersistenceRecordFromPersistResult(persistResult = null) { outcome = "fallback"; } else if (queued) { outcome = "queued"; + } else if (recoverable) { + outcome = "recoverable"; } else if (blocked) { outcome = "blocked"; } @@ -6446,6 +6605,7 @@ function buildBatchPersistenceRecordFromPersistResult(persistResult = null) { return { outcome, accepted, + recoverable, storageTier: String(persistResult?.storageTier || "none"), reason: String(persistResult?.reason || ""), revision: Number.isFinite(Number(persistResult?.revision)) @@ -6589,6 +6749,11 @@ function applyAcceptedPendingPersistState( } if (persistenceRecord.accepted === true) { + updateGraphPersistenceState({ + acceptedStorageTier: String(persistenceRecord.storageTier || "none"), + lastRecoverableStorageTier: "none", + pendingPersist: false, + }); const safeFloor = Number.isFinite(Number(lastProcessedAssistantFloor)) ? Math.floor(Number(lastProcessedAssistantFloor)) : null; @@ -6616,6 +6781,9 @@ function schedulePendingGraphPersistRetry( reason = "pending-graph-persist-retry", attempt = 0, ) { + if (isRestoreLockActive()) { + return false; + } if (!graphPersistenceState.pendingPersist) { clearPendingGraphPersistRetry(); return false; @@ -6671,6 +6839,8 @@ function persistGraphToChatMetadata( return buildGraphPersistResult({ saved: false, blocked: true, + accepted: false, + recoverable: false, reason: "missing-context-or-graph", revision, }); @@ -6681,6 +6851,8 @@ function persistGraphToChatMetadata( return buildGraphPersistResult({ saved: false, blocked: true, + accepted: false, + recoverable: false, reason: "missing-chat-id", revision, }); @@ -6694,54 +6866,35 @@ function persistGraphToChatMetadata( chatId, integrity: nextIntegrity, }); - if (graph === currentGraph) { - stampGraphPersistenceMeta(currentGraph, { - revision, - reason, - chatId, - integrity: nextIntegrity, - }); - } + writeChatMetadataPatch(context, { [GRAPH_METADATA_KEY]: persistedGraph, }); const saveMode = triggerChatMetadataSave(context, { immediate }); - applyGraphLoadState(graphPersistenceState.loadState, { - chatId, - reason: graphPersistenceState.reason, - attemptIndex: graphPersistenceState.attemptIndex, - shadowSnapshotUsed: false, - shadowSnapshotRevision: 0, - shadowSnapshotUpdatedAt: "", - shadowSnapshotReason: "", - revision, - lastPersistedRevision: revision, - queuedPersistRevision: 0, - queuedPersistChatId: "", - pendingPersist: false, - writesBlocked: false, - }); - clearPendingGraphPersistRetry(); - removeGraphShadowSnapshot(chatId); updateGraphPersistenceState({ lastPersistReason: String(reason || ""), - lastPersistMode: saveMode, - metadataIntegrity: String(nextIntegrity || ""), - persistMismatchReason: "", - storagePrimary: "metadata", - storageMode: "metadata", - indexedDbLastError: "", - queuedPersistChatId: "", - queuedPersistMode: "", - queuedPersistRotateIntegrity: false, - queuedPersistReason: "", + lastPersistMode: `metadata-full:${saveMode}`, + metadataIntegrity: String(nextIntegrity || graphPersistenceState.metadataIntegrity || ""), + indexedDbLastError: graphPersistenceState.indexedDbLastError || "", + lastRecoverableStorageTier: "metadata-full", + dualWriteLastResult: { + action: "save", + target: "metadata", + success: true, + recoverable: true, + chatId, + revision: normalizeIndexedDbRevision(revision), + reason: String(reason || "graph-persist"), + at: Date.now(), + }, }); rememberResolvedGraphIdentityAlias(context, chatId); return buildGraphPersistResult({ saved: true, - accepted: true, + accepted: false, + recoverable: true, reason, loadState: graphPersistenceState.loadState, revision, @@ -6753,19 +6906,39 @@ function persistGraphToChatMetadata( function queueGraphPersist( reason = "graph-persist-blocked", revision = graphPersistenceState.revision, - { immediate = true, graph = currentGraph, chatId = undefined } = {}, + { + immediate = true, + graph = currentGraph, + chatId = undefined, + captureShadow = true, + recoverableTier = "none", + } = {}, ) { const queuedChatId = String(chatId || graphPersistenceState.chatId || getCurrentChatId()) || ""; - const shadowCaptured = maybeCaptureGraphShadowSnapshot(reason, { - graph, - chatId: queuedChatId, - revision, - }); + const normalizedRevision = Math.max( + 1, + allocateRequestedPersistRevision(revision, graph), + ); + let effectiveRecoverableTier = isRecoveryOnlyPersistTier(recoverableTier) + ? String(recoverableTier) + : "none"; + + if (captureShadow) { + const shadowCaptured = maybeCaptureGraphShadowSnapshot(reason, { + graph, + chatId: queuedChatId, + revision: normalizedRevision, + }); + if (shadowCaptured && effectiveRecoverableTier === "none") { + effectiveRecoverableTier = "shadow"; + } + } + updateGraphPersistenceState({ queuedPersistRevision: Math.max( - graphPersistenceState.queuedPersistRevision || 0, - revision || 0, + normalizeIndexedDbRevision(graphPersistenceState.queuedPersistRevision), + normalizedRevision, ), queuedPersistChatId: String(queuedChatId || ""), queuedPersistMode: immediate ? "immediate" : "debounced", @@ -6774,18 +6947,23 @@ function queueGraphPersist( pendingPersist: true, writesBlocked: true, lastPersistReason: String(reason || ""), + lastPersistMode: immediate ? "pending-immediate" : "pending-debounced", + lastRecoverableStorageTier: isRecoveryOnlyPersistTier(effectiveRecoverableTier) + ? effectiveRecoverableTier + : graphPersistenceState.lastRecoverableStorageTier, }); schedulePendingGraphPersistRetry(String(reason || "graph-persist-blocked"), 0); return buildGraphPersistResult({ queued: true, blocked: true, - accepted: shadowCaptured, + accepted: false, + recoverable: isRecoveryOnlyPersistTier(effectiveRecoverableTier), reason, loadState: graphPersistenceState.loadState, - revision, + revision: normalizedRevision, saveMode: immediate ? "immediate" : "debounced", - storageTier: shadowCaptured ? "shadow" : "none", + storageTier: effectiveRecoverableTier !== "none" ? effectiveRecoverableTier : "none", }); } @@ -6846,9 +7024,22 @@ async function retryPendingGraphPersist({ reason = "pending-graph-persist-retry", retryAttempt = 0, scheduleRetryOnFailure = false, + ignoreRestoreLock = false, } = {}) { ensureCurrentGraphRuntimeState(); + if (!ignoreRestoreLock && isRestoreLockActive()) { + return buildGraphPersistResult({ + saved: false, + blocked: true, + accepted: false, + reason: "restore-lock-active", + revision: graphPersistenceState.revision, + saveMode: graphPersistenceState.lastPersistMode, + storageTier: "none", + }); + } + if (!graphPersistenceState.pendingPersist) { clearPendingGraphPersistRetry(); return buildGraphPersistResult({ @@ -6937,49 +7128,34 @@ async function retryPendingGraphPersist({ reason, }); if (indexedDbResult?.saved) { - const chatStateMirrorResult = canUseHostGraphChatStatePersistence(context) - ? await persistGraphToHostChatState(context, { - graph: pendingPersistGraph, - revision: targetRevision, - reason: `${reason}:chat-state-mirror`, - storageTier: "chat-state", - accepted: true, - lastProcessedAssistantFloor, - extractionCount, - mode: "mirror", - }) - : null; + if (canUseHostGraphChatStatePersistence(context)) { + await persistGraphToHostChatState(context, { + graph: pendingPersistGraph, + revision: indexedDbResult.revision || targetRevision, + reason: `${reason}:chat-state-mirror`, + storageTier: "chat-state", + accepted: true, + lastProcessedAssistantFloor, + extractionCount, + mode: "mirror", + }); + } clearPendingGraphPersistRetry(); persistGraphCommitMarker(context, { reason, - revision: targetRevision, + revision: indexedDbResult.revision || targetRevision, storageTier: "indexeddb", accepted: true, lastProcessedAssistantFloor, extractionCount, immediate: true, }); - updateGraphPersistenceState({ - pendingPersist: false, - persistMismatchReason: "", - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - targetRevision, - ), - lastPersistReason: String(reason || ""), - lastPersistMode: "indexeddb", - queuedPersistRevision: 0, - queuedPersistChatId: "", - queuedPersistMode: "", - queuedPersistRotateIntegrity: false, - queuedPersistReason: "", - }); const persistResult = buildGraphPersistResult({ saved: true, accepted: true, reason, - revision: targetRevision, - saveMode: "indexeddb", + revision: indexedDbResult.revision || targetRevision, + saveMode: String(indexedDbResult.saveMode || "indexeddb-delta"), storageTier: "indexeddb", }); applyAcceptedPendingPersistState(persistResult, { @@ -7005,7 +7181,7 @@ async function retryPendingGraphPersist({ clearPendingGraphPersistRetry(); persistGraphCommitMarker(context, { reason: `${reason}:chat-state-fallback`, - revision: targetRevision, + revision: chatStateResult.revision || targetRevision, storageTier: "chat-state", accepted: true, lastProcessedAssistantFloor, @@ -7013,14 +7189,20 @@ async function retryPendingGraphPersist({ immediate: true, }); updateGraphPersistenceState({ + revision: Math.max( + Number(graphPersistenceState.revision || 0), + Number(chatStateResult.revision || targetRevision), + ), pendingPersist: false, persistMismatchReason: "", lastAcceptedRevision: Math.max( Number(graphPersistenceState.lastAcceptedRevision || 0), - targetRevision, + Number(chatStateResult.revision || targetRevision), ), + acceptedStorageTier: "chat-state", + lastRecoverableStorageTier: "none", lastPersistReason: `${reason}:chat-state-fallback`, - lastPersistMode: "chat-state", + lastPersistMode: String(chatStateResult.saveMode || "chat-state"), queuedPersistRevision: 0, queuedPersistChatId: "", queuedPersistMode: "", @@ -7033,8 +7215,8 @@ async function retryPendingGraphPersist({ saved: true, accepted: true, reason: `${reason}:chat-state-fallback`, - revision: targetRevision, - saveMode: "chat-state", + revision: Number(chatStateResult.revision || targetRevision), + saveMode: String(chatStateResult.saveMode || "chat-state"), storageTier: "chat-state", }); applyAcceptedPendingPersistState(persistResult, { @@ -7042,7 +7224,7 @@ async function retryPendingGraphPersist({ persistedGraph: pendingPersistGraph, }); queueGraphPersistToIndexedDb(activeChatId, pendingPersistGraph, { - revision: targetRevision, + revision: Number(chatStateResult.revision || targetRevision), reason: `${reason}:chat-state-fallback:promote-indexeddb`, }); void maybeResumePendingAutoExtraction("pending-persist-resolved:chat-state"); @@ -7050,6 +7232,7 @@ async function retryPendingGraphPersist({ } } + let recoverableTier = "none"; if (canPersistGraphToMetadataFallback(context, pendingPersistGraph)) { const metadataReason = `${reason}:metadata-full-fallback`; const metadataResult = persistGraphToChatMetadata(context, { @@ -7059,49 +7242,36 @@ async function retryPendingGraphPersist({ graph: pendingPersistGraph, }); if (metadataResult?.saved) { - clearPendingGraphPersistRetry(); - persistGraphCommitMarker(context, { - reason: metadataReason, - revision: targetRevision, - storageTier: "metadata-full", - accepted: true, - lastProcessedAssistantFloor, - extractionCount, - immediate: true, - }); - updateGraphPersistenceState({ - pendingPersist: false, - persistMismatchReason: "", - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - targetRevision, - ), - lastPersistReason: metadataReason, - lastPersistMode: String(metadataResult.saveMode || "metadata"), - queuedPersistRevision: 0, - queuedPersistChatId: "", - queuedPersistMode: "", - queuedPersistRotateIntegrity: false, - queuedPersistReason: "", - }); - const persistResult = buildGraphPersistResult({ - saved: true, - accepted: true, - reason: metadataReason, - revision: targetRevision, - saveMode: metadataResult.saveMode, - storageTier: "metadata-full", - }); - applyAcceptedPendingPersistState(persistResult, { - lastProcessedAssistantFloor, - persistedGraph: pendingPersistGraph, - }); - void maybeResumePendingAutoExtraction("pending-persist-resolved:metadata"); - return persistResult; + recoverableTier = "metadata-full"; } } - if (scheduleRetryOnFailure) { + if ( + recoverableTier === "none" && + maybeCaptureGraphShadowSnapshot(`${reason}:shadow-fallback`, { + graph: pendingPersistGraph, + chatId: activeChatId, + revision: targetRevision, + }) + ) { + recoverableTier = "shadow"; + } + + const queuedReason = `${reason}:still-pending`; + const queuedResult = queueGraphPersist(queuedReason, targetRevision, { + immediate: graphPersistenceState.queuedPersistMode !== "debounced", + graph: pendingPersistGraph, + chatId: activeChatId, + captureShadow: recoverableTier === "none", + recoverableTier, + }); + if (recoverableTier !== "none") { + updateGraphPersistenceState({ + lastPersistReason: queuedReason, + lastRecoverableStorageTier: recoverableTier, + }); + } + if (scheduleRetryOnFailure && recoverableTier === "none") { schedulePendingGraphPersistRetry(reason, Number(retryAttempt) + 1); } return buildGraphPersistResult({ @@ -7109,10 +7279,17 @@ async function retryPendingGraphPersist({ queued: true, blocked: true, accepted: false, - reason: `${reason}:still-pending`, - revision: targetRevision, - saveMode: graphPersistenceState.queuedPersistMode || "immediate", - storageTier: "none", + recoverable: + recoverableTier !== "none" || queuedResult?.recoverable === true, + reason: queuedReason, + revision: Number(queuedResult?.revision || targetRevision), + saveMode: String( + queuedResult?.saveMode || graphPersistenceState.queuedPersistMode || "immediate", + ), + storageTier: + recoverableTier !== "none" + ? recoverableTier + : String(queuedResult?.storageTier || "none"), }); } @@ -7148,55 +7325,40 @@ async function persistExtractionBatchResult({ }); } - const revision = bumpGraphRevision(reason); + const revision = allocateRequestedPersistRevision(0, persistGraph); const indexedDbResult = await saveGraphToIndexedDb(chatId, persistGraph, { revision, reason, }); if (indexedDbResult?.saved) { - const chatStateMirrorResult = canUseHostGraphChatStatePersistence(context) - ? await persistGraphToHostChatState(context, { - graph: persistGraph, - revision, - reason: `${reason}:chat-state-mirror`, - storageTier: "chat-state", - accepted: true, - lastProcessedAssistantFloor, - extractionCount, - mode: "mirror", - }) - : null; + if (canUseHostGraphChatStatePersistence(context)) { + await persistGraphToHostChatState(context, { + graph: persistGraph, + revision: indexedDbResult.revision || revision, + reason: `${reason}:chat-state-mirror`, + storageTier: "chat-state", + accepted: true, + lastProcessedAssistantFloor, + extractionCount, + mode: "mirror", + }); + } persistGraphCommitMarker(context, { reason, - revision, + revision: indexedDbResult.revision || revision, storageTier: "indexeddb", accepted: true, lastProcessedAssistantFloor, extractionCount, immediate: true, }); - updateGraphPersistenceState({ - pendingPersist: false, - persistMismatchReason: "", - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - revision, - ), - lastPersistReason: String(reason || ""), - lastPersistMode: "indexeddb", - queuedPersistRevision: 0, - queuedPersistChatId: "", - queuedPersistMode: "", - queuedPersistRotateIntegrity: false, - queuedPersistReason: "", - }); clearPendingGraphPersistRetry(); return buildGraphPersistResult({ saved: true, accepted: true, reason, - revision, - saveMode: "indexeddb", + revision: indexedDbResult.revision || revision, + saveMode: String(indexedDbResult.saveMode || "indexeddb-delta"), storageTier: "indexeddb", }); } @@ -7215,7 +7377,7 @@ async function persistExtractionBatchResult({ if (chatStateResult?.saved) { persistGraphCommitMarker(context, { reason: `${reason}:chat-state-fallback`, - revision, + revision: chatStateResult.revision || revision, storageTier: "chat-state", accepted: true, lastProcessedAssistantFloor, @@ -7223,14 +7385,20 @@ async function persistExtractionBatchResult({ immediate: true, }); updateGraphPersistenceState({ + revision: Math.max( + Number(graphPersistenceState.revision || 0), + Number(chatStateResult.revision || revision), + ), pendingPersist: false, persistMismatchReason: "", lastAcceptedRevision: Math.max( Number(graphPersistenceState.lastAcceptedRevision || 0), - revision, + Number(chatStateResult.revision || revision), ), + acceptedStorageTier: "chat-state", + lastRecoverableStorageTier: "none", lastPersistReason: `${reason}:chat-state-fallback`, - lastPersistMode: "chat-state", + lastPersistMode: String(chatStateResult.saveMode || "chat-state"), queuedPersistRevision: 0, queuedPersistChatId: "", queuedPersistMode: "", @@ -7241,66 +7409,29 @@ async function persistExtractionBatchResult({ }); clearPendingGraphPersistRetry(); queueGraphPersistToIndexedDb(chatId, persistGraph, { - revision, + revision: Number(chatStateResult.revision || revision), reason: `${reason}:chat-state-fallback:promote-indexeddb`, }); return buildGraphPersistResult({ saved: true, accepted: true, reason: `${reason}:chat-state-fallback`, - revision, - saveMode: "chat-state", + revision: Number(chatStateResult.revision || revision), + saveMode: String(chatStateResult.saveMode || "chat-state"), storageTier: "chat-state", }); } } - const shadowReason = `${reason}:shadow-fallback`; - const shadowCaptured = maybeCaptureGraphShadowSnapshot(shadowReason, { - graph: persistGraph, - chatId, - revision, - }); - if (shadowCaptured) { - if (isGraphMetadataWriteAllowed()) { - persistGraphCommitMarker(context, { - reason: shadowReason, - revision, - storageTier: "shadow", - accepted: true, - lastProcessedAssistantFloor, - extractionCount, - immediate: true, - }); - } - updateGraphPersistenceState({ - pendingPersist: false, - persistMismatchReason: "", - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - revision, - ), - lastPersistReason: shadowReason, - lastPersistMode: "shadow", - queuedPersistRevision: 0, - queuedPersistChatId: "", - queuedPersistMode: "", - queuedPersistRotateIntegrity: false, - queuedPersistReason: "", - }); - clearPendingGraphPersistRetry(); - queueGraphPersistToIndexedDb(chatId, persistGraph, { + let recoverableTier = "none"; + if ( + maybeCaptureGraphShadowSnapshot(`${reason}:shadow-fallback`, { + graph: persistGraph, + chatId, revision, - reason: `${shadowReason}:promote-indexeddb`, - }); - return buildGraphPersistResult({ - saved: false, - accepted: true, - reason: shadowReason, - revision, - saveMode: "shadow", - storageTier: "shadow", - }); + }) + ) { + recoverableTier = "shadow"; } if (canPersistGraphToMetadataFallback(context, persistGraph)) { @@ -7312,41 +7443,7 @@ async function persistExtractionBatchResult({ graph: persistGraph, }); if (metadataResult?.saved) { - persistGraphCommitMarker(context, { - reason: metadataReason, - revision, - storageTier: "metadata-full", - accepted: true, - lastProcessedAssistantFloor, - extractionCount, - immediate: true, - }); - updateGraphPersistenceState({ - pendingPersist: false, - persistMismatchReason: "", - lastAcceptedRevision: Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - revision, - ), - queuedPersistRevision: 0, - queuedPersistChatId: "", - queuedPersistMode: "", - queuedPersistRotateIntegrity: false, - queuedPersistReason: "", - }); - clearPendingGraphPersistRetry(); - queueGraphPersistToIndexedDb(chatId, persistGraph, { - revision, - reason: `${metadataReason}:promote-indexeddb`, - }); - return buildGraphPersistResult({ - saved: true, - accepted: true, - reason: metadataReason, - revision, - saveMode: metadataResult.saveMode, - storageTier: "metadata-full", - }); + recoverableTier = "metadata-full"; } } @@ -7354,21 +7451,32 @@ async function persistExtractionBatchResult({ immediate: true, graph: persistGraph, chatId, + captureShadow: recoverableTier === "none", + recoverableTier, }); updateGraphPersistenceState({ pendingPersist: true, lastPersistReason: String(queuedResult.reason || `${reason}:pending`), lastPersistMode: String(queuedResult.saveMode || ""), + lastRecoverableStorageTier: + recoverableTier !== "none" + ? recoverableTier + : String(queuedResult.storageTier || graphPersistenceState.lastRecoverableStorageTier || "none"), }); return buildGraphPersistResult({ saved: false, queued: Boolean(queuedResult?.queued), blocked: Boolean(queuedResult?.blocked), accepted: false, + recoverable: + recoverableTier !== "none" || queuedResult?.recoverable === true, reason: String(queuedResult?.reason || `${reason}:pending`), - revision, + revision: Number(queuedResult?.revision || revision), saveMode: String(queuedResult?.saveMode || ""), - storageTier: String(queuedResult?.storageTier || "none"), + storageTier: + recoverableTier !== "none" + ? recoverableTier + : String(queuedResult?.storageTier || "none"), }); } @@ -7583,7 +7691,7 @@ function clearInjectionState(options = {}) { try { applyModuleInjectionPrompt("", getSettings()); } catch (error) { - console.warn("[ST-BME] 清理旧注入失败:", error); + console.warn("[ST-BME] 清理旧注入失?", error); } refreshPanelLiveState(); @@ -7607,7 +7715,7 @@ function notifyStatusToast(key, kind, message, title = "ST-BME") { function setRuntimeStatus(text, meta, level = "info") { runtimeStatus = createUiStatus(text, meta, level); refreshPanelLiveState(); - // 同步悬浮球状态 + // 同步悬浮球状? const fabStatus = level === "info" ? "idle" : level; _panelModule?.updateFloatingBallStatus?.(fabStatus, text || "BME 记忆图谱"); } @@ -7968,7 +8076,7 @@ function buildMaintenanceSummary(action, result, mode = "manual") { const prefix = mode === "auto" ? "自动" : "手动"; switch (String(action || "")) { case "compress": - return `${prefix}压缩:新建 ${result?.created || 0},归档 ${result?.archived || 0}`; + return `${prefix}压缩:新增 ${result?.created || 0},归档 ${result?.archived || 0}`; case "consolidate": return `${prefix}整合:合并 ${result?.merged || 0},跳过 ${result?.skipped || 0},保留 ${result?.kept || 0},进化 ${result?.evolved || 0},新链接 ${result?.connections || 0},回溯更新 ${result?.updates || 0}`; case "sleep": @@ -8648,26 +8756,31 @@ function loadGraphFromChat(options = {}) { }; } + let metadataMismatchDiagnostic = null; if (metadataCommitMismatch.mismatched) { clearPendingGraphLoadRetry(); + metadataMismatchDiagnostic = recordPersistMismatchDiagnostic( + metadataCommitMismatch, + { + source: `${source}:metadata-compat`, + }, + ); if ( shadowSnapshot && Number(shadowSnapshot.revision || 0) >= Number(metadataCommitMismatch.markerRevision || 0) ) { - return applyShadowSnapshotToRuntime(chatId, shadowSnapshot, { + const shadowResult = applyShadowSnapshotToRuntime(chatId, shadowSnapshot, { source: `${source}:metadata-shadow`, attemptIndex, }); + if (shadowResult?.loaded && metadataMismatchDiagnostic?.reason) { + updateGraphPersistenceState({ + persistMismatchReason: metadataMismatchDiagnostic.reason, + }); + } + return shadowResult; } - return applyPersistMismatchBlockedState( - chatId, - metadataCommitMismatch, - { - source: `${source}:metadata-compat`, - attemptIndex, - }, - ); } if (shadowSnapshot && shadowDecision?.reason) { @@ -8757,6 +8870,10 @@ function loadGraphFromChat(options = {}) { storageMode: "indexeddb", dbReady: false, indexedDbLastError: "", + persistMismatchReason: + metadataMismatchDiagnostic?.reason || + graphPersistenceState.persistMismatchReason || + "", dualWriteLastResult: { action: "load", source: `${source}:metadata-compat`, @@ -8797,13 +8914,13 @@ function loadGraphFromChat(options = {}) { if (shadowSnapshot) { const acceptedCommitRevision = getAcceptedCommitMarkerRevision(commitMarker); + let shadowOnlyMismatch = null; if ( acceptedCommitRevision > 0 && Number(shadowSnapshot.revision || 0) < acceptedCommitRevision ) { clearPendingGraphLoadRetry(); - return applyPersistMismatchBlockedState( - chatId, + shadowOnlyMismatch = recordPersistMismatchDiagnostic( { mismatched: true, reason: "persist-mismatch:indexeddb-behind-commit-marker", @@ -8813,15 +8930,21 @@ function loadGraphFromChat(options = {}) { }, { source: `${source}:shadow-no-official`, - attemptIndex, + resolvedBy: "shadow", }, ); } clearPendingGraphLoadRetry(); - return applyShadowSnapshotToRuntime(chatId, shadowSnapshot, { + const shadowResult = applyShadowSnapshotToRuntime(chatId, shadowSnapshot, { source: `${source}:shadow-no-official`, attemptIndex, }); + if (shadowOnlyMismatch?.reason && shadowResult?.loaded) { + updateGraphPersistenceState({ + persistMismatchReason: shadowOnlyMismatch.reason, + }); + } + return shadowResult; } applyGraphLoadState(GRAPH_LOAD_STATES.LOADING, { @@ -8885,9 +9008,10 @@ async function saveGraphToIndexedDb( const baseSnapshot = readCachedIndexedDbSnapshot(normalizedChatId) || (await db.exportSnapshot()); + const requestedRevision = resolvePersistRevisionFloor(revision, graph); const snapshot = buildSnapshotFromGraph(graph, { chatId: normalizedChatId, - revision, + revision: requestedRevision, baseSnapshot, lastModified: Date.now(), meta: { @@ -8898,27 +9022,35 @@ async function saveGraphToIndexedDb( hostChatId: currentIdentity.hostChatId || "", }, }); - const importResult = await db.importSnapshot(snapshot, { - mode: "replace", - preserveRevision: true, - revision, + const delta = buildPersistDelta(baseSnapshot, snapshot); + const commitResult = await db.commitDelta(delta, { + reason, + requestedRevision, markSyncDirty: true, }); - let syncDirtyWarning = ""; + let scheduleUploadWarning = ""; - try { - await db.markSyncDirty(reason); - } catch (error) { - syncDirtyWarning = - error?.message || String(error) || "mark-sync-dirty-failed"; - console.warn("[ST-BME] IndexedDB 已写入,但补记 syncDirty 失败:", error); + snapshot.meta.revision = normalizeIndexedDbRevision( + commitResult?.revision, + requestedRevision, + ); + snapshot.meta.lastModified = Number(commitResult?.lastModified || Date.now()); + snapshot.meta.lastMutationReason = String(reason || "graph-save"); + snapshot.meta.storagePrimary = "indexeddb"; + cacheIndexedDbSnapshot(normalizedChatId, snapshot); + + if (graph === currentGraph) { + stampGraphPersistenceMeta(currentGraph, { + revision: snapshot.meta.revision, + reason: String(reason || "graph-save"), + chatId: normalizedChatId, + integrity: + currentIdentity.integrity || + getChatMetadataIntegrity(getContext()) || + graphPersistenceState.metadataIntegrity, + }); } - snapshot.meta.revision = normalizeIndexedDbRevision( - importResult?.revision, - revision, - ); - cacheIndexedDbSnapshot(normalizedChatId, snapshot); try { scheduleUpload( normalizedChatId, @@ -8933,6 +9065,7 @@ async function saveGraphToIndexedDb( } updateGraphPersistenceState({ + revision: snapshot.meta.revision, storagePrimary: "indexeddb", storageMode: "indexeddb", dbReady: true, @@ -8946,9 +9079,20 @@ async function saveGraphToIndexedDb( indexedDbRevision: snapshot.meta.revision, metadataIntegrity: getChatMetadataIntegrity(getContext()) || + currentIdentity.integrity || graphPersistenceState.metadataIntegrity, indexedDbLastError: "", lastSyncError: scheduleUploadWarning, + syncDirty: true, + syncDirtyReason: String(reason || "graph-save"), + lastPersistReason: String(reason || "graph-save"), + lastPersistMode: "indexeddb-delta", + lastAcceptedRevision: Math.max( + Number(graphPersistenceState.lastAcceptedRevision || 0), + snapshot.meta.revision, + ), + acceptedStorageTier: "indexeddb", + lastRecoverableStorageTier: "none", dualWriteLastResult: { action: "save", target: "indexeddb", @@ -8956,9 +9100,8 @@ async function saveGraphToIndexedDb( chatId: normalizedChatId, revision: snapshot.meta.revision, reason: String(reason || "graph-save"), - warning: - [syncDirtyWarning, scheduleUploadWarning].filter(Boolean).join(" | ") || - "", + warning: scheduleUploadWarning || "", + delta: cloneRuntimeDebugValue(commitResult?.delta, null), at: Date.now(), }, }); @@ -9001,11 +9144,13 @@ async function saveGraphToIndexedDb( chatId: normalizedChatId, revision: snapshot.meta.revision, reason: String(reason || "graph-save"), - warning: - [syncDirtyWarning, scheduleUploadWarning].filter(Boolean).join(" | ") || "", + saveMode: "indexeddb-delta", + warning: scheduleUploadWarning || "", + delta: cloneRuntimeDebugValue(commitResult?.delta, null), + snapshot, }; } catch (error) { - console.warn("[ST-BME] IndexedDB 写入失败,保留 metadata 兜底:", error); + console.warn("[ST-BME] IndexedDB 写入失败,保鐣?metadata 兜底:", error); updateGraphPersistenceState({ indexedDbLastError: error?.message || String(error), dualWriteLastResult: { @@ -9113,8 +9258,8 @@ function saveGraphToChat(options = {}) { } const revision = markMutation - ? bumpGraphRevision(reason) - : graphPersistenceState.revision || 0; + ? allocateRequestedPersistRevision(0, currentGraph) + : resolvePersistRevisionFloor(0, currentGraph); if (captureShadow) { maybeCaptureGraphShadowSnapshot(reason); @@ -9204,8 +9349,7 @@ function saveGraphToChat(options = {}) { immediate, }); updateGraphPersistenceState({ - storagePrimary: "metadata", - storageMode: "metadata", + storageMode: "metadata-full", dualWriteLastResult: { action: "save", target: "metadata", @@ -9319,7 +9463,7 @@ function buildGenerationAfterCommandsRecallInput(type, params = {}, chat) { generationType, }); - // 对于 history 类型(continue/regenerate/swipe),必须有 chat 中的用户消息 + // 对于 history 类型(continue/regenerate/swipe),必须?chat 中的用户消息 if (generationType !== "normal") { if (!Number.isFinite(targetUserMessageIndex)) { return { @@ -9341,10 +9485,10 @@ function buildGenerationAfterCommandsRecallInput(type, params = {}, chat) { }; } - // 对于 normal 类型:GENERATION_AFTER_COMMANDS 触发时用户消息可能不在 chat 末尾 - // (ST 可能已追加空 assistant 消息)。如果 chat 中存在任何用户消息, - // 继续走 buildNormalGenerationRecallInput,它会通过 latestUserText 兜底找到。 - // 如果 chat 中完全没有用户消息,则延迟到 BEFORE_COMBINE_PROMPTS 处理。 + // 对于 normal 类型:GENERATION_AFTER_COMMANDS 触发时用户消息可能不?chat 末尾 + // (ST 可能已追加空 assistant 消息)。如?chat 中存在任何用户消息, + // 继续?buildNormalGenerationRecallInput,它会通过 latestUserText 兜底找到? + // 如果 chat 中完全没有用户消息,则延迟到 BEFORE_COMBINE_PROMPTS 处理? if (!Number.isFinite(targetUserMessageIndex) && !getLatestUserChatMessage(chat)) { return { generationType, @@ -9370,9 +9514,9 @@ function buildNormalGenerationRecallInput(chat, options = {}) { const tailUserText = lastNonSystemMessage?.is_user ? normalizeRecallInputText(lastNonSystemMessage?.mes || "") : ""; - // 当 GENERATION_AFTER_COMMANDS 触发时,ST 可能已追加了空 assistant 消息, + // ?GENERATION_AFTER_COMMANDS 触发时,ST 可能已追加了?assistant 消息? // 导致 lastNonSystemMessage 不是 user。用 getLatestUserChatMessage 反向扫描 - // 定位真正的用户消息(与 shujuku 参考实现一致)。 + // 定位真正的用户消息(?shujuku 参考实现一致)? const latestUserMessage = !tailUserText ? getLatestUserChatMessage(chat) : null; const latestUserText = latestUserMessage ? normalizeRecallInputText(latestUserMessage?.mes || "") @@ -9707,10 +9851,10 @@ function resolveGenerationRecallDeliveryMode( return "immediate"; } - // GENERATION_AFTER_COMMANDS: immediate —— await 完召回后直接通过 - // setExtensionPrompt 注入记忆,与 shujuku 参考实现一致。 - // GENERATE_BEFORE_COMBINE_PROMPTS: deferred —— 作为兜底,通过 promptData - // rewrite 补救注入。 + // GENERATION_AFTER_COMMANDS: immediate —?await 完召回后直接通过 + // setExtensionPrompt 注入记忆,与 shujuku 参考实现一致? + // GENERATE_BEFORE_COMBINE_PROMPTS: deferred —?作为兜底,通过 promptData + // rewrite 补救注入? if (hookName === "GENERATE_BEFORE_COMBINE_PROMPTS") { return "deferred"; } @@ -10024,6 +10168,10 @@ function clearGenerationRecallTransactionsForChat( } function invalidateRecallAfterHistoryMutation(reason = "聊天记录已变更") { + if (isRestoreLockActive()) { + return false; + } + const hadActiveRecall = Boolean( isRecalling || (stageAbortControllers.recall && @@ -10400,7 +10548,7 @@ async function handleExtractionSuccess( const prefix = mode === "auto" ? "自动" : "手动"; switch (String(action || "")) { case "compress": - return `${prefix}压缩:新建 ${maintenanceResult?.created || 0},归档 ${maintenanceResult?.archived || 0}`; + return `${prefix}压缩:新增 ${maintenanceResult?.created || 0},归档 ${maintenanceResult?.archived || 0}`; case "consolidate": return `${prefix}整合:合并 ${maintenanceResult?.merged || 0},跳过 ${maintenanceResult?.skipped || 0},保留 ${maintenanceResult?.kept || 0},进化 ${maintenanceResult?.evolved || 0},新链接 ${maintenanceResult?.connections || 0},回溯更新 ${maintenanceResult?.updates || 0}`; case "sleep": @@ -10563,8 +10711,8 @@ async function handleExtractionSuccess( updateExtractionPostProcessStatus( summaryStageLabel === "概要生成" ? "概要更新中" : "层级总结处理中", summaryStageLabel === "概要生成" - ? `第 ${extractionCount} 次提取,正在生成全局概要` - : `第 ${extractionCount} 次提取,正在检查小总结与折叠总结`, + ? `${extractionCount} 次提取,正在生成全局概要` + : `${extractionCount} 次提取,正在检查小总结与折叠总结`, ); const summaryResult = await runSummaryPostProcess({ graph: currentGraph, @@ -10606,7 +10754,7 @@ async function handleExtractionSuccess( try { updateExtractionPostProcessStatus( "反思生成中", - `第 ${extractionCount} 次提取,正在生成长期反思`, + `${extractionCount} 次提取,正在生成长期反思`, ); await generateReflection({ graph: currentGraph, @@ -10636,7 +10784,7 @@ async function handleExtractionSuccess( try { updateExtractionPostProcessStatus( "主动遗忘中", - `第 ${extractionCount} 次提取,正在归档低价值记忆`, + `${extractionCount} 次提取,正在归档低价值记忆`, ); const beforeSnapshot = cloneMaintenanceSnapshot(currentGraph); const sleepResult = sleepCycle(currentGraph, settings); @@ -11389,7 +11537,11 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { if (!detection.dirty && !Number.isFinite(dirtyFrom)) { return true; } + if (isRestoreLockActive()) { + return false; + } + enterRestoreLock("history-recovery", trigger); isRecoveringHistory = true; clearInjectionState(); @@ -11677,6 +11829,7 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { } } finally { finishStageAbortController("history", historyController); + leaveRestoreLock("history-recovery"); isRecoveringHistory = false; const enqueueMicrotask = typeof globalThis.queueMicrotask === "function" @@ -11994,6 +12147,19 @@ async function runPlannerRecallForEna({ * 召回管线:检索并注入记忆 */ async function runRecall(options = {}) { + if (!options?.ignoreRestoreLock && isRestoreLockActive()) { + const message = getRestoreLockMessage("召回"); + setLastRecallStatus("召回已暂停", message, "warning", { + syncRuntime: true, + }); + return createRecallRunResult("skipped", { + reason: "restore-lock-active", + restoreLock: cloneRuntimeDebugValue( + normalizeRestoreLockState(graphPersistenceState.restoreLock), + null, + ), + }); + } return await runRecallController( { abortRecallStageWithReason, @@ -12375,38 +12541,47 @@ async function onViewGraph() { } async function onRebuild() { - return await onRebuildController({ - buildRecoveryResult, - clearHistoryDirty, - clearInjectionState, - cloneGraphSnapshot, - confirm: (message) => { - if (typeof globalThis.confirm === "function") { - return globalThis.confirm(message); - } - return false; - }, - createEmptyGraph, - ensureGraphMutationReady, - getContext, - getCurrentChatId, - getCurrentGraph: () => currentGraph, - getSettings, - normalizeGraphRuntimeState, - prepareVectorStateForReplay, - refreshPanelLiveState, - replayExtractionFromHistory, - restoreRuntimeUiState, - saveGraphToChat, - updateProcessedHistorySnapshot, - setCurrentGraph: (graph) => { - currentGraph = graph; - }, - setLastExtractionStatus, - setRuntimeStatus, - snapshotRuntimeUiState, - toastr, - }); + return await runWithRestoreLock( + "manual-rebuild", + "manual-rebuild", + async () => + await onRebuildController({ + buildRecoveryResult, + clearHistoryDirty, + clearInjectionState, + cloneGraphSnapshot, + confirm: (message) => { + if (typeof globalThis.confirm === "function") { + return globalThis.confirm(message); + } + return false; + }, + createEmptyGraph, + ensureGraphMutationReady: (operationLabel, options = {}) => + ensureGraphMutationReady(operationLabel, { + ...(options || {}), + ignoreRestoreLock: true, + }), + getContext, + getCurrentChatId, + getCurrentGraph: () => currentGraph, + getSettings, + normalizeGraphRuntimeState, + prepareVectorStateForReplay, + refreshPanelLiveState, + replayExtractionFromHistory, + restoreRuntimeUiState, + saveGraphToChat, + updateProcessedHistorySnapshot, + setCurrentGraph: (graph) => { + currentGraph = graph; + }, + setLastExtractionStatus, + setRuntimeStatus, + snapshotRuntimeUiState, + toastr, + }), + ); } async function onManualCompress() { @@ -12664,32 +12839,41 @@ async function onExportGraph() { } async function onImportGraph() { - return await onImportGraphController({ - clearInjectionState, - clearTimeout, - document, - ensureGraphMutationReady, - getAssistantTurns, - getContext, - getCurrentChatId, - importGraph, - markVectorStateDirty, - normalizeGraphRuntimeState, - rebindProcessedHistoryStateToChat, - saveGraphToChat, - setCurrentGraph: (graph) => { - currentGraph = graph; - }, - setExtractionCount: (value) => { - extractionCount = value; - }, - setLastExtractedItems: (items) => { - lastExtractedItems = items; - }, - toastr, - updateLastRecalledItems, - window, - }); + return await runWithRestoreLock( + "graph-import", + "graph-import", + async () => + await onImportGraphController({ + clearInjectionState, + clearTimeout, + document, + ensureGraphMutationReady: (operationLabel, options = {}) => + ensureGraphMutationReady(operationLabel, { + ...(options || {}), + ignoreRestoreLock: true, + }), + getAssistantTurns, + getContext, + getCurrentChatId, + importGraph, + markVectorStateDirty, + normalizeGraphRuntimeState, + rebindProcessedHistoryStateToChat, + saveGraphToChat, + setCurrentGraph: (graph) => { + currentGraph = graph; + }, + setExtractionCount: (value) => { + extractionCount = value; + }, + setLastExtractedItems: (items) => { + lastExtractedItems = items; + }, + toastr, + updateLastRecalledItems, + window, + }), + ); } async function onViewLastInjection() { @@ -12882,17 +13066,29 @@ async function onManualSummaryRollup() { } async function onRebuildSummaryState(options = {}) { - return await onRebuildSummaryStateController({ - ensureGraphMutationReady, - getContext, - getCurrentGraph: () => currentGraph, - getSettings, - rebuildHierarchicalSummaryState, - refreshPanelLiveState, - saveGraphToChat, - setRuntimeStatus, - toastr, - }, options); + return await runWithRestoreLock( + "summary-rebuild", + "summary-rebuild", + async () => + await onRebuildSummaryStateController( + { + ensureGraphMutationReady: (operationLabel, nextOptions = {}) => + ensureGraphMutationReady(operationLabel, { + ...(nextOptions || {}), + ignoreRestoreLock: true, + }), + getContext, + getCurrentGraph: () => currentGraph, + getSettings, + rebuildHierarchicalSummaryState, + refreshPanelLiveState, + saveGraphToChat, + setRuntimeStatus, + toastr, + }, + options, + ), + ); } async function onClearSummaryState() { @@ -13069,48 +13265,54 @@ async function onBackupCurrentChatToCloud() { } async function onRestoreCurrentChatFromCloud() { - const chatId = getCurrentChatId(); - if (!chatId) { - toastr.warning("当前没有聊天上下文"); - return { handledToast: true }; - } + return await runWithRestoreLock( + "cloud-restore", + "manual-restore", + async () => { + const chatId = getCurrentChatId(); + if (!chatId) { + toastr.warning("当前没有聊天上下鏂?"); + return { handledToast: true }; + } - const confirmed = globalThis.confirm?.( - "这会用云端备份完整覆盖当前聊天的本地记忆,并先保留一份本地安全快照。确定继续吗?", + const confirmed = globalThis.confirm?.( + "这会用云端备份完整覆盖当前聊天的本地记忆,并先保留一份本地安全快照。确定继续吗锛?, + ); + if (!confirmed) { + return { cancelled: true }; + } + + const result = await restoreFromServer( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "manual-restore", + trigger: "panel:manual-restore", + }), + ); + + if (!result?.restored) { + const reasonMap = { + "not-found": "服务器上没有找到当前聊天的备浠?, + "backup-missing": "服务器上没有找到当前聊天的备浠?, + "backup-version-mismatch": "备份版本与当前运行时不兼瀹?, + "backup-chat-id-mismatch": "备份聊天 ID 与当前聊天不匹配", + "snapshot-chat-id-mismatch": "备份内部快照与当前聊天不匹配", + }; + toastr.error( + reasonMap[result?.reason] || + `恢复失败: ${result?.error?.message || result?.reason || "未知原因"}`, + ); + return { handledToast: true, result }; + } + + toastr.success("已从云端恢复当前聊天备份"); + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "idle", + lastSyncError: "", + }); + return { handledToast: true, result }; + }, ); - if (!confirmed) { - return { cancelled: true }; - } - - const result = await restoreFromServer( - chatId, - buildBmeSyncRuntimeOptions({ - reason: "manual-restore", - trigger: "panel:manual-restore", - }), - ); - - if (!result?.restored) { - const reasonMap = { - "not-found": "服务器上没有找到当前聊天的备份", - "backup-missing": "服务器上没有找到当前聊天的备份", - "backup-version-mismatch": "备份版本与当前运行时不兼容", - "backup-chat-id-mismatch": "备份聊天 ID 与当前聊天不匹配", - "snapshot-chat-id-mismatch": "备份内部快照与当前聊天不匹配", - }; - toastr.error( - reasonMap[result?.reason] || - `恢复失败: ${result?.error?.message || result?.reason || "未知原因"}`, - ); - return { handledToast: true, result }; - } - - toastr.success("已从云端恢复当前聊天备份"); - await syncIndexedDbMetaToPersistenceState(chatId, { - syncState: "idle", - lastSyncError: "", - }); - return { handledToast: true, result }; } async function onManageServerBackups() { @@ -13174,7 +13376,7 @@ async function onDeleteServerBackupEntry(payload = {}) { }; } -// ==================== 初始化 ==================== +// ==================== 初始?==================== async function onGetRestoreSafetySnapshotStatus() { const chatId = getCurrentChatId(); @@ -13197,46 +13399,52 @@ async function onGetRestoreSafetySnapshotStatus() { } async function onRollbackLastRestore() { - const chatId = getCurrentChatId(); - if (!chatId) { - toastr.warning("当前没有聊天上下文"); - return { handledToast: true }; - } + return await runWithRestoreLock( + "restore-rollback", + "manual-restore-rollback", + async () => { + const chatId = getCurrentChatId(); + if (!chatId) { + toastr.warning("当前没有聊天上下鏂?"); + return { handledToast: true }; + } - const safetyStatus = await onGetRestoreSafetySnapshotStatus(); - if (!safetyStatus?.exists) { - toastr.info("当前聊天还没有可用的上次恢复回滚点"); - return { handledToast: true, result: safetyStatus }; - } + const safetyStatus = await onGetRestoreSafetySnapshotStatus(); + if (!safetyStatus?.exists) { + toastr.info("当前聊天还没有可用的上次恢复回滚鐐?"); + return { handledToast: true, result: safetyStatus }; + } - const confirmed = globalThis.confirm?.( - "这会回滚到上次从云端恢复之前的本地状态。确定继续吗?", + const confirmed = globalThis.confirm?.( + "这会回滚到上次从云端恢复之前的本地状态。确定继续吗锛?, + ); + if (!confirmed) { + return { cancelled: true }; + } + + const result = await rollbackFromRestoreSafetySnapshot( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "manual-restore-safety-rollback", + trigger: "panel:rollback-last-restore", + }), + ); + + if (!result?.restored) { + toastr.error( + `回滚失败: ${result?.error?.message || result?.reason || "未知原因"}`, + ); + return { handledToast: true, result }; + } + + toastr.success("已回滚到上次恢复前的本地状鎬?"); + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "idle", + lastSyncError: "", + }); + return { handledToast: true, result }; + }, ); - if (!confirmed) { - return { cancelled: true }; - } - - const result = await rollbackFromRestoreSafetySnapshot( - chatId, - buildBmeSyncRuntimeOptions({ - reason: "manual-restore-safety-rollback", - trigger: "panel:rollback-last-restore", - }), - ); - - if (!result?.restored) { - toastr.error( - `回滚失败: ${result?.error?.message || result?.reason || "未知原因"}`, - ); - return { handledToast: true, result }; - } - - toastr.success("已回滚到上次恢复前的本地状态"); - await syncIndexedDbMetaToPersistenceState(chatId, { - syncState: "idle", - lastSyncError: "", - }); - return { handledToast: true, result }; } async function onRetryPendingPersist() { @@ -13244,6 +13452,7 @@ async function onRetryPendingPersist() { const result = await retryPendingGraphPersist({ reason: "panel-manual-persist-retry", scheduleRetryOnFailure: false, + ignoreRestoreLock: true, }); refreshPanelLiveState(); @@ -13408,7 +13617,7 @@ async function onProbeGraphLoad() { setCoreEventBindingState, }); - // 加载当前聊天的图谱 + // 加载当前聊天的图? scheduleBmeIndexedDbTask(async () => { const syncResult = await syncBmeChatManagerWithCurrentChat("initial-load"); if (!syncResult?.chatId) { diff --git a/maintenance/extraction-controller.js b/maintenance/extraction-controller.js index 03d8f59..b769749 100644 --- a/maintenance/extraction-controller.js +++ b/maintenance/extraction-controller.js @@ -82,6 +82,7 @@ function normalizePersistenceStateRecord(persistResult = null) { ? Number(persistResult.revision) : 0, saveMode: String(persistResult?.saveMode || ""), + recoverable: persistResult?.recoverable === true, saved: persistResult?.saved === true, queued, blocked, @@ -349,12 +350,7 @@ function isPersistenceRevisionAccepted(runtime, persistence = null) { if (!Number.isFinite(persistenceRevision) || persistenceRevision <= 0) { return false; } - const lastAcceptedRevision = Math.max( - Number(graphPersistenceState?.lastAcceptedRevision || 0), - Number(graphPersistenceState?.commitMarker?.accepted === true - ? graphPersistenceState?.commitMarker?.revision - : 0), - ); + const lastAcceptedRevision = Number(graphPersistenceState?.lastAcceptedRevision || 0); return Number.isFinite(lastAcceptedRevision) && lastAcceptedRevision >= persistenceRevision; } diff --git a/runtime/runtime-state.js b/runtime/runtime-state.js index f291121..3550c19 100644 --- a/runtime/runtime-state.js +++ b/runtime/runtime-state.js @@ -155,6 +155,8 @@ export function normalizeGraphRuntimeState(graph, chatId = "") { saveMode: String( historyState.lastBatchStatus.persistence.saveMode || "", ), + recoverable: + historyState.lastBatchStatus.persistence.recoverable === true, saved: historyState.lastBatchStatus.persistence.saved === true, queued: diff --git a/sync/bme-db.js b/sync/bme-db.js index 37d7576..9cd13cb 100644 --- a/sync/bme-db.js +++ b/sync/bme-db.js @@ -127,6 +127,17 @@ function normalizeMode(mode = "replace") { return String(mode || "").toLowerCase() === "merge" ? "merge" : "replace"; } +const BME_PERSIST_META_RESERVED_KEYS = new Set([ + "revision", + "lastModified", + "nodeCount", + "edgeCount", + "tombstoneCount", + "syncDirty", + "syncDirtyReason", + "lastMutationReason", +]); + function sanitizeSnapshot(snapshot = {}) { if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { return { @@ -430,6 +441,153 @@ export function buildSnapshotFromGraph(graph, options = {}) { }; } +function buildSnapshotRecordIndex(records = []) { + const map = new Map(); + for (const record of toArray(records)) { + const id = normalizeRecordId(record?.id); + if (!id) continue; + map.set(id, JSON.stringify(record)); + } + return map; +} + +function buildSnapshotRecordArrayIndex(records = []) { + const map = new Map(); + for (const record of toArray(records)) { + const id = normalizeRecordId(record?.id); + if (!id) continue; + map.set(id, toPlainData(record, record)); + } + return map; +} + +function buildRuntimeMetaPatch(snapshot = {}) { + const normalizedSnapshot = sanitizeSnapshot(snapshot); + const patch = {}; + for (const [rawKey, value] of Object.entries(normalizedSnapshot.meta || {})) { + const key = normalizeRecordId(rawKey); + if (!key || BME_PERSIST_META_RESERVED_KEYS.has(key)) continue; + patch[key] = toPlainData(value, value); + } + const state = normalizeStateSnapshot(normalizedSnapshot); + patch.lastProcessedFloor = state.lastProcessedFloor; + patch.extractionCount = state.extractionCount; + patch.schemaVersion = BME_DB_SCHEMA_VERSION; + patch.chatId = normalizeChatId( + normalizedSnapshot.meta?.chatId || patch.chatId || "", + ); + return patch; +} + +function ensureDeleteTombstone( + tombstoneMap, + kind, + targetId, + deletedAt, + sourceDeviceId = "", +) { + const normalizedKind = normalizeRecordId(kind); + const normalizedTargetId = normalizeRecordId(targetId); + if (!normalizedKind || !normalizedTargetId) return; + const targetKey = `${normalizedKind}:${normalizedTargetId}`; + if (tombstoneMap.has(targetKey)) return; + tombstoneMap.set(targetKey, { + id: `${normalizedKind}:${normalizedTargetId}`, + kind: normalizedKind, + targetId: normalizedTargetId, + sourceDeviceId: normalizeRecordId(sourceDeviceId), + deletedAt: normalizeTimestamp(deletedAt), + }); +} + +export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { + const normalizedBefore = sanitizeSnapshot(beforeSnapshot); + const normalizedAfter = sanitizeSnapshot(afterSnapshot); + const nowMs = normalizeTimestamp(options.nowMs, Date.now()); + const beforeNodeJsonById = buildSnapshotRecordIndex(normalizedBefore.nodes); + const afterNodeJsonById = buildSnapshotRecordIndex(normalizedAfter.nodes); + const beforeEdgeJsonById = buildSnapshotRecordIndex(normalizedBefore.edges); + const afterEdgeJsonById = buildSnapshotRecordIndex(normalizedAfter.edges); + const beforeTombstoneJsonById = buildSnapshotRecordIndex( + normalizedBefore.tombstones, + ); + const afterNodeById = buildSnapshotRecordArrayIndex(normalizedAfter.nodes); + const afterEdgeById = buildSnapshotRecordArrayIndex(normalizedAfter.edges); + const afterTombstoneById = buildSnapshotRecordArrayIndex( + normalizedAfter.tombstones, + ); + + const upsertNodes = []; + for (const [id, record] of afterNodeById.entries()) { + if (beforeNodeJsonById.get(id) !== JSON.stringify(record)) { + upsertNodes.push(record); + } + } + + const upsertEdges = []; + for (const [id, record] of afterEdgeById.entries()) { + if (beforeEdgeJsonById.get(id) !== JSON.stringify(record)) { + upsertEdges.push(record); + } + } + + const deleteNodeIds = []; + for (const id of beforeNodeJsonById.keys()) { + if (!afterNodeJsonById.has(id)) { + deleteNodeIds.push(id); + } + } + + const deleteEdgeIds = []; + for (const id of beforeEdgeJsonById.keys()) { + if (!afterEdgeJsonById.has(id)) { + deleteEdgeIds.push(id); + } + } + + const tombstoneMap = new Map(); + for (const [id, record] of afterTombstoneById.entries()) { + if (beforeTombstoneJsonById.get(id) !== JSON.stringify(record)) { + tombstoneMap.set(`${record.kind}:${record.targetId}`, record); + } + } + + for (const nodeId of deleteNodeIds) { + ensureDeleteTombstone( + tombstoneMap, + "node", + nodeId, + nowMs, + normalizedAfter.meta?.deviceId || normalizedBefore.meta?.deviceId || "", + ); + } + for (const edgeId of deleteEdgeIds) { + ensureDeleteTombstone( + tombstoneMap, + "edge", + edgeId, + nowMs, + normalizedAfter.meta?.deviceId || normalizedBefore.meta?.deviceId || "", + ); + } + + return { + upsertNodes, + upsertEdges, + deleteNodeIds, + deleteEdgeIds, + tombstones: Array.from(tombstoneMap.values()), + runtimeMetaPatch: { + ...buildRuntimeMetaPatch(normalizedAfter), + ...(options.runtimeMetaPatch && + typeof options.runtimeMetaPatch === "object" && + !Array.isArray(options.runtimeMetaPatch) + ? toPlainData(options.runtimeMetaPatch, {}) + : {}), + }, + }; +} + export function buildGraphFromSnapshot(snapshot, options = {}) { const normalizedSnapshot = sanitizeSnapshot(snapshot); const chatId = @@ -915,6 +1073,105 @@ export class BmeDatabase { return true; } + async commitDelta(delta = {}, options = {}) { + const db = await this.open(); + const nowMs = Date.now(); + const normalizedDelta = + delta && typeof delta === "object" && !Array.isArray(delta) ? delta : {}; + const upsertNodes = this._normalizeNodeRecords(normalizedDelta.upsertNodes, nowMs); + const upsertEdges = this._normalizeEdgeRecords(normalizedDelta.upsertEdges, nowMs); + const tombstones = this._normalizeTombstoneRecords( + normalizedDelta.tombstones, + nowMs, + ); + const deleteNodeIds = toArray(normalizedDelta.deleteNodeIds) + .map((value) => normalizeRecordId(value)) + .filter(Boolean); + const deleteEdgeIds = toArray(normalizedDelta.deleteEdgeIds) + .map((value) => normalizeRecordId(value)) + .filter(Boolean); + const runtimeMetaPatch = + normalizedDelta.runtimeMetaPatch && + typeof normalizedDelta.runtimeMetaPatch === "object" && + !Array.isArray(normalizedDelta.runtimeMetaPatch) + ? normalizedDelta.runtimeMetaPatch + : {}; + const reason = String(options.reason || "commitDelta"); + const requestedRevision = normalizeRevision(options.requestedRevision); + const shouldMarkSyncDirty = options.markSyncDirty !== false; + + let nextRevision = 0; + let counts = { + nodes: 0, + edges: 0, + tombstones: 0, + }; + + await db.transaction( + "rw", + db.table("nodes"), + db.table("edges"), + db.table("tombstones"), + db.table("meta"), + async () => { + if (deleteEdgeIds.length) { + await db.table("edges").bulkDelete(deleteEdgeIds); + } + if (deleteNodeIds.length) { + await db.table("nodes").bulkDelete(deleteNodeIds); + } + if (upsertNodes.length) { + await db.table("nodes").bulkPut(upsertNodes); + } + if (upsertEdges.length) { + await db.table("edges").bulkPut(upsertEdges); + } + if (tombstones.length) { + await db.table("tombstones").bulkPut(tombstones); + } + + for (const [rawKey, value] of Object.entries(runtimeMetaPatch)) { + const key = normalizeRecordId(rawKey); + if (!key || BME_PERSIST_META_RESERVED_KEYS.has(key)) continue; + await this._setMetaInTx(db, key, value, nowMs); + } + + counts = await this._updateCountMetaInTx(db, nowMs); + const currentRevision = normalizeRevision( + (await db.table("meta").get("revision"))?.value, + ); + nextRevision = Math.max(currentRevision + 1, requestedRevision); + await this._setMetaInTx(db, "revision", nextRevision, nowMs); + await this._setMetaInTx(db, "lastModified", nowMs, nowMs); + await this._setMetaInTx(db, "lastMutationReason", reason, nowMs); + await this._setMetaInTx(db, "syncDirty", shouldMarkSyncDirty, nowMs); + await this._setMetaInTx( + db, + "syncDirtyReason", + shouldMarkSyncDirty ? reason : "", + nowMs, + ); + }, + ); + + return { + revision: nextRevision, + lastModified: nowMs, + imported: { + nodes: counts.nodes, + edges: counts.edges, + tombstones: counts.tombstones, + }, + delta: { + upsertNodes: upsertNodes.length, + upsertEdges: upsertEdges.length, + deleteNodeIds: deleteNodeIds.length, + deleteEdgeIds: deleteEdgeIds.length, + tombstones: tombstones.length, + }, + }; + } + async bulkUpsertNodes(nodes = []) { const records = this._normalizeNodeRecords(nodes); if (!records.length) { diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index a38878f..66b5933 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -7,6 +7,7 @@ import vm from "node:vm"; import { buildBmeDbName, buildGraphFromSnapshot, + buildPersistDelta, buildSnapshotFromGraph, } from "../sync/bme-db.js"; import { onMessageReceivedController } from "../host/event-binding.js"; @@ -104,7 +105,7 @@ const persistenceCore = extractSnippet( ); const messageSnippet = extractSnippet( 'function onMessageReceived(messageId = null, type = "") {', - "// ==================== UI 操作 ====================", + "async function onViewGraph() {", ); function createSessionStorage(seed = null) { @@ -271,6 +272,109 @@ async function createGraphPersistenceHarness({ indexedDbSnapshotMap.set(normalizedChatId, structuredClone(snapshot)); } + function commitIndexedDbDelta(targetChatId = "", delta = {}, options = {}) { + const normalizedChatId = String(targetChatId || ""); + const currentSnapshot = getIndexedDbSnapshotForChat(normalizedChatId); + const now = Date.now(); + + const nodeMap = new Map( + (Array.isArray(currentSnapshot?.nodes) ? currentSnapshot.nodes : []) + .filter((record) => record?.id) + .map((record) => [String(record.id), structuredClone(record)]), + ); + const edgeMap = new Map( + (Array.isArray(currentSnapshot?.edges) ? currentSnapshot.edges : []) + .filter((record) => record?.id) + .map((record) => [String(record.id), structuredClone(record)]), + ); + const tombstoneMap = new Map( + (Array.isArray(currentSnapshot?.tombstones) ? currentSnapshot.tombstones : []) + .filter((record) => record?.id) + .map((record) => [String(record.id), structuredClone(record)]), + ); + + for (const edgeId of Array.isArray(delta?.deleteEdgeIds) ? delta.deleteEdgeIds : []) { + edgeMap.delete(String(edgeId)); + } + for (const nodeId of Array.isArray(delta?.deleteNodeIds) ? delta.deleteNodeIds : []) { + nodeMap.delete(String(nodeId)); + } + for (const record of Array.isArray(delta?.upsertNodes) ? delta.upsertNodes : []) { + if (!record?.id) continue; + nodeMap.set(String(record.id), structuredClone(record)); + } + for (const record of Array.isArray(delta?.upsertEdges) ? delta.upsertEdges : []) { + if (!record?.id) continue; + edgeMap.set(String(record.id), structuredClone(record)); + } + for (const record of Array.isArray(delta?.tombstones) ? delta.tombstones : []) { + if (!record?.id) continue; + tombstoneMap.set(String(record.id), structuredClone(record)); + } + + const runtimeMetaPatch = + delta?.runtimeMetaPatch && + typeof delta.runtimeMetaPatch === "object" && + !Array.isArray(delta.runtimeMetaPatch) + ? structuredClone(delta.runtimeMetaPatch) + : {}; + const shouldMarkSyncDirty = options?.markSyncDirty !== false; + const nextRevision = Math.max( + Number(currentSnapshot?.meta?.revision || 0) + 1, + Number(options?.requestedRevision || 0), + ); + const nextState = { + lastProcessedFloor: Number.isFinite(Number(runtimeMetaPatch.lastProcessedFloor)) + ? Number(runtimeMetaPatch.lastProcessedFloor) + : Number(currentSnapshot?.state?.lastProcessedFloor ?? -1), + extractionCount: Number.isFinite(Number(runtimeMetaPatch.extractionCount)) + ? Number(runtimeMetaPatch.extractionCount) + : Number(currentSnapshot?.state?.extractionCount ?? 0), + }; + const nextSnapshot = { + meta: { + ...(currentSnapshot?.meta || {}), + ...runtimeMetaPatch, + chatId: normalizedChatId, + revision: nextRevision, + lastModified: now, + lastMutationReason: String(options?.reason || "commitDelta"), + syncDirty: shouldMarkSyncDirty, + syncDirtyReason: shouldMarkSyncDirty + ? String(options?.reason || "commitDelta") + : "", + nodeCount: nodeMap.size, + edgeCount: edgeMap.size, + tombstoneCount: tombstoneMap.size, + }, + nodes: Array.from(nodeMap.values()), + edges: Array.from(edgeMap.values()), + tombstones: Array.from(tombstoneMap.values()), + state: nextState, + }; + + setIndexedDbSnapshotForChat(normalizedChatId, nextSnapshot); + runtimeContext.__indexedDbSnapshot = + getIndexedDbSnapshotForChat(normalizedChatId); + + return { + revision: nextRevision, + lastModified: now, + imported: { + nodes: nodeMap.size, + edges: edgeMap.size, + tombstones: tombstoneMap.size, + }, + delta: { + upsertNodes: Array.isArray(delta?.upsertNodes) ? delta.upsertNodes.length : 0, + upsertEdges: Array.isArray(delta?.upsertEdges) ? delta.upsertEdges.length : 0, + deleteNodeIds: Array.isArray(delta?.deleteNodeIds) ? delta.deleteNodeIds.length : 0, + deleteEdgeIds: Array.isArray(delta?.deleteEdgeIds) ? delta.deleteEdgeIds.length : 0, + tombstones: Array.isArray(delta?.tombstones) ? delta.tombstones.length : 0, + }, + }; + } + const runtimeContext = { console, Date, @@ -774,6 +878,7 @@ async function createGraphPersistenceHarness({ __contextSaveCalls: 0, __contextImmediateSaveCalls: 0, buildGraphFromSnapshot, + buildPersistDelta, buildSnapshotFromGraph, buildBmeDbName, scheduleUpload() { @@ -790,6 +895,9 @@ async function createGraphPersistenceHarness({ async exportSnapshot() { return getIndexedDbSnapshotForChat(this.chatId); } + async commitDelta(delta, options = {}) { + return commitIndexedDbDelta(this.chatId, delta, options); + } async importSnapshot(snapshot) { setIndexedDbSnapshotForChat(this.chatId, snapshot); return { @@ -806,6 +914,9 @@ async function createGraphPersistenceHarness({ async exportSnapshot() { return getIndexedDbSnapshotForChat(dbChatId); }, + async commitDelta(delta, options = {}) { + return commitIndexedDbDelta(dbChatId, delta, options); + }, async importSnapshot(snapshot) { setIndexedDbSnapshotForChat(dbChatId, snapshot); runtimeContext.__indexedDbSnapshot = @@ -2312,7 +2423,6 @@ result = { lastPersistedRevision: 0, writesBlocked: false, }); - harness.runtimeContext.__markSyncDirtyShouldThrow = true; harness.runtimeContext.__scheduleUploadShouldThrow = true; const result = await harness.api.saveGraphToIndexedDb( @@ -2325,7 +2435,6 @@ result = { ); assert.equal(result.saved, true); - assert.match(String(result.warning || ""), /mark-sync-dirty-failed/); assert.match(String(result.warning || ""), /schedule-upload-failed/); assert.equal( harness.api.getIndexedDbSnapshot().meta.revision, diff --git a/tests/helpers/generation-recall-harness.mjs b/tests/helpers/generation-recall-harness.mjs index 1a8483e..caa9edf 100644 --- a/tests/helpers/generation-recall-harness.mjs +++ b/tests/helpers/generation-recall-harness.mjs @@ -169,6 +169,7 @@ export function createGenerationRecallHarness(options = {}) { hideScheduleCalls: [], isExtracting: false, isRecoveringHistory: false, + isRestoreLockActive: () => false, isAssistantChatMessage: (message) => Boolean(message) && !message.is_user && !message.is_system, createRecallInputRecord, diff --git a/tests/mobile-status-regressions.mjs b/tests/mobile-status-regressions.mjs index 8fb6a9e..76ffd57 100644 --- a/tests/mobile-status-regressions.mjs +++ b/tests/mobile-status-regressions.mjs @@ -539,6 +539,9 @@ async function testManualRebuildSetsTerminalRuntimeStatus() { context.currentGraph.historyState.processedMessageHashesNeedRefresh; }, restoreRuntimeUiState() {}, + async runWithRestoreLock(_source, _reason, task) { + return await task(); + }, onRebuildController, result: null, }; diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index c82d678..b222863 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -350,6 +350,12 @@ function createHistoryRecoveryHarness() { refreshPanelCalls: 0, notices: [], embeddingConfig: { mode: "backend" }, + isRestoreLockActive() { + return false; + }, + enterRestoreLock() {}, + leaveRestoreLock() {}, + async maybeResumePendingAutoExtraction() {}, ensureCurrentGraphRuntimeState() { return context.currentGraph; }, diff --git a/ui/panel.js b/ui/panel.js index 5ab4d72..497420f 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -9518,6 +9518,8 @@ function _formatPersistenceOutcomeLabel(outcome = "") { return "已阻塞"; case "failed": return "失败"; + case "recoverable": + return "已捕获恢复锚点"; default: return "未知"; } @@ -9548,14 +9550,7 @@ function _isPersistenceRevisionAccepted(persistence = null, loadInfo = {}) { if (!Number.isFinite(persistenceRevision) || persistenceRevision <= 0) { return false; } - const commitMarkerRevision = - loadInfo?.commitMarker?.accepted === true - ? Number(loadInfo.commitMarker.revision || 0) - : 0; - const lastAcceptedRevision = Math.max( - Number(loadInfo?.lastAcceptedRevision || 0), - commitMarkerRevision, - ); + const lastAcceptedRevision = Number(loadInfo?.lastAcceptedRevision || 0); return Number.isFinite(lastAcceptedRevision) && lastAcceptedRevision >= persistenceRevision; } @@ -9564,7 +9559,11 @@ function _formatDashboardPersistMeta(loadInfo = {}, batchStatus = null) { if (_hasMeaningfulPersistenceRecord(persistence)) { const accepted = _isPersistenceRevisionAccepted(persistence, loadInfo); const parts = [ - accepted ? "已确认" : _formatPersistenceOutcomeLabel(persistence.outcome), + accepted + ? "已确认" + : persistence.recoverable === true + ? "已捕获恢复锚点" + : _formatPersistenceOutcomeLabel(persistence.outcome), persistence.storageTier ? `tier ${persistence.storageTier}` : "", Number.isFinite(Number(persistence.revision)) && Number(persistence.revision) > 0 ? `rev ${Number(persistence.revision)}` @@ -9685,7 +9684,9 @@ function _refreshPersistenceRepairUi( } help.textContent = - "最近一批持久化没有被接受。可以先重试持久化;如果宿主延迟加载了本地存储,再重新探测图谱。"; + persistence?.recoverable === true + ? "最近一批已经捕获了恢复锚点,但还没有进入正式 accepted 存储。可以先重试持久化;如果仍未确认,再重新探测图谱。" + : "最近一批持久化没有被接受。可以先重试持久化;如果宿主延迟加载了本地存储,再重新探测图谱。"; } function _canRenderGraphData(loadInfo = _getGraphPersistenceSnapshot()) { diff --git a/ui/ui-status.js b/ui/ui-status.js index 9e7412f..53e8df0 100644 --- a/ui/ui-status.js +++ b/ui/ui-status.js @@ -50,8 +50,17 @@ export function createGraphPersistenceState() { writesBlocked: false, pendingPersist: false, lastAcceptedRevision: 0, + acceptedStorageTier: "none", + lastRecoverableStorageTier: "none", persistMismatchReason: "", commitMarker: null, + restoreLock: { + active: false, + depth: 0, + source: "", + reason: "", + startedAt: 0, + }, storagePrimary: "indexeddb", storageMode: "indexeddb", dbReady: false,