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

379 lines
44 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 编成按迭代顺序排列的元组数组;自有 readMap 视图可缓存成员外壳,WeakMap 不额外保活源视图。
* 未知来源表每次重新枚举,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 成员。
* documentViews 只缓存 documentValue 的表示,不缓存“该文档已经通过完整 schema/图校验”的结论。 */
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 无关,不能互相代替。
* 三元条件按优先级挑一个格式:17读证明 >16流式文档 >15文档 >14数组 >13采纳 >7–12加载 >6尺寸/历史效果,
* 再到5孤儿/4导入退休/3资源退休/2导入/1基础;不是逐项独立版本取最大值,兼容组合仍由解码检查。
* 只投影字段,不在此调用 decode 的完整校验;snapshot 各域必须由宿主作为同一稳定根提供。 */
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 不可变 JSON 图像;编码成功尚不代表领域校验通过或已 durable。
* sealed 是 codec 内部来源/字节成本标记,不是数据库提交回执或恢复准入证明。 */
export function encodeRecovery(snapshot:EngineSnapshot):RecoveryImage {
return sealRecoveryImage(...recoveryInput(snapshot));
}
/** 相同输入合同的分片编码入口,预算与外部数据捕获边界由 recovery-encoding 明确处理。
* recoveryInput 的表枚举/格式选择先同步完成;不是异步遍历 live editor,也不返回部分可用 Root。 */
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 与非二元组,返回隐藏 set/delete 的 readMap 视图。
* 每行 decode 决定 value 的校验与冻结;表外壳本身不递归捕获任意值,任一行抛错就不返回该表。 */
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 的基础形状、目标代次、字段白名单及 before/after 自有字段存在性。
* 不按当前文档执行 before 条件,不逐一验证整元素前后像的全部领域语义;历史回放另有验证入口。
* 输入已经过顶层 JSON 捕获,这里冻结同一份捕获数组,不 clone 第二份 changes。 */
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);
// 未知正版本保留捕获后的 JSON 值,不按 v1 重写字段;不保证输入原始序列化字节未变。
// 真正回放前仍须查对应解释器;“能留存”不等于当前实现能执行该证据。
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);
}
/** undo/redo 合起来必须无重复且精确覆盖 entries;栈顺序原样保留,不从网络队列重新推断。
* entry.operationIds 要在 core operations 存在,但此处不尝试撤销/重做或证明当前每个栈项都可执行。 */
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});
}
/** 防御恢复入口:同步捕获 payload 后逐域校验,所有通过才返回候选冻结 Root;不部分发布合法前缀。
* 先读 working/history/ledger,再核依赖和请求,再读 ACK/资源/审计/任务,最后联合检查 loading 与 pin。
* 不读取 IDB/Blob/服务端,不对快照做自动修复;失败直接抛出(子校验器可保留自己的错误码)。
* JSON 往返隔离调用者记录,但 getter/toJSON 可能在捕获时执行,其外部副作用不会被撤销。 */
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 还含上传/控制任务,因此不是两个表逐项一对一同大小。
// 文档效果项必须能关联 core 意图,并核对 historyTransition/historyEffect;这一步不比较所有 intent 字段。
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,避免深链递归溢出;缺依赖/重复依赖/环均拒绝。
// counts 是未消解入度,dependents 是反向邻接表,ready 用数组+递增下标作 FIFO,不用反复 shift。
// 所有顶点出队才证明无环;这个次序只供校验,不重排持久 operation.sequence 或执行任何操作。
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');
// FrozenRequest.body 保留捕获后的字符串内容,不 parse 后重新 stringify 替换请求体。
// 只对已识别 run 协议另 parse 核身份;普通文档/未知协议不在此编译或发请求,执行前仍须解释器准入。
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);
});
// 请求列出的操作必须反指该请求;操作声明的 requestId 必须存在,frozen 操作不能缺 requestId。
// 这是这里实现的关联检查,并不单独证明所有服务执行结果或整份冻结请求语义。
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 是含本地编辑的当前模型;分别完整校验,并要求属于同一项目。
// versions 必须覆盖 ACK 全部活 canvas,允许仍带其它版本项;这里不把 ACK 覆盖到 working。
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');
// 每次审计内去重原操作,全局 replacementOwners 防同一替代操作被两次审计重复认领。
// keep-local 可 superseded/satisfied,discard-local 必须 discarded;替代意图的 sequence 必须更晚。
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 关联原 run.start 操作和 outcome=applied 请求;不向服务查询任务现在是否仍存在/已经完成。
// pendingPage 从当前 cursor 连续编号到 next,outputEvents 按 step 唯一且 seq 不得超出已知页尾。
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 版本必须互相匹配。
// 只验证已存任务声明与项目归属,不重读源文件或启动 runner;恢复执行由 engine 后续装配。
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,保留 localRevision/rootRevision 和 ledger 两种水位。
// readMap/冻结外壳让已捕获事实只读;不在这里推进 durableGeneration、启动 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) }),
});
// 对已有 purge 声明的本地独有资源/孤儿再查保留引用:仍被 pin 则拒绝,而非静默删除引用。
// 有 stored 的资源不走此“本地唯一字节”拒绝分支,后续能否再取原图仍由资源端口决定。
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;
}