Files
Endoleap/src/lib/canvas-runtime/recovery.ts
T

350 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* EngineSnapshot 与可序列化 RecoveryImage 的边界。图像同时保存 working/history/operations 与 runtime,
* 不把它们分别恢复成可互相漂移的 store;Blob 字节走 DurableBatch.files,不放进 JSON payload。
* 编码按所需特性选择格式,解码校验表/跨表身份、依赖、请求、历史、资源与任务证据后才返回候选 Root。
* 解码不发 HTTP、不重启外部副作用,最终恢复准入和可靠存储仍由 engine/adapter 负责。
*/
import {referencedResources} from './resources.ts';
import {validateSharedAsset,type SharedAssetBinding} from './asset-depot.ts';
import {validateLoadingRuntime,type LoadingState} from './loading.ts';
import {validateOrphanFile,type OrphanFile} from './orphan-files.ts';
import {importFiles} from './import-file-retention.ts';
import {resourceRetentionPins} from './resource-retention.ts';
import {dataArray,dataRecord} from '../canvas-core/data-containers.ts';
import type {ImportScheduling} from './import-budget.ts';
import { sealRecoveryImage, sealRecoveryImageAsync } from './recovery-encoding.ts';
import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
import { validateDefaultImageSizing } from './default-image-size.ts';
import { validateImportJob, type ImportJob } from './imports.ts';
import { validateElisions, withLocalLifecycles } from './elision.ts';
import type { Change, CoreSnapshot, Document, HistoryEntry, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
import { validateHistoryReplay, replayHistoryEvidence, sameHistory } from './history-replay.ts';
import type { RecoveryAudit } from './conflicts.ts';
import { applyHistoryTransition, validateHistoryTransition, type HistoryTransition } from '../canvas-core/history-transition.ts';
import { createDocument, validateDocument, isFactoryDocument } from '../canvas-core/document.ts';
import { fault, freeze, readMap, validJson, equal, isOwnedMapView } from '../canvas-core/values.ts';
import type { EngineSnapshot, RuntimeState } from './contracts.ts';
import type { FrozenRequest, LedgerSnapshot, OperationRecord } from './ledger.ts';
import { validateRunInput, type RunRecord } from './runs.ts';
import type { ResourceRecord } from './resources.ts';
import type { RecoveryImage } from './storage-port.ts';
// Membership is stable only for core-owned read views. Never infer ownership
// from Object.freeze: a frozen native Map can still change through .set().
// 仅核心自有只读 Map 的成员稳定可缓存;Object.freeze(native Map) 并不禁止 set,不能作为来源证明。
const entryViews=new WeakMap<object,readonly (readonly [unknown,unknown])[]>();
/** 可选表缺省的共享空数组,只表示没有表成员,不表示其数据已完成业务校验。 */
const noEntries=dataArray([]);
/** 把 Map 编成有序元组数组;自有读视图可复用外壳,其 value 是否可复用由 codec 另查。 */
function mapValue<K,V>(map?:ReadonlyMap<K,V>):readonly (readonly [K,V])[]{
if(!map)return noEntries;
if(!isOwnedMapView(map))return [...map];
let value=entryViews.get(map);
if(!value){value=dataArray([...map].map(pair=>dataArray(pair) as readonly [K,V]));entryViews.set(map,value);}
return value as readonly (readonly [K,V])[];
}
/** 显式选择规范化文档字段并建立框架拥有的外层容器,不假定嵌套外部记录天然不可变。 */
function encodeDocumentValue(document:Document){
// Freeze our own containers only. The codec still independently proves whether
// each nested value can be reused, including external mutable records.
return dataRecord({projectId:document.projectId,canvases:dataArray(document.canvases.values()),elements:dataArray(document.elements.values()),tombstones:dataArray([...document.tombstones].map(pair=>dataArray(pair)))});
}
// 缓存工厂信封的序列化外壳,不缓存/跳过组件数据校验,也不将该外壳视为发布文档。
const documentViews=new WeakMap<Document,ReturnType<typeof encodeDocumentValue>>();
/** 工厂文档可复用成员外壳;未知来源文档重新枚举,防止冻结外壳掩盖可变 Map 成员。 */
function documentValue(document:Document){
if(!isFactoryDocument(document))return encodeDocumentValue(document);
let value=documentViews.get(document);if(!value){value=encodeDocumentValue(document);documentViews.set(document,value);}return value;
}
/** 显式选择模型/运行事实;不采集 view、renderer、显示句柄或 Blob 字节。
* stored asset URL 和 sourceURL 属于已选协议数据,会按原值保存;不应把本函数当成任意数据的脱敏器。 */
/** 从同一 snapshot 选择文档/双栈/意图及所有运行事实,按当前特性选择兼容 envelope 格式。
* 格式优先级覆盖读完整性、原生文档、数组、共享binding、partial loading、尺寸/文件退休等;
* 与 parserVersion、component dataVersion、IDB/journalVersion 无关,不能互相代替。 */
function recoveryInput(snapshot: EngineSnapshot) {
const payload = dataRecord({
document: documentValue(snapshot.document),
history: dataRecord({ undo: snapshot.history.undo, redo: snapshot.history.redo, entries: mapValue(snapshot.history.entries) }),
operations: mapValue(snapshot.operations),
runtime: dataRecord({ ...(snapshot.runtime.assetBindings?.size?{assetBindings:mapValue(snapshot.runtime.assetBindings)}:{}), ...(snapshot.runtime.loadingRead?{loadingRead:dataRecord({document:documentValue(snapshot.runtime.loadingRead.document),versions:mapValue(snapshot.runtime.loadingRead.versions)})}:{}), ...(snapshot.runtime.loading?{loading:snapshot.runtime.loading}:{}), ...(snapshot.runtime.orphanFiles?.size?{orphanFiles:mapValue(snapshot.runtime.orphanFiles)}:{}), imports:mapValue(snapshot.runtime.imports), recoveries:mapValue(snapshot.runtime.recoveries), runs: mapValue(snapshot.runtime.runs), resources: mapValue(snapshot.runtime.resources), resourcePins: mapValue(snapshot.runtime.resourcePins), acknowledged: documentValue(snapshot.runtime.acknowledged), versions: mapValue(snapshot.runtime.versions), receipts: mapValue(snapshot.runtime.receipts), conflicts: mapValue(snapshot.runtime.conflicts), ledger: dataRecord({ ...snapshot.runtime.ledger, operations: mapValue(snapshot.runtime.ledger.operations), requests: mapValue(snapshot.runtime.ledger.requests) }) }),
});
return [[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.integrityVersion===2)?17:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document'&&job.parserVersion===5)?16:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document')?15:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-json-array')?14:snapshot.runtime.assetBindings?.size?13:snapshot.runtime.loading?(snapshot.runtime.loading.version===6?12:snapshot.runtime.loading.version===5?11:snapshot.runtime.loading.version===4?10:snapshot.runtime.loading.version===3?9:snapshot.runtime.loading.version===2?8:7):[...snapshot.operations.values()].some(intent=>intent.historyEffect)||[...snapshot.runtime.resources.values()].some(resource=>resource.sizing)?6:snapshot.runtime.orphanFiles?.size?5:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.retirement||job.source?.previousFiles?.length)?4:[...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload] as const;
}
/** 同步编码完整 Root,返回 sealed 不可变图像;编码成功尚不代表已 durable。 */
export function encodeRecovery(snapshot:EngineSnapshot):RecoveryImage {
return sealRecoveryImage(...recoveryInput(snapshot));
}
/** 相同输入合同的分片编码入口,预算与外部数据捕获边界由 recovery-encoding 明确处理。 */
export function encodeRecoveryAsync(snapshot:EngineSnapshot,options:ImportScheduling={}):Promise<RecoveryImage> {
return sealRecoveryImageAsync(...recoveryInput(snapshot),options);
}
/** 解码边界的未验证对象字段表,后续每个字段仍需具体校验。 */
type ObjectValue = Record<string, unknown>;
/** 要求非 null 的非数组对象,否则统一报告图像损坏。 */
function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; }
/** 要求数组;不能把缺失字段或对象静默转成空数组。 */
function array(value: unknown): unknown[] { if (!Array.isArray(value)) throw fault('corrupt_recovery_image'); return value; }
/** ID/代码等字段要求非空字符串;允许空标签的地方另行检查。 */
function string(value: unknown): string { if (typeof value !== 'string' || !value) throw fault('corrupt_recovery_image'); return value; }
/** 验证安全整数与调用点指定下限,用于 revision、计数、代次和版本。 */
function integer(value: unknown, min = 0): number { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min) throw fault('corrupt_recovery_image'); return value; }
/** 验证非空字符串数组;是否允许重复由具体持久字段/关联约束决定。 */
function strings(value: unknown): string[] { return array(value).map(string); }
/** 解码二元组表,拒绝重复 ID 与非二元组;完成后返回运行时安全只读 Map。 */
function table<T>(value: unknown, decode: (entry: unknown, id: Id) => T): ReadonlyMap<Id, T> {
const result = new Map<Id, T>();
for (const item of array(value)) { const pair = array(item), id = string(pair[0]); if (pair.length !== 2 || result.has(id)) throw fault('corrupt_recovery_image'); result.set(id, decode(pair[1], id)); }
return readMap(result);
}
/** 重建 document/tombstones,调用核心完整校验树、owner、端点与已知组件 schema。 */
function readDocument(value: unknown, registry: Registry): Document {
const raw = object(value);
const tombstones = table(raw.tombstones, entry => integer(entry, 1));
const document = createDocument(string(raw.projectId), array(raw.canvases) as never, array(raw.elements) as never, tombstones);
validateDocument(document, registry); return document;
}
/** 校验持久 Change 的基础形状/目标/字段白名单/存在性;不在恢复解码时执行历史修改。 */
function changes(value: unknown): readonly Change[] {
for (const item of array(value)) {
const change = object(item);
if (change.kind === 'field') {
const target = object(change.target); string(target.id); integer(target.incarnation, 1);
const field = string(change.field);
if (!['geometry', 'ownerCanvasId', 'source', 'target', 'path'].includes(field) && (!field.startsWith('data.') || !field.slice(5))) throw fault('corrupt_recovery_image');
for (const flag of ['beforePresent', 'afterPresent']) if (change[flag] !== undefined && typeof change[flag] !== 'boolean') throw fault('corrupt_recovery_image');
} else if (change.kind === 'element' || change.kind === 'canvas') {
const id = string(change.id);
for (const key of ['before', 'after']) if (change[key] !== null && string(object(change[key]).id) !== id) throw fault('corrupt_recovery_image');
} else throw fault('corrupt_recovery_image');
if (!Object.hasOwn(change, 'before') || !Object.hasOwn(change, 'after')) throw fault('corrupt_recovery_image');
}
return freeze(value as Change[]);
}
/** 已知版本校验历史增量,未来版本原样保留;真正回放前必须另查解释器,不能自动重写。 */
function historyTransition(value:unknown){
const raw=object(value);integer(raw.version,1);
// Preserve future evidence with its original bytes. Its interpreter must be checked before any replay.
if(raw.version===1){try{validateHistoryTransition(value,changes);}catch{throw fault('corrupt_recovery_image');}}
}
/** 校验意图身份与已知 upload/run 字段约束,保留 schema version;可恢复不等于可直接执行。 */
function intent(value: unknown, id: Id): OperationIntent {
const raw = object(value);
if (string(raw.id) !== id) throw fault('corrupt_recovery_image');
const kind = string(raw.operationType);
if (kind === 'asset.upload') { string(raw.resourceId); if (array(raw.changes).length || array(raw.canvasIds).length) throw fault('corrupt_recovery_image'); }
if (kind === 'run.start') { validateRunInput(raw.run as never);if(array(raw.changes).length || !strings(raw.canvasIds).includes(string(object(raw.run).boardId)))throw fault('corrupt_recovery_image'); }
if(kind==='run.cancel'||kind==='run.apply'){
const action=object(raw.action);string(action.runId);string(action.boardId);
if(kind==='run.cancel'){if(array(raw.changes).length||array(raw.canvasIds).length)throw fault('corrupt_recovery_image');}
else {const target=object(action.target);string(target.id);integer(target.incarnation,1);string(action.outputId);if(!strings(raw.canvasIds).includes(string(action.targetCanvasId)))throw fault('corrupt_recovery_image');}
}
if(raw.historyTransition!==undefined)historyTransition(raw.historyTransition);
if(raw.historyEffect!==undefined){const effect=object(raw.historyEffect);integer(effect.version,1);if(raw.origin!=='asset-resolution'||kind!=='document.change')throw fault('corrupt_recovery_image');if(effect.version===1)validateCreationSizeEffect(effect);}
integer(raw.operationSchemaVersion, 1); integer(raw.sequence, 1); string(raw.origin);
strings(raw.canvasIds); strings(raw.dependencies); changes(raw.changes);
return freeze(value as OperationIntent);
}
/** 双栈 ID 必须唯一且精确覆盖 entries;每个 entry 关联的 operation 必须存在,不从网络队列推断历史。 */
function readHistory(history:ObjectValue,operations:ReadonlyMap<Id,OperationIntent>):HistoryState {
const entries=table<HistoryEntry>(history.entries,(value,id)=>{const raw=object(value);if(string(raw.id)!==id||typeof raw.label!=='string')throw fault('corrupt_recovery_image');changes(raw.changes);for(const operationId of strings(raw.operationIds))if(!operations.has(operationId))throw fault('corrupt_recovery_image');if(raw.groupId!==undefined)string(raw.groupId);return freeze(value as HistoryEntry);});
const undo=strings(history.undo),redo=strings(history.redo),ids=[...undo,...redo];
if(new Set(ids).size!==ids.length||ids.length!==entries.size||ids.some(id=>!entries.has(id)))throw fault('corrupt_recovery_image');
return Object.freeze({undo:freeze(undo),redo:freeze(redo),entries});
}
/** 防御恢复入口:捕获 JSON 后逐域校验,所有通过才返回冻结 Root;不部分发布合法前缀。 */
export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSnapshot & { readonly rootRevision: number; readonly runtime: RuntimeState } {
if (![1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
integer(image.rootRevision); integer(image.localRevision);
if (image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image');
// Own the parsed records; mutating a caller's RecoveryImage must not mutate live state.
const raw = object(JSON.parse(JSON.stringify(image.payload))), document = readDocument(raw.document, registry), history = object(raw.history), runtime = object(raw.runtime), ledger = object(runtime.ledger);
// 部分加载的解释器与 envelope 有明确兼容关系;不能把完整图像误按 partial 或反向解释。
if(runtime.loading!==undefined&&image.formatVersion<13&&image.formatVersion!==(object(runtime.loading).version===6?12:object(runtime.loading).version===5?11:object(runtime.loading).version===4?10:object(runtime.loading).version===3?9:object(runtime.loading).version===2?8:7))throw fault('unsupported_recovery_version');
if(image.formatVersion>=7&&image.formatVersion<=12&&runtime.loading===undefined)throw fault('invalid_loading_state');
const operations = table(raw.operations, intent),restoredHistory=readHistory(history,operations);
if(image.formatVersion<6&&[...operations.values()].some(intent=>intent.historyEffect))throw fault('unsupported_recovery_version');
for(const operation of operations.values())if(operation.historyTransition?.version===1&&(operation.historyTransition.rootRevision<1||operation.historyTransition.rootRevision>image.rootRevision))throw fault('corrupt_recovery_image');
if(image.formatVersion===1&&array(runtime.imports??[]).length)throw fault('unsupported_recovery_version');
// core operations 与 ledger 的文档意图/历史证据互相核对;上传/无文档效果的控制动作另有归属。
const records = table<OperationRecord>(ledger.operations, (value, id) => {
const raw = object(value); intent(raw.intent, id); strings(raw.blockers); object(raw.basePresence);
if (((object(raw.intent).operationType === 'document.change'||object(raw.intent).operationType==='run.apply'&&array(object(raw.intent).changes).length>0) && !operations.has(id)) || !['pending', 'frozen', 'resolved', 'blocked'].includes(string(raw.phase))) throw fault('corrupt_recovery_image');
if(operations.has(id)&&!equal(operations.get(id)!.historyTransition,(raw.intent as OperationIntent).historyTransition))throw fault('corrupt_recovery_image');
if(operations.has(id)&&!equal(operations.get(id)!.historyEffect,(raw.intent as OperationIntent).historyEffect))throw fault('corrupt_recovery_image');
if(raw.resolution!==undefined&&!['applied','not-applied','superseded','discarded','satisfied','elided'].includes(string(raw.resolution)))throw fault('corrupt_recovery_image');
return freeze(value as OperationRecord);
});
if (operations.size !== [...records.values()].filter(record => (record.intent.operationType === 'document.change'||record.intent.operationType==='run.apply'&&record.intent.changes.length>0)).length) throw fault('corrupt_recovery_image');
// Validate the dependency DAG iteratively, including missing dependencies, without recursion overflow.
// 用 Kahn 迭代检查整个依赖 DAG,避免深链递归溢出;缺依赖/重复依赖/环均拒绝。
const counts = new Map<Id, number>(), dependents = new Map<Id, Id[]>(), ready: Id[] = [];
for (const [id, record] of records) {
const dependencies = record.intent.dependencies;
if (new Set(dependencies).size !== dependencies.length) throw fault('corrupt_recovery_image');
counts.set(id, dependencies.length); if (!dependencies.length) ready.push(id);
for (const dependency of dependencies) { if (!records.has(dependency)) throw fault('corrupt_recovery_image'); dependents.set(dependency, [...(dependents.get(dependency) ?? []), id]); }
}
for (let index = 0; index < ready.length; index++) for (const id of dependents.get(ready[index]) ?? []) { counts.set(id, counts.get(id)! - 1); if (!counts.get(id)) ready.push(id); }
if (ready.length !== records.size) throw fault('corrupt_recovery_image');
// 恢复原 body 字符串,不 parse 后重新 stringify 以免改变冻结字节;仅为身份核验读取已知协议字段。
const requests = table<FrozenRequest>(ledger.requests, (value, id) => {
const raw = object(value);
if (string(raw.id) !== id || typeof raw.body !== 'string') throw fault('corrupt_recovery_image');
integer(raw.protocolVersion, 1);
integer(raw.generation, 1); integer(raw.attempt); strings(raw.canvasIds); object(raw.expectedVersions);
if (raw.kind === 'run.start' && !strings(raw.canvasIds).includes(string(raw.ownerCanvasId)))throw fault('corrupt_recovery_image');
if (raw.kind === 'asset.upload' && (array(raw.canvasIds).length || Object.keys(object(raw.expectedVersions)).length || array(raw.operationIds).length !== 1)) throw fault('corrupt_recovery_image');
if (!['frozen', 'dispatch-marked', 'waiting', 'resolved'].includes(string(raw.phase)) || !['unresolved', 'accepted', 'applied', 'not-applied'].includes(string(raw.outcome))) throw fault('corrupt_recovery_image');
for (const operationId of strings(raw.operationIds)) if (records.get(operationId)?.requestId !== id || (raw.kind ?? 'document.change') !== records.get(operationId)?.intent.operationType) throw fault('corrupt_recovery_image');
// 已知 run.start body 必须与原操作目标/模型/参数/参与者完全对应,不能只因 request ID 相同就信任。
if(raw.kind==='run.start'){
if(array(raw.operationIds).length!==1)throw fault('corrupt_recovery_image');
const operation=records.get(string(array(raw.operationIds)[0]))!.intent;if(operation.operationType!=='run.start')throw fault('corrupt_recovery_image');
let body:ObjectValue;try{body=object(JSON.parse(raw.body as string));}catch{throw fault('corrupt_recovery_image');}
if(raw.ownerCanvasId!==operation.run.boardId||body.protocol_version!==2||body.request_id!==id||body.operation_id!==operation.id||body.node_id!==operation.run.target.id||body.target_incarnation!==operation.run.target.incarnation||body.model_id!==operation.run.modelId||body.prompt!==operation.run.prompt||body.propagate!==operation.run.propagate||!equal(body.parameters,operation.run.parameters)||!equal(body.expected_versions,raw.expectedVersions)||!equal([...strings(raw.canvasIds)].sort(),[...operation.canvasIds].sort()))throw fault('corrupt_recovery_image');
}
// cancel/apply 核对动作归属;cancel 无文档参与者,apply 要绑定目标代次与 output ID。
if(raw.kind==='run.cancel'||raw.kind==='run.apply'){
if(array(raw.operationIds).length!==1)throw fault('corrupt_recovery_image');const operation=records.get(string(array(raw.operationIds)[0]))!.intent;
if(operation.operationType!=='run.cancel'&&operation.operationType!=='run.apply')throw fault('corrupt_recovery_image');
const body=object(JSON.parse(raw.body as string));
if(raw.runId!==operation.action.runId||raw.ownerCanvasId!==operation.action.boardId||body.protocol_version!==2||body.request_id!==id||body.operation_id!==operation.id)throw fault('corrupt_recovery_image');
if(operation.operationType==='run.cancel'){if(array(raw.canvasIds).length||Object.keys(object(raw.expectedVersions)).length)throw fault('corrupt_recovery_image');}
else if(body.target_canvas_id!==operation.action.targetCanvasId||body.target_incarnation!==operation.action.target.incarnation||body.node_id!==operation.action.target.id||body.output_id!==operation.action.outputId||!equal([...strings(raw.canvasIds)].sort(),[...operation.canvasIds].sort()))throw fault('corrupt_recovery_image');
}
if (raw.retryAt !== undefined && (typeof raw.retryAt !== 'number' || !Number.isFinite(raw.retryAt))) throw fault('corrupt_recovery_image');
if (raw.retryAction !== undefined && !['submit', 'query'].includes(string(raw.retryAction))) throw fault('corrupt_recovery_image');
return freeze(value as FrozenRequest);
});
// 请求→操作和操作→请求双向校验,冻结状态不能缺少原请求身份。
for (const record of records.values()) if (record.requestId && !requests.has(record.requestId) || record.phase === 'frozen' && !record.requestId) throw fault('corrupt_recovery_image');
try {
// 本地零效果证明也要独立复验,working 墓碑必须已经含其水位,不能因服务未见过而复用身份。
validateElisions({ operations: records });
for (const record of records.values()) if (record.resolution === 'elided') { const original = operations.get(record.intent.id); if (!original || !equal(original.changes, record.intent.changes) || original.sequence !== record.intent.sequence || original.origin !== record.intent.origin) throw fault('invalid_elision'); }
const local = withLocalLifecycles(document, { operations: records });
if (!equal([...local.tombstones], [...document.tombstones])) throw fault('invalid_elision');
} catch (error) { if ((error as {code?:string}).code === 'elision_interpreter_unavailable') throw error; throw fault('corrupt_recovery_image'); }
const receipts=table(runtime.receipts,value=>freeze(value as Json));
// 控制依赖被零效果解决时必须有原请求的确定终态证明,404/传输错误不够。
for(const record of records.values())if(record.resolution==='not-applied'){
const request=record.requestId?requests.get(record.requestId):undefined;
if(!['run.start','run.cancel'].includes(record.intent.operationType)||record.phase!=='resolved'||request?.outcome!=='not-applied'||request.phase!=='resolved')throw fault('corrupt_recovery_image');
const proof=object(receipts.get(request.id));
if(proof.protocol_version!==2||proof.request_id!==request.id||proof.operation_id!==record.intent.id||proof.action!==record.intent.operationType||proof.status!=='not-applied'||proof.terminal!==true)throw fault('corrupt_recovery_image');
}
const generation = integer(ledger.generation), durableGeneration = integer(ledger.durableGeneration);
if (durableGeneration > generation) throw fault('corrupt_recovery_image');
// ACK 与 working 同项目,但保持两份不同意义的文档;服务版本要求覆盖 ACK 的全部活 canvas。
const acknowledged = readDocument(runtime.acknowledged, registry);
if (acknowledged.projectId !== document.projectId) throw fault('project_mismatch');
const versions = table(runtime.versions, value => integer(value, 1));
for (const id of acknowledged.canvases.keys()) if (!versions.has(id)) throw fault('corrupt_recovery_image');
// 资源记录绑定唯一 upload 意图;解码/尺寸/退休子协议各自验版本,stored 不等于本地字节已存在。
const resources = table<ResourceRecord>(runtime.resources ?? [], (value, id) => {
const raw = object(value);
if (string(raw.id) !== id || string(raw.operationId) === id) throw fault('corrupt_recovery_image');
string(raw.name); string(raw.mimeType); integer(raw.byteLength, 1);
if(raw.loadingAdmission!==undefined){const proof=object(raw.loadingAdmission);if(proof.version!==1)throw fault('loading_asset_interpreter_unavailable');string(proof.sourceToken);string(proof.sourceURL);const canvases=strings(proof.canvasIds);if((proof.sourceToken as string).length>256||(proof.sourceURL as string).length>8192||!canvases.length||new Set(canvases).size!==canvases.length)throw fault('corrupt_recovery_image');}
if(raw.sizing!==undefined){if(image.formatVersion<6)throw fault('unsupported_recovery_version');const sizing=object(raw.sizing);integer(sizing.version,1);if(sizing.version===1)validateDefaultImageSizing(sizing);}
if(raw.retirement!==undefined){if(image.formatVersion<3)throw fault('unsupported_recovery_version');const marker=object(raw.retirement);if(marker.version!==1)throw fault('resource_retirement_interpreter_unavailable');integer(marker.unreferencedAt);if(marker.purgedAt!==undefined&&integer(marker.purgedAt)<(marker.unreferencedAt as number))throw fault('corrupt_recovery_image');}
if(raw.image!==undefined){const image=object(raw.image);integer(image.version,1);if(image.version===1){if(!['pending','ready','failed'].includes(String(image.status)))throw fault('corrupt_recovery_image');if(image.status==='ready'){integer(image.width,1);integer(image.height,1);if(!Number.isSafeInteger((image.width as number)*(image.height as number))||!['decode','server'].includes(String(image.source)))throw fault('corrupt_recovery_image');}if(image.status==='failed')string(image.code);}}
if (raw.digest !== undefined && !/^[a-f0-9]{64}$/.test(string(raw.digest))) throw fault('corrupt_recovery_image');
const operation = records.get(raw.operationId as string)?.intent;
if (operation?.operationType !== 'asset.upload' || operation.resourceId !== id) throw fault('corrupt_recovery_image');
if (raw.stored !== undefined) { const stored = object(raw.stored); if (string(stored.id) !== id || integer(stored.bytes, 1) !== raw.byteLength) throw fault('corrupt_recovery_image'); string(stored.url); string(stored.mime_type); integer(stored.width, 1); integer(stored.height, 1); }
return freeze(value as ResourceRecord);
});
for (const record of records.values()) if (record.intent.operationType === 'asset.upload' && resources.get(record.intent.resourceId)?.operationId !== record.intent.id) throw fault('corrupt_recovery_image');
// 跨项目采纳 binding 必须由对应 document.change 引用该资源,不能与本地 upload 资源表撞 ID。
const assetBindings=table<SharedAssetBinding>(runtime.assetBindings??[],(value,id)=>{
const binding=value as SharedAssetBinding;validateSharedAsset(binding);const row=object(value);
if(image.formatVersion<13||binding.asset.id!==id||resources.has(id)||!records.has(string(row.operationId)))throw fault('corrupt_asset_binding');
const operation=records.get(binding.operationId)!.intent;if(operation.operationType!=='document.change'||!referencedResources(operation.changes,registry).has(id))throw fault('corrupt_asset_binding');
if(row.loadingAdmission!==undefined){const proof=object(row.loadingAdmission);if(proof.version!==1||proof.operationId!==binding.operationId||string(proof.sourceURL)!==binding.asset.url||(string(proof.sourceToken)).length>256)throw fault('corrupt_asset_binding');const owners=strings(proof.canvasIds);if(!owners.length||new Set(owners).size!==owners.length)throw fault('corrupt_asset_binding');}
return freeze(binding);
});
if(image.formatVersion===13&&!assetBindings.size||assetBindings.size&&runtime.loading!==undefined&&object(runtime.loading).version!==6)throw fault('unsupported_recovery_version');
// 显式 owner→资源 ID 清单与恢复 audit 互证;完整反向 pin 集仍由保留扫描派生。
const resourcePins = table(runtime.resourcePins ?? [], strings);
const replacementOwners=new Set<Id>();
// 审计备份、选择、原/替代操作和 history replay 是一个闭合证明,不能只保留一句 keep-local 标签。
const recoveries=table<RecoveryAudit>(runtime.recoveries??[],(value,id)=>{
const raw=object(value),backup=object(raw.backup);if(raw.historyTransition!==undefined){historyTransition(raw.historyTransition);if(object(raw.historyTransition).version===1&&object(raw.historyTransition).rootRevision!==(raw.baseRootRevision as number)+1)throw fault('corrupt_recovery_image');}if(string(raw.id)!==id||raw.formatVersion!==1||integer(raw.baseRootRevision)>=image.rootRevision)throw fault('corrupt_recovery_image');
const working=readDocument(backup.document,registry),base=readDocument(backup.acknowledged,registry),versions=table(backup.versions,value=>integer(value,1));
if(working.projectId!==document.projectId||base.projectId!==document.projectId||[...base.canvases.keys()].some(id=>!versions.has(id)))throw fault('corrupt_recovery_image');
readHistory(object(backup.history),operations);if(!equal(strings(backup.resourceIds),resourcePins.get(`recovery:${id}`)))throw fault('corrupt_recovery_image');
const localReplacements:Id[]=[],ids=new Set<Id>();for(const value of array(raw.decisions)){const decision=object(value),operationId=string(decision.operationId),record=records.get(operationId),request=record?.requestId?requests.get(record.requestId):undefined;
if(ids.has(operationId)||!record||record.phase!=='resolved'||record.recoveryId!==id||record.resolution!==decision.resolution||record.replacementId!==decision.replacementId||record.intent.operationType==='asset.upload'||record.requestId&&(!request||request.phase!=='resolved'||request.outcome!=='not-applied'))throw fault('corrupt_recovery_image');ids.add(operationId);
if(!['keep-local','discard-local'].includes(string(decision.choice))||(decision.choice==='discard-local')!==(decision.resolution==='discarded'))throw fault('corrupt_recovery_image');
if(decision.resolution==='superseded'){const replacementId=string(decision.replacementId),replacement=records.get(replacementId);if(replacementOwners.has(replacementId))throw fault('corrupt_recovery_image');replacementOwners.add(replacementId);localReplacements.push(replacementId);if(!replacement||replacement.intent.sequence<=record.intent.sequence||replacement.intent.operationType!==record.intent.operationType)throw fault('corrupt_recovery_image');}
else if(!['discarded','satisfied'].includes(string(decision.resolution))||decision.replacementId!==undefined)throw fault('corrupt_recovery_image');
}
if(raw.historyReplay!==undefined){
const replay=object(raw.historyReplay);integer(replay.version,1);
if(replay.version===1){
try{
validateHistoryReplay(replay,(raw.baseRootRevision as number)+1,changes);
if(!raw.historyTransition)throw fault('corrupt_recovery_image');
const local=new Set(localReplacements);
// 新替代意图的事件顺序必须按其真实 sequence;anchor+events 的效果还要等于整体 historyTransition。
const expectedOrder=[...localReplacements].sort((a,b)=>records.get(a)!.intent.sequence-records.get(b)!.intent.sequence);
const actualOrder=replay.events.flatMap(event=>event.kind==='operation'&&local.has(event.operationId)?[event.operationId]:[]);
if(!equal(actualOrder,expectedOrder))throw fault('corrupt_recovery_image');
const original=readHistory(object(backup.history),operations);
const composed=replayHistoryEvidence(original,replay,id=>{
const record=records.get(id);
return!!record?.intent.changes.length&&(['user','undo','redo'].includes(record.intent.origin)||record.intent.origin==='asset-resolution'&&!!record.intent.historyEffect)&&(local.has(id)||['applied','elided'].includes(record.resolution??''));
});
const expected=applyHistoryTransition(original,raw.historyTransition as HistoryTransition,'forward');
if(!sameHistory(composed,expected))throw fault('corrupt_recovery_image');
}catch{throw fault('corrupt_recovery_image');}
}
}
if(!resourcePins.has(`recovery:${id}`))throw fault('corrupt_recovery_image');return freeze(value as RecoveryAudit);
});
for(const record of records.values()){
if(['superseded','discarded','satisfied'].includes(record.resolution??'')){const audit=record.recoveryId?recoveries.get(record.recoveryId):undefined;if(!audit||!audit.decisions.some(decision=>decision.operationId===record.intent.id))throw fault('corrupt_recovery_image');}
else if(record.recoveryId!==undefined||record.replacementId!==undefined)throw fault('corrupt_recovery_image');
}
// run 必须由已 applied 的原受理请求产生,轮询游标、待合并页和 output 事件均有身份/顺序约束。
const runs=table<RunRecord>(runtime.runs ?? [],(value,id)=>{
const raw=object(value),operation=records.get(string(raw.operationId)),request=requests.get(string(raw.requestId)),target=object(raw.target);
if(string(raw.id)!==id||operation?.intent.operationType!=='run.start'||operation.requestId!==raw.requestId||request?.kind!=='run.start'||request.outcome!=='applied'||operation.intent.run.boardId!==string(raw.boardId)||operation.intent.run.target.id!==string(target.id)||operation.intent.run.target.incarnation!==integer(target.incarnation,1))throw fault('corrupt_recovery_image');
if(!['queued','running','succeeded','failed','conflict','cancelled','partially_succeeded'].includes(string(raw.status))||typeof raw.hasMore!=='boolean')throw fault('corrupt_recovery_image');
if(raw.controlEpoch!==undefined)integer(raw.controlEpoch);
if(raw.appliedOutputIds!==undefined)strings(raw.appliedOutputIds);
integer(raw.cursor);integer(raw.attempt);object(raw.plan);array(raw.outputs);array(raw.steps);array(raw.outputEvents ?? []);
if(raw.blocked!==undefined&&typeof raw.blocked!=='boolean')throw fault('corrupt_recovery_image');
if(raw.error!==undefined)string(raw.error);
if(raw.nextPollAt!==undefined&&(typeof raw.nextPollAt!=='number'||!Number.isFinite(raw.nextPollAt)))throw fault('corrupt_recovery_image');
if(raw.pendingPage!==undefined){const page=object(raw.pendingPage);let cursor=integer(raw.cursor);for(const value of array(page.events)){const event=object(value);if(event.run_id!==id||integer(event.seq,1)!==++cursor)throw fault('corrupt_recovery_image');}if(integer(page.next)!==cursor||typeof page.hasMore!=='boolean'||!['queued','running','succeeded','failed','conflict','cancelled','partially_succeeded'].includes(string(page.status)))throw fault('corrupt_recovery_image');array(page.outputs);array(page.steps);}
const outputIds=new Set<string>();
for(const value of array(raw.outputEvents ?? [])){const event=object(value),step=string(event.step_id);if(event.run_id!==id||event.type!=='step.output'||outputIds.has(step)||integer(event.seq,1)>Math.max(integer(raw.cursor),raw.pendingPage===undefined?0:integer(object(raw.pendingPage).next)))throw fault('corrupt_recovery_image');object(event.output);outputIds.add(step);}
return freeze({...value as RunRecord,outputEvents:array(raw.outputEvents ?? []) as never});
});
// 导入 job 有独立 validator;source/parser/read-proof 特性与 envelope 版本必须互相匹配。
const imports=table<ImportJob>(runtime.imports??[],(value,id)=>{const job=value as ImportJob;validateImportJob(job);if(job.id!==id||job.projectId!==document.projectId)throw fault('corrupt_recovery_image');return freeze(job);});
const hasJsonArrays=[...imports.values()].some(job=>job.source?.kind==='native-json-array');if(hasJsonArrays&&image.formatVersion<14||image.formatVersion===14&&!hasJsonArrays)throw fault('unsupported_recovery_version');
const hasReadProof=[...imports.values()].some(job=>job.source?.integrityVersion===2);if(hasReadProof!==(image.formatVersion===17))throw fault('unsupported_recovery_version');
const hasDocuments=[...imports.values()].some(job=>job.source?.kind==='native-document'),hasStreamingDocuments=[...imports.values()].some(job=>job.source?.kind==='native-document'&&job.parserVersion===5);if(hasDocuments&&image.formatVersion!==(hasReadProof?17:hasStreamingDocuments?16:15)||image.formatVersion>=15&&!hasDocuments&&!hasReadProof)throw fault('unsupported_recovery_version');
for(const [id,entry]of importFiles(imports)){if(resources.has(id)||assetBindings.has(id))throw fault('import_file_identity_conflict',[id]);if((entry.file.retirement||imports.get(entry.jobId)?.source?.previousFiles?.length)&&image.formatVersion<4)throw fault('unsupported_recovery_version');}
// 上传资源、采纳binding、导入源和孤儿文件共享本地文件身份空间,任何交叉冲突均拒绝。
const orphanFiles=table<OrphanFile>(runtime.orphanFiles??[],(file,id)=>{validateOrphanFile(file,id);return freeze(file);});
if(orphanFiles.size&&image.formatVersion<5)throw fault('unsupported_recovery_version');for(const id of orphanFiles.keys())if(resources.has(id)||assetBindings.has(id)||importFiles(imports).has(id))throw fault('local_file_identity_conflict',[id]);
// 直到所有分表校验完成才组装一个候选 Root;不在这里启动 scheduler 或创建 view。
const restoredLedger: LedgerSnapshot = Object.freeze({ generation, durableGeneration, operations: records, requests });
const snapshot=Object.freeze({ revision: image.localRevision, rootRevision: image.rootRevision, document, history:restoredHistory, operations,
runtime: Object.freeze({ ...(runtime.loadingRead!==undefined?{loadingRead:Object.freeze({document:readDocument(object(runtime.loadingRead).document,registry),versions:table(object(runtime.loadingRead).versions,(value)=>integer(value,1))})}:{}), ...(runtime.loading!==undefined?{loading:freeze(runtime.loading as LoadingState)}:{}), orphanFiles, imports, recoveries, runs, resources, ...(assetBindings.size?{assetBindings}:{}), resourcePins, ledger: restoredLedger, acknowledged, versions, receipts, conflicts: table(runtime.conflicts, string) }),
});
// 永久清理的本地独有字节仍被 pin 是损坏状态,不能恢复成看似可用的图片引用。
if([...resources.values()].some(resource=>resource.retirement?.purgedAt!==undefined&&!resource.stored)){
const pins=resourceRetentionPins(snapshot,registry);for(const [id,resource]of resources)if(resource.retirement?.purgedAt!==undefined&&!resource.stored&&pins.pins.has(id))throw fault('corrupt_recovery_image');
}
if([...orphanFiles.values()].some(file=>file.retirement?.purgedAt!==undefined)){const pins=resourceRetentionPins(snapshot,registry);for(const [id,file]of orphanFiles)if(file.retirement?.purgedAt!==undefined&&pins.pins.has(id))throw fault('corrupt_recovery_image');}
// 最后把 loading、源捕获、ACK 与运行事实再联合校验,防止各表单独合法却组合矛盾。
validateLoadingRuntime(snapshot.runtime,registry);
return snapshot;
}