From ed3c3a2ab338c07362d325f565ddf9dc9fd061b3 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 20:03:02 +0800 Subject: [PATCH] Resume durable copy preparation from saved planning cursors --- src/lib/canvas-core/duplicate-checkpoint.ts | 72 ++++++++++++++++++++ src/lib/canvas-core/duplicate.ts | 59 +++++++++------- src/lib/canvas-core/index.ts | 5 +- src/lib/canvas-runtime/duplicate.ts | 14 ++++ src/lib/canvas-runtime/engine.ts | 11 +-- src/lib/canvas-runtime/imports.ts | 7 +- tests/headless/browser-storage-cases.mjs | 18 +++++ tests/headless/copy-snapshot.test.mjs | 2 +- tests/headless/duplicate-checkpoint.test.mjs | 32 +++++++++ tests/headless/imports-engine.test.mjs | 11 ++- 10 files changed, 195 insertions(+), 36 deletions(-) create mode 100644 src/lib/canvas-core/duplicate-checkpoint.ts create mode 100644 tests/headless/duplicate-checkpoint.test.mjs diff --git a/src/lib/canvas-core/duplicate-checkpoint.ts b/src/lib/canvas-core/duplicate-checkpoint.ts new file mode 100644 index 0000000..1dd95b2 --- /dev/null +++ b/src/lib/canvas-core/duplicate-checkpoint.ts @@ -0,0 +1,72 @@ +import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts'; +import { ancestors, targetElement, validateDocumentSteps } from './document.ts'; +import { duplicateTreeChange, duplicateEdgeChange, duplicateOverlay, type DuplicateOptions, type DuplicatePlan } from './duplicate.ts'; +import { clone, equal, fault, freeze, validJson } from './values.ts'; +const phases=['selection','canvases','members','roots','seed','dfs','edges','allocate-elements','allocate-canvases','mapping','emit','emit-edges','validate','done'] as const; +type Task={kind:'element'|'canvas';id:Id}; +/** Internal preparation state: IDs/cursors only. Emitted records are a deterministic derived cache. */ +type MutableCheckpoint={version:1;projectId:Id;sourceToken:string;schemaToken:string;selection:Target[];offset:Point;mapping?:DuplicateOptions['mapping'];phase:typeof phases[number];cursor:number;steps:number;selected:Id[];children:[Id,Id][];members:[Id,Id[]][];roots:Id[];copied:Id[];canvases:Id[];order:Task[];work:Task[];expanding?:{canvas:Id;index:number};edges:Id[];elementIds:[Id,Id][];canvasIds:[Id,Id][];emitted:number;emittedEdges:number}; +type Immutable=T extends object?{readonly [K in keyof T]:Immutable}:T; +export type DuplicateCheckpoint=Immutable; +const ids=(value:unknown):value is Id[]=>Array.isArray(value)&&value.every(id=>typeof id==='string'&&!!id); +export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{ + if(!value||!validJson(value)||value.version!==1)throw fault('copy_checkpoint_interpreter_unavailable'); + if(![value.projectId,value.sourceToken,value.schemaToken].every(id=>typeof id==='string'&&!!id)||!phases.includes(value.phase)||!Number.isSafeInteger(value.cursor)||value.cursor<(value.phase==='seed'?-1:0)||![value.steps,value.emitted,value.emittedEdges].every(n=>Number.isSafeInteger(n)&&n>=0)||!Array.isArray(value.selection)||!value.selection.length||value.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!value.offset||![value.offset.x,value.offset.y].every(Number.isFinite))throw fault('invalid_copy_checkpoint'); + for(const list of [value.selected,value.roots,value.copied,value.canvases,value.edges])if(!ids(list)||new Set(list).size!==list.length)throw fault('invalid_copy_checkpoint'); + for(const list of [value.order,value.work])if(!Array.isArray(list)||list.some(t=>!t||!['element','canvas'].includes(t.kind)||typeof t.id!=='string'||!t.id))throw fault('invalid_copy_checkpoint'); + for(const list of [value.children,value.elementIds,value.canvasIds])if(!Array.isArray(list)||list.some(pair=>!ids(pair)||pair.length!==2)||new Set(list.map(pair=>pair[0])).size!==list.length)throw fault('invalid_copy_checkpoint'); + if(!Array.isArray(value.members)||value.members.some(pair=>!Array.isArray(pair)||pair.length!==2||typeof pair[0]!=='string'||!pair[0]||!ids(pair[1]))||new Set(value.members.map(pair=>pair[0])).size!==value.members.length||value.emitted>value.order.length||value.emittedEdges>value.edges.length||value.emittedEdges>0&&value.emitted!==value.order.length||value.expanding&&(typeof value.expanding.canvas!=='string'||!value.expanding.canvas||!Number.isSafeInteger(value.expanding.index)||value.expanding.index< -1))throw fault('invalid_copy_checkpoint'); +} +export function createDuplicatePlanner(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint}){ + const offset=options.worldOffset??{x:32,y:32},idFactory=options.createId,suppliedMapping=options.mapping?freeze(clone(options.mapping)):undefined; + const state:MutableCheckpoint=binding.checkpoint?clone(binding.checkpoint) as MutableCheckpoint:{version:1,projectId:document.projectId,sourceToken:binding.sourceToken,schemaToken:binding.schemaToken,selection:clone(selection) as Target[],offset:clone(offset),...(suppliedMapping?{mapping:suppliedMapping}:{}),phase:'selection',cursor:0,steps:0,selected:[],children:[],members:[],roots:[],copied:[],canvases:[],order:[],work:[],edges:[],elementIds:[],canvasIds:[],emitted:0,emittedEdges:0}; + validateDuplicateCheckpoint(state); + if(state.projectId!==document.projectId||state.sourceToken!==binding.sourceToken||state.schemaToken!==binding.schemaToken||!equal(state.selection,selection)||!equal(state.offset,offset)||!equal(state.mapping,suppliedMapping))throw fault('copy_checkpoint_binding_mismatch'); + const sourceElements=[...document.elements.values()],sourceCanvases=[...document.canvases.values()],selected=new Set(state.selected),copied=new Set(state.copied),canvases=new Set(state.canvases),children=new Map(state.children),members=new Map(state.members),elementIds=new Map(state.elementIds),canvasIds=new Map(state.canvasIds),used=new Set([...document.elements.keys(),...document.canvases.keys(),...document.tombstones.keys()]); + for(const id of [...state.selected,...state.roots,...state.copied,...state.edges])if(!document.elements.has(id))throw fault('invalid_copy_checkpoint'); + for(const id of state.canvases)if(!document.canvases.has(id))throw fault('invalid_copy_checkpoint'); + for(const [entry,id]of children)if(document.canvases.get(id)?.parent?.entryElementId!==entry)throw fault('invalid_copy_checkpoint'); + for(const [owner,list]of members)for(const id of list)if(document.elements.get(id)?.ownerCanvasId!==owner)throw fault('invalid_copy_checkpoint'); + for(const [list,scope]of [[state.elementIds,copied],[state.canvasIds,canvases]] as const)for(const [id,mapped]of list){if(!scope.has(id)||used.has(mapped))throw fault('invalid_copy_checkpoint');used.add(mapped);} + for(const task of [...state.order,...state.work])if(!(task.kind==='element'?document.elements:document.canvases).has(task.id))throw fault('invalid_copy_checkpoint'); + if(phases.indexOf(state.phase)>=phases.indexOf('emit')&&(elementIds.size!==copied.size||canvasIds.size!==canvases.size))throw fault('invalid_copy_checkpoint'); + const limits:Partial>={selection:state.selection.length,canvases:sourceCanvases.length,members:sourceElements.length,roots:state.selected.length,seed:state.roots.length,edges:sourceElements.length,'allocate-elements':state.copied.length,'allocate-canvases':state.canvases.length};if(limits[state.phase]!==undefined&&state.cursor>limits[state.phase]!)throw fault('invalid_copy_checkpoint'); + const supplied=suppliedMapping?[...Object.keys(suppliedMapping.elements).map(id=>({id,kind:'element' as const})),...Object.keys(suppliedMapping.canvases).map(id=>({id,kind:'canvas' as const}))]:[]; + const outputElements=new Map(),outputCanvases=new Map(),output=duplicateOverlay(document,outputElements,outputCanvases),changes:Change[]=[]; + let treeCache=0,edgeCache=0,sourceChecks:Generator|undefined=validateDocumentSteps(document,registry),outputChecks:Generator|undefined,validated=false; + const phase=(next:DuplicateCheckpoint['phase'],cursor=0)=>{state.phase=next;state.cursor=cursor;}; + function add(change:Exclude){freeze(change);changes.push(change);if(change.kind==='element')outputElements.set(change.id,change.after!);else outputCanvases.set(change.id,change.after!);} + function tree(index:number){const task=state.order[index];add(duplicateTreeChange(document,registry,task.kind,(task.kind==='element'?document.elements:document.canvases).get(task.id)!,elementIds,canvasIds,canvases,state.offset));} + function edge(index:number){add(duplicateEdgeChange(document,registry,document.elements.get(state.edges[index])!,elementIds,canvasIds,output));} + function allocate(id:Id,kind:'elements'|'canvases'){const map=suppliedMapping?.[kind],value=map&&Object.hasOwn(map,id)?map[id]:idFactory();if(typeof value!=='string'||!value||used.has(value))throw fault('duplicate_identity',[value]);used.add(value);return value;} + return Object.freeze({ + checkpoint():DuplicateCheckpoint{return freeze(clone(state));}, + step():boolean{ + if(sourceChecks){if(sourceChecks.next().done)sourceChecks=undefined;return false;} + // Reconstruct only the derived output cache. Index/DFS/allocation work stays at its saved cursor. + if(treeCache{const parent=document.canvases.get(canvas)!.parent;return parent&&selected.has(parent.entryElementId);}))state.roots.push(id);}else phase('seed',state.roots.length-1);break; + case 'seed':if(state.cursor>=0){const id=state.roots[state.cursor--];if(document.elements.get(id)!.structure==='node')state.work.push({kind:'element',id});}else phase('dfs');break; + case 'dfs':{ + if(state.expanding){const list=members.get(state.expanding.canvas)??[];if(state.expanding.index>=list.length)throw fault('invalid_copy_checkpoint');if(state.expanding.index<0)delete state.expanding;else{const id=list[state.expanding.index--];if(document.elements.get(id)!.structure==='node')state.work.push({kind:'element',id});}break;} + const task=state.work.pop();if(!task){phase('edges');break;} + if(task.kind==='canvas'){if(canvases.has(task.id))break;canvases.add(task.id);state.canvases.push(task.id);state.order.push(task);state.expanding={canvas:task.id,index:(members.get(task.id)?.length??0)-1};} + else{if(copied.has(task.id))break;copied.add(task.id);state.copied.push(task.id);state.order.push(task);const child=children.get(task.id);if(child)state.work.push({kind:'canvas',id:child});}break; + } + case 'edges':if(state.cursorelementIds.get(id)!),elementIds:Object.fromEntries(elementIds),canvasIds:Object.fromEntries(canvasIds),changes:[...changes]});}, + }); +} diff --git a/src/lib/canvas-core/duplicate.ts b/src/lib/canvas-core/duplicate.ts index 8176991..9a7da93 100644 --- a/src/lib/canvas-core/duplicate.ts +++ b/src/lib/canvas-core/duplicate.ts @@ -77,27 +77,44 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele const changes: Change[] = []; for (const item of order) { yield; - if (item.kind === 'canvas') { - const parent = item.record.parent!; - const after = { ...clone(item.record), id: canvasIds.get(item.record.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } }; - changes.push({ kind: 'canvas', id: after.id, before: null, after }); - } else { - const old = item.record; if (old.structure !== 'node') throw fault('invalid_copy_node'); - let geometry = clone(old.geometry); - if (!copiedCanvases.has(old.ownerCanvasId)) { - const transform = canvasTransform(document, old.ownerCanvasId), zero = toLocal(transform, { x: 0, y: 0 }), shift = toLocal(transform, offset); - geometry = { ...geometry, x: geometry.x + shift.x - zero.x, y: geometry.y + shift.y - zero.y }; - } - const after = { ...clone(old), id: elementIds.get(old.id)!, incarnation: 1, ownerCanvasId: canvasIds.get(old.ownerCanvasId) ?? old.ownerCanvasId, data: remapCopyData(old, registry, elementIds, canvasIds, document), geometry }; - changes.push({ kind: 'element', id: after.id, before: null, after }); - } + changes.push(duplicateTreeChange(document,registry,item.kind,item.record,elementIds,canvasIds,copiedCanvases,offset)); } const nodeElements = new Map(), nodeCanvases = new Map(); for (const change of changes) { yield; if (change.kind === 'element' && change.after) nodeElements.set(change.id, change.after); else if (change.kind === 'canvas' && change.after) nodeCanvases.set(change.id, change.after); } const nodes: Document = { ...document, elements: overlay(document.elements, nodeElements), canvases: overlay(document.canvases, nodeCanvases) }; for (const edge of edges) { yield; - if (edge.structure !== 'connection') continue; + const change=duplicateEdgeChange(document,registry,edge,elementIds,canvasIds,nodes); + changes.push(change);nodeElements.set(change.id,change.after!); + } + yield* validateDocumentSteps(nodes, registry); + const elementMapping: Record = {}, canvasMapping: Record = {}, copiedRoots: Id[] = []; + for (const [id, value] of elementIds) { yield; Object.defineProperty(elementMapping,id,{value,enumerable:true}); } + for (const [id, value] of canvasIds) { yield; Object.defineProperty(canvasMapping,id,{value,enumerable:true}); } + for (const id of roots) { yield; copiedRoots.push(elementIds.get(id)!); } + for (const change of changes) { yield; freeze(change); } + return Object.freeze({ version: 1, roots: Object.freeze(copiedRoots), elementIds: Object.freeze(elementMapping), canvasIds: Object.freeze(canvasMapping), changes: Object.freeze(changes) }); +} + +/** Shared deterministic emitters for synchronous and checkpointed planners. */ +export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap,canvasIds:ReadonlyMap,copiedCanvases:ReadonlySet,offset:Point):Exclude{ + if (kind === 'canvas') { + const canvas=record as CanvasRecord,parent=canvas.parent!; + const after = { ...clone(canvas), id: canvasIds.get(canvas.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } }; + return { kind: 'canvas', id: after.id, before: null, after }; + } else { + const old = record as ElementRecord; if (old.structure !== 'node') throw fault('invalid_copy_node'); + let geometry = clone(old.geometry); + if (!copiedCanvases.has(old.ownerCanvasId)) { + const transform = canvasTransform(document, old.ownerCanvasId), zero = toLocal(transform, { x: 0, y: 0 }), shift = toLocal(transform, offset); + geometry = { ...geometry, x: geometry.x + shift.x - zero.x, y: geometry.y + shift.y - zero.y }; + } + const after = { ...clone(old), id: elementIds.get(old.id)!, incarnation: 1, ownerCanvasId: canvasIds.get(old.ownerCanvasId) ?? old.ownerCanvasId, data: remapCopyData(old, registry, elementIds, canvasIds, document), geometry }; + return { kind: 'element', id: after.id, before: null, after }; + } +} +export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap,canvasIds:ReadonlyMap,nodes:Document):Extract{ + if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint'); if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]); const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId }; const owner = commonCanvas(nodes, nodes.elements.get(source.elementId)!.ownerCanvasId, nodes.elements.get(target.elementId)!.ownerCanvasId), transform = canvasTransform(nodes, owner); @@ -113,17 +130,9 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele path = { ...path, knots: knots.map((raw, index) => { const knot = raw as Record, point = knot.position as { x: number; y: number }, world = toWorld(transform, point), t = (index + 1) / (knots.length + 1); return { ...knot, position: toLocal(transform, { x: world.x + from.x * (1 - t) + to.x * t, y: world.y + from.y * (1 - t) + to.y * t }) }; }) }; } const after: ElementRecord = { ...clone(edge), id: elementIds.get(edge.id)!, incarnation: 1, ownerCanvasId: owner, source, target, path, data: remapCopyData(edge, registry, elementIds, canvasIds, document) }; - changes.push({ kind: 'element', id: after.id, before: null, after }); - nodeElements.set(after.id, after); - } - yield* validateDocumentSteps(nodes, registry); - const elementMapping: Record = {}, canvasMapping: Record = {}, copiedRoots: Id[] = []; - for (const [id, value] of elementIds) { yield; Object.defineProperty(elementMapping,id,{value,enumerable:true}); } - for (const [id, value] of canvasIds) { yield; Object.defineProperty(canvasMapping,id,{value,enumerable:true}); } - for (const id of roots) { yield; copiedRoots.push(elementIds.get(id)!); } - for (const change of changes) { yield; freeze(change); } - return Object.freeze({ version: 1, roots: Object.freeze(copiedRoots), elementIds: Object.freeze(elementMapping), canvasIds: Object.freeze(canvasMapping), changes: Object.freeze(changes) }); + return { kind: 'element', id: after.id, before: null, after }; } +export function duplicateOverlay(document:Document,elements:ReadonlyMap,canvases:ReadonlyMap):Document{return {...document,elements:overlay(document.elements,elements),canvases:overlay(document.canvases,canvases)};} /** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */ function overlay(base: ReadonlyMap, additions: ReadonlyMap): ReadonlyMap { diff --git a/src/lib/canvas-core/index.ts b/src/lib/canvas-core/index.ts index cd0e89a..b3d9012 100644 --- a/src/lib/canvas-core/index.ts +++ b/src/lib/canvas-core/index.ts @@ -9,4 +9,7 @@ export * from './coordinates.ts'; export * from './structure.ts'; export * from './units.ts'; export * from './history-transition.ts'; -export * from './duplicate.ts'; +export {planDuplicate,planDuplicateSteps,remapCopyData} from './duplicate.ts'; +export type {DuplicatePlan,DuplicateOptions} from './duplicate.ts'; +export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-checkpoint.ts'; +export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts'; diff --git a/src/lib/canvas-runtime/duplicate.ts b/src/lib/canvas-runtime/duplicate.ts index b9c1557..bfb73da 100644 --- a/src/lib/canvas-runtime/duplicate.ts +++ b/src/lib/canvas-runtime/duplicate.ts @@ -1,3 +1,4 @@ +import { createDuplicatePlanner, type DuplicateCheckpoint } from '../canvas-core/duplicate-checkpoint.ts'; import type { Document, Registry, Target } from '../canvas-core/types.ts'; import { planDuplicateSteps, type DuplicateOptions, type DuplicatePlan } from '../canvas-core/duplicate.ts'; import { clone, fault, freeze } from '../canvas-core/values.ts'; @@ -45,3 +46,16 @@ export async function planDuplicateAsync(document: Document, registry: Registry, steps.return(undefined as never); } } + +/** Resume semantic planning cursors. Source/output validation and derived output caches are rebuilt. */ +export async function planDuplicateResumable(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint},scheduling:Omit&{checkpoint?:(state:DuplicateCheckpoint)=>Promise}={}):Promise{ + const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise(resolve=>setTimeout(resolve,0))); + if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget'); + const check=()=>{if(scheduling.signal?.aborted)throw fault('copy_aborted');};check(); + const settings=Object.freeze({createId:options.createId,...(options.worldOffset?{worldOffset:freeze(clone(options.worldOffset))}:{}),...(options.mapping?{mapping:freeze(clone(options.mapping))}:{})}),planner=createDuplicatePlanner(document,registry,freeze(clone(selection)),settings,binding); + while(true){ + const start=now();let count=0; + do{check();const done=planner.step();check();if(done)return planner.result();count++;}while(count{const index=cursor++;if(index{ + const plan=preparation.version===2?await planDuplicateResumable(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId},{sourceToken:admitted.source.sha256!,schemaToken:preparation.registrySignature,checkpoint:preparation.planner},{signal:controller.signal,checkpoint:async state=>{ + const job=check(),previous=job.preparation!;if(previous.planner&&state.steps<=previous.steps)return; + publishImport(freeze({...job,generation:job.generation+1,preparation:{...previous,steps:state.steps,allocationIds:[...state.elementIds,...state.canvasIds].map(pair=>pair[1]),planner:state}}),job.generation);await persist();check(); + }}):await planDuplicateAsync(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId:()=>{const index=cursor++;if(index{ const job=check(),previous=job.preparation!; if(steps<=previous.steps&&allocationIds.length===previous.allocationIds.length)return; publishImport(freeze({...job,generation:job.generation+1,preparation:{...previous,steps:Math.max(previous.steps,steps),allocationIds:[...allocationIds]}}),job.generation);await persist();check(); }}); - check();if(cursor!==allocationIds.length)throw fault('copy_interpreter_changed'); + check();if(preparation.version===1&&cursor!==allocationIds.length)throw fault('copy_interpreter_changed'); const encoded=await encodeDuplicateSource(document,options.registry,plan,controller.signal),sha256=await importSourceDigest(encoded,controller.signal),job=check();assertWritable();assertNativeImportTarget(job,editor.getSnapshot().document); const permission=options.policy?.({type:'changes.apply',changes:plan.changes},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets); const fileId=createId(),runtime=editor.getSnapshot().runtime;if(!fileId||fileWrites.has(fileId)||runtime.resources.has(fileId)||[...(runtime.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict'); @@ -402,7 +405,7 @@ export async function openProjectEngine(options: EngineOptions) { const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime; if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||fileWrites.has(fileId)||[...(current.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict'); const entry=canvas.parent?captured.document.elements.get(canvas.parent.entryElementId)!:undefined; - const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{kind:'duplicate-snapshot',name:'复制选中内容',fileId,byteLength:bytes.size,targetCanvasId:target,copy:{sourceRevision:captured.revision,roots:[]},...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'preparing' as const,preparation:{version:1 as const,registrySignature:copyRegistrySignature(captured.document,options.registry),selection:targets,worldOffset:offset,allocationIds:[],steps:0}}); + const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{kind:'duplicate-snapshot',name:'复制选中内容',fileId,byteLength:bytes.size,targetCanvasId:target,copy:{sourceRevision:captured.revision,roots:[]},...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'preparing' as const,preparation:{version:2 as const,registrySignature:copyRegistrySignature(captured.document,options.registry),selection:targets,worldOffset:offset,allocationIds:[],steps:0}}); fileWrites.set(fileId,bytes);const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});}); if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id; })(); diff --git a/src/lib/canvas-runtime/imports.ts b/src/lib/canvas-runtime/imports.ts index 8aa91b8..8170ebf 100644 --- a/src/lib/canvas-runtime/imports.ts +++ b/src/lib/canvas-runtime/imports.ts @@ -1,3 +1,4 @@ +import { validateDuplicateCheckpoint, type DuplicateCheckpoint } from '../canvas-core/duplicate-checkpoint.ts'; import type { Change, Id, Json, Result, Target, Point } from '../canvas-core/types.ts'; import { selectImportGroups } from './import-groups.ts'; import { clone, equal, fault, freeze, validJson } from '../canvas-core/values.ts'; @@ -12,7 +13,7 @@ export type ImportBinding = {readonly kind:'element';readonly incarnation:number export type ImportJob = { readonly version: 1 | 2 | 3; readonly id: Id; readonly projectId: Id; readonly sourceFingerprint: string; readonly parserVersion: number; readonly source?: NativeImportSource; - readonly preparation?: {readonly version:1;readonly registrySignature:string;readonly selection:readonly Target[];readonly worldOffset:Point;readonly allocationIds:readonly Id[];readonly steps:number}; + readonly preparation?: {readonly version:1|2;readonly planner?:DuplicateCheckpoint;readonly registrySignature:string;readonly selection:readonly Target[];readonly worldOffset:Point;readonly allocationIds:readonly Id[];readonly steps:number}; readonly generation: number; readonly status: 'preparing' | 'paused' | 'running' | 'completed' | 'partial' | 'cancelled'; readonly cursor: number; readonly eof: boolean; readonly bindings?: Readonly>; readonly stableIds: Readonly>; readonly pending: readonly ImportLocator[]; readonly committedSourceKeys: readonly string[]; readonly failures: readonly ImportFailure[]; readonly error?: string; @@ -34,7 +35,9 @@ export function validateImportJob(job: ImportJob): void { } if(job.source?.kind==='duplicate-snapshot'){ const p=job.preparation; - if(!p||p.version!==1||typeof p.registrySignature!=='string'||!p.registrySignature||!Array.isArray(p.selection)||!p.selection.length||p.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!p.worldOffset||![p.worldOffset.x,p.worldOffset.y].every(Number.isFinite)||!Array.isArray(p.allocationIds)||p.allocationIds.some(id=>typeof id!=='string'||!id)||new Set(p.allocationIds).size!==p.allocationIds.length||!Number.isSafeInteger(p.steps)||p.steps<0||!['preparing','paused','cancelled'].includes(job.status)||job.cursor!==0||job.eof||job.pending.length||job.committedSourceKeys.length||Object.keys(job.stableIds).length||!job.source.copy||!Number.isSafeInteger(job.source.copy.sourceRevision)||job.source.copy.sourceRevision<0)throw fault('invalid_copy_preparation'); + if(!p||![1,2].includes(p.version)||typeof p.registrySignature!=='string'||!p.registrySignature||!Array.isArray(p.selection)||!p.selection.length||p.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!p.worldOffset||![p.worldOffset.x,p.worldOffset.y].every(Number.isFinite)||!Array.isArray(p.allocationIds)||p.allocationIds.some(id=>typeof id!=='string'||!id)||new Set(p.allocationIds).size!==p.allocationIds.length||!Number.isSafeInteger(p.steps)||p.steps<0||!['preparing','paused','cancelled'].includes(job.status)||job.cursor!==0||job.eof||job.pending.length||job.committedSourceKeys.length||Object.keys(job.stableIds).length||!job.source.copy||!Number.isSafeInteger(job.source.copy.sourceRevision)||job.source.copy.sourceRevision<0)throw fault('invalid_copy_preparation'); + if(p.planner){validateDuplicateCheckpoint(p.planner);if(p.version!==2||p.planner.sourceToken!==job.source.sha256||p.planner.schemaToken!==p.registrySignature||p.planner.projectId!==job.projectId||!equal(p.planner.selection,p.selection)||!equal(p.planner.offset,p.worldOffset)||p.planner.steps!==p.steps||!equal([...p.planner.elementIds,...p.planner.canvasIds].map(pair=>pair[1]),p.allocationIds))throw fault('invalid_copy_preparation');} + else if(p.version===2&&(p.steps!==0||p.allocationIds.length))throw fault('invalid_copy_preparation'); }else if(job.status==='preparing'||job.preparation!==undefined)throw fault('invalid_copy_preparation'); const keys=new Set(),ids=Object.values(job.stableIds),stableIdSet=new Set(ids); if (stableIdSet.size!==ids.length || ids.some(id=>typeof id!=='string'||!id) || Object.keys(job.stableIds).some(key=>!key)) throw fault('invalid_import_job'); diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 1f32c0f..f363746 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -394,6 +394,22 @@ async function duplicateRecoveryCase(name,assert){ }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);} } +async function duplicatePlannerRecoveryCase(name,assert){ + const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),database=`${name}-duplicate-planner`,storage=createBrowserStorage({name:database}); + const node=(id,owner,componentId='text')=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:''},geometry:{x:0,y:0,width:200,height:100,rotation:0}}); + const original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],[node('F',root,'frame'),...Array.from({length:500},(_,i)=>node(`member-${i}`,child))]); + let document=original,versions=new Map([[root,1],[child,1]]),engine; + const transport=async request=>{const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'planner recovery lost expected version');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);const participants=Object.keys(body.expected_versions);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}}};}; + const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; + try{ + engine=await openProjectEngine(options);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]); + await new Promise((resolve,reject)=>{const timer=setTimeout(()=>{release();reject(new Error('no durable DFS checkpoint'));},30000);const release=engine.editor.subscribe((_,snapshot)=>{const planner=snapshot.runtime.imports.get(id)?.preparation?.planner;if(planner?.phase==='dfs'&&planner.work.length){clearTimeout(timer);release();resolve();}});}); + await engine.imports.pause(id);await engine.settle();const before=engine.imports.getJobs().get(id),saved=JSON.stringify(before.preparation.planner);assert(before.preparation.version===2&&before.preparation.planner.phase==='dfs','did not pause within DFS');assert(before.preparation.planner.sourceToken===before.source.sha256,'checkpoint lost source binding');assert(before.committedSourceKeys.length===0,'preparation published content prematurely');await engine.close();engine=await openProjectEngine(options); + assert(JSON.stringify(engine.imports.getJobs().get(id).preparation.planner)===saved,'IndexedDB changed the DFS checkpoint');let resumed=false;const release=engine.editor.subscribe((_,snapshot)=>{const planner=snapshot.runtime.imports.get(id)?.preparation?.planner;if(planner){assert(planner.steps>=before.preparation.steps,'resume regressed the cursor');assert(!['selection','canvases','members','roots','seed'].includes(planner.phase),'resume repeated an earlier preparation phase');resumed=true;}}); + await engine.imports.resume(id);await engine.settle();release();const job=engine.imports.getJobs().get(id);assert(resumed&&job.status==='completed'&&job.failures.length===0,'DFS recovery did not complete');assert(job.committedSourceKeys.length===502&&Object.keys(job.stableIds).length===502,'DFS recovery lost a source record');assert(document.elements.size===1002&&document.canvases.size===3,'DFS recovery duplicated or lost content');const copiedChild=job.stableIds[`canvas:${child}`];for(let i=0;i<500;i++)assert(document.elements.get(job.stableIds[`element:member-${i}`])?.ownerCanvasId===copiedChild,'DFS sibling lost its copied owner'); + }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);} +} + async function nativeImportRecoveryCase(name,assert){ const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),original=createDocument(root,[{id:root,parent:null}],[]),database=`${name}-native-import`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1]]),engine; const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}); @@ -593,6 +609,8 @@ export async function run() { results.push('blocked source durability permits a bounded local prefix, fences all network writes and recovers all records after real IndexedDB resumes'); await referenceCycleCase(name,assert); results.push('native frame reference cycles and copied port mappings commit atomically through protocol 2 and real IndexedDB undo/redo/reopening'); + await duplicatePlannerRecoveryCase(name,assert); + results.push('preparation v2 persists a DFS stack/cursor in real IndexedDB and resumes 500 siblings without restarting scans or duplicating identities'); await duplicateRecoveryCase(name,assert); results.push('deep copy source/mapping survive IndexedDB pause/reopen/resume, retain C to F-prime and shared assets, preserve edits and do not revive undone content'); await nativeImportRecoveryCase(name,assert); diff --git a/tests/headless/copy-snapshot.test.mjs b/tests/headless/copy-snapshot.test.mjs index a509d57..d677168 100644 --- a/tests/headless/copy-snapshot.test.mjs +++ b/tests/headless/copy-snapshot.test.mjs @@ -15,6 +15,6 @@ test('snapshot decoding rejects malformed, future, duplicate, invalid and trunca }); test('preparation checkpoints require a matching interpreter, valid selection, unique allocations and no published progress',()=>{ const base={...createImportJob({id:'job',projectId:'project',sourceFingerprint:'snapshot',parserVersion:1,source:{kind:'duplicate-snapshot',sha256:'a'.repeat(64),fileId:'file',byteLength:10,targetCanvasId:'P',copy:{sourceRevision:0,roots:[]}}}),status:'preparing',preparation:{version:1,registrySignature:'registry-v1',selection:[{id:'A',incarnation:2}],worldOffset:{x:32,y:32},allocationIds:['new-A'],steps:512}};validateImportJob(base); - for(const patch of [{preparation:undefined},{preparation:{...base.preparation,version:2}},{preparation:{...base.preparation,allocationIds:['same','same']}},{preparation:{...base.preparation,selection:[]}},{cursor:1},{committedSourceKeys:['element:A']},{status:'running'}])assert.throws(()=>validateImportJob({...base,...patch})); + for(const patch of [{preparation:undefined},{preparation:{...base.preparation,version:3}},{preparation:{...base.preparation,allocationIds:['same','same']}},{preparation:{...base.preparation,selection:[]}},{cursor:1},{committedSourceKeys:['element:A']},{status:'running'}])assert.throws(()=>validateImportJob({...base,...patch})); assert.throws(()=>createImportRunner({getJob:()=>base}),/import_interpreter_unavailable/); }); diff --git a/tests/headless/duplicate-checkpoint.test.mjs b/tests/headless/duplicate-checkpoint.test.mjs new file mode 100644 index 0000000..5a579f9 --- /dev/null +++ b/tests/headless/duplicate-checkpoint.test.mjs @@ -0,0 +1,32 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts'; +import {createDuplicatePlanner,validateDuplicateCheckpoint} from '../../src/lib/canvas-core/duplicate-checkpoint.ts'; +import {planDuplicateResumable} from '../../src/lib/canvas-runtime/duplicate.ts'; +const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:2,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'literal-data-not-in-checkpoint'},geometry:{x:10,y:20,width:200,height:100,rotation:0}}),edge=(id,source,target,owner='P')=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source,portId:'right'},target:{elementId:target,portId:'left'},data:{},path:{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:100,y:80},in:{x:-2,y:3},out:{x:4,y:5}}]}}); +const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G'}}],[node('C'),{...node('F','P','frame'),geometry:{...node('F').geometry,rotation:30},data:{title:'F',text:'',presentation:{contentScale:2},ports:[{id:'in',nodeId:'A',direction:'input',handle:'left'}]}},{...node('A','S','image'),data:{title:'A',text:'',assetId:'shared',runId:'not-copied'}},{...node('G','S','frame'),data:{title:'G',text:'',presentation:{contentScale:.75}}},node('B','T'),node('D','T'),edge('incoming','C','F'),edge('deep','C','B'),edge('internal','B','D','T'),edge('across','A','B','S'),edge('outgoing','F','C')]); +const binding={sourceToken:'source-sha',schemaToken:'schema-v1'},targets=(ids=['F','A'])=>ids.map(id=>({id,incarnation:document.elements.get(id).incarnation})),drain=planner=>{for(let i=0;i<100000;i++)if(planner.step())return planner.result();throw Error('planner did not terminate');}; + +test('each durable planning phase resumes with the same deep-copy plan and no repeated identity allocation',()=>{ + const phases=['selection','canvases','members','roots','seed','dfs','edges','allocate-elements','allocate-canvases','mapping','emit','emit-edges','validate','done']; + for(const phase of phases){let next=0,planner=createDuplicatePlanner(document,registry,targets(),{createId:()=>`new-${++next}`},binding),checkpoint; + for(let i=0;i<10000;i++){const done=planner.step(),state=planner.checkpoint();if(state.phase===phase&&(phase!=='emit'||state.emitted>0)&&(phase!=='emit-edges'||state.emittedEdges>0)){checkpoint=state;break;}if(done)break;} + assert.ok(checkpoint,`missing ${phase} checkpoint`);validateDuplicateCheckpoint(JSON.parse(JSON.stringify(checkpoint)));const before=JSON.stringify(checkpoint),allocated=next,source=createDocument(document.projectId,[...document.canvases.values()],[...document.elements.values()]),resumed=createDuplicatePlanner(source,registry,targets(),{createId:()=>`new-${++next}`},{...binding,checkpoint:JSON.parse(before)}),actual=drain(resumed);let reference=0;const expected=planDuplicate(document,registry,targets(),{createId:()=>`new-${++reference}`});assert.deepEqual(actual,expected,phase);assert.equal(next,reference,`identity replay in ${phase}`);assert.equal(JSON.stringify(checkpoint),before);assert.ok(next>=allocated);assert.ok(!before.includes('literal-data-not-in-checkpoint')); + } +}); + +test('nested DFS expansion checkpoints preserve pending siblings, selected edges and explicit stable mappings',()=>{ + for(const selection of [['F','C','incoming'],['A','internal'],['incoming']]){let reference=0;const selected=targets(selection),expected=planDuplicate(document,registry,selected,{createId:()=>`copy-${++reference}`}),mapping={elements:expected.elementIds,canvases:expected.canvasIds};let planner=createDuplicatePlanner(document,registry,selected,{mapping,createId:()=>{throw Error('allocated despite complete mapping');}},binding),state; + for(let i=0;i<10000;i++){const done=planner.step();state=planner.checkpoint();if(state.expanding?.index>=0||done)break;} + assert.deepEqual(drain(createDuplicatePlanner(document,registry,selected,{mapping,createId:()=>{throw Error('reallocated');}},{...binding,checkpoint:state})),expected); + } +}); + +test('checkpoint bindings, shape and source identities reject incompatible state before producing a plan',()=>{ + const planner=createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},binding),state=planner.checkpoint();for(const patch of [{sourceToken:'different'},{schemaToken:'different'}])assert.throws(()=>createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},{...binding,...patch,checkpoint:state}),/binding_mismatch/); + for(const patch of [{version:2},{phase:'future'},{cursor:-2},{elementIds:[['F','C']]},{work:[{kind:'element',id:'missing'}]}])assert.throws(()=>createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},{...binding,checkpoint:{...state,...patch}}));assert.throws(()=>planner.result(),/copy_plan_not_ready/); +}); + +test('cooperative resume checkpoints advance semantic cursors, yield and stop cleanly after cancellation',async()=>{ + const controller=new AbortController();let saved,ids=0,yields=0;await assert.rejects(planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},binding,{maxStepsPerSlice:1,yieldTask:async()=>{yields++;},checkpoint:async state=>{if(state.elementIds.length>=2){saved=state;controller.abort();}},signal:controller.signal}),/copy_aborted/);assert.ok(yields>0);assert.ok(saved.elementIds.length>=2);const old=ids,plan=await planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},{...binding,checkpoint:saved},{maxStepsPerSlice:2});let reference=0;assert.deepEqual(plan,planDuplicate(document,registry,targets(),{createId:()=>`async-${++reference}`}));assert.equal(ids,reference);assert.ok(ids>=old); +}); diff --git a/tests/headless/imports-engine.test.mjs b/tests/headless/imports-engine.test.mjs index 32b2734..2a8ce5a 100644 --- a/tests/headless/imports-engine.test.mjs +++ b/tests/headless/imports-engine.test.mjs @@ -127,11 +127,11 @@ test('read-only policy rejects copy/import admission without storing a source or }); -test('preparing copy checkpoints source and allocated identities, reopens paused and reconstructs the same mapping',async(t)=>{ +test('preparing copy resumes saved planning cursors and allocated identities without repeating earlier phases',async(t)=>{ const f=fixture(),save=f.storage.commit;let durableCuts=0;f.storage.commit=async(...args)=>{durableCuts++;return save(...args);};let engine=await f.open();try{ await seedCopy(engine);assert.equal(engine.editor.transaction(tx=>{for(let i=0;i<1000;i++)tx.dispatch({type:'element.create',element:copyNode(`extra-${i}`,'child')});}).status,'applied');await engine.settle(); - const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>(snapshot.runtime.imports.get(id).preparation?.allocationIds.length??0)>0);await engine.imports.pause(id);await engine.settle();const checkpoint=engine.imports.getJobs().get(id);assert.equal(checkpoint.status,'paused');assert.equal(checkpoint.source.kind,'duplicate-snapshot');assert.ok(checkpoint.preparation.allocationIds.length>0);assert.ok(checkpoint.preparation.steps>0);assert.equal(checkpoint.committedSourceKeys.length,0);decodeRecovery(f.image(),registry); - engine.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'new source title'}});await engine.settle();await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');assert.deepEqual(engine.imports.getJobs().get(id).preparation.allocationIds,checkpoint.preparation.allocationIds);await engine.imports.resume(id);await engine.settle(); + const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>(snapshot.runtime.imports.get(id).preparation?.allocationIds.length??0)>0);await engine.imports.pause(id);await engine.settle();const checkpoint=engine.imports.getJobs().get(id);assert.equal(checkpoint.status,'paused');assert.equal(checkpoint.source.kind,'duplicate-snapshot');assert.ok(checkpoint.preparation.allocationIds.length>0);assert.ok(checkpoint.preparation.steps>0);assert.equal(checkpoint.preparation.version,2);assert.ok(checkpoint.preparation.planner);assert.equal(checkpoint.preparation.planner.sourceToken,checkpoint.source.sha256);assert.equal(checkpoint.committedSourceKeys.length,0);decodeRecovery(f.image(),registry); + engine.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'new source title'}});await engine.settle();await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');assert.deepEqual(engine.imports.getJobs().get(id).preparation.allocationIds,checkpoint.preparation.allocationIds);assert.deepEqual(engine.imports.getJobs().get(id).preparation.planner,checkpoint.preparation.planner);const observed=[],stop=engine.editor.subscribe((_,snapshot)=>{const state=snapshot.runtime.imports.get(id)?.preparation?.planner;if(state)observed.push({phase:state.phase,steps:state.steps});});await engine.imports.resume(id);await engine.settle();stop();assert.ok(observed.some(state=>state.steps>checkpoint.preparation.steps));assert.ok(observed.every(state=>state.steps>=checkpoint.preparation.steps&&!['selection','canvases','members','roots','seed','dfs','edges'].includes(state.phase)));await engine.settle(); const job=engine.imports.getJobs().get(id);assert.equal(job.status,'completed');assert.deepEqual(Object.values(job.stableIds).slice(0,checkpoint.preparation.allocationIds.length),checkpoint.preparation.allocationIds);assert.equal(engine.getSnapshot().document.elements.get(job.stableIds['element:F']).data.title,'F');assert.equal(engine.getSnapshot().document.elements.get('F').data.title,'new source title');assert.equal(job.preparation,undefined);assert.equal(f.errors.length,0);t.diagnostic(JSON.stringify({imported:job.committedSourceKeys.length,durableCuts,requests:f.requests.length})); }finally{await engine.close();} }); @@ -193,3 +193,8 @@ test('late source digest cannot revive a cancelled job or publish into a held re test('legacy unfinished sources remain readable but cannot acquire a new content identity silently',async()=>{ const f=fixture();let engine=await f.open();try{const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();await engine.close();const image=f.image(),job=image.payload.runtime.imports.find(([key])=>key===id)[1];job.version=2;delete job.source.sha256;delete job.source.integrityVersion;const sent=f.requests.length;engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).error,'import_source_integrity_unavailable');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.requests.length,sent);decodeRecovery(f.image(),registry);}finally{await engine.close();} }); + + +test('legacy preparation version 1 still replays its reserved identities instead of allocating replacements',async()=>{ + const f=fixture();let engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();await engine.close();const job=f.image().payload.runtime.imports.find(([key])=>key===id)[1];job.preparation.version=1;job.preparation.steps=1;job.preparation.allocationIds=['legacy-frame-id'];delete job.preparation.planner;engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:F'],'legacy-frame-id');assert.equal(f.errors.length,0);}finally{await engine.close();} +});