mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
fix: harden history recovery and graph persistence regressions
This commit is contained in:
@@ -242,6 +242,7 @@ export function shouldPreferShadowSnapshotOverOfficial(
|
|||||||
return {
|
return {
|
||||||
prefer: false,
|
prefer: false,
|
||||||
reason: "shadow-missing",
|
reason: "shadow-missing",
|
||||||
|
resultCode: "shadow.missing",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +261,7 @@ export function shouldPreferShadowSnapshotOverOfficial(
|
|||||||
return {
|
return {
|
||||||
prefer: false,
|
prefer: false,
|
||||||
reason: "shadow-revision-invalid",
|
reason: "shadow-revision-invalid",
|
||||||
|
resultCode: "shadow.reject.revision-invalid",
|
||||||
shadowRevision,
|
shadowRevision,
|
||||||
officialRevision,
|
officialRevision,
|
||||||
};
|
};
|
||||||
@@ -273,6 +275,7 @@ export function shouldPreferShadowSnapshotOverOfficial(
|
|||||||
return {
|
return {
|
||||||
prefer: false,
|
prefer: false,
|
||||||
reason: "shadow-persisted-chat-mismatch",
|
reason: "shadow-persisted-chat-mismatch",
|
||||||
|
resultCode: "shadow.reject.persisted-chat-mismatch",
|
||||||
shadowRevision,
|
shadowRevision,
|
||||||
officialRevision,
|
officialRevision,
|
||||||
officialChatId: normalizedOfficialChatId,
|
officialChatId: normalizedOfficialChatId,
|
||||||
@@ -288,6 +291,7 @@ export function shouldPreferShadowSnapshotOverOfficial(
|
|||||||
return {
|
return {
|
||||||
prefer: false,
|
prefer: false,
|
||||||
reason: "shadow-chat-mismatch",
|
reason: "shadow-chat-mismatch",
|
||||||
|
resultCode: "shadow.reject.chat-mismatch",
|
||||||
shadowRevision,
|
shadowRevision,
|
||||||
officialRevision,
|
officialRevision,
|
||||||
officialChatId: normalizedOfficialChatId,
|
officialChatId: normalizedOfficialChatId,
|
||||||
@@ -303,6 +307,7 @@ export function shouldPreferShadowSnapshotOverOfficial(
|
|||||||
return {
|
return {
|
||||||
prefer: false,
|
prefer: false,
|
||||||
reason: "shadow-integrity-mismatch",
|
reason: "shadow-integrity-mismatch",
|
||||||
|
resultCode: "shadow.reject.integrity-mismatch",
|
||||||
shadowRevision,
|
shadowRevision,
|
||||||
officialRevision,
|
officialRevision,
|
||||||
officialIntegrity,
|
officialIntegrity,
|
||||||
@@ -310,12 +315,54 @@ export function shouldPreferShadowSnapshotOverOfficial(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedShadowPersistedChatId &&
|
||||||
|
normalizedShadowChatId &&
|
||||||
|
normalizedShadowPersistedChatId !== normalizedShadowChatId
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
prefer: false,
|
||||||
|
reason: "shadow-self-chat-mismatch",
|
||||||
|
resultCode: "shadow.reject.self-chat-mismatch",
|
||||||
|
shadowRevision,
|
||||||
|
officialRevision,
|
||||||
|
shadowChatId: normalizedShadowChatId,
|
||||||
|
shadowPersistedChatId: normalizedShadowPersistedChatId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedShadowPersistedChatId && !normalizedOfficialChatId) {
|
||||||
|
return {
|
||||||
|
prefer: false,
|
||||||
|
reason: "shadow-persisted-chat-without-official-chat",
|
||||||
|
resultCode: "shadow.reject.persisted-chat-without-official-chat",
|
||||||
|
shadowRevision,
|
||||||
|
officialRevision,
|
||||||
|
shadowPersistedChatId: normalizedShadowPersistedChatId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shadowIntegrity && !officialIntegrity) {
|
||||||
|
return {
|
||||||
|
prefer: false,
|
||||||
|
reason: "shadow-integrity-without-official-integrity",
|
||||||
|
resultCode: "shadow.reject.integrity-without-official-integrity",
|
||||||
|
shadowRevision,
|
||||||
|
officialRevision,
|
||||||
|
shadowIntegrity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prefer: shadowRevision > 0 && shadowRevision > officialRevision,
|
prefer: shadowRevision > 0 && shadowRevision > officialRevision,
|
||||||
reason:
|
reason:
|
||||||
shadowRevision > officialRevision
|
shadowRevision > officialRevision
|
||||||
? "shadow-newer-than-official"
|
? "shadow-newer-than-official"
|
||||||
: "shadow-not-newer-than-official",
|
: "shadow-not-newer-than-official",
|
||||||
|
resultCode:
|
||||||
|
shadowRevision > officialRevision
|
||||||
|
? "shadow.accept.newer-than-official"
|
||||||
|
: "shadow.keep.official-not-older",
|
||||||
shadowRevision,
|
shadowRevision,
|
||||||
officialRevision,
|
officialRevision,
|
||||||
};
|
};
|
||||||
|
|||||||
142
index.js
142
index.js
@@ -2790,6 +2790,7 @@ function applyIndexedDbSnapshotToRuntime(
|
|||||||
normalizeGraphRuntimeState(graphFromSnapshot, normalizedChatId),
|
normalizeGraphRuntimeState(graphFromSnapshot, normalizedChatId),
|
||||||
normalizedChatId,
|
normalizedChatId,
|
||||||
);
|
);
|
||||||
|
currentGraph.vectorIndexState.lastIntegrityIssue = null;
|
||||||
|
|
||||||
extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount)
|
extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount)
|
||||||
? currentGraph.historyState.extractionCount
|
? currentGraph.historyState.extractionCount
|
||||||
@@ -4373,6 +4374,7 @@ function loadGraphFromChat(options = {}) {
|
|||||||
source: `${source}:metadata-shadow-compare`,
|
source: `${source}:metadata-shadow-compare`,
|
||||||
success: Boolean(shadowDecision.prefer),
|
success: Boolean(shadowDecision.prefer),
|
||||||
reason: shadowDecision.reason,
|
reason: shadowDecision.reason,
|
||||||
|
resultCode: String(shadowDecision.resultCode || ""),
|
||||||
shadowRevision: Number(shadowSnapshot.revision || 0),
|
shadowRevision: Number(shadowSnapshot.revision || 0),
|
||||||
officialRevision,
|
officialRevision,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
@@ -4441,6 +4443,8 @@ function loadGraphFromChat(options = {}) {
|
|||||||
success: true,
|
success: true,
|
||||||
provisional: true,
|
provisional: true,
|
||||||
revision: officialRevision,
|
revision: officialRevision,
|
||||||
|
resultCode: "graph.load.metadata-compat.provisional",
|
||||||
|
reason: `${source}:metadata-compat-provisional`,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -6142,20 +6146,31 @@ function applyRecoveryPlanToVectorState(
|
|||||||
async function rollbackGraphForReroll(targetFloor, context = getContext()) {
|
async function rollbackGraphForReroll(targetFloor, context = getContext()) {
|
||||||
ensureCurrentGraphRuntimeState();
|
ensureCurrentGraphRuntimeState();
|
||||||
const chatId = getCurrentChatId(context);
|
const chatId = getCurrentChatId(context);
|
||||||
|
const buildRerollFailure = (
|
||||||
|
recoveryPath,
|
||||||
|
error,
|
||||||
|
{ resultCode = "reroll.rollback.failed", affectedBatchCount = 0 } = {},
|
||||||
|
) => ({
|
||||||
|
success: false,
|
||||||
|
rollbackPerformed: false,
|
||||||
|
extractionTriggered: false,
|
||||||
|
requestedFloor: targetFloor,
|
||||||
|
effectiveFromFloor: null,
|
||||||
|
recoveryPath,
|
||||||
|
affectedBatchCount,
|
||||||
|
resultCode,
|
||||||
|
error,
|
||||||
|
});
|
||||||
const recoveryPoint = findJournalRecoveryPoint(currentGraph, targetFloor);
|
const recoveryPoint = findJournalRecoveryPoint(currentGraph, targetFloor);
|
||||||
|
|
||||||
if (!recoveryPoint) {
|
if (!recoveryPoint) {
|
||||||
return {
|
return buildRerollFailure(
|
||||||
success: false,
|
"unavailable",
|
||||||
rollbackPerformed: false,
|
"未找到可用的回滚点,无法安全重新提取。请先执行一次历史恢复或重新提取更早的批次。",
|
||||||
extractionTriggered: false,
|
{
|
||||||
requestedFloor: targetFloor,
|
resultCode: "reroll.rollback.unavailable",
|
||||||
effectiveFromFloor: null,
|
},
|
||||||
recoveryPath: "unavailable",
|
);
|
||||||
affectedBatchCount: 0,
|
|
||||||
error:
|
|
||||||
"未找到可用的回滚点,无法安全重新提取。请先执行一次历史恢复或重新提取更早的批次。",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearInjectionState();
|
clearInjectionState();
|
||||||
@@ -6171,16 +6186,33 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) {
|
|||||||
targetFloor,
|
targetFloor,
|
||||||
);
|
);
|
||||||
if (recoveryPlan?.valid === false) {
|
if (recoveryPlan?.valid === false) {
|
||||||
return {
|
const invalidReason = String(
|
||||||
success: false,
|
recoveryPlan.invalidReason || "unknown",
|
||||||
rollbackPerformed: false,
|
).trim();
|
||||||
extractionTriggered: false,
|
currentGraph.historyState.lastRecoveryResult = buildRecoveryResult(
|
||||||
requestedFloor: targetFloor,
|
"reroll-rollback-rejected",
|
||||||
effectiveFromFloor: null,
|
{
|
||||||
recoveryPath: "reverse-journal-rejected",
|
fromFloor: targetFloor,
|
||||||
affectedBatchCount,
|
effectiveFromFloor: null,
|
||||||
error: `回滚计划完整性校验失败: ${recoveryPlan.invalidReason || "unknown"}`,
|
path: "reverse-journal",
|
||||||
};
|
affectedBatchCount,
|
||||||
|
detectionSource: "manual-reroll",
|
||||||
|
reason: `回滚计划完整性校验失败: ${invalidReason}`,
|
||||||
|
debugReason: `reroll-rollback-plan-invalid:${invalidReason}`,
|
||||||
|
resultCode: "reroll.rollback.plan-invalid",
|
||||||
|
invalidReason,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
saveGraphToChat({ reason: "reroll-rollback-rejected" });
|
||||||
|
refreshPanelLiveState();
|
||||||
|
return buildRerollFailure(
|
||||||
|
"reverse-journal-rejected",
|
||||||
|
`回滚计划完整性校验失败: ${invalidReason}`,
|
||||||
|
{
|
||||||
|
affectedBatchCount,
|
||||||
|
resultCode: "reroll.rollback.plan-invalid",
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
rollbackAffectedJournals(currentGraph, recoveryPoint.affectedJournals);
|
rollbackAffectedJournals(currentGraph, recoveryPoint.affectedJournals);
|
||||||
currentGraph = normalizeGraphRuntimeState(currentGraph, chatId);
|
currentGraph = normalizeGraphRuntimeState(currentGraph, chatId);
|
||||||
@@ -6211,16 +6243,29 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) {
|
|||||||
extractionCount = currentGraph.historyState.extractionCount || 0;
|
extractionCount = currentGraph.historyState.extractionCount || 0;
|
||||||
await prepareVectorStateForReplay(false);
|
await prepareVectorStateForReplay(false);
|
||||||
} else {
|
} else {
|
||||||
return {
|
currentGraph.historyState.lastRecoveryResult = buildRecoveryResult(
|
||||||
success: false,
|
"reroll-rollback-rejected",
|
||||||
rollbackPerformed: false,
|
{
|
||||||
extractionTriggered: false,
|
fromFloor: targetFloor,
|
||||||
requestedFloor: targetFloor,
|
effectiveFromFloor: null,
|
||||||
effectiveFromFloor: null,
|
path: recoveryPath,
|
||||||
|
affectedBatchCount,
|
||||||
|
detectionSource: "manual-reroll",
|
||||||
|
reason: `不支持的回滚路径: ${recoveryPath}`,
|
||||||
|
debugReason: `reroll-rollback-unsupported:${recoveryPath}`,
|
||||||
|
resultCode: "reroll.rollback.path-unsupported",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
saveGraphToChat({ reason: "reroll-rollback-rejected" });
|
||||||
|
refreshPanelLiveState();
|
||||||
|
return buildRerollFailure(
|
||||||
recoveryPath,
|
recoveryPath,
|
||||||
affectedBatchCount,
|
`不支持的回滚路径: ${recoveryPath}`,
|
||||||
error: `不支持的回滚路径: ${recoveryPath}`,
|
{
|
||||||
};
|
affectedBatchCount,
|
||||||
|
resultCode: "reroll.rollback.path-unsupported",
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveFromFloor = Number.isFinite(
|
const effectiveFromFloor = Number.isFinite(
|
||||||
@@ -6229,9 +6274,6 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) {
|
|||||||
? currentGraph.historyState.lastProcessedAssistantFloor + 1
|
? currentGraph.historyState.lastProcessedAssistantFloor + 1
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
pruneProcessedMessageHashesFromFloor(currentGraph, effectiveFromFloor);
|
|
||||||
currentGraph.lastProcessedSeq =
|
|
||||||
currentGraph.historyState?.lastProcessedAssistantFloor ?? -1;
|
|
||||||
clearHistoryDirty(
|
clearHistoryDirty(
|
||||||
currentGraph,
|
currentGraph,
|
||||||
buildRecoveryResult("reroll-rollback", {
|
buildRecoveryResult("reroll-rollback", {
|
||||||
@@ -6241,8 +6283,13 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) {
|
|||||||
affectedBatchCount,
|
affectedBatchCount,
|
||||||
detectionSource: "manual-reroll",
|
detectionSource: "manual-reroll",
|
||||||
reason: "manual-reroll",
|
reason: "manual-reroll",
|
||||||
|
resultCode: "reroll.rollback.applied",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
pruneProcessedMessageHashesFromFloor(currentGraph, effectiveFromFloor);
|
||||||
|
currentGraph.lastProcessedSeq =
|
||||||
|
currentGraph.historyState?.lastProcessedAssistantFloor ?? -1;
|
||||||
|
currentGraph.vectorIndexState.lastIntegrityIssue = null;
|
||||||
saveGraphToChat({ reason: "reroll-rollback-complete" });
|
saveGraphToChat({ reason: "reroll-rollback-complete" });
|
||||||
refreshPanelLiveState();
|
refreshPanelLiveState();
|
||||||
|
|
||||||
@@ -6254,6 +6301,7 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) {
|
|||||||
effectiveFromFloor,
|
effectiveFromFloor,
|
||||||
recoveryPath,
|
recoveryPath,
|
||||||
affectedBatchCount,
|
affectedBatchCount,
|
||||||
|
resultCode: "reroll.rollback.applied",
|
||||||
error: "",
|
error: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -6408,6 +6456,28 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAbortError(error)) {
|
if (isAbortError(error)) {
|
||||||
|
clearHistoryDirty(
|
||||||
|
currentGraph,
|
||||||
|
buildRecoveryResult("aborted", {
|
||||||
|
fromFloor: initialDirtyFrom,
|
||||||
|
path: recoveryPath,
|
||||||
|
detectionSource:
|
||||||
|
detection.source ||
|
||||||
|
currentGraph?.historyState?.lastMutationSource ||
|
||||||
|
"hash-recheck",
|
||||||
|
affectedBatchCount,
|
||||||
|
replayedBatchCount: replayedBatches,
|
||||||
|
reason: error?.message || "已手动终止当前恢复流程",
|
||||||
|
debugReason: `history-recovery-aborted:${recoveryPath}`,
|
||||||
|
resultCode: "history.recovery.aborted",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
currentGraph.vectorIndexState.lastIntegrityIssue = null;
|
||||||
|
currentGraph.vectorIndexState.lastWarning = "";
|
||||||
|
currentGraph.vectorIndexState.pendingRepairFromFloor = null;
|
||||||
|
currentGraph.vectorIndexState.replayRequiredNodeIds = [];
|
||||||
|
currentGraph.vectorIndexState.dirty = false;
|
||||||
|
currentGraph.vectorIndexState.dirtyReason = "";
|
||||||
updateStageNotice(
|
updateStageNotice(
|
||||||
"history",
|
"history",
|
||||||
"历史恢复已终止",
|
"历史恢复已终止",
|
||||||
@@ -6447,8 +6517,11 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
|
|||||||
affectedBatchCount,
|
affectedBatchCount,
|
||||||
replayedBatchCount: replayedBatches,
|
replayedBatchCount: replayedBatches,
|
||||||
reason: `恢复失败后兜底全量重建: ${error?.message || error}`,
|
reason: `恢复失败后兜底全量重建: ${error?.message || error}`,
|
||||||
|
debugReason: `history-recovery-fallback-full-rebuild:${recoveryPath}`,
|
||||||
|
resultCode: "history.recovery.fallback-full-rebuild",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
currentGraph.vectorIndexState.lastIntegrityIssue = null;
|
||||||
saveGraphToChat({ reason: "history-recovery-fallback-rebuild" });
|
saveGraphToChat({ reason: "history-recovery-fallback-rebuild" });
|
||||||
refreshPanelLiveState();
|
refreshPanelLiveState();
|
||||||
updateStageNotice(
|
updateStageNotice(
|
||||||
@@ -6476,8 +6549,11 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
|
|||||||
affectedBatchCount,
|
affectedBatchCount,
|
||||||
replayedBatchCount: replayedBatches,
|
replayedBatchCount: replayedBatches,
|
||||||
reason: String(fallbackError),
|
reason: String(fallbackError),
|
||||||
|
debugReason: `history-recovery-failed:${recoveryPath}`,
|
||||||
|
resultCode: "history.recovery.failed",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
currentGraph.vectorIndexState.lastIntegrityIssue = null;
|
||||||
saveGraphToChat({ reason: "history-recovery-failed" });
|
saveGraphToChat({ reason: "history-recovery-failed" });
|
||||||
refreshPanelLiveState();
|
refreshPanelLiveState();
|
||||||
updateStageNotice(
|
updateStageNotice(
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ export function clearHistoryDirty(graph, result = null) {
|
|||||||
graph.historyState.historyDirtyFrom = null;
|
graph.historyState.historyDirtyFrom = null;
|
||||||
graph.historyState.lastMutationReason = "";
|
graph.historyState.lastMutationReason = "";
|
||||||
graph.historyState.lastMutationSource = "";
|
graph.historyState.lastMutationSource = "";
|
||||||
|
graph.historyState.processedMessageHashes = {};
|
||||||
if (result) {
|
if (result) {
|
||||||
graph.historyState.lastRecoveryResult = result;
|
graph.historyState.lastRecoveryResult = result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -591,6 +591,18 @@ result = {
|
|||||||
);
|
);
|
||||||
assert.equal(harness.api.getGraphPersistenceState().dbReady, false);
|
assert.equal(harness.api.getGraphPersistenceState().dbReady, false);
|
||||||
assert.equal(harness.api.getGraphPersistenceLiveState().writesBlocked, true);
|
assert.equal(harness.api.getGraphPersistenceLiveState().writesBlocked, true);
|
||||||
|
assert.equal(
|
||||||
|
harness.api.getGraphPersistenceState().dualWriteLastResult?.resultCode,
|
||||||
|
"graph.load.metadata-compat.provisional",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
harness.api.getGraphPersistenceState().dualWriteLastResult?.provisional,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
harness.api.getGraphPersistenceState().dualWriteLastResult?.reason,
|
||||||
|
"global-chat-id:metadata-compat-provisional",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1158,6 +1170,77 @@ result = {
|
|||||||
const live = reader.api.getGraphPersistenceLiveState();
|
const live = reader.api.getGraphPersistenceLiveState();
|
||||||
assert.equal(live.shadowSnapshotRevision, 9);
|
assert.equal(live.shadowSnapshotRevision, 9);
|
||||||
assert.equal(live.shadowSnapshotReason, "shadow-integrity-mismatch");
|
assert.equal(live.shadowSnapshotReason, "shadow-integrity-mismatch");
|
||||||
|
const compareDecision = shouldPreferShadowSnapshotOverOfficial(
|
||||||
|
officialGraph,
|
||||||
|
reader.api.readGraphShadowSnapshot("chat-shadow-newer"),
|
||||||
|
);
|
||||||
|
assert.equal(compareDecision.resultCode, "shadow.reject.integrity-mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const decision = shouldPreferShadowSnapshotOverOfficial(
|
||||||
|
stampPersistedGraph(createMeaningfulGraph("chat-self-mismatch"), {
|
||||||
|
revision: 0,
|
||||||
|
chatId: "",
|
||||||
|
integrity: "",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
chatId: "chat-self-mismatch",
|
||||||
|
persistedChatId: "chat-other",
|
||||||
|
revision: 5,
|
||||||
|
integrity: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(decision.prefer, false);
|
||||||
|
assert.equal(decision.reason, "shadow-self-chat-mismatch");
|
||||||
|
assert.equal(decision.resultCode, "shadow.reject.self-chat-mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const decision = shouldPreferShadowSnapshotOverOfficial(
|
||||||
|
stampPersistedGraph(createMeaningfulGraph("chat-official-missing"), {
|
||||||
|
revision: 0,
|
||||||
|
chatId: "",
|
||||||
|
integrity: "",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
chatId: "chat-official-missing",
|
||||||
|
persistedChatId: "chat-official-missing",
|
||||||
|
revision: 4,
|
||||||
|
integrity: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(decision.prefer, false);
|
||||||
|
assert.equal(decision.reason, "shadow-persisted-chat-without-official-chat");
|
||||||
|
assert.equal(
|
||||||
|
decision.resultCode,
|
||||||
|
"shadow.reject.persisted-chat-without-official-chat",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const decision = shouldPreferShadowSnapshotOverOfficial(
|
||||||
|
stampPersistedGraph(
|
||||||
|
createMeaningfulGraph("chat-official-integrity-missing"),
|
||||||
|
{
|
||||||
|
revision: 0,
|
||||||
|
chatId: "chat-official-integrity-missing",
|
||||||
|
integrity: "",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
chatId: "chat-official-integrity-missing",
|
||||||
|
persistedChatId: "chat-official-integrity-missing",
|
||||||
|
revision: 4,
|
||||||
|
integrity: "shadow-only-integrity",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(decision.prefer, false);
|
||||||
|
assert.equal(decision.reason, "shadow-integrity-without-official-integrity");
|
||||||
|
assert.equal(
|
||||||
|
decision.resultCode,
|
||||||
|
"shadow.reject.integrity-without-official-integrity",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -368,6 +368,185 @@ function createGenerationRecallHarness() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createHistoryRecoveryHarness() {
|
||||||
|
return fs.readFile(indexPath, "utf8").then((source) => {
|
||||||
|
const start = source.indexOf("async function recoverHistoryIfNeeded(");
|
||||||
|
const end = source.indexOf("/**\n * 提取管线:处理未提取的对话楼层");
|
||||||
|
if (start < 0 || end < 0 || end <= start) {
|
||||||
|
throw new Error("无法从 index.js 提取 history recovery 定义");
|
||||||
|
}
|
||||||
|
const snippet = source.slice(start, end).replace(/^export\s+/gm, "");
|
||||||
|
const context = {
|
||||||
|
console,
|
||||||
|
Date,
|
||||||
|
result: null,
|
||||||
|
currentGraph: null,
|
||||||
|
extractionCount: 0,
|
||||||
|
isRecoveringHistory: false,
|
||||||
|
chat: [],
|
||||||
|
clearedHistoryDirty: null,
|
||||||
|
prepareVectorStateCalls: [],
|
||||||
|
saveGraphToChatCalls: 0,
|
||||||
|
refreshPanelCalls: 0,
|
||||||
|
notices: [],
|
||||||
|
embeddingConfig: { mode: "backend" },
|
||||||
|
ensureCurrentGraphRuntimeState() {
|
||||||
|
return context.currentGraph;
|
||||||
|
},
|
||||||
|
beginStageAbortController() {
|
||||||
|
return {
|
||||||
|
signal: { aborted: false },
|
||||||
|
abort() {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
finishStageAbortController() {},
|
||||||
|
updateStageNotice(...args) {
|
||||||
|
context.notices.push(args);
|
||||||
|
},
|
||||||
|
inspectHistoryMutation() {
|
||||||
|
return context.inspectHistoryMutationImpl();
|
||||||
|
},
|
||||||
|
inspectHistoryMutationImpl() {
|
||||||
|
return {
|
||||||
|
dirty: true,
|
||||||
|
earliestAffectedFloor: 0,
|
||||||
|
source: "manual-test",
|
||||||
|
reason: "edited",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getContext() {
|
||||||
|
return {
|
||||||
|
chat: context.chat,
|
||||||
|
chatId: "chat-main",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getCurrentChatId() {
|
||||||
|
return "chat-main";
|
||||||
|
},
|
||||||
|
clampRecoveryStartFloor(chat, floor) {
|
||||||
|
return Math.max(0, Number(floor) || 0);
|
||||||
|
},
|
||||||
|
throwIfAborted(signal, message = "aborted") {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.name = "AbortError";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
createAbortError(message = "aborted") {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.name = "AbortError";
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
isAbortError(error) {
|
||||||
|
return error?.name === "AbortError";
|
||||||
|
},
|
||||||
|
findJournalRecoveryPoint(graph, floor) {
|
||||||
|
return context.findJournalRecoveryPointImpl(graph, floor);
|
||||||
|
},
|
||||||
|
findJournalRecoveryPointImpl() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
buildReverseJournalRecoveryPlan(...args) {
|
||||||
|
return context.buildReverseJournalRecoveryPlanImpl(...args);
|
||||||
|
},
|
||||||
|
buildReverseJournalRecoveryPlanImpl() {
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
backendDeleteHashes: [],
|
||||||
|
replayRequiredNodeIds: [],
|
||||||
|
pendingRepairFromFloor: 0,
|
||||||
|
legacyGapFallback: false,
|
||||||
|
dirtyReason: "history-recovery-replay",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
rollbackAffectedJournals() {},
|
||||||
|
normalizeGraphRuntimeState(graph) {
|
||||||
|
return graph;
|
||||||
|
},
|
||||||
|
createEmptyGraph() {
|
||||||
|
return {
|
||||||
|
historyState: {
|
||||||
|
extractionCount: 0,
|
||||||
|
lastMutationSource: "",
|
||||||
|
lastMutationReason: "",
|
||||||
|
},
|
||||||
|
vectorIndexState: {
|
||||||
|
collectionId: "col-1",
|
||||||
|
dirty: false,
|
||||||
|
dirtyReason: "",
|
||||||
|
pendingRepairFromFloor: null,
|
||||||
|
replayRequiredNodeIds: [],
|
||||||
|
lastWarning: "",
|
||||||
|
lastIntegrityIssue: null,
|
||||||
|
},
|
||||||
|
batchJournal: [],
|
||||||
|
lastProcessedSeq: -1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getEmbeddingConfig() {
|
||||||
|
return context.embeddingConfig;
|
||||||
|
},
|
||||||
|
getSettings() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
isBackendVectorConfig(config) {
|
||||||
|
return config?.mode === "backend";
|
||||||
|
},
|
||||||
|
async deleteBackendVectorHashesForRecovery(...args) {
|
||||||
|
context.deletedHashesCalls ||= [];
|
||||||
|
context.deletedHashesCalls.push(args);
|
||||||
|
},
|
||||||
|
async prepareVectorStateForReplay(...args) {
|
||||||
|
context.prepareVectorStateCalls.push(args);
|
||||||
|
if (typeof context.prepareVectorStateForReplayImpl === "function") {
|
||||||
|
return await context.prepareVectorStateForReplayImpl(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
applyRecoveryPlanToVectorState() {},
|
||||||
|
async replayExtractionFromHistory(...args) {
|
||||||
|
if (typeof context.replayExtractionFromHistoryImpl === "function") {
|
||||||
|
return await context.replayExtractionFromHistoryImpl(...args);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
clearHistoryDirty(graph, result) {
|
||||||
|
context.clearedHistoryDirty = result;
|
||||||
|
graph.historyState ||= {};
|
||||||
|
graph.historyState.historyDirtyFrom = null;
|
||||||
|
graph.historyState.processedMessageHashes = {};
|
||||||
|
graph.historyState.lastRecoveryResult = result;
|
||||||
|
},
|
||||||
|
buildRecoveryResult(status, extra = {}) {
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
saveGraphToChat() {
|
||||||
|
context.saveGraphToChatCalls += 1;
|
||||||
|
},
|
||||||
|
clearInjectionState() {},
|
||||||
|
assertRecoveryChatStillActive() {},
|
||||||
|
refreshPanelLiveState() {
|
||||||
|
context.refreshPanelCalls += 1;
|
||||||
|
},
|
||||||
|
toastr: {
|
||||||
|
success() {},
|
||||||
|
warning() {},
|
||||||
|
error() {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(
|
||||||
|
`${snippet}\nresult = { recoverFromHistoryMutation: recoverHistoryIfNeeded };`,
|
||||||
|
context,
|
||||||
|
{ filename: indexPath },
|
||||||
|
);
|
||||||
|
return context;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createRerollHarness() {
|
function createRerollHarness() {
|
||||||
return fs.readFile(indexPath, "utf8").then((source) => {
|
return fs.readFile(indexPath, "utf8").then((source) => {
|
||||||
const rollbackStart = source.indexOf(
|
const rollbackStart = source.indexOf(
|
||||||
@@ -487,6 +666,7 @@ function createRerollHarness() {
|
|||||||
context.clearedHistoryDirty = result;
|
context.clearedHistoryDirty = result;
|
||||||
graph.historyState ||= {};
|
graph.historyState ||= {};
|
||||||
graph.historyState.historyDirtyFrom = null;
|
graph.historyState.historyDirtyFrom = null;
|
||||||
|
graph.historyState.processedMessageHashes = {};
|
||||||
graph.historyState.lastRecoveryResult = result;
|
graph.historyState.lastRecoveryResult = result;
|
||||||
},
|
},
|
||||||
buildRecoveryResult(status, extra = {}) {
|
buildRecoveryResult(status, extra = {}) {
|
||||||
@@ -2869,6 +3049,7 @@ async function testRerollUsesBatchBoundaryRollbackAndPersistsState() {
|
|||||||
assert.equal(result.rollbackPerformed, true);
|
assert.equal(result.rollbackPerformed, true);
|
||||||
assert.equal(result.recoveryPath, "reverse-journal");
|
assert.equal(result.recoveryPath, "reverse-journal");
|
||||||
assert.equal(result.effectiveFromFloor, 2);
|
assert.equal(result.effectiveFromFloor, 2);
|
||||||
|
assert.equal(result.resultCode, "reroll.rollback.applied");
|
||||||
assert.equal(harness.rollbackAffectedJournalsCalls.length, 1);
|
assert.equal(harness.rollbackAffectedJournalsCalls.length, 1);
|
||||||
assert.equal(harness.deletedHashesCalls.length, 1);
|
assert.equal(harness.deletedHashesCalls.length, 1);
|
||||||
assert.equal(harness.prepareVectorStateCalls.length, 1);
|
assert.equal(harness.prepareVectorStateCalls.length, 1);
|
||||||
@@ -2881,9 +3062,252 @@ async function testRerollUsesBatchBoundaryRollbackAndPersistsState() {
|
|||||||
harness.currentGraph.historyState.processedMessageHashes[3],
|
harness.currentGraph.historyState.processedMessageHashes[3],
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
assert.equal(harness.currentGraph.vectorIndexState.lastIntegrityIssue, null);
|
||||||
|
assert.equal(
|
||||||
|
harness.currentGraph.historyState.lastRecoveryResult.resultCode,
|
||||||
|
"reroll.rollback.applied",
|
||||||
|
);
|
||||||
assert.equal(harness.lastExtractedItems.length, 0);
|
assert.equal(harness.lastExtractedItems.length, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testRerollRejectsInvalidReverseJournalPlanFailClosed() {
|
||||||
|
const harness = await createRerollHarness();
|
||||||
|
harness.chat = [
|
||||||
|
{ is_user: true, mes: "u1" },
|
||||||
|
{ is_user: false, mes: "a1" },
|
||||||
|
{ is_user: true, mes: "u2" },
|
||||||
|
{ is_user: false, mes: "a2" },
|
||||||
|
];
|
||||||
|
harness.currentGraph = {
|
||||||
|
historyState: {
|
||||||
|
lastProcessedAssistantFloor: 3,
|
||||||
|
processedMessageHashes: {
|
||||||
|
1: "hash-1",
|
||||||
|
3: "hash-3",
|
||||||
|
},
|
||||||
|
lastRecoveryResult: null,
|
||||||
|
},
|
||||||
|
vectorIndexState: {
|
||||||
|
collectionId: "col-1",
|
||||||
|
},
|
||||||
|
batchJournal: [{ id: "journal-1" }],
|
||||||
|
lastProcessedSeq: 3,
|
||||||
|
};
|
||||||
|
harness.findJournalRecoveryPointImpl = () => ({
|
||||||
|
path: "reverse-journal",
|
||||||
|
affectedBatchCount: 1,
|
||||||
|
affectedJournals: [{ id: "journal-1" }],
|
||||||
|
});
|
||||||
|
harness.buildReverseJournalRecoveryPlanImpl = () => ({
|
||||||
|
valid: false,
|
||||||
|
invalidReason: "pending-repair-floor-missing",
|
||||||
|
backendDeleteHashes: [],
|
||||||
|
replayRequiredNodeIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await harness.result.onReroll({ fromFloor: 3 });
|
||||||
|
|
||||||
|
assert.equal(result.success, false);
|
||||||
|
assert.equal(result.recoveryPath, "reverse-journal-rejected");
|
||||||
|
assert.equal(result.resultCode, "reroll.rollback.plan-invalid");
|
||||||
|
assert.equal(harness.rollbackAffectedJournalsCalls.length, 0);
|
||||||
|
assert.equal(harness.prepareVectorStateCalls.length, 0);
|
||||||
|
assert.equal(harness.deletedHashesCalls.length, 0);
|
||||||
|
assert.equal(harness.saveGraphToChatCalls, 1);
|
||||||
|
assert.equal(harness.refreshPanelCalls, 1);
|
||||||
|
assert.equal(
|
||||||
|
harness.currentGraph.historyState.lastRecoveryResult.status,
|
||||||
|
"reroll-rollback-rejected",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
harness.currentGraph.historyState.lastRecoveryResult.resultCode,
|
||||||
|
"reroll.rollback.plan-invalid",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
harness.currentGraph.historyState.lastRecoveryResult.debugReason,
|
||||||
|
"reroll-rollback-plan-invalid:pending-repair-floor-missing",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testHistoryRecoveryAbortClearsVectorRepairState() {
|
||||||
|
const harness = await createHistoryRecoveryHarness();
|
||||||
|
harness.chat = [
|
||||||
|
{ is_user: true, mes: "u1" },
|
||||||
|
{ is_user: false, mes: "a1" },
|
||||||
|
];
|
||||||
|
harness.currentGraph = {
|
||||||
|
historyState: {
|
||||||
|
lastProcessedAssistantFloor: 1,
|
||||||
|
processedMessageHashes: { 1: "hash-1" },
|
||||||
|
historyDirtyFrom: 1,
|
||||||
|
lastMutationSource: "message-edited",
|
||||||
|
},
|
||||||
|
vectorIndexState: {
|
||||||
|
collectionId: "col-1",
|
||||||
|
dirty: true,
|
||||||
|
dirtyReason: "history-recovery-replay",
|
||||||
|
pendingRepairFromFloor: 1,
|
||||||
|
replayRequiredNodeIds: ["node-1"],
|
||||||
|
lastWarning: "repair pending",
|
||||||
|
lastIntegrityIssue: { code: "dangling-vector" },
|
||||||
|
},
|
||||||
|
batchJournal: [],
|
||||||
|
lastProcessedSeq: 1,
|
||||||
|
};
|
||||||
|
harness.findJournalRecoveryPointImpl = () => ({
|
||||||
|
path: "full-rebuild",
|
||||||
|
affectedBatchCount: 0,
|
||||||
|
});
|
||||||
|
harness.prepareVectorStateForReplayImpl = async () => {
|
||||||
|
throw harness.createAbortError("manual abort");
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await harness.result.recoverFromHistoryMutation({
|
||||||
|
trigger: "message-edited",
|
||||||
|
dirtyFrom: 1,
|
||||||
|
detection: { source: "manual-test", reason: "edited" },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.equal(
|
||||||
|
harness.currentGraph.historyState.lastRecoveryResult.resultCode,
|
||||||
|
"history.recovery.aborted",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
harness.currentGraph.historyState.lastRecoveryResult.debugReason,
|
||||||
|
"history-recovery-aborted:full-rebuild",
|
||||||
|
);
|
||||||
|
assert.equal(harness.currentGraph.vectorIndexState.lastIntegrityIssue, null);
|
||||||
|
assert.equal(harness.currentGraph.vectorIndexState.lastWarning, "");
|
||||||
|
assert.equal(
|
||||||
|
harness.currentGraph.vectorIndexState.pendingRepairFromFloor,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
harness.currentGraph.vectorIndexState.replayRequiredNodeIds.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(harness.currentGraph.vectorIndexState.dirty, false);
|
||||||
|
assert.equal(harness.currentGraph.vectorIndexState.dirtyReason, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testHistoryRecoveryFallbackFullRebuildCarriesResultCode() {
|
||||||
|
const harness = await createHistoryRecoveryHarness();
|
||||||
|
harness.chat = [
|
||||||
|
{ is_user: true, mes: "u1" },
|
||||||
|
{ is_user: false, mes: "a1" },
|
||||||
|
];
|
||||||
|
harness.currentGraph = {
|
||||||
|
historyState: {
|
||||||
|
lastProcessedAssistantFloor: 1,
|
||||||
|
processedMessageHashes: { 1: "hash-1" },
|
||||||
|
historyDirtyFrom: 1,
|
||||||
|
lastMutationSource: "message-edited",
|
||||||
|
},
|
||||||
|
vectorIndexState: {
|
||||||
|
collectionId: "col-1",
|
||||||
|
dirty: true,
|
||||||
|
dirtyReason: "history-recovery-replay",
|
||||||
|
pendingRepairFromFloor: 1,
|
||||||
|
replayRequiredNodeIds: ["node-1"],
|
||||||
|
lastWarning: "repair pending",
|
||||||
|
lastIntegrityIssue: { code: "dangling-vector" },
|
||||||
|
},
|
||||||
|
batchJournal: [],
|
||||||
|
lastProcessedSeq: 1,
|
||||||
|
};
|
||||||
|
harness.findJournalRecoveryPointImpl = () => ({
|
||||||
|
path: "legacy-snapshot",
|
||||||
|
affectedBatchCount: 2,
|
||||||
|
snapshotBefore: {
|
||||||
|
historyState: { extractionCount: 0 },
|
||||||
|
vectorIndexState: { collectionId: "col-1" },
|
||||||
|
batchJournal: [],
|
||||||
|
lastProcessedSeq: -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let replayCallCount = 0;
|
||||||
|
harness.replayExtractionFromHistoryImpl = async () => {
|
||||||
|
replayCallCount += 1;
|
||||||
|
if (replayCallCount === 1) {
|
||||||
|
throw new Error("replay failed");
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await harness.result.recoverFromHistoryMutation({
|
||||||
|
trigger: "message-edited",
|
||||||
|
dirtyFrom: 1,
|
||||||
|
detection: { source: "manual-test", reason: "edited" },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, true);
|
||||||
|
assert.equal(
|
||||||
|
harness.clearedHistoryDirty.resultCode,
|
||||||
|
"history.recovery.fallback-full-rebuild",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
harness.clearedHistoryDirty.debugReason,
|
||||||
|
"history-recovery-fallback-full-rebuild:legacy-snapshot",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testHistoryRecoveryFailureCarriesResultCode() {
|
||||||
|
const harness = await createHistoryRecoveryHarness();
|
||||||
|
harness.chat = [
|
||||||
|
{ is_user: true, mes: "u1" },
|
||||||
|
{ is_user: false, mes: "a1" },
|
||||||
|
];
|
||||||
|
harness.currentGraph = {
|
||||||
|
historyState: {
|
||||||
|
lastProcessedAssistantFloor: 1,
|
||||||
|
processedMessageHashes: { 1: "hash-1" },
|
||||||
|
historyDirtyFrom: 1,
|
||||||
|
lastMutationSource: "message-edited",
|
||||||
|
},
|
||||||
|
vectorIndexState: {
|
||||||
|
collectionId: "col-1",
|
||||||
|
dirty: true,
|
||||||
|
dirtyReason: "history-recovery-replay",
|
||||||
|
pendingRepairFromFloor: 1,
|
||||||
|
replayRequiredNodeIds: ["node-1"],
|
||||||
|
lastWarning: "repair pending",
|
||||||
|
lastIntegrityIssue: { code: "dangling-vector" },
|
||||||
|
},
|
||||||
|
batchJournal: [],
|
||||||
|
lastProcessedSeq: 1,
|
||||||
|
};
|
||||||
|
harness.findJournalRecoveryPointImpl = () => ({
|
||||||
|
path: "legacy-snapshot",
|
||||||
|
affectedBatchCount: 1,
|
||||||
|
snapshotBefore: {
|
||||||
|
historyState: { extractionCount: 0 },
|
||||||
|
vectorIndexState: { collectionId: "col-1" },
|
||||||
|
batchJournal: [],
|
||||||
|
lastProcessedSeq: -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
harness.replayExtractionFromHistoryImpl = async () => {
|
||||||
|
throw new Error("replay failed twice");
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await harness.result.recoverFromHistoryMutation({
|
||||||
|
trigger: "message-edited",
|
||||||
|
dirtyFrom: 1,
|
||||||
|
detection: { source: "manual-test", reason: "edited" },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.equal(
|
||||||
|
harness.currentGraph.historyState.lastRecoveryResult.resultCode,
|
||||||
|
"history.recovery.failed",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
harness.currentGraph.historyState.lastRecoveryResult.debugReason,
|
||||||
|
"history-recovery-failed:legacy-snapshot",
|
||||||
|
);
|
||||||
|
assert.equal(harness.currentGraph.vectorIndexState.lastIntegrityIssue, null);
|
||||||
|
}
|
||||||
async function testRerollRejectsMissingRecoveryPoint() {
|
async function testRerollRejectsMissingRecoveryPoint() {
|
||||||
const harness = await createRerollHarness();
|
const harness = await createRerollHarness();
|
||||||
harness.chat = [
|
harness.chat = [
|
||||||
@@ -2911,6 +3335,7 @@ async function testRerollRejectsMissingRecoveryPoint() {
|
|||||||
|
|
||||||
assert.equal(result.success, false);
|
assert.equal(result.success, false);
|
||||||
assert.equal(result.recoveryPath, "unavailable");
|
assert.equal(result.recoveryPath, "unavailable");
|
||||||
|
assert.equal(result.resultCode, "reroll.rollback.unavailable");
|
||||||
assert.equal(harness.onManualExtractCalls, 0);
|
assert.equal(harness.onManualExtractCalls, 0);
|
||||||
assert.equal(harness.saveGraphToChatCalls, 0);
|
assert.equal(harness.saveGraphToChatCalls, 0);
|
||||||
}
|
}
|
||||||
@@ -2943,6 +3368,7 @@ async function testRerollFallsBackToDirectExtractForUnprocessedFloor() {
|
|||||||
assert.equal(result.rollbackPerformed, false);
|
assert.equal(result.rollbackPerformed, false);
|
||||||
assert.equal(result.recoveryPath, "direct-extract");
|
assert.equal(result.recoveryPath, "direct-extract");
|
||||||
assert.equal(result.effectiveFromFloor, 2);
|
assert.equal(result.effectiveFromFloor, 2);
|
||||||
|
assert.equal(result.resultCode, undefined);
|
||||||
assert.equal(harness.onManualExtractCalls, 1);
|
assert.equal(harness.onManualExtractCalls, 1);
|
||||||
assert.equal(harness.saveGraphToChatCalls, 0);
|
assert.equal(harness.saveGraphToChatCalls, 0);
|
||||||
}
|
}
|
||||||
@@ -3188,6 +3614,9 @@ await testRecallCardExpandedContentRerendersAfterRecordUpdate();
|
|||||||
await testRecallCardUserTextRefreshesWithoutCardRecreate();
|
await testRecallCardUserTextRefreshesWithoutCardRecreate();
|
||||||
await testRecallSubGraphAndDataLayerEntryPoints();
|
await testRecallSubGraphAndDataLayerEntryPoints();
|
||||||
await testRerollUsesBatchBoundaryRollbackAndPersistsState();
|
await testRerollUsesBatchBoundaryRollbackAndPersistsState();
|
||||||
|
await testHistoryRecoveryAbortClearsVectorRepairState();
|
||||||
|
await testHistoryRecoveryFallbackFullRebuildCarriesResultCode();
|
||||||
|
await testHistoryRecoveryFailureCarriesResultCode();
|
||||||
await testRerollRejectsMissingRecoveryPoint();
|
await testRerollRejectsMissingRecoveryPoint();
|
||||||
await testRerollFallsBackToDirectExtractForUnprocessedFloor();
|
await testRerollFallsBackToDirectExtractForUnprocessedFloor();
|
||||||
await testLlmDebugSnapshotRedactsSecretsBeforeStorage();
|
await testLlmDebugSnapshotRedactsSecretsBeforeStorage();
|
||||||
|
|||||||
Reference in New Issue
Block a user