diff --git a/src/components/canvas/headless-imports.tsx b/src/components/canvas/headless-imports.tsx index 5f25086..7fd524c 100644 --- a/src/components/canvas/headless-imports.tsx +++ b/src/components/canvas/headless-imports.tsx @@ -26,6 +26,7 @@ export function HeadlessImports({ engine, canvasId }: { engine: ProjectEngine; c {jobs.map((job, index) =>
{job.source?.name || `导入任务 ${index + 1}`}

{["running","preparing"].includes(job.status) && job.source?.integrityVersion === 1 && !job.source.sha256 ? "正在校验源文件" : job.status === "preparing" ? "正在准备复制" : job.status === "running" ? "正在加入" : job.status === "paused" ? "已暂停" : job.status === "cancelled" ? "已停止" : job.status === "partial" ? "部分加入" : "已加入画布"} · {job.committedSourceKeys.length} 项{job.pending.length > 0 && `,${job.pending.length} 项等待`}

+ {job.source?.retirement?.purgedAt !== undefined && 本地源文件已清理;画布内容和撤销记录保留。} {job.error &&

{reason(job.error)}

}
{["running","preparing"].includes(job.status) && } diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index 919f875..30b7ed8 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -1,3 +1,4 @@ +import {importFiles,terminalImport,markImportFiles,prepareImportFileRetention,type FileRetirement} from './import-file-retention.ts'; import {resourceRetentionPins,prepareResourceRetention} from './resource-retention.ts'; import { encodeIndexedCopySnapshot, openIndexedCopySnapshot, type IndexedCopySnapshot } from './copy-snapshot-index.ts'; import { decodeCopySnapshot, copyRegistrySignature } from './copy-snapshot.ts'; @@ -63,7 +64,10 @@ export async function openProjectEngine(options: EngineOptions) { const runClock: Clock = options.clock ?? { now: () => Date.now(), setTimer: (fn, delay) => setTimeout(fn, delay), clearTimer: handle => clearTimeout(handle as ReturnType) }; const displays = new Set<() => void>(); const displayPins=new Map(),collectionReservations=new Set(),durablyPurged=new Set([...(recovered?.runtime.resources.values()??[])].filter(resource=>resource.retirement?.purgedAt!==undefined).map(resource=>resource.id)); - let collection:Promise<{readonly markedIds:readonly Id[];readonly purgedIds:readonly Id[];readonly complete:boolean;readonly checkedRootRevision:number;readonly rootRevision:number;readonly durableRootRevision:number}>|undefined; + type CollectionReport={readonly markedIds:readonly Id[];readonly purgedIds:readonly Id[];readonly complete:boolean;readonly checkedRootRevision:number;readonly rootRevision:number;readonly durableRootRevision:number}; + let sourceCollection:Promise|undefined; + for(const [id,entry]of importFiles(recovered?.runtime.imports))if(entry.file.retirement?.purgedAt!==undefined)durablyPurged.add(id); + let collection:Promise|undefined; const fileWrites = new Map(), digestTasks = new Map>(); let staged: readonly ResourceRecord[] = []; let applying: RunApplyInput | undefined; @@ -79,7 +83,10 @@ export async function openProjectEngine(options: EngineOptions) { policy(command, document) { if (closing || closed || recovery) return { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] }; return options.policy?.(command, document) ?? { enabled: true }; }, traversalPolicy(entry, direction) { return closing || closed || recovery ? { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] } : options.traversalPolicy?.(entry, direction) ?? { enabled: true }; }, onListenerError: report, - prepareRuntime:(_previous,next)=>prepareResourceRetention(next,options.registry,displayPins,collectionReservations,admissionPins(next)), + prepareRuntime:(previous,next)=>{ + const runtime=prepareResourceRetention(next,options.registry,displayPins,collectionReservations,admissionPins(next)); + return prepareImportFileRetention(previous.runtime,runtime,()=>resourceRetentionPins({...next,runtime},options.registry,displayPins,admissionPins({...next,runtime})),collectionReservations,previous.document!==next.document||previous.history!==next.history||previous.runtime.ledger!==runtime.ledger||previous.runtime.resourcePins!==runtime.resourcePins); + }, prepareIntent: intent => applying && intent.origin==='user' ? {...intent,operationType:'run.apply',action:applying} : intent, nextOperationSequence(runtime, fallback) { let next = fallback; for (const record of runtime.ledger.operations.values()) next = Math.max(next, record.intent.sequence + 1); return next + staged.length; }, reduceRuntime(previous, next, commit) { @@ -117,8 +124,9 @@ export async function openProjectEngine(options: EngineOptions) { const task = (async () => { while (pending.size) { const [revision, snapshot] = pending.entries().next().value!; - const files = [...fileWrites].filter(([id]) => snapshot.runtime.resources.has(id)||[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.fileId===id)).map(([id, bytes]) => ({ id, bytes })); - const deleteFiles=[...snapshot.runtime.resources.values()].filter(resource=>resource.retirement?.purgedAt!==undefined&&!durablyPurged.has(resource.id)).map(resource=>resource.id); + const sourceFiles=importFiles(snapshot.runtime.imports); + const files = [...fileWrites].filter(([id]) => snapshot.runtime.resources.has(id)||sourceFiles.has(id)).map(([id, bytes]) => ({ id, bytes })); + const deleteFiles=[...[...snapshot.runtime.resources.values()].filter(resource=>resource.retirement?.purgedAt!==undefined&&!durablyPurged.has(resource.id)).map(resource=>resource.id),...[...sourceFiles].filter(([id,entry])=>entry.file.retirement?.purgedAt!==undefined&&!durablyPurged.has(id)).map(([id])=>id)]; const receipt = await options.storage.commit(leader, { image: encodeRecovery(snapshot), files,deleteFiles }); if (receipt.durableRootRevision !== revision) throw fault('invalid_durable_receipt'); durableRootRevision = revision; pending.delete(revision); @@ -164,6 +172,52 @@ export async function openProjectEngine(options: EngineOptions) { })(); collection=task.finally(()=>{collection=undefined;});return collection; } + function collectImportSources(settings:{retentionMs:number;maxFiles?:number}):Promise { + assertWritable();if(options.storage.fileDeletion!=='atomic-v1')throw fault('resource_collection_unavailable'); + const retentionMs=settings.retentionMs,maxFiles=settings.maxFiles??64; + if(!Number.isSafeInteger(retentionMs)||retentionMs<0||!Number.isSafeInteger(maxFiles)||maxFiles<1||maxFiles>128)throw fault('invalid_resource_collection'); + if(sourceCollection)return sourceCollection; + const task=(async()=>{ + await persist();assertWritable(); + const now=runClock.now();if(!Number.isSafeInteger(now)||now<0)throw fault('invalid_resource_collection_clock'); + let root=editor.getSnapshot(),pins=resourceRetentionPins(root,options.registry,displayPins,admissionPins(root)); + const markedIds:Id[]=[],purgedIds:Id[]=[]; + const report=()=>Object.freeze({markedIds:Object.freeze(markedIds),purgedIds:Object.freeze(purgedIds),complete:pins.complete,checkedRootRevision:root.rootRevision,rootRevision:editor.getSnapshot().rootRevision,durableRootRevision}); + const candidates=()=>[...importFiles(root.runtime.imports)].filter(([id,entry])=>!pins.pins.has(id)&&!fileWrites.has(id)&&(!entry.current||terminalImport(root.runtime.imports!.get(entry.jobId)!.status)&&!importLoads.has(entry.jobId))); + if(!pins.complete)return report(); + const fresh=candidates().filter(([,entry])=>!entry.file.retirement).slice(0,maxFiles); + if(fresh.length){ + const markers=new Map(fresh.map(([id])=>[id,{version:1 as const,unreferencedAt:now}])); + const result=editor.updateRuntime(previous=>markImportFiles(previous,markers),root.rootRevision);if(result.status==='rejected')throw fault(result.code,result.targets); + markedIds.push(...markers.keys());await persist();assertWritable(); + } + root=editor.getSnapshot();pins=resourceRetentionPins(root,options.registry,displayPins,admissionPins(root));if(!pins.complete)return report(); + const eligible=candidates().filter(([,entry])=>entry.file.retirement&&entry.file.retirement.purgedAt===undefined&&now-entry.file.retirement.unreferencedAt>=retentionMs).slice(0,maxFiles); + if(eligible.length){ + const markers=new Map(eligible.map(([id,entry])=>[id,{...entry.file.retirement!,purgedAt:now}])); + for(const id of markers.keys())collectionReservations.add(id); + const result=editor.updateRuntime(previous=>markImportFiles(previous,markers),root.rootRevision); + if(result.status==='rejected'){for(const id of markers.keys())collectionReservations.delete(id);throw fault(result.code,result.targets);} + await persist();purgedIds.push(...markers.keys()); + } + return report(); + })(); + sourceCollection=task.finally(()=>{sourceCollection=undefined;});return sourceCollection; + } + async function openImportSource(jobId:Id,fileId?:Id) { + if(closing||closed)throw fault('engine_closing');const job=getImport(jobId),file=fileId!==undefined?importFiles(new Map([[jobId,job]])).get(fileId)?.file:job.source;if(!file)throw fault('import_source_unavailable'); + if(collectionReservations.has(file.fileId))throw fault('resource_collection_in_progress',[file.fileId]); + if(file.retirement?.purgedAt!==undefined)throw fault('import_source_retired',[file.fileId]); + displayPins.set(file.fileId,(displayPins.get(file.fileId)??0)+1);let released=false; + const release=()=>{if(released)return;released=true;displays.delete(release);const count=displayPins.get(file.fileId)??1;if(count<=1)displayPins.delete(file.fileId);else displayPins.set(file.fileId,count-1);};displays.add(release); + try{ + if(file.retirement){const result=editor.updateRuntime(previous=>Object.freeze({...previous}));if(result.status==='rejected')throw fault(result.code,result.targets);} + const bytes=fileWrites.get(file.fileId)??await options.storage.readFile(leader,file.fileId); + if(closing||closed)throw fault('engine_closing');if(!bytes||bytes.size!==file.byteLength)throw fault('import_source_unavailable',[file.fileId]); + if(file.sha256&&await sourceDigest(bytes)!==file.sha256)throw fault('import_source_changed',[file.fileId]); + if(closing||closed)throw fault('engine_closing');return Object.freeze({bytes,release}); + }catch(error){release();if(closing||closed)throw fault('engine_closing');throw error;} + } function mutate(reduce: (ledger: ReturnType) => void) { const result = editor.updateRuntime(previous => { const ledger = createLedger(previous.ledger); reduce(ledger); return Object.freeze({ ...previous, ledger: ledger.getSnapshot() }); }); if (result.status === 'rejected') throw fault(result.code, result.targets); @@ -398,7 +452,7 @@ export async function openProjectEngine(options: EngineOptions) { 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'); const stableIds=Object.fromEntries([...Object.entries(plan.elementIds).map(([old,id])=>[`element:${old}`,id]),...Object.entries(plan.canvasIds).map(([old,id])=>[`canvas:${old}`,id])]); - const next={...job,generation:job.generation+1,status:'running' as const,stableIds,sourceFingerprint:`local-file:${fileId}:${encoded.size}`,source:{...job.source!,kind:'duplicate-ndjson' as const,fileId,byteLength:encoded.size,sha256,integrityVersion:1 as const,copy:{sourceRevision:job.source!.copy!.sourceRevision,roots:plan.roots}}};delete next.preparation;delete next.error; + const next={...job,generation:job.generation+1,status:'running' as const,stableIds,sourceFingerprint:`local-file:${fileId}:${encoded.size}`,source:{...job.source!,previousFiles:[...(job.source!.previousFiles??[]),{fileId:job.source!.fileId,byteLength:job.source!.byteLength,...(job.source!.sha256?{sha256:job.source!.sha256}:{})}],kind:'duplicate-ndjson' as const,fileId,byteLength:encoded.size,sha256,integrityVersion:1 as const,copy:{sourceRevision:job.source!.copy!.sourceRevision,roots:plan.roots}}};delete next.preparation;delete next.error; fileWrites.set(fileId,encoded);try{publishImport(freeze(next),job.generation);}catch(error){fileWrites.delete(fileId);throw error;} return{job:getImport(id),bytes:encoded,document:undefined,indexed:undefined}; } @@ -443,9 +497,10 @@ export async function openProjectEngine(options: EngineOptions) { } const imports=Object.freeze({ getJobs:()=>readMap(editor.getSnapshot().runtime.imports??new Map()), + openSource:openImportSource, startNdjson(bytes:Blob,targetCanvasId:Id):Id{ assertWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);const canvas=editor.getSnapshot().document.canvases.get(targetCanvasId);if(!canvas)throw fault('canvas_not_loaded',[targetCanvasId]);if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source'); - 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 id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||fileWrites.has(fileId)||importFiles(current.imports).has(fileId))throw fault('import_identity_conflict'); const entry=canvas.parent?editor.getSnapshot().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:'native-ndjson',name:((bytes as Blob&{name?:string}).name||'画布数据').slice(0,255),fileId,byteLength:bytes.size,targetCanvasId,...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'running' as const}); fileWrites.set(fileId,bytes); @@ -467,7 +522,7 @@ export async function openProjectEngine(options: EngineOptions) { const bytes=await encodeIndexedCopySnapshot(captured.document,controller.signal);assertWritable();if(controller.signal.aborted)throw fault('copy_aborted'); const canvas=captured.document.canvases.get(target);if(!canvas)throw fault('canvas_not_loaded',[target]); 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'); + if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||fileWrites.has(fileId)||importFiles(current.imports).has(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',snapshotVersion:2,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:4 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)});}); @@ -482,7 +537,7 @@ export async function openProjectEngine(options: EngineOptions) { scheduler.start(); prepareDigests(); runTracker.notify([...initial.runs.keys()]); for(const job of initial.imports?.values()??[])if(['running','preparing'].includes(job.status))void loadImport(job.id); return Object.freeze({ - editor, sessions, imports, collectResources, + editor, sessions, imports, collectResources,collectImportSources, getResourcePins:()=>resourceRetentionPins(editor.getSnapshot(),options.registry,displayPins,admissionPins(editor.getSnapshot())), /** Read/preview under a scheduling hold. No request is rewritten or declared resolved here. */ async prepareRecovery(): Promise { @@ -622,7 +677,7 @@ export async function openProjectEngine(options: EngineOptions) { if (!(file.bytes instanceof Blob) || !file.bytes.size || file.bytes.size > maxResourceBytes || !file.bytes.type.startsWith('image/') || typeof file.name !== 'string' || !file.name) throw fault('invalid_staged_file'); return freeze({ id: createId(), operationId: createId(), name: file.name, mimeType: file.bytes.type, byteLength: file.bytes.size }); }); - if (new Set(resources.flatMap(resource => [resource.id, resource.operationId])).size !== resources.length * 2 || resources.some(resource => editor.getSnapshot().runtime.resources.has(resource.id) || fileWrites.has(resource.id) || [...(editor.getSnapshot().runtime.imports?.values()??[])].some(job=>job.source?.fileId===resource.id))) throw fault('resource_identity_conflict'); + if (new Set(resources.flatMap(resource => [resource.id, resource.operationId])).size !== resources.length * 2 || resources.some(resource => editor.getSnapshot().runtime.resources.has(resource.id) || fileWrites.has(resource.id) || importFiles(editor.getSnapshot().runtime.imports).has(resource.id))) throw fault('resource_identity_conflict'); staged = resources; resources.forEach((resource, index) => fileWrites.set(resource.id, files[index].bytes)); try { const handles = freeze(resources.map(resource => ({ id: resource.id, src: options.assets!.sourceURL(resource.id) }))); @@ -644,6 +699,11 @@ export async function openProjectEngine(options: EngineOptions) { const result=editor.updateRuntime(previous=>{const resources=new Map(previous.resources),{retirement:_,...kept}=retained;resources.set(id,freeze(kept));return Object.freeze({...previous,resources:readMap(resources)});}); if(result.status==='rejected')throw fault(result.code,result.targets); } + if(!retained){ + const source=importFiles(editor.getSnapshot().runtime.imports).get(id)?.file; + if(source?.retirement?.purgedAt!==undefined)throw fault('import_source_retired',[id]); + if(source?.retirement){const result=editor.updateRuntime(previous=>Object.freeze({...previous}));if(result.status==='rejected')throw fault(result.code,result.targets);} + } const bytes = fileWrites.get(id) ?? await options.storage.readFile(leader, id); if (closing || closed) throw fault('engine_closing'); const resource=editor.getSnapshot().runtime.resources.get(id); @@ -660,7 +720,7 @@ export async function openProjectEngine(options: EngineOptions) { async settle() { await Promise.resolve();await Promise.allSettled([...copyPreparations.values()]);while(importLoads.size)await Promise.all([...importLoads.values()]);await Promise.all([...importRunners.values()].map(runner=>runner.settle())); while (digestTasks.size) await Promise.all([...digestTasks.values()]); await scheduler.settle(); await runTracker.settle(); await persist(); }, async close() { if (closed) return; - closing = true;await collection?.catch(report);digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop(); + closing = true;await Promise.allSettled([collection,sourceCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop(); await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);while(digestTasks.size)await Promise.allSettled([...digestTasks.values()]); recovery?.controller.abort();recovery?.release(); for (const release of [...displays]) release(); diff --git a/src/lib/canvas-runtime/import-file-retention.ts b/src/lib/canvas-runtime/import-file-retention.ts new file mode 100644 index 0000000..3669367 --- /dev/null +++ b/src/lib/canvas-runtime/import-file-retention.ts @@ -0,0 +1,64 @@ +import type {Id} from '../canvas-core/types.ts'; +import {equal,fault,freeze,readMap} from '../canvas-core/values.ts'; +import type {ImportJob,NativeImportSource} from './imports.ts'; +import type {RuntimeState} from './contracts.ts'; + +export type FileRetirement={readonly version:1;readonly unreferencedAt:number;readonly purgedAt?:number}; +export type PreviousImportFile={readonly fileId:Id;readonly byteLength:number;readonly sha256?:string;readonly retirement?:FileRetirement}; +export type ImportFile={readonly jobId:Id;readonly current:boolean;readonly file:PreviousImportFile}; +export const terminalImport=(status:ImportJob['status'])=>['completed','partial','cancelled'].includes(status); +export function validFileRetirement(value:unknown):value is FileRetirement { + const marker=value as FileRetirement; + return !!marker&&marker.version===1&&Number.isSafeInteger(marker.unreferencedAt)&&marker.unreferencedAt>=0&&(marker.purgedAt===undefined||Number.isSafeInteger(marker.purgedAt)&&marker.purgedAt>=marker.unreferencedAt); +} +export function importFiles(jobs:ReadonlyMap|undefined):ReadonlyMap { + const files=new Map(); + for(const [jobId,job]of jobs??[]){ + if(jobId!==job.id)throw fault('import_file_identity_conflict',[jobId]); + if(!job.source)continue; + const add=(file:PreviousImportFile,current:boolean)=>{if(files.has(file.fileId))throw fault('import_file_identity_conflict',[file.fileId]);files.set(file.fileId,{jobId:job.id,current,file});}; + add(job.source,true);for(const file of job.source.previousFiles??[])add(file,false); + } + return files; +} +export function validateImportFileRetention(source:NativeImportSource,status:ImportJob['status']) { + if(source.retirement!==undefined&&(!validFileRetirement(source.retirement)||!terminalImport(status)))throw fault('invalid_import_file_retirement'); + if(source.previousFiles!==undefined&&(!Array.isArray(source.previousFiles)||source.previousFiles.some(file=>!file||typeof file.fileId!=='string'||!file.fileId||!Number.isSafeInteger(file.byteLength)||file.byteLength<0||file.sha256!==undefined&&!/^[a-f0-9]{64}$/.test(file.sha256)||file.retirement!==undefined&&!validFileRetirement(file.retirement))))throw fault('invalid_import_file_retirement'); + const ids=[source.fileId,...(source.previousFiles??[]).map(file=>file.fileId)];if(new Set(ids).size!==ids.length)throw fault('import_file_identity_conflict'); +} +function mark(file:T,retirement:FileRetirement|undefined):T { + if(equal(file.retirement,retirement))return file; + const {retirement:_,...rest}=file;return freeze(retirement?{...rest,retirement}:rest) as T; +} +export function markImportFiles(runtime:RuntimeState,markers:ReadonlyMap):RuntimeState { + let imports=runtime.imports; + for(const [id,job]of runtime.imports??[]){ + if(!job.source)continue; + let source=markers.has(job.source.fileId)?mark(job.source,markers.get(job.source.fileId)):job.source; + const previous=source.previousFiles?.map(file=>markers.has(file.fileId)?mark(file,markers.get(file.fileId)):file); + if(previous&&previous.some((file,index)=>file!==source.previousFiles![index]))source=freeze({...source,previousFiles:previous}); + if(source!==job.source){if(!Number.isSafeInteger(job.generation+1))throw fault('invalid_import_job');if(imports===runtime.imports)imports=new Map(imports);(imports as Map).set(id,freeze({...job,source,generation:job.generation+1}));} + } + return imports===runtime.imports?runtime:Object.freeze({...runtime,imports:readMap(imports!)}); +} +/** A source file cannot disappear from its owning job, become an asset or regain execution after purge. */ +export function prepareImportFileRetention(previous:RuntimeState,next:RuntimeState,pins:()=>{complete:boolean;pins:ReadonlyMap},reserved:ReadonlySet,referencesChanged=true):RuntimeState { + const hasSoft=[...(next.imports?.values()??[])].some(job=>job.source&&[job.source,...(job.source.previousFiles??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined)); + if(previous.imports===next.imports&&previous.resources===next.resources&&previous.resourcePins===next.resourcePins&&!reserved.size&&!hasSoft&&!referencesChanged)return next; + const before=importFiles(previous.imports),after=importFiles(next.imports); + for(const job of next.imports?.values()??[])if(job.source)validateImportFileRetention(job.source,job.status); + for(const id of after.keys())if(next.resources.has(id))throw fault('import_file_identity_conflict',[id]); + for(const [id,old]of before){ + const entry=after.get(id); + if(!entry||entry.jobId!==old.jobId||entry.file.byteLength!==old.file.byteLength||old.file.sha256!==undefined&&entry.file.sha256!==old.file.sha256||!old.current&&entry.current)throw fault('import_file_identity_conflict',[id]); + if(old.file.retirement?.purgedAt!==undefined&&!equal(old.file.retirement,entry.file.retirement))throw fault('import_source_retired',[id]); + } + if(!hasSoft&&!reserved.size&&![...after.values()].some(entry=>entry.file.retirement?.purgedAt!==undefined))return next; + const coverage=pins(),clear=new Map(); + for(const [id,entry]of after){ + if(entry.file.retirement?.purgedAt!==undefined){if(coverage.pins.has(id))throw fault('import_source_retired',[id]);continue;} + if(!entry.file.retirement)continue; + if(!coverage.complete||coverage.pins.has(id))clear.set(id,undefined); + } + return markImportFiles(next,clear); +} diff --git a/src/lib/canvas-runtime/imports.ts b/src/lib/canvas-runtime/imports.ts index 8c1d5dd..aa710bb 100644 --- a/src/lib/canvas-runtime/imports.ts +++ b/src/lib/canvas-runtime/imports.ts @@ -1,3 +1,4 @@ +import {validateImportFileRetention,type FileRetirement,type PreviousImportFile} from './import-file-retention.ts'; 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'; @@ -8,7 +9,7 @@ export type ImportSource = { readonly fingerprint: string; readonly parserVersio export type ImportUnit = { readonly key: string; readonly dependencies: readonly string[]; readonly changes: readonly Change[]; readonly guard?: Json }; export type ImportLocator = { readonly key: string; readonly cursor: number; readonly nextCursor: number; readonly dependencies: readonly string[]; readonly digest: string; readonly byteLength: number; readonly changeCount: number }; export type ImportFailure = { readonly key: string; readonly cursor: number; readonly code: string; readonly dependencies?: readonly string[] }; -export type NativeImportSource = { readonly kind: 'native-ndjson' | 'duplicate-ndjson' | 'duplicate-snapshot' | 'duplicate-plan'; readonly snapshotVersion?:2;readonly plan?:DuplicateCheckpoint; readonly copy?: {readonly sourceRevision:number; readonly roots:readonly Id[]}; readonly integrityVersion?:1;readonly sha256?:string; readonly name?:string; readonly fileId: Id; readonly byteLength: number; readonly targetCanvasId: Id; readonly targetEntry?: {readonly id:Id;readonly incarnation:number} }; +export type NativeImportSource = { readonly kind: 'native-ndjson' | 'duplicate-ndjson' | 'duplicate-snapshot' | 'duplicate-plan'; readonly retirement?:FileRetirement;readonly previousFiles?:readonly PreviousImportFile[];readonly snapshotVersion?:2;readonly plan?:DuplicateCheckpoint; readonly copy?: {readonly sourceRevision:number; readonly roots:readonly Id[]}; readonly integrityVersion?:1;readonly sha256?:string; readonly name?:string; readonly fileId: Id; readonly byteLength: number; readonly targetCanvasId: Id; readonly targetEntry?: {readonly id:Id;readonly incarnation:number} }; export type ImportBinding = {readonly kind:'element';readonly incarnation:number}|{readonly kind:'canvas';readonly entryElementId?:Id}; export type ImportJob = { readonly version: 1 | 2 | 3; readonly id: Id; readonly projectId: Id; readonly sourceFingerprint: string; readonly parserVersion: number; @@ -29,6 +30,7 @@ export function validateImportJob(job: ImportJob): void { if (!Number.isSafeInteger(job.generation) || job.generation<0 || !Number.isSafeInteger(job.cursor) || job.cursor<0 || typeof job.eof!=='boolean' || !['preparing','paused','running','completed','partial','cancelled'].includes(job.status)) throw fault('invalid_import_job'); if(job.source&&(!['native-ndjson','duplicate-ndjson','duplicate-snapshot','duplicate-plan'].includes(job.source.kind)||['duplicate-ndjson','duplicate-plan'].includes(job.source.kind)&&(!job.source.copy||!Number.isSafeInteger(job.source.copy.sourceRevision)||job.source.copy.sourceRevision<0||!Array.isArray(job.source.copy.roots)||!job.source.copy.roots.length||job.source.copy.roots.some(id=>typeof id!=='string'||!id))||job.source.name!==undefined&&(typeof job.source.name!=='string'||job.source.name.length>255)||!job.source.fileId||!job.source.targetCanvasId||!Number.isSafeInteger(job.source.byteLength)||job.source.byteLength<0||job.source.targetEntry&&(!job.source.targetEntry.id||!Number.isSafeInteger(job.source.targetEntry.incarnation)||job.source.targetEntry.incarnation<1)))throw fault('invalid_import_source'); if(job.source){ + validateImportFileRetention(job.source,job.status); if(job.source.snapshotVersion!==undefined&&(job.source.snapshotVersion!==2||!['duplicate-snapshot','duplicate-plan'].includes(job.source.kind)))throw fault('copy_snapshot_interpreter_unavailable'); if(job.source.integrityVersion!==undefined&&job.source.integrityVersion!==1)throw fault('import_source_integrity_interpreter_unavailable'); if(job.source.sha256!==undefined&&(typeof job.source.sha256!=='string'||!(/^[a-f0-9]{64}$/.test(job.source.sha256))))throw fault('invalid_import_source'); diff --git a/src/lib/canvas-runtime/recovery-encoding.ts b/src/lib/canvas-runtime/recovery-encoding.ts index cb70ba4..6fcd75c 100644 --- a/src/lib/canvas-runtime/recovery-encoding.ts +++ b/src/lib/canvas-runtime/recovery-encoding.ts @@ -52,8 +52,8 @@ export function encodeRecoveryValue(input:unknown):Json { } /** Only codec-owned, validated immutable images qualify for the storage copy fast path. */ -export function sealRecoveryImage(formatVersion:1|2|3,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage { - if(![1,2,3].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image'); +export function sealRecoveryImage(formatVersion:1|2|3|4,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage { + if(![1,2,3,4].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image'); const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encodeRecoveryValue(payload)});sealed.add(image);return image; } export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);} diff --git a/src/lib/canvas-runtime/recovery.ts b/src/lib/canvas-runtime/recovery.ts index 3d1363a..a5bfaf7 100644 --- a/src/lib/canvas-runtime/recovery.ts +++ b/src/lib/canvas-runtime/recovery.ts @@ -1,3 +1,4 @@ +import {importFiles} from './import-file-retention.ts'; import {resourceRetentionPins} from './resource-retention.ts'; import { sealRecoveryImage } from './recovery-encoding.ts'; import { validateImportJob, type ImportJob } from './imports.ts'; @@ -23,7 +24,7 @@ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage { operations: [...snapshot.operations], runtime: { imports:[...(snapshot.runtime.imports??[])], recoveries:[...(snapshot.runtime.recoveries??[])], runs: [...snapshot.runtime.runs], resources: [...snapshot.runtime.resources], resourcePins: [...snapshot.runtime.resourcePins], acknowledged: documentValue(snapshot.runtime.acknowledged), versions: [...snapshot.runtime.versions], receipts: [...snapshot.runtime.receipts], conflicts: [...snapshot.runtime.conflicts], ledger: { ...snapshot.runtime.ledger, operations: [...snapshot.runtime.ledger.operations], requests: [...snapshot.runtime.ledger.requests] } }, }; - return sealRecoveryImage([...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload); + return sealRecoveryImage([...(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); } type ObjectValue = Record; function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; } @@ -86,7 +87,7 @@ function readHistory(history:ObjectValue,operations:ReadonlyMap image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image'); // Own the parsed records; mutating a caller's RecoveryImage must not mutate live state. @@ -163,7 +164,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn 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.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.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.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'); @@ -226,6 +227,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn return freeze({...value as RunRecord,outputEvents:array(raw.outputEvents ?? []) as never}); }); const imports=table(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);}); + for(const [id,entry]of importFiles(imports)){if(resources.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');} 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({ imports, recoveries, runs, resources, resourcePins, ledger: restoredLedger, acknowledged, versions, receipts, conflicts: table(runtime.conflicts, string) }), diff --git a/src/lib/canvas-runtime/resource-retention.ts b/src/lib/canvas-runtime/resource-retention.ts index a92e865..b7ed8d6 100644 --- a/src/lib/canvas-runtime/resource-retention.ts +++ b/src/lib/canvas-runtime/resource-retention.ts @@ -14,7 +14,7 @@ export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry, for(const [id,owners]of base.pins)for(const owner of owners)if(owner.startsWith('element:'))add(id,`acknowledged:${owner}`); // Sources and server run envelopes are not all interpreted resource schemas yet. // Pin the whole known local depot rather than claiming opaque task data contains no references. - for(const job of runtime.imports?.values()??[])if(!['completed','partial','cancelled'].includes(job.status))for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`import:${job.id}`); + for(const job of runtime.imports?.values()??[])if(!['completed','partial','cancelled'].includes(job.status)){if(job.source)add(job.source.fileId,`import-source:${job.id}`);for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`import:${job.id}`);} for(const run of runtime.runs.values())for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`run:${run.id}`); for(const [owner,ids]of temporary)for(const id of ids)add(id,owner); for(const [id,count]of displays)if(count>0)add(id,'display'); diff --git a/src/lib/canvas-runtime/storage.ts b/src/lib/canvas-runtime/storage.ts index b696718..2b3ac3f 100644 --- a/src/lib/canvas-runtime/storage.ts +++ b/src/lib/canvas-runtime/storage.ts @@ -4,7 +4,7 @@ import type { Json } from '../canvas-core/types.ts'; import { clone, equal, fault, freeze, validJson } from '../canvas-core/values.ts'; export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string }; -export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json }; +export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json }; export type DurableBatch = { readonly image: RecoveryImage; readonly deleteFiles?:readonly string[]; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] }; export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise }; export type StoragePort = { @@ -34,7 +34,7 @@ const keyFor = (scope: StorageScope) => { return JSON.stringify([scope.accountId, scope.workspaceId, scope.projectId]); }; function validateImage(image: RecoveryImage) { - if (![1,2,3].includes(image.formatVersion)) throw fault('unsupported_recovery_version'); + if (![1,2,3,4].includes(image.formatVersion)) throw fault('unsupported_recovery_version'); if (!Number.isSafeInteger(image.rootRevision) || image.rootRevision < 0 || !Number.isSafeInteger(image.localRevision) || image.localRevision < 0 || image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image'); } const requestValue = (request: IDBRequest) => new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); @@ -134,9 +134,13 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: // Detach mutable callers and descriptors before the first await, including queued commits. const image=isSealedRecoveryImage(batch.image)?batch.image:freeze(clone(batch.image)),files=(batch.files??[]).map(file=>({id:file.id,bytes:file.bytes})),deleteFiles=[...(batch.deleteFiles??[])];if(deleteFiles.length>128||new Set(deleteFiles).size!==deleteFiles.length||deleteFiles.some(id=>typeof id!=='string'||!id||files.some(file=>file.id===id)))throw fault('invalid_file_deletion');if(!isSealedRecoveryImage(image))validateImage(image); if(deleteFiles.length){ - const payload=image.payload as {runtime?:{resources?:[string,{retirement?:{version:number;unreferencedAt:number;purgedAt?:number}}][]}}; - const rows=payload?.runtime?.resources; - if(image.formatVersion!==3||!Array.isArray(rows)||deleteFiles.some(id=>!rows.some(([key,resource])=>key===id&&resource.retirement?.version===1&&Number.isSafeInteger(resource.retirement.unreferencedAt)&&resource.retirement.unreferencedAt>=0&&Number.isSafeInteger(resource.retirement.purgedAt)&&resource.retirement.purgedAt!>=resource.retirement.unreferencedAt)))throw fault('invalid_file_deletion'); + type FileProof={fileId?:string;retirement?:{version:number;unreferencedAt:number;purgedAt?:number}}; + const payload=image.payload as {runtime?:{resources?:[string,FileProof][];imports?:[string,{status:string;source?:FileProof&{previousFiles?:FileProof[]}}][]}}; + const proofs=new Map(),owned=new Set(); + const own=(id:string,file:FileProof,eligible:boolean)=>{if(typeof id!=='string'||!id||owned.has(id))throw fault('invalid_file_deletion');owned.add(id);if(eligible)proofs.set(id,file);}; + for(const [id,resource]of payload?.runtime?.resources??[])own(id,resource,true); + if(image.formatVersion===4)for(const [,job]of payload?.runtime?.imports??[]){if(job.source){own(job.source.fileId!,job.source,['completed','partial','cancelled'].includes(job.status));for(const file of job.source.previousFiles??[])own(file.fileId!,file,true);}} + if(image.formatVersion<3||deleteFiles.some(id=>{const marker=proofs.get(id)?.retirement;return !marker||marker.version!==1||!Number.isSafeInteger(marker.unreferencedAt)||marker.unreferencedAt<0||!Number.isSafeInteger(marker.purgedAt)||marker.purgedAt!{}).then(async()=>{ diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 782fd69..e2ce4d0 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -302,6 +302,21 @@ async function atomicResourceDeletionCase(name,assert){ }finally{await other?.release();await leader?.release();storage.close();indexedDB.deleteDatabase(database);} } +async function importSourceCollectionCase(name,assert){ + const database=`${name}-import-source-gc`,storage=createBrowserStorage({name:database,checkpointEvery:3}),scope={accountId:'account',workspaceId:'workspace',projectId:'project'},registry=createRegistry(builtinComponents()),original=createDocument('project',[{id:'P',parent:null}],[]),receipts=new Map();let engine,owner,server=original,version=1,now=1000; + const port={compile:(intent,id)=>({payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:{P:version}}),async submit(request){const {intent}=JSON.parse(request.body);server=applyChanges(server,intent.changes);receipts.set(request.id,{document:server,versions:new Map([['P',++version]])});return{outcome:'applied'};},async query(request){return{outcome:receipts.has(request.id)?'applied':'unresolved'};},canonical:request=>receipts.get(request.id)}; + const options={scope,registry,document:original,versions:new Map([['P',1]]),storage:{...storage,commit:(leader,batch)=>{owner=leader;return storage.commit(leader,batch);}},persistence:port,clock:{now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}}; + try{ + engine=await openProjectEngine(options);const line={kind:'element',key:'text',record:{id:'text',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'$target',data:{title:'text',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}},id=engine.imports.startNdjson(new Blob([JSON.stringify(line)+'\nnot-json']),'P');await engine.settle();const job=engine.imports.getJobs().get(id),fileId=job.source.fileId;assert(job.status==='partial','partial source fixture did not finish');const document=engine.getSnapshot().document,history=engine.getSnapshot().history; + await engine.collectImportSources({retentionMs:100});now+=100;const nativeDelete=IDBObjectStore.prototype.delete;let injected=false; + IDBObjectStore.prototype.delete=function(key){const request=nativeDelete.call(this,key);if(!injected&&this.name==='files'&&Array.isArray(key)&&key[1]===fileId){injected=true;throw Error('source_gc_abort');}return request;}; + let failure;try{await engine.collectImportSources({retentionMs:100});}catch(error){failure=error.message;}finally{IDBObjectStore.prototype.delete=nativeDelete;} + assert(injected&&failure==='source_gc_abort','source GC did not reach an IDB deletion fault');assert((await storage.readFile(owner,fileId)).size===job.source.byteLength,'source bytes vanished after aborted GC');const soft=await storage.recover(owner);assert(soft.formatVersion===4&&soft.payload.runtime.imports[0][1].source.retirement.purgedAt===undefined,'aborted source GC advanced the purge cut'); + await engine.persist();assert(await storage.readFile(owner,fileId)===undefined,'source deletion retry did not remove bytes');const cut=await storage.recover(owner);assert(cut.formatVersion===4&&decodeRecovery(cut,registry).runtime.imports.get(id).source.retirement.purgedAt===1100,'source purge and recovery marker disagree');assert(engine.getSnapshot().document===document&&engine.getSnapshot().history===history,'source GC changed content/history'); + let oldReader;try{decodeRecovery({...cut,formatVersion:3},registry);}catch(error){oldReader=error.code;}assert(oldReader==='unsupported_recovery_version','source retirement was accepted as v3');await engine.close();engine=await openProjectEngine(options);await engine.imports.resume(id);await engine.settle();assert(engine.imports.getJobs().get(id).status==='partial'&&engine.imports.getJobs().get(id).failures.length===1,'reopening a retired source lost its failure report');let retired;try{await engine.imports.openSource(id);}catch(error){retired=error.code;}assert(retired==='import_source_retired','retired source was silently reinterpreted or fetched as an asset');assert(engine.editor.history.undo().status==='applied','undo depended on purged source');await engine.settle();assert(engine.getSnapshot().document.elements.size===0,'undo retained imported data');assert(engine.editor.history.redo().status==='applied','redo depended on purged source');await engine.settle();assert(engine.getSnapshot().document.elements.size===1,'redo did not restore imported content'); + }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);} +} + async function resourceRecoveryCase(name, assert) { const scope = { accountId: crypto.randomUUID(), workspaceId: crypto.randomUUID(), projectId: crypto.randomUUID() }; const registry = createRegistry(builtinComponents()), document = createDocument(scope.projectId, [{ id: scope.projectId, parent: null }], []); @@ -592,7 +607,7 @@ export async function run() { await storage.commit(leader, { image: image(1) }); await rejects(() => storage.commit(leader, { image: { ...image(1), payload: null } }), 'storage_revision_conflict'); await rejects(() => storage.commit(leader, { image: image(3) }), 'storage_revision_gap'); - await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 4 } }), 'unsupported_recovery_version'); + await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 5 } }), 'unsupported_recovery_version'); results.push('same-cut retry is idempotent; conflicting, skipped and future-format cuts reject'); // The first put succeeds before a later duplicate resource aborts the whole transaction. @@ -635,6 +650,8 @@ export async function run() { results.push('project engine reopens real IndexedDB with one history unit and the original request; ACK resolves without duplicate edit'); await atomicResourceDeletionCase(name,assert); results.push('atomic local file collection rolls back injected failure after IDB delete, captures descriptors, rejects missing markers and survives reopening'); + await importSourceCollectionCase(name,assert); + results.push('terminal import source GC atomically rolls back a post-delete fault, retries its v4 cut, preserves partial reports and reopens with undo/redo independent of source bytes'); await resourceRecoveryCase(name, assert); results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity'); await digestWorkerCase(assert); diff --git a/tests/headless/import-file-retention.test.mjs b/tests/headless/import-file-retention.test.mjs new file mode 100644 index 0000000..e8b0dc3 --- /dev/null +++ b/tests/headless/import-file-retention.test.mjs @@ -0,0 +1,18 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createImportJob,validateImportJob} from '../../src/lib/canvas-runtime/imports.ts'; +import {markImportFiles,importFiles,prepareImportFileRetention} from '../../src/lib/canvas-runtime/import-file-retention.ts'; +const job={...createImportJob({id:'job',projectId:'P',sourceFingerprint:'source',parserVersion:1,source:{kind:'native-ndjson',fileId:'source',byteLength:10,targetCanvasId:'P'}}),status:'cancelled'},runtime={imports:new Map([['job',job]]),resources:new Map()},coverage=()=>({complete:true,pins:new Map()}); +test('source retention marks are immutable metadata with monotonic job generations and per-file clearing',()=>{ + const next=markImportFiles(runtime,new Map([['source',{version:1,unreferencedAt:100}]]));assert.notEqual(next,runtime);assert.equal(job.source.retirement,undefined);assert.equal(next.imports.get('job').generation,1);assert.ok(Object.isFrozen(next.imports.get('job').source.retirement));validateImportJob(next.imports.get('job'));assert.equal(markImportFiles(next,new Map([['source',{version:1,unreferencedAt:100}]])),next); + const pinned=prepareImportFileRetention(next,next,()=>({complete:true,pins:new Map([['source',new Set(['export'])]])}),new Set());assert.equal(pinned.imports.get('job').source.retirement,undefined);assert.equal(pinned.imports.get('job').generation,2);assert.equal(next.imports.get('job').source.retirement.unreferencedAt,100); + const incomplete=prepareImportFileRetention(next,next,()=>({complete:false,pins:new Map()}),new Set());assert.equal(incomplete.imports.get('job').source.retirement,undefined); +}); +test('source ownership, archive direction and completed purge metadata cannot be silently rewritten',()=>{ + const archived={...job,source:{...job.source,fileId:'next',previousFiles:[{fileId:'source',byteLength:10}]}};assert.equal(importFiles(new Map([['job',archived]])).size,2);assert.equal(prepareImportFileRetention(runtime,{...runtime,imports:new Map([['job',archived]])},coverage,new Set()).imports.get('job'),archived); + assert.throws(()=>prepareImportFileRetention(runtime,{...runtime,imports:new Map()},coverage,new Set()),/import_file_identity_conflict/);assert.throws(()=>prepareImportFileRetention(runtime,{...runtime,resources:new Map([['source',{}]])},coverage,new Set()),/import_file_identity_conflict/); + const retired=markImportFiles(runtime,new Map([['source',{version:1,unreferencedAt:100,purgedAt:200}]]));assert.throws(()=>prepareImportFileRetention(retired,runtime,coverage,new Set()),/import_source_retired/); + assert.throws(()=>importFiles(new Map([['job',job],['other',{...job,id:'other'}]])),/import_file_identity_conflict/); + const resumed={...job,status:'running',source:{...job.source,retirement:{version:1,unreferencedAt:100,purgedAt:200}}};assert.throws(()=>validateImportJob(resumed),/invalid_import_file_retirement/); + for(const retirement of [{version:2,unreferencedAt:1},{version:1,unreferencedAt:-1},{version:1,unreferencedAt:2,purgedAt:1}])assert.throws(()=>validateImportJob({...job,source:{...job.source,retirement}}),/invalid_import_file_retirement/); +}); diff --git a/tests/headless/imports-engine.test.mjs b/tests/headless/imports-engine.test.mjs index cf83f02..7a969b8 100644 --- a/tests/headless/imports-engine.test.mjs +++ b/tests/headless/imports-engine.test.mjs @@ -12,7 +12,7 @@ const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);retu function fixture(initial=original){ let image=null,leader,next=0,server=initial;const versions=new Map([...initial.canvases.keys()].map(id=>[id,1]));const files=new Map(),requests=[],receipts=new Map(),errors=[]; const f={files,requests,errors,gate:null,broken:false, - storage:{async claim(){assert.equal(leader,undefined);const value={scope,fence:1,active:true,async release(){leader=undefined;value.active=false;}};leader=value;return value;},async recover(){return image&&structuredClone(image);},async commit(owner,batch){assert.equal(owner,leader);if(f.broken)throw Error('quota');for(const part of batch.files??[])files.set(part.id,part.bytes);image=structuredClone(batch.image);return{durableRootRevision:batch.image.rootRevision};},async readFile(owner,id){assert.equal(owner,leader);return files.get(id);},close(){}}, + storage:{fileDeletion:'atomic-v1',async claim(){assert.equal(leader,undefined);const value={scope,fence:1,active:true,async release(){leader=undefined;value.active=false;}};leader=value;return value;},async recover(){return image&&structuredClone(image);},async commit(owner,batch){assert.equal(owner,leader);if(f.broken)throw Error('quota');for(const part of batch.files??[])files.set(part.id,part.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);return{durableRootRevision:batch.image.rootRevision};},async readFile(owner,id){assert.equal(owner,leader);return files.get(id);},close(){}}, port:{compile(intent,requestId,snapshot){return{payload:{requestId,intent},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},async submit(request){requests.push(request);if(f.gate)await f.gate.promise;const{intent}=JSON.parse(request.body);if(!receipts.has(request.id)){server=applyChanges(server,intent.changes);for(const canvasId of intent.canvasIds)versions.set(canvasId,(versions.get(canvasId)??0)+1);receipts.set(request.id,{document:server,versions:new Map(versions)});}return{outcome:'applied',canonical:{requestId:request.id}};},async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:{requestId:request.id}}:{outcome:'unresolved'};},canonical(request){return receipts.get(request.id);},async read(){return{document:server,versions:new Map(versions)};}}, image:()=>image,server:()=>server,open:(extra={})=>openProjectEngine({scope,registry,document:initial,versions:new Map([...initial.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port,createId:()=>`identity-${++next}`,onError:error=>errors.push(error),...extra})};return f; } @@ -241,3 +241,52 @@ test('closing a project aborts its injected digest without closing another proje const {createDigestPool}=await import('../../src/lib/canvas-runtime/digest-pool.ts'),entered=deferred();let first=true;const pool=createDigestPool({concurrency:1,execute:async(bytes,signal)=>{if(first){first=false;entered.resolve();await new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(Error('stopped')),{once:true}));}return importSourceDigest(bytes,signal);}}),a=fixture(),b=fixture();let ea=await a.open({digests:pool}),eb=await b.open({digests:pool}); try{const aid=ea.imports.startNdjson(file([node('A')]),'canvas');await entered.promise;const bid=eb.imports.startNdjson(file([node('B')]),'canvas');await ea.close();await eb.settle();assert.equal(eb.imports.getJobs().get(bid).status,'completed');assert.equal(eb.getSnapshot().document.elements.size,1);assert.equal(a.errors.length,0);assert.equal(b.errors.length,0);ea=await a.open();await ea.settle();assert.equal(ea.imports.getJobs().get(aid).status,'completed');assert.equal(ea.getSnapshot().document.elements.size,1);}finally{await ea.close();await eb.close();pool.close();} }); + +const retentionClock=()=>{let now=1000;return{now:()=>now,advance:ms=>{now+=ms;},setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)};}; +test('terminal import source collection preserves every imported record, undo unit and pending request across v4 reopening',async()=>{ + const f=fixture(),clock=retentionClock(),gate=deferred();f.gate=gate;let engine=await f.open({clock});try{ + const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='completed');await engine.persist();const job=engine.imports.getJobs().get(id),fileId=job.source.fileId,document=engine.getSnapshot().document,history=engine.getSnapshot().history; + const marked=await engine.collectImportSources({retentionMs:100});assert.deepEqual(marked.markedIds,[fileId]);assert.deepEqual(marked.purgedIds,[]);assert.ok(f.files.has(fileId));assert.equal(f.image().formatVersion,4);clock.advance(100); + const result=await engine.collectImportSources({retentionMs:100});assert.deepEqual(result.purgedIds,[fileId]);assert.equal(f.files.has(fileId),false);assert.equal(engine.getSnapshot().document,document);assert.equal(engine.getSnapshot().history,history);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].some(record=>record.phase!=='resolved')); + const frozen=[...engine.getSnapshot().runtime.ledger.requests.values()].map(request=>[request.id,request.body]);decodeRecovery(f.image(),registry);for(const formatVersion of [1,2,3])assert.throws(()=>decodeRecovery({...f.image(),formatVersion},registry),/unsupported_recovery_version/); + gate.resolve();await engine.settle();await engine.close();engine=await f.open({clock});await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,2);for(const [id,body]of frozen)assert.equal(engine.getSnapshot().runtime.ledger.requests.get(id).body,body);await assert.rejects(engine.imports.openSource(id),/import_source_retired/); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(f.errors.length,0); + const bad={...node('bad').record,incarnation:1,ownerCanvasId:'canvas',componentId:'image',data:{title:'bad',text:'',assetId:fileId}};assert.equal(engine.editor.dispatch({type:'element.create',element:bad}).code,'import_source_retired');assert.equal(engine.editor.updateRuntime(previous=>({...previous,resourcePins:new Map([['late-export',[fileId]]])})).code,'import_source_retired'); + }finally{gate.resolve();await engine.close();} +}); + +test('paused source remains recoverable while partial and cancelled sources become collectible without reviving content',async()=>{ + const f=fixture();let engine=await f.open();try{ + const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const source=engine.imports.getJobs().get(id).source;assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[]);assert.ok(f.files.has(source.fileId));const handle=await engine.imports.openSource(id);assert.equal(await handle.bytes.text(),await file([node('one'),node('two'),node('three')]).text());handle.release(); + await engine.imports.cancel(id);await engine.settle();const partial=engine.imports.startNdjson(file([node('valid'),'not json']),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(partial).status,'partial');const expected=new Set([source.fileId,engine.imports.getJobs().get(partial).source.fileId]);assert.deepEqual(new Set((await engine.collectImportSources({retentionMs:0})).purgedIds),expected);const size=engine.getSnapshot().document.elements.size;await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.imports.resume(partial);await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,size);assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.imports.getJobs().get(partial).failures.length,1);assert.equal(f.errors.length,0); + }finally{await engine.close();} +}); + +test('opening a terminal source pins before I/O, clears its soft marker and restarts the full retention interval',async()=>{ + const f=fixture(),clock=retentionClock(),engine=await f.open({clock}),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{ + const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId;await engine.collectImportSources({retentionMs:100});clock.advance(200);f.storage.readFile=async(...args)=>{if(args[1]===fileId){entered.resolve();await gate.promise;}return read(...args);}; + const opening=engine.imports.openSource(id);await entered.promise;assert.equal(engine.imports.getJobs().get(id).source.retirement,undefined);assert.ok(engine.getResourcePins().pins.get(fileId).has('display'));assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[]);gate.resolve();const handle=await opening;handle.release();handle.release();const result=await engine.collectImportSources({retentionMs:100});assert.deepEqual(result.markedIds,[fileId]);assert.deepEqual(result.purgedIds,[]);assert.equal(engine.imports.getJobs().get(id).source.retirement.unreferencedAt,1200); + }finally{gate.resolve();await engine.close();} +}); + +test('failed source deletion retains byte identity and blocks late reads until the same purge cut becomes durable',async()=>{ + const f=fixture(),engine=await f.open(),commit=f.storage.commit;let fail=false;try{ + const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId;f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('source disk failure');return commit(...args);};fail=true;await assert.rejects(engine.collectImportSources({retentionMs:0}),/source disk failure/);assert.ok(f.files.has(fileId));await assert.rejects(engine.imports.openSource(id),/resource_collection_in_progress/);const root=engine.getSnapshot(),job=root.runtime.imports.get(id);assert.equal(engine.editor.updateRuntime(previous=>{const jobs=new Map(previous.imports);jobs.set(id,{...job,status:'running',source:{...job.source,retirement:undefined}});return{...previous,imports:jobs};}).status,'rejected');assert.equal(engine.getSnapshot(),root); + fail=false;await engine.persist();assert.equal(f.files.has(fileId),false);await assert.rejects(engine.imports.openSource(id),/import_source_retired/);assert.equal(engine.editor.dispatch({type:'element.data',target:{id:job.stableIds['element:one'],incarnation:1},patch:{title:'still editable'}}).status,'applied');await engine.settle(); + }finally{fail=false;await engine.close();} +}); + +test('legacy snapshot conversion records its previous file, then collects both generations without losing copied references',async()=>{ + const f=fixture();let engine=await f.open();try{ + await seedCopy(engine);const captured=engine.getSnapshot().document,id=await engine.imports.duplicate([{id:'F',incarnation: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],bytes=await encodeCopySnapshot(captured,new AbortController().signal),oldFile=job.source.fileId;f.files.set(oldFile,bytes);job.source.byteLength=bytes.size;job.source.sha256=await importSourceDigest(bytes);job.sourceFingerprint=`local-file:${oldFile}:${bytes.size}`;delete job.source.snapshotVersion;job.preparation={version:1,registrySignature:job.preparation.registrySignature,selection:[{id:'F',incarnation:1}],worldOffset:{x:32,y:32},allocationIds:[],steps:0};decodeRecovery(image,registry); + engine=await f.open();await engine.imports.resume(id);await engine.imports.pause(id);await engine.settle();const converted=engine.imports.getJobs().get(id),newFile=converted.source.fileId;assert.equal(converted.status,'paused');assert.equal(converted.source.kind,'duplicate-ndjson');assert.notEqual(newFile,oldFile);assert.equal(converted.source.previousFiles.length,1);assert.equal(converted.source.previousFiles[0].fileId,oldFile);assert.equal(f.image().formatVersion,4);assert.ok(f.files.has(oldFile)&&f.files.has(newFile));const held=await engine.imports.openSource(id,oldFile);assert.equal(held.bytes.size,bytes.size);assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[]);held.release();assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[oldFile]);assert.ok(f.files.has(newFile));await engine.imports.resume(id);await engine.settle();const completed=engine.imports.getJobs().get(id);assert.equal(completed.status,'completed');const copied=completed.stableIds['element:F'];assert.ok(engine.getSnapshot().document.elements.has(copied)); + const result=await engine.collectImportSources({retentionMs:0});assert.deepEqual(result.purgedIds,[newFile]);assert.equal(f.files.has(oldFile),false);assert.equal(f.files.has(newFile),false);decodeRecovery(f.image(),registry);await engine.close();engine=await f.open();assert.equal(engine.getSnapshot().document.elements.get(completed.stableIds['element:A']).data.assetId,'shared');assert.equal(engine.getSnapshot().document.elements.get(completed.stableIds['element:incoming']).source.elementId,'C');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has(copied),false);assert.equal(f.errors.length,0); + }finally{await engine.close();} +}); + +test('source read handles verify bound bytes and never publish a result after project close',async()=>{ + const f=fixture(),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{ + const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId,bytes=f.files.get(fileId),text=await bytes.text();f.files.set(fileId,new Blob([text.replace('one','two')]));await assert.rejects(engine.imports.openSource(id),/import_source_changed/);assert.equal(engine.getResourcePins().pins.has(fileId),false);f.files.set(fileId,bytes); + f.storage.readFile=async(owner,key)=>{const value=await read(owner,key);if(key===fileId){entered.resolve();await gate.promise;}return value;};const opening=engine.imports.openSource(id);await entered.promise;await engine.close();gate.resolve();await assert.rejects(opening,/engine_closing/);assert.equal(engine.getResourcePins().pins.has(fileId),false); + }finally{gate.resolve();await engine.close();} +}); diff --git a/tests/headless/recovery-encoding.test.mjs b/tests/headless/recovery-encoding.test.mjs index 0c28b28..74c0c87 100644 --- a/tests/headless/recovery-encoding.test.mjs +++ b/tests/headless/recovery-encoding.test.mjs @@ -24,5 +24,5 @@ test('cached subtree height cannot bypass the depth bound and omitted object fie const cycle={};cycle.self=cycle;assert.throws(()=>encodeRecoveryValue(cycle),/invalid_recovery_payload/);assert.throws(()=>encodeRecoveryValue({big:1n})); }); test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{ - const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.throws(()=>sealRecoveryImage(4,1,1,{}),/corrupt_recovery_image/); + const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.throws(()=>sealRecoveryImage(5,1,1,{}),/corrupt_recovery_image/); }); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index db46a2c..fcfc1b3 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -89,6 +89,10 @@ try { await cp(join(source, 'public'), join(directory, 'public'), { recursive: true }); if(!performanceMode){ await copyFile(join(source,'tests/headless/renderer-extension.tsx'),join(directory,'src/components/canvas/renderer-fixture.tsx')); + // A fixture Host action exercises the public engine API; no debug entry point ships in the app. + const importsFile=join(directory,'src/components/canvas/headless-imports.tsx'),importsSource=await readFile(importsFile,'utf8'); + await writeFile(importsFile,importsSource.replace('import { useCallback,','import { useEffect, useCallback,').replace(' const subscribe =',` useEffect(()=>{const host=globalThis as typeof globalThis&{__headlessCollectSources?:()=>ReturnType};const collect=()=>engine.collectImportSources({retentionMs:0});host.__headlessCollectSources=collect;return()=>{if(host.__headlessCollectSources===collect)delete host.__headlessCollectSources;};},[engine]); + const subscribe =`)); const hostFile=join(directory,'src/components/workspace/canvas-runtime-host.tsx'),host=await readFile(hostFile,'utf8');assert.ok(host.includes('extensions=[]'),'host extension installation point changed');await writeFile(hostFile,host.replace('"use client";','"use client";\nimport {fixtureExtension} from "@/components/canvas/renderer-fixture";').replace('extensions=[]','extensions=[fixtureExtension]')); } @@ -262,6 +266,12 @@ try { await evaluate(`[...document.querySelectorAll('summary')].find(summary=>summary.textContent.includes('条未加入记录')).click()`);await until(`document.body.innerText.includes('记录格式错误')`,'import failure reason was hidden'); const importScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-import-integration.png',Buffer.from(importScreenshot.data,'base64'));releaseImport(); for(let i=0;i<200&&documents.get(root).elements.size!==beforeImport+1;i++)await pause(20);assert.equal(documents.get(root).elements.size,beforeImport+1);await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('部分加入')`,'import task and content did not recover after reload'); + const beforeSourceCollection=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length; + await until(`typeof window.__headlessCollectSources==='function'`,'fixture Host action was not installed');const sourceCollection=await evaluate(`window.__headlessCollectSources()`);assert.equal(sourceCollection.purgedIds.length,1); + await evaluate(`(()=>{const details=document.querySelector('[aria-label="画布导入"] details');if(!details.open)details.querySelector('summary').click();})()`); + await until(`document.body.innerText.includes('本地源文件已清理')`,'source collection status was not displayed');assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeSourceCollection,'source GC created a document operation'); + const sourceScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-import-source-collection.png',Buffer.from(sourceScreenshot.data,'base64'));await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('本地源文件已清理')`,'retired source status/content did not recover'); + console.log('PASS Host-triggered source collection displays its status, survives reload and creates zero model operations'); await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import undo did not remove its successful content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import recovered an undone record'); console.log('PASS actual NDJSON import displays before backend ACK, reports a failed record, restores task/source state and never revives undone content'); await evaluate(`document.querySelector('.react-flow__node[data-id=b]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);