mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
feat(authority): add checkpoint audit scaffolding
This commit is contained in:
476
maintenance/authority-consistency.js
Normal file
476
maintenance/authority-consistency.js
Normal file
@@ -0,0 +1,476 @@
|
||||
import { deserializeGraph } from "../graph/graph.js";
|
||||
import { normalizeGraphRuntimeState } from "../runtime/runtime-state.js";
|
||||
import { buildSnapshotFromGraph } from "../sync/bme-db.js";
|
||||
|
||||
function clonePlain(value, fallbackValue = null) {
|
||||
if (value == null) return fallbackValue;
|
||||
if (typeof globalThis.structuredClone === "function") {
|
||||
try {
|
||||
return globalThis.structuredClone(value);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeChatId(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizeOptionalInteger(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return Math.max(0, Math.floor(parsed));
|
||||
}
|
||||
|
||||
function normalizeIssue(severity, code, message) {
|
||||
return {
|
||||
severity,
|
||||
code: String(code || "unknown"),
|
||||
message: String(message || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function readNestedValue(source = null, path = []) {
|
||||
let current = source;
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function readFirstValue(source = null, candidates = []) {
|
||||
for (const candidate of candidates) {
|
||||
const path = Array.isArray(candidate) ? candidate : [candidate];
|
||||
const value = readNestedValue(source, path);
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readFirstInteger(source = null, candidates = []) {
|
||||
const value = readFirstValue(source, candidates);
|
||||
return normalizeOptionalInteger(value);
|
||||
}
|
||||
|
||||
function readFirstString(source = null, candidates = []) {
|
||||
const value = readFirstValue(source, candidates);
|
||||
return value == null ? "" : String(value || "").trim();
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error = null) {
|
||||
if (!error) return "";
|
||||
return String(error?.message || error || "").trim();
|
||||
}
|
||||
|
||||
function buildRevisionDelta(left = null, right = null) {
|
||||
if (!Number.isFinite(left) || !Number.isFinite(right)) return null;
|
||||
return Number(left) - Number(right);
|
||||
}
|
||||
|
||||
export function buildAuthorityCheckpointImportSnapshot(checkpoint = null, options = {}) {
|
||||
const normalizedCheckpoint =
|
||||
checkpoint && typeof checkpoint === "object" && !Array.isArray(checkpoint)
|
||||
? checkpoint
|
||||
: null;
|
||||
if (!normalizedCheckpoint) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "checkpoint-missing",
|
||||
snapshot: null,
|
||||
};
|
||||
}
|
||||
|
||||
const chatId = normalizeChatId(options.chatId || normalizedCheckpoint.chatId);
|
||||
if (!chatId) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "checkpoint-chat-id-missing",
|
||||
snapshot: null,
|
||||
};
|
||||
}
|
||||
|
||||
const serializedGraph = String(normalizedCheckpoint.serializedGraph || "").trim();
|
||||
const rawGraph =
|
||||
normalizedCheckpoint.graph &&
|
||||
typeof normalizedCheckpoint.graph === "object" &&
|
||||
!Array.isArray(normalizedCheckpoint.graph)
|
||||
? clonePlain(normalizedCheckpoint.graph, null)
|
||||
: null;
|
||||
if (!serializedGraph && !rawGraph) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "checkpoint-serialized-graph-missing",
|
||||
snapshot: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const restoredGraph = normalizeGraphRuntimeState(
|
||||
rawGraph || deserializeGraph(serializedGraph),
|
||||
chatId,
|
||||
);
|
||||
const revision = Math.max(
|
||||
0,
|
||||
normalizeOptionalInteger(options.revision) ?? -1,
|
||||
normalizeOptionalInteger(normalizedCheckpoint.revision) ?? -1,
|
||||
);
|
||||
const source = String(options.source || "authority-checkpoint-restore").trim() ||
|
||||
"authority-checkpoint-restore";
|
||||
const integrity = String(
|
||||
normalizedCheckpoint.integrity || options.integrity || "",
|
||||
).trim();
|
||||
const snapshot = buildSnapshotFromGraph(restoredGraph, {
|
||||
chatId,
|
||||
revision,
|
||||
lastModified: Date.now(),
|
||||
meta: {
|
||||
integrity,
|
||||
storagePrimary: String(options.storagePrimary || "authority"),
|
||||
storageMode: String(options.storageMode || "authority-sql-primary"),
|
||||
lastMutationReason: source,
|
||||
authorityCheckpointSource: source,
|
||||
authorityCheckpointChatId: chatId,
|
||||
authorityCheckpointRevision: revision,
|
||||
authorityCheckpointPersistedAt: String(
|
||||
normalizedCheckpoint.persistedAt || "",
|
||||
),
|
||||
authorityCheckpointPath: String(options.path || ""),
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
reason: "checkpoint-import-snapshot-ready",
|
||||
snapshot,
|
||||
checkpoint: {
|
||||
chatId,
|
||||
revision,
|
||||
integrity,
|
||||
persistedAt: String(normalizedCheckpoint.persistedAt || ""),
|
||||
hasSerializedGraph: Boolean(serializedGraph || rawGraph),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "checkpoint-invalid",
|
||||
error,
|
||||
snapshot: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyAuthorityCheckpointToStore(
|
||||
targetStore,
|
||||
checkpoint = null,
|
||||
options = {},
|
||||
) {
|
||||
const prepared = buildAuthorityCheckpointImportSnapshot(checkpoint, options);
|
||||
if (!prepared.ok || !prepared.snapshot) {
|
||||
return {
|
||||
...prepared,
|
||||
restored: false,
|
||||
};
|
||||
}
|
||||
if (!targetStore || typeof targetStore.importSnapshot !== "function") {
|
||||
return {
|
||||
...prepared,
|
||||
ok: false,
|
||||
reason: "target-store-import-unavailable",
|
||||
restored: false,
|
||||
};
|
||||
}
|
||||
if (typeof targetStore.open === "function") {
|
||||
await targetStore.open();
|
||||
}
|
||||
if (typeof options.beforeImport === "function") {
|
||||
await options.beforeImport(prepared.snapshot);
|
||||
}
|
||||
const importResult = await targetStore.importSnapshot(prepared.snapshot, {
|
||||
mode: "replace",
|
||||
preserveRevision: true,
|
||||
revision: prepared.snapshot.meta.revision,
|
||||
markSyncDirty: options.markSyncDirty === true,
|
||||
});
|
||||
prepared.snapshot.meta.revision = Math.max(
|
||||
normalizeOptionalInteger(importResult?.revision) ?? 0,
|
||||
normalizeOptionalInteger(prepared.snapshot.meta.revision) ?? 0,
|
||||
);
|
||||
return {
|
||||
...prepared,
|
||||
ok: true,
|
||||
restored: true,
|
||||
revision: prepared.snapshot.meta.revision,
|
||||
importResult: clonePlain(importResult, importResult),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAuthorityConsistencyAudit(input = {}) {
|
||||
const source = input && typeof input === "object" && !Array.isArray(input) ? input : {};
|
||||
const updatedAt = String(source.updatedAt || new Date().toISOString());
|
||||
const chatId = normalizeChatId(
|
||||
source.chatId ||
|
||||
source.runtimeGraph?.chatId ||
|
||||
source.graphPersistenceState?.chatId ||
|
||||
source.blobResult?.checkpoint?.chatId,
|
||||
);
|
||||
const collectionId = normalizeChatId(
|
||||
source.collectionId ||
|
||||
source.runtimeGraph?.vectorIndexState?.collectionId ||
|
||||
source.runtimeGraph?.runtimeState?.vectorIndexState?.collectionId,
|
||||
);
|
||||
const sqlSnapshot =
|
||||
source.sqlSnapshot && typeof source.sqlSnapshot === "object" && !Array.isArray(source.sqlSnapshot)
|
||||
? source.sqlSnapshot
|
||||
: null;
|
||||
const sqlError = normalizeErrorMessage(source.sqlError);
|
||||
const sql = {
|
||||
available: Boolean(sqlSnapshot),
|
||||
ok: Boolean(sqlSnapshot) && !sqlError,
|
||||
error: sqlError,
|
||||
revision: readFirstInteger(sqlSnapshot, [["meta", "revision"]]),
|
||||
nodeCount: readFirstInteger(sqlSnapshot, [["meta", "nodeCount"]]) ??
|
||||
(Array.isArray(sqlSnapshot?.nodes) ? sqlSnapshot.nodes.length : null),
|
||||
edgeCount: readFirstInteger(sqlSnapshot, [["meta", "edgeCount"]]) ??
|
||||
(Array.isArray(sqlSnapshot?.edges) ? sqlSnapshot.edges.length : null),
|
||||
tombstoneCount: readFirstInteger(sqlSnapshot, [["meta", "tombstoneCount"]]) ??
|
||||
(Array.isArray(sqlSnapshot?.tombstones) ? sqlSnapshot.tombstones.length : null),
|
||||
lastModified: readFirstString(sqlSnapshot, [["meta", "lastModified"], ["meta", "updatedAt"]]),
|
||||
};
|
||||
|
||||
const blobError = normalizeErrorMessage(source.blobError || source.blobResult?.error);
|
||||
const blobCheckpoint =
|
||||
source.blobResult?.checkpoint &&
|
||||
typeof source.blobResult.checkpoint === "object" &&
|
||||
!Array.isArray(source.blobResult.checkpoint)
|
||||
? source.blobResult.checkpoint
|
||||
: null;
|
||||
const blob = {
|
||||
available: source.blobResult != null,
|
||||
ok: source.blobResult?.ok !== false && !blobError,
|
||||
error: blobError,
|
||||
exists: Boolean(source.blobResult?.exists && blobCheckpoint),
|
||||
path: String(
|
||||
source.blobResult?.path || source.graphPersistenceState?.authorityBlobCheckpointPath || "",
|
||||
).trim(),
|
||||
revision:
|
||||
readFirstInteger(blobCheckpoint, [["revision"]]) ??
|
||||
normalizeOptionalInteger(source.graphPersistenceState?.authorityBlobCheckpointRevision),
|
||||
chatId: normalizeChatId(blobCheckpoint?.chatId),
|
||||
persistedAt: readFirstString(blobCheckpoint, [["persistedAt"], ["updatedAt"]]),
|
||||
hasSerializedGraph: Boolean(
|
||||
String(blobCheckpoint?.serializedGraph || "").trim() ||
|
||||
(blobCheckpoint?.graph && typeof blobCheckpoint.graph === "object"),
|
||||
),
|
||||
};
|
||||
|
||||
const triviumSource =
|
||||
source.triviumStat && typeof source.triviumStat === "object" && !Array.isArray(source.triviumStat)
|
||||
? source.triviumStat
|
||||
: null;
|
||||
const triviumError = normalizeErrorMessage(source.triviumError);
|
||||
const trivium = {
|
||||
available: Boolean(triviumSource),
|
||||
ok: Boolean(triviumSource) && !triviumError,
|
||||
error: triviumError,
|
||||
revision: readFirstInteger(triviumSource, [
|
||||
["revision"],
|
||||
["graphRevision"],
|
||||
["result", "revision"],
|
||||
["result", "graphRevision"],
|
||||
["stats", "revision"],
|
||||
["meta", "revision"],
|
||||
]),
|
||||
itemCount: readFirstInteger(triviumSource, [
|
||||
["itemCount"],
|
||||
["count"],
|
||||
["total"],
|
||||
["vectorCount"],
|
||||
["documentCount"],
|
||||
["result", "itemCount"],
|
||||
["result", "count"],
|
||||
["result", "total"],
|
||||
["stats", "itemCount"],
|
||||
["stats", "count"],
|
||||
]),
|
||||
linkCount: readFirstInteger(triviumSource, [
|
||||
["linkCount"],
|
||||
["edgeCount"],
|
||||
["relationCount"],
|
||||
["result", "linkCount"],
|
||||
["result", "edgeCount"],
|
||||
["stats", "linkCount"],
|
||||
]),
|
||||
namespace: readFirstString(triviumSource, [
|
||||
["namespace"],
|
||||
["result", "namespace"],
|
||||
["collectionId"],
|
||||
["result", "collectionId"],
|
||||
]),
|
||||
};
|
||||
|
||||
const runtimeGraph =
|
||||
source.runtimeGraph && typeof source.runtimeGraph === "object" && !Array.isArray(source.runtimeGraph)
|
||||
? source.runtimeGraph
|
||||
: {};
|
||||
const runtimePersistence =
|
||||
source.graphPersistenceState &&
|
||||
typeof source.graphPersistenceState === "object" &&
|
||||
!Array.isArray(source.graphPersistenceState)
|
||||
? source.graphPersistenceState
|
||||
: {};
|
||||
const runtime = {
|
||||
revision: Math.max(
|
||||
normalizeOptionalInteger(runtimeGraph?.meta?.revision) ?? 0,
|
||||
normalizeOptionalInteger(runtimePersistence?.revision) ?? 0,
|
||||
),
|
||||
nodeCount: Array.isArray(runtimeGraph?.nodes) ? runtimeGraph.nodes.length : null,
|
||||
edgeCount: Array.isArray(runtimeGraph?.edges) ? runtimeGraph.edges.length : null,
|
||||
collectionId,
|
||||
vectorDirty: Boolean(runtimeGraph?.vectorIndexState?.dirty),
|
||||
lastJobId: String(
|
||||
source.lastJob?.id || runtimePersistence?.authorityLastJobId || "",
|
||||
).trim(),
|
||||
lastJobStatus: String(
|
||||
source.lastJob?.status || runtimePersistence?.authorityLastJobStatus || "",
|
||||
).trim(),
|
||||
};
|
||||
|
||||
const drift = {
|
||||
runtimeVsSqlRevision: buildRevisionDelta(runtime.revision, sql.revision),
|
||||
runtimeVsBlobRevision: buildRevisionDelta(runtime.revision, blob.revision),
|
||||
sqlVsBlobRevision: buildRevisionDelta(sql.revision, blob.revision),
|
||||
triviumVsSqlRevision: buildRevisionDelta(trivium.revision, sql.revision),
|
||||
collectionMatchesRuntime:
|
||||
!trivium.namespace || !runtime.collectionId || trivium.namespace === runtime.collectionId,
|
||||
checkpointRestorable:
|
||||
blob.exists && blob.hasSerializedGraph && (!blob.chatId || !chatId || blob.chatId === chatId),
|
||||
};
|
||||
|
||||
const issues = [];
|
||||
if (sql.error) {
|
||||
issues.push(normalizeIssue("error", "sql-probe-error", `Authority SQL 探针失败:${sql.error}`));
|
||||
}
|
||||
if (blob.error) {
|
||||
issues.push(normalizeIssue("warning", "blob-probe-error", `Authority Blob 读取失败:${blob.error}`));
|
||||
}
|
||||
if (trivium.error) {
|
||||
issues.push(normalizeIssue("warning", "trivium-probe-error", `Authority Trivium 探针失败:${trivium.error}`));
|
||||
}
|
||||
if (blob.exists && blob.chatId && chatId && blob.chatId !== chatId) {
|
||||
issues.push(normalizeIssue("error", "blob-chat-mismatch", `Checkpoint chatId 不匹配:${blob.chatId} ≠ ${chatId}`));
|
||||
}
|
||||
if (
|
||||
Number.isFinite(sql.revision) &&
|
||||
Number.isFinite(runtime.revision) &&
|
||||
sql.revision !== runtime.revision
|
||||
) {
|
||||
issues.push(
|
||||
normalizeIssue(
|
||||
"warning",
|
||||
"sql-runtime-revision-drift",
|
||||
`SQL revision 与 runtime 不一致:${sql.revision} ≠ ${runtime.revision}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
Number.isFinite(blob.revision) &&
|
||||
Number.isFinite(runtime.revision) &&
|
||||
blob.revision !== runtime.revision
|
||||
) {
|
||||
issues.push(
|
||||
normalizeIssue(
|
||||
"warning",
|
||||
"blob-runtime-revision-drift",
|
||||
`Blob checkpoint revision 与 runtime 不一致:${blob.revision} ≠ ${runtime.revision}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
Number.isFinite(trivium.revision) &&
|
||||
Number.isFinite(sql.revision) &&
|
||||
trivium.revision !== sql.revision
|
||||
) {
|
||||
issues.push(
|
||||
normalizeIssue(
|
||||
"warning",
|
||||
"trivium-sql-revision-drift",
|
||||
`Trivium revision 与 SQL 不一致:${trivium.revision} ≠ ${sql.revision}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!drift.collectionMatchesRuntime) {
|
||||
issues.push(
|
||||
normalizeIssue(
|
||||
"warning",
|
||||
"trivium-collection-mismatch",
|
||||
`Trivium collection/namespace 与 runtime 不一致:${trivium.namespace} ≠ ${runtime.collectionId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (runtime.vectorDirty) {
|
||||
issues.push(normalizeIssue("warning", "vector-dirty", "当前向量索引仍处于 dirty 状态"));
|
||||
}
|
||||
if (!blob.exists && source.capability?.blobReady) {
|
||||
issues.push(normalizeIssue("warning", "blob-checkpoint-missing", "Authority Blob 尚无可用 checkpoint"));
|
||||
}
|
||||
|
||||
const actions = [];
|
||||
if (drift.checkpointRestorable) actions.push("restore-from-authority-blob-checkpoint");
|
||||
if (runtime.vectorDirty || (Number.isFinite(drift.triviumVsSqlRevision) && drift.triviumVsSqlRevision < 0)) {
|
||||
actions.push("rebuild-authority-trivium");
|
||||
}
|
||||
if (!blob.exists && source.capability?.blobReady) {
|
||||
actions.push("write-authority-checkpoint");
|
||||
}
|
||||
if (issues.some((issue) => issue.code === "sql-runtime-revision-drift" || issue.code === "blob-runtime-revision-drift")) {
|
||||
actions.push("run-authority-consistency-audit");
|
||||
}
|
||||
|
||||
const level = issues.some((issue) => issue.severity === "error")
|
||||
? "error"
|
||||
: issues.length
|
||||
? "warning"
|
||||
: sql.available || blob.available || trivium.available
|
||||
? "success"
|
||||
: "idle";
|
||||
const label =
|
||||
level === "error"
|
||||
? "存在阻塞性不一致"
|
||||
: level === "warning"
|
||||
? "存在待处理漂移"
|
||||
: level === "success"
|
||||
? "Authority 工件已对齐"
|
||||
: "等待审计";
|
||||
const detail = issues[0]?.message || (level === "success"
|
||||
? "Authority SQL / Trivium / Blob 已达到当前可观测的一致状态"
|
||||
: "尚未运行审计");
|
||||
|
||||
return {
|
||||
updatedAt,
|
||||
chatId,
|
||||
collectionId,
|
||||
sql,
|
||||
trivium,
|
||||
blob,
|
||||
runtime,
|
||||
drift,
|
||||
issues,
|
||||
actions,
|
||||
summary: {
|
||||
level,
|
||||
label,
|
||||
detail,
|
||||
issueCount: issues.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user