/** * 运行任务从受理、轮询到取消及显式应用结果的引擎测试。真实 engine 与 Foundry 适配器连接内存服务替身。 * 账本记录一次请求的执行证据,RunRecord 记录持续执行和事件游标,history 只记录可撤销的内容修改。 * 这里验证客户端协议与恢复逻辑;服务端数据库和实际模型执行由单独的远程集成用例覆盖。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createDocument, applyChanges, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts'; import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts'; import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts'; import { createFoundryPersistence, createFoundryRuns, createFoundryTransport, mergeFoundryRunContext } from '../../src/lib/canvas-service/index.ts'; import { createNetworkGate } from '../../src/lib/canvas-runtime/network.ts'; // 固定作用域和目标身份;请求 ID 使用 UUID 形式,元素身份由 id 与 incarnation 共同确定。 const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,root=id(1),child=id(2),workspace=id(3),account=id(4),target={id:'a',incarnation:1}; // 使用内置类型和完整文本几何,避免以不合法的模型绕过结果校验。 const registry=createRegistry(builtinComponents()),node=(name,owner=root)=>({id:name,incarnation:1,structure:'node',ownerCanvasId:owner,componentId:'text',dataVersion:1,data:{title:name,text:'original'},geometry:{x:0,y:0,width:260,height:200,rotation:0}}); // 根 canvas 包含目标和分区入口,子 canvas 中保留未知插件数据,用来检查局部回执不会清掉无关内容。 const original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'f'}}],[node('a'),{...node('f'),componentId:'frame'}, {...node('future',child),componentId:'plugin.future',dataVersion:9,data:{opaque:{keep:true}}}]); // 所有默认生成请求针对同一元素;各场景显式替换参数,便于辨别第二次调用和原请求恢复。 const runInput={target,boardId:root,modelId:'text-model',prompt:'Generate',parameters:{},propagate:false}; // 返回手动完成的 Promise,把测试停在文档写入、任务受理或轮询返回的边界。 const defer=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};}; // 仅推进有限轮微任务,适用于故意保持网络挂起、不能等待整个 engine.settle 的阶段。 const tick=async()=>{for(let i=0;i<100;i++)await Promise.resolve();}; // 假时钟保存到期任务;advance 最多执行 100 个到期回调并推进微任务,避免真实等待。 function clockFixture(){let now=1000,next=0;const timers=new Map();return{now:()=>now,setTimer(fn,delay){const id=++next;timers.set(id,{fn,due:now+delay});return id;},clearTimer(id){timers.delete(id);},async advance(ms){now+=ms;for(let count=0;count<100;count++){const ready=[...timers].find(([,value])=>value.due<=now);if(!ready)break;timers.delete(ready[0]);ready[1].fn();await tick();}await tick();},timers};} // 内存存储模拟独占 owner、恢复镜像和 quota 故障;复制镜像避免后续内存修改偷改已保存数据。 function storageFixture(){let image,owner;const storage={broken:false,saved:[],async claim(scope){assert.equal(owner,undefined);owner={scope,active:true,fence:1,async release(){owner.active=false;owner=undefined;}};return owner;},async recover(){return image&&structuredClone(image);},async commit(leader,batch){assert.equal(leader,owner);if(storage.broken)throw new Error('quota');image=structuredClone(batch.image);storage.saved.push(image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){},image:()=>structuredClone(image)};return storage;} // 组合内存服务、真实适配器、存储及可控时钟;请求回执按 requestId 保存,响应丢失不撤销已发生的服务端修改。 function fixture(){ let document=original,versions=new Map([[root,1],[child,7]]),next=100,runCounter=500,postGate,docGate; const storage=storageFixture(),clock=clockFixture(),calls=[],receipts=new Map(),runs=new Map(),errors=[]; // 只返回指定 canvas 的成员和版本,模拟普通局部回执;墓碑仍来自服务端文档。 const chunk=board=>({documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[document.canvases.get(board)],elements:[...document.elements.values()].filter(element=>element.ownerCanvasId===board),tombstones:Object.fromEntries(document.tombstones),versions:{[board]:versions.get(board)}}); // 组合多个参与 canvas 的局部上下文,版本只包含这些 canvas;不是整份项目替换。 const partial=ids=>({documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:ids.map(id=>document.canvases.get(id)),elements:[...document.elements.values()].filter(element=>ids.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(ids.map(id=>[id,versions.get(id)]))}); // 将所有 canvas 的局部数据标记为完整上下文,供跨 owner 结果协调使用。 const context=()=>({...partial([...document.canvases.keys()]),complete:true}); // 记录每次请求并按路径分派;未识别路径立即报错,防止测试意外走未实现的服务分支。 const transport=async request=>{ calls.push(request); if(request.method==='GET'&&request.path.endsWith('/model'))return{status:200,body:{model:context()}}; // 文档写入可被屏障挂起;首次请求检查参与版本,成功才修改文档并递增版本,重复请求读取原回执。 if(request.path.endsWith('/model/operations')){ if(docGate)await docGate.promise; const input=JSON.parse(request.body);let body=receipts.get(request.requestId); if(!body){ if(Object.entries(input.expected_versions).some(([board,version])=>versions.get(board)!==version))body={protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}; else{for(const operation of input.operations)document=applyChanges(document,operation.changes);for(const board of Object.keys(input.expected_versions))versions.set(board,versions.get(board)+1);body={protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:input.operations.map(operation=>operation.id),model:partial(Object.keys(input.expected_versions))};} receipts.set(request.requestId,structuredClone(body)); } return{status:body.status==='applied'?200:412,body}; } // 取消只改变任务控制状态;应用输出还检查元素当前 owner 和版本,写入文本后生成可查询的成功回执。 if(request.method==='POST'&&/\/ai\/runs\/[^/]+\/(cancel|apply)$/.test(request.path)){ const action=request.path.endsWith('/cancel')?'run.cancel':'run.apply',runId=request.path.split('/').at(-2),run=runs.get(runId),input=JSON.parse(request.body);let body=receipts.get(request.requestId); if(!body){ if(action==='run.cancel'){ run.status='cancelled';run.events.push({seq:run.events.length+1,run_id:runId,type:'run.completed',status:'cancelled'}); body={protocol_version:2,request_id:request.requestId,operation_id:input.operation_id,action,status:'applied',run_id:runId,run_status:'cancelled'}; }else{ const owner=document.elements.get(input.node_id).ownerCanvasId;assert.equal(input.target_canvas_id,owner);assert.equal(input.target_incarnation,1);assert.equal(request.expectedVersion,versions.get(owner));const event=run.events.find(event=>event.type==='step.output'&&event.output.output_id===input.output_id),element=document.elements.get(input.node_id);assert.ok(event); document=applyChanges(document,[{kind:'field',target,field:'data.text',before:element.data.text,after:event.output.text,beforePresent:true,afterPresent:true}]);versions.set(owner,versions.get(owner)+1); body={protocol_version:2,request_id:request.requestId,operation_id:input.operation_id,action,status:'applied',run_id:runId,step_id:'step-a',board_id:owner,origin_board_id:root,canonical_root:root,node_id:input.node_id,output_id:input.output_id,target_incarnation:1,document_format_version:1,canvas_version:versions.get(owner),model:owner===root?chunk(root):context()}; run.events.push({...structuredClone(body),seq:run.events.length+1,type:'step.applied'}); } receipts.set(request.requestId,structuredClone(body)); } if(action==='run.cancel'&&f.loseCancel||action==='run.apply'&&f.loseApply)return{status:503,body:{error:'response_lost'}}; return{status:200,body}; } // 受理与执行分开:受理写入 AI 配置并冻结计划,但不生成文本;零写入拒绝和成功都留下可查询证据。 if(request.method==='POST'&&request.path.endsWith('/ai/runs')){ const input=JSON.parse(request.body);let body=receipts.get(request.requestId); if(!body){ if(f.rejectAdmission){body={protocol_version:2,request_id:request.requestId,operation_id:input.operation_id,action:'run.start',status:'not-applied',terminal:true,error:'version_conflict'};receipts.set(request.requestId,body);if(postGate)await postGate.promise;return f.loseAdmission?{status:503,body:{error:'response_lost'}}:{status:412,body};} assert.equal(request.expectedVersion,versions.get(root));assert.deepEqual(input.expected_versions,Object.fromEntries(versions)); const element=document.elements.get(input.node_id),before=element.data.ai; document=applyChanges(document,[{kind:'field',target,field:'data.ai',before:before??null,beforePresent:before!==undefined,after:{enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:input.parameters,propagate:input.propagate},afterPresent:true}]);versions.set(root,versions.get(root)+1); const runId=id(++runCounter),plan={steps:[{step_id:'step-a',board_id:root,node_id:'a',target_incarnation:1,canonical_root:root,kind:'text',model_id:'text-model',status:'queued',dependencies:[],static_inputs:[]}],edges:[],frozen_versions:Object.fromEntries(versions)}; body={protocol_version:2,request_id:request.requestId,operation_id:input.operation_id,admission:'accepted',status:'queued',run_id:runId,plan,canvas_version:versions.get(root),target_incarnation:1,document_format_version:1,model:chunk(root)}; receipts.set(request.requestId,structuredClone(body));runs.set(runId,{status:'queued',plan,events:[{seq:1,run_id:runId,type:'run.queued',status:'queued'}],outputs:[]}); } if(postGate)await postGate.promise; if(f.loseAdmission)return{status:503,body:{error:'response_lost'}}; return{status:202,body}; } // 原 requestId 查询是响应丢失后的恢复依据;没有保存回执只能返回 unknown,不能推定未执行。 if(request.path.includes('/ai/operations/')){const receipt=receipts.get(request.path.split('/').at(-1));return receipt?{status:200,body:receipt}:{status:404,body:{protocol_version:2,request_id:request.path.split('/').at(-1),status:'unknown'}};} // 按 after_seq 分页读取事件;先捕获响应再等待 pollGate,能重现取消确认之后才返回的旧轮询。 if(request.path.includes('/ai/runs/')){ const url=new URL(request.path,'https://fixture.test'),runId=url.pathname.split('/').at(-1),run=runs.get(runId),after=Number(url.searchParams.get('after_seq')); const events=run.events.filter(event=>event.seq>after).slice(0,f.pageSize??100); const response={status:200,body:{run_id:runId,status:run.status,plan:run.plan,events,next_seq:events.at(-1)?.seq??after,has_more:after+events.lengthid(++next),persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport}),runs:(()=>{const port=createFoundryRuns({workspaceId:workspace,rootId:root,registry,transport});return{...port,accept(...args){if(f.failAdmissionCodec)throw Error('codec_temporarily_unavailable');return port.accept(...args);},acceptAction(...args){if(f.failActionCodec)throw Error('codec_temporarily_unavailable');return port.acceptAction(...args);}};})(),onError:error=>errors.push(error)});}, // 模拟其他客户端只改变元素归属,同时推进旧、新 owner 的版本;目标 id 与 incarnation 不变。 move(owner){const element=document.elements.get('a');document=applyChanges(document,[{kind:'field',target,field:'ownerCanvasId',before:element.ownerCanvasId,after:owner}]);versions.set(element.ownerCanvasId,versions.get(element.ownerCanvasId)+1);versions.set(owner,versions.get(owner)+1);}, // 完成最新任务:普通结果写入当前 owner,冲突结果只保留输出;两者都追加输出、结果和完成事件。 finish(text='generated',conflict=false){ const [runId,run]=[...runs].at(-1),element=document.elements.get('a'),owner=element.ownerCanvasId; if(!conflict){document=applyChanges(document,[{kind:'field',target,field:'data.text',before:element.data.text,after:text,beforePresent:true,afterPresent:true}]);versions.set(owner,versions.get(owner)+1);} run.status=conflict?'conflict':'succeeded';run.outputs=[{step_id:'step-a',output:{output_id:id(999),kind:'text'}}]; run.events.push({seq:2,run_id:runId,type:'step.output',step_id:'step-a',board_id:owner,origin_board_id:root,canonical_root:root,target_incarnation:1,node_id:'a',...(conflict&&owner!==root?{document_format_version:1,canvas_version:versions.get(owner),model:structuredClone(context())}:{}),output:{output_id:id(999),kind:'text',text}}, {seq:3,run_id:runId,type:conflict?'step.conflict':'step.applied',step_id:'step-a',board_id:owner,origin_board_id:root,canonical_root:root,node_id:'a',canvas_version:versions.get(owner),target_incarnation:1,document_format_version:1,model:structuredClone(owner===root?chunk(root):context())}, {seq:4,run_id:runId,type:'run.completed',status:run.status}); },server:()=>({document,versions}), };return f; } // 先前编辑完成后才受理,受理确认后放行后续移动;生成执行期间目标仍 busy,但文档 writer 不再被任务长期占用。分页游标和输出可恢复。 test('AI admission orders prior and later edits, persists its job, and does not hold the writer for execution',async()=>{ const f=fixture(),engine=await f.open(),gate=f.holdAdmission();const detach=engine.sessions.attach('view'); engine.editor.dispatch({type:'element.data',target,patch:{title:'before click'}}); const start=engine.startRun(runInput);assert.equal(start.status,'applied'); const later=engine.editor.dispatch({type:'element.geometry',target,patch:{x:200}}); assert.equal(engine.getSnapshot().document.elements.get('a').geometry.x,200); assert.ok(engine.getSnapshot().runtime.ledger.operations.get(later.operationIds[0]).intent.dependencies.includes(start.operationIds[0])); await tick();assert.equal(f.calls.filter(call=>call.method==='POST').length,2); const admission=f.calls.find(call=>call.path.endsWith('/ai/runs')); assert.equal(JSON.parse(admission.body).expected_versions[root],2);assert.equal(f.server().document.elements.get('a').data.title,'before click'); assert.equal(f.server().document.elements.get('a').geometry.x,0);detach();gate.resolve();await engine.settle(); const run=[...engine.getSnapshot().runtime.runs.values()][0];assert.ok(run);assert.equal(run.cursor,0); assert.equal(engine.getSnapshot().runtime.ledger.operations.get(start.operationIds[0]).phase,'resolved'); assert.equal(f.server().document.elements.get('a').geometry.x,200);assert.equal(run.status,'queued'); assert.equal(engine.startRun(runInput).code,'node_busy'); f.finish();f.pageSize=2;await f.clock.advance(0);await engine.settle(); assert.equal(engine.getSnapshot().runtime.runs.get(run.id).cursor,2);assert.equal(engine.getSnapshot().runtime.runs.get(run.id).hasMore,true);assert.equal(engine.startRun(runInput).code,'node_busy'); await f.clock.advance(1);await engine.settle(); const finished=engine.getSnapshot();assert.equal(finished.runtime.runs.get(run.id).status,'succeeded');assert.equal(finished.runtime.runs.get(run.id).cursor,4); assert.equal(finished.document.elements.get('a').data.text,'generated');assert.equal(finished.document.elements.get('a').geometry.x,200);assert.equal(finished.document.elements.get('future').data.opaque.keep,true);assert.equal(finished.history.undo.length,2); // 两次用户编辑有可撤销的内容意图;run.start 另占账本操作。自动结果由事件协调接纳,不作为第三次用户编辑压栈。 assert.equal(finished.operations.size,2);assert.equal(finished.runtime.ledger.operations.size,3); const restored=decodeRecovery(f.storage.image(),registry);assert.equal(restored.runtime.runs.get(run.id).cursor,4);assert.equal(restored.runtime.runs.get(run.id).outputEvents[0].output.text,'generated'); await engine.close(); }); // 服务已受理但响应丢失时重开引擎,查询原请求恢复同一任务和请求体;POST 次数保持一次,随后正常接收结果。 test('lost AI admission response recovers its original request and run without a second invocation',async()=>{ const f=fixture();f.loseAdmission=true;const engine=await f.open();engine.startRun(runInput);await engine.settle(); assert.equal(engine.getSnapshot().runtime.runs.size,0);const frozen=[...engine.getSnapshot().runtime.ledger.requests.values()][0];await engine.close(); f.loseAdmission=false;const recovered=await f.open();await f.clock.advance(5000);await recovered.settle(); assert.equal(f.calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1); assert.ok(f.calls.some(call=>call.path.endsWith('/ai/operations/'+frozen.id))); assert.equal(recovered.getSnapshot().runtime.runs.size,1);assert.equal([...recovered.getSnapshot().runtime.ledger.requests.values()][0].body,frozen.body); f.finish();await f.clock.advance(1000);await recovered.settle();assert.equal(recovered.getSnapshot().document.elements.get('a').data.text,'generated');await recovered.close(); }); // 事件先等待未决文档回执,再暴露内容冲突;保留本地文字、生成输出和未消费页面,重开后也不能丢失它们。 test('run event waits for outstanding receipts and retains a content conflict, output and page across recovery',async()=>{ const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle(); const gate=f.holdDocument();engine.editor.dispatch({type:'element.data',target,patch:{text:'local edit'}});await tick();f.finish(); await f.clock.advance(0);await tick();const run=[...engine.getSnapshot().runtime.runs.values()][0]; assert.equal(run.cursor,0);assert.equal(run.error,'run_waiting_for_receipts');assert.ok(run.pendingPage);assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'local edit'); gate.resolve();await engine.settle();await f.clock.advance(1000);await engine.settle(); const conflict=engine.getSnapshot().runtime.runs.get(run.id);assert.equal(conflict.cursor,0);assert.equal(conflict.blocked,true);assert.ok(conflict.pendingPage);assert.equal(conflict.outputs.length,1);assert.equal(conflict.outputEvents[0].output.text,'generated'); assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'local edit');await engine.close(); const recovered=await f.open();assert.equal(recovered.getSnapshot().runtime.runs.get(run.id).pendingPage.next,4);assert.equal(recovered.getSnapshot().document.elements.get('a').data.text,'local edit');await recovered.close(); }); // 先明确解决旧版本文档请求,再刷新规范基线;后到的旧事件不得回退新文字或重放后的本地几何。 test('older run event cannot rewind a later canonical document receipt',async()=>{ const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.finish(); engine.editor.dispatch({type:'element.geometry',target,patch:{x:500}});await engine.settle(); // finish 已推进服务端版本,旧版本的几何请求先取得明确拒绝;再接纳完整基线并重放本地几何,旧事件不能覆盖它。 const server=f.server();const refreshed=engine.refresh(server.document,server.versions);assert.equal(refreshed.status,'applied'); await f.clock.advance(0);await engine.settle(); assert.equal(engine.getSnapshot().document.elements.get('a').geometry.x,500);assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'generated'); assert.equal([...engine.getSnapshot().runtime.runs.values()][0].cursor,4);await engine.close(); }); // 本地镜像保存失败时不发受理请求;恢复存储后可继续,关闭期间迟到的轮询结果不得改变根修订或文档。 test('run durability failure prevents admission and close fences late poll responses',async()=>{ const f=fixture(),engine=await f.open();await engine.persist();f.storage.broken=true;engine.startRun(runInput);await tick();assert.equal(f.calls.length,0); f.storage.broken=false;await engine.retry();await f.clock.advance(0);await f.clock.advance(1000);await engine.settle();assert.equal(f.runs.size,1); f.pollGate=defer();await f.clock.advance(0);await tick();const revision=engine.getSnapshot().rootRevision; const closing=engine.close();f.finish();f.pollGate.resolve();await closing;await tick(); assert.equal(engine.getSnapshot().rootRevision,revision);assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'original'); }); // 直接测试容量为二的网络门:排队请求被 abort 后退出,剩余请求完成时活跃数归零。 // execute 是统一模拟工作,本例未装配 HTTP transport;它验证共用门的并发/取消机制,不单独证明每类网络入口都已接入。 test('transport gate bounds combined run/read/write capacity and drops aborted waiters',async()=>{ const gate=createNetworkGate(2),release=defer(),controller=new AbortController();let active=0,max=0; const execute=signal=>gate.run(signal,async()=>{max=Math.max(max,++active);await release.promise;active--;}); const first=execute(new AbortController().signal),second=execute(new AbortController().signal),cancelled=execute(controller.signal);controller.abort();await assert.rejects(cancelled); const third=execute(new AbortController().signal);await tick();assert.equal(active,2);release.resolve();await Promise.all([first,second,third]);assert.equal(max,2);assert.equal(active,0); }); // 篡改受理响应的 incarnation 后保留成功证据但暂不发布任务或 AI 配置;查询原回执修复,无需第二次 POST。 test('malformed admission identity keeps its receipt and claims until the original query repairs it',async()=>{ const f=fixture(),real=f.transport; const runs=createFoundryRuns({workspaceId:workspace,rootId:root,registry,transport:async request=>{const response=await real(request);return request.method==='POST'?{...response,body:{...response.body,target_incarnation:99}}:response;}}); const engine=await openProjectEngine({scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1],[child,7]]),storage:f.storage,clock:f.clock,createId:(()=>{let next=900;return()=>id(++next)})(),persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport:real}),runs}); engine.startRun(runInput);await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0]; assert.equal(request.outcome,'applied');assert.equal(request.phase,'waiting');assert.equal(engine.getSnapshot().runtime.runs.size,0);assert.ok(engine.getSnapshot().runtime.receipts.has(request.id));assert.equal(engine.getSnapshot().document.elements.get('a').data.ai,undefined); await engine.retry();await f.clock.advance(0);await engine.settle();assert.equal(engine.getSnapshot().runtime.runs.size,1);assert.equal(f.calls.filter(call=>call.method==='POST').length,1);await engine.close(); }); // 外来 incarnation 的事件不能覆盖当前内容;恢复镜像中篡改目标代次或请求 owner 同样必须被拒绝。 test('foreign event identity and corrupted persisted run bindings cannot overwrite the working document',async()=>{ const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.finish(); const remote=[...f.runs.values()][0];remote.events[2].target_incarnation=99; await f.clock.advance(0);await engine.settle();const run=[...engine.getSnapshot().runtime.runs.values()][0]; assert.equal(run.blocked,true);assert.equal(run.cursor,0);assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'original'); const corrupted=f.storage.image();corrupted.payload.runtime.runs[0][1].target.incarnation=99; assert.throws(()=>decodeRecovery(corrupted,registry),/corrupt_recovery_image/); const wrongOwner=f.storage.image();wrongOwner.payload.runtime.ledger.requests[0][1].ownerCanvasId=child; assert.throws(()=>decodeRecovery(wrongOwner,registry),/corrupt_recovery_image/);await engine.close(); }); // 即使事件声明更高 canvas 版本,也不能用旧 incarnation 的结果覆盖已经恢复成第二代的同 ID 元素。 test('even a higher-version malformed event cannot restore an old incarnation',async()=>{ const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.finish(); const server=f.server(),records=[...server.document.elements.values()].map(element=>element.id==='a'?{...element,incarnation:2,data:{...element.data,text:'restored content'}}:element),restored=createDocument(root,[...server.document.canvases.values()],records,new Map([['a',1]])),nextVersion=server.versions.get(root)+1; assert.equal(engine.refresh(restored,new Map([[root,nextVersion],[child,7]])).status,'applied'); const event=[...f.runs.values()][0].events[2];event.canvas_version=nextVersion+1;event.model.versions[root]=nextVersion+1; await f.clock.advance(0);await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').incarnation,2);assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'restored content');assert.equal([...engine.getSnapshot().runtime.runs.values()][0].blocked,true);await engine.close(); }); // 受理拒绝的响应丢失后先保持排序阻塞;查到绑定原请求的 terminal 零写入证据才放行后续编辑,篡改该证据会使镜像校验失败。 test('queryable zero-effect admission rejection releases ordering without discarding later editing',async()=>{ const f=fixture();f.rejectAdmission=true;f.loseAdmission=true;const engine=await f.open();const started=engine.startRun(runInput);engine.editor.dispatch({type:'element.geometry',target,patch:{x:700}});await engine.settle(); assert.equal(f.server().document.elements.get('a').geometry.x,0);await engine.close();f.loseAdmission=false; const recovered=await f.open();await f.clock.advance(5000);await recovered.settle(); const record=recovered.getSnapshot().runtime.ledger.operations.get(started.operationIds[0]);assert.equal(record.phase,'resolved');assert.equal(record.resolution,'not-applied');assert.equal(record.blockers[0],'version_conflict');assert.equal(recovered.getSnapshot().runtime.runs.size,0); assert.equal(f.server().document.elements.get('a').geometry.x,700);assert.equal(f.calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1);assert.equal(recovered.getSnapshot().history.undo.length,1);const decoded=decodeRecovery(f.storage.image(),registry);assert.equal(decoded.runtime.ledger.operations.get(started.operationIds[0]).resolution,'not-applied');const corrupt=f.storage.image();corrupt.payload.runtime.receipts[0][1].terminal=false;assert.throws(()=>decodeRecovery(corrupt,registry),/corrupt_recovery_image/);await recovered.close(); }); // 取消成功但响应丢失,重开后只查询原动作并恢复 cancelled;取消不创建内容历史,也不声明 canvas 写入参与者。 test('durable cancellation survives response loss and does not create content history',async()=>{ const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();const run=[...engine.getSnapshot().runtime.runs.values()][0];f.loseCancel=true; const cancelled=engine.cancelRun(run.id);assert.equal(cancelled.status,'applied');await engine.settle();assert.equal(f.runs.get(run.id).status,'cancelled');assert.equal(engine.getSnapshot().history.undo.length,0); const request=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.cancel');assert.equal(request.canvasIds.length,0);await engine.close();f.loseCancel=false; const recovered=await f.open();await f.clock.advance(5000);await recovered.settle();assert.equal(recovered.getSnapshot().runtime.ledger.operations.get(cancelled.operationIds[0]).phase,'resolved');assert.equal(recovered.getSnapshot().runtime.runs.get(run.id).status,'cancelled');assert.equal(f.calls.filter(call=>call.method==='POST'&&call.path.endsWith('/cancel')).length,1);assert.ok(f.calls.some(call=>call.path.endsWith('/ai/operations/'+request.id)));await recovered.close(); }); // 显式应用立即形成可撤销内容修改;结果未知时撤销仍保留,恢复后先确认原应用再同步逆向编辑,redo 不重新生成或重复 apply。 // history 保留的是选定输出造成的字段前后值;redo 走普通文档同步,不能再次调用可能产生新副作用的 run.start/run.apply。 test('explicit output application is immediate and undoable while unknown; recovery and redo never regenerate',async()=>{ const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.finish('chosen output',true);await f.clock.advance(0);await engine.settle();const run=[...engine.getSnapshot().runtime.runs.values()][0];assert.equal(run.status,'conflict'); f.loseApply=true;const applied=engine.applyRunOutput(run.id,target,id(999));assert.equal(applied.status,'applied');assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'chosen output');assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.getSnapshot().operations.get(applied.operationIds[0]).operationType,'run.apply');await engine.settle(); assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'original');await engine.settle();assert.equal(f.server().document.elements.get('a').data.text,'chosen output');await engine.close(); f.loseApply=false;const recovered=await f.open();await f.clock.advance(5000);await recovered.settle();assert.equal(recovered.getSnapshot().document.elements.get('a').data.text,'original');assert.equal(f.server().document.elements.get('a').data.text,'original');assert.equal(recovered.getSnapshot().history.redo.length,1);assert.ok(recovered.getSnapshot().runtime.runs.get(run.id).appliedOutputIds.includes(id(999))); assert.equal(recovered.editor.history.redo().status,'applied');await recovered.settle();assert.equal(f.server().document.elements.get('a').data.text,'chosen output');assert.equal(f.calls.filter(call=>call.method==='POST'&&call.path.endsWith('/apply')).length,1);assert.equal(f.calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1);await recovered.close(); }); // 取消确认推进 controlEpoch;此前捕获的旧轮询不能回退 cancelled,也不能提前推进游标,下一轮新响应才消费取消事件。 // controlEpoch 是控制状态的换代屏障,cursor 是已接纳事件序号;取消已确认时 cursor 仍可为 0,两者不应合成一个计数。 test('a poll captured before cancellation cannot regress the acknowledged control state',async()=>{ const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();const run=[...engine.getSnapshot().runtime.runs.values()][0];f.pollGate=defer();await f.clock.advance(0);await tick(); engine.cancelRun(run.id);await tick();assert.equal(engine.getSnapshot().runtime.runs.get(run.id).status,'cancelled');assert.equal(engine.getSnapshot().runtime.runs.get(run.id).controlEpoch,1); f.pollGate.resolve();await engine.settle();assert.equal(engine.getSnapshot().runtime.runs.get(run.id).status,'cancelled');assert.equal(engine.getSnapshot().runtime.runs.get(run.id).cursor,0); await f.clock.advance(1);await engine.settle();assert.equal(engine.getSnapshot().runtime.runs.get(run.id).status,'cancelled');assert.equal(engine.getSnapshot().runtime.runs.get(run.id).cursor,2);await engine.close(); }); // 分别检查 start、cancel、apply 的零写入证据绑定;字段不匹配或只有 HTTP 错误时仍未知,权限错误仅增加 blocked。 test('AI action rejection proof must match request, operation, action and run; bare errors remain unknown',async()=>{ const requestId=id(8800),operationId=id(8801),runId=id(8802),signal=new AbortController().signal; for(const kind of ['run.start','run.cancel','run.apply']){ const expectedVersions=kind==='run.cancel'?{}:{[root]:1},payload={protocol_version:2,request_id:requestId,operation_id:operationId,...(kind==='run.start'?{target_incarnation:1,expected_versions:expectedVersions,node_id:'a',model_id:'text',prompt:'Generate',parameters:{},propagate:false}:kind==='run.apply'?{target_canvas_id:root,target_incarnation:1,node_id:'a',output_id:id(8803)}:{})}; const request={id:requestId,kind,protocolVersion:2,ownerCanvasId:root,...(kind!=='run.start'?{runId}:{}),operationIds:[operationId],canvasIds:Object.keys(expectedVersions),expectedVersions,body:JSON.stringify(payload)}; const proof={protocol_version:2,request_id:requestId,operation_id:operationId,action:kind,...(kind!=='run.start'?{run_id:runId}:{}),status:'not-applied',terminal:true,error:'rejected'}; let response={status:200,body:proof};const port=createFoundryRuns({workspaceId:workspace,rootId:root,registry,transport:async()=>response});assert.equal((await port.query(request,signal)).outcome,'not-applied'); for(const patch of [{request_id:id(8890)},{operation_id:id(8891)},{action:'different'},{terminal:false},...(kind!=='run.start'?[{run_id:id(8892)}]:[])]){response={status:200,body:{...proof,...patch}};assert.equal((await port.query(request,signal)).outcome,'unresolved');} response={status:403,body:{error:'forbidden'}};const denied=await port.query(request,signal);assert.equal(denied.outcome,'unresolved');assert.equal(denied.blocked,true); response={status:404,body:{protocol_version:2,request_id:requestId,status:'unknown'}};const unknown=await port.query(request,signal);assert.equal(unknown.outcome,'unresolved');assert.equal(unknown.blocked,undefined); } }); // 任务结果跟随同 incarnation 的外部 owner 迁移;文档中仍只有一个元素,原计划归属不改,自动结果不重复写入用户历史。 test('a completed run follows an external owner-only move and publishes one global element without duplicate history',async()=>{ const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.move(child);f.finish('moved output');await f.clock.advance(0);await engine.settle(); const snapshot=engine.getSnapshot(),run=[...snapshot.runtime.runs.values()][0];assert.equal(snapshot.document.elements.get('a').ownerCanvasId,child);assert.equal(snapshot.document.elements.get('a').data.text,'moved output');assert.equal([...snapshot.document.elements.values()].filter(element=>element.id==='a').length,1);assert.equal(snapshot.document.elements.get('future').data.opaque.keep,true);assert.equal(snapshot.history.undo.length,0);assert.equal(run.boardId,root);assert.equal(run.plan.steps[0].board_id,root);assert.equal(run.cursor,4); assert.equal(decodeRecovery(f.storage.image(),registry).document.elements.get('a').ownerCanvasId,child);await engine.close(); }); // 迁移上下文等待所有受影响 canvas 的未决回执;协调完成后保留无关分区入口的本地标题。 test('a relocated context waits for receipts in every affected canvas and preserves an unrelated pending title',async()=>{ const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();const gate=f.holdDocument();engine.editor.dispatch({type:'element.data',target:{id:'f',incarnation:1},patch:{title:'local title'}});await tick();f.move(child);f.finish();await f.clock.advance(0);await tick(); assert.equal([...engine.getSnapshot().runtime.runs.values()][0].error,'run_waiting_for_receipts');assert.equal(engine.getSnapshot().document.elements.get('a').ownerCanvasId,root);gate.resolve();await engine.settle();await f.clock.advance(1000);await engine.settle(); assert.equal(engine.getSnapshot().document.elements.get('a').ownerCanvasId,child);assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'generated');assert.equal(engine.getSnapshot().document.elements.get('f').data.title,'local title');await engine.close(); }); // 坐标基于旧 owner 的未决移动不能直接套入新 canvas;保留本地几何和待处理页,并报告 ownership_conflict。 test('a pending geometry edit cannot silently reuse the coordinates of an old owner',async()=>{ const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();const gate=f.holdDocument();engine.editor.dispatch({type:'element.geometry',target,patch:{x:200}});await tick();f.move(child);f.finish();await f.clock.advance(0);gate.resolve();await engine.settle();await f.clock.advance(1000);await engine.settle(); const run=[...engine.getSnapshot().runtime.runs.values()][0];assert.equal(run.error,'ownership_conflict');assert.equal(run.blocked,true);assert.ok(run.pendingPage);assert.equal(engine.getSnapshot().document.elements.get('a').ownerCanvasId,root);assert.equal(engine.getSnapshot().document.elements.get('a').geometry.x,200);await engine.close(); }); // 冲突输出先接纳新的 owner 上下文;显式应用的 payload 和撤销写入当前子 canvas,任务路径仍使用原根作用域。 test('a moved content conflict adopts its owner context and explicit application/undo uses that current canvas',async()=>{ const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.move(child);f.finish('chosen moved output',true);await f.clock.advance(0);await engine.settle();const run=[...engine.getSnapshot().runtime.runs.values()][0];assert.equal(run.status,'conflict');assert.equal(engine.getSnapshot().document.elements.get('a').ownerCanvasId,child); assert.equal(engine.applyRunOutput(run.id,target,id(999)).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'chosen moved output');const apply=f.calls.find(call=>call.path.endsWith('/apply'));assert.equal(JSON.parse(apply.body).target_canvas_id,child);assert.ok(apply.path.includes('/materials/'+root+'/')); assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().document.elements.get('a').ownerCanvasId,child);assert.equal(f.server().document.elements.get('a').data.text,'original');await engine.close(); }); // 完整上下文按各 canvas 版本协调:保留第三个 canvas 的更新 ACK,同时拒绝同版本数据被改写。 test('complete run context retains later unrelated canvas ACKs and rejects changed data at the same version',()=>{ const third=id(22),canvases=[...original.canvases.values(),{id:third,parent:{canvasId:root,entryElementId:'g'}}],frame={...node('g'),componentId:'frame'},moved={...node('a',child),data:{title:'a',text:'before'}},sibling={...node('sibling',third),data:{title:'newer title',text:''}},elements=[moved,original.elements.get('f'),original.elements.get('future'),frame,sibling],document=createDocument(root,canvases,elements),snapshot={document,runtime:{acknowledged:document,versions:new Map([[root,3],[child,8],[third,10]])}}; const model={documentFormatVersion:1,projectId:root,complete:true,removedCanvasIds:[],canvases,elements:elements.map(element=>element.id==='a'?{...element,data:{...element.data,text:'generated'}}:element.id==='sibling'?{...element,data:{title:'old title',text:''}}:element),tombstones:{},versions:{[root]:3,[child]:9,[third]:9}}; const proposal=mergeFoundryRunContext(model,snapshot,registry,root);assert.equal(proposal.document.elements.get('a').data.text,'generated');assert.equal(proposal.document.elements.get('sibling').data.title,'newer title');assert.equal(proposal.versions.get(third),10); const invalid=structuredClone(model);invalid.elements.find(element=>element.id==='f').data.title='same-version change';assert.throws(()=>mergeFoundryRunContext(invalid,snapshot,registry,root),/same_version_context_conflict/); }); // 成功受理回执已保存但解释失败时,恢复会话承认原请求并重放后续本地配置;恢复 RunRecord 和已完成输出,保持单次调用及原始请求体。 test('a saved successful AI admission recovers its RunRecord and later local configuration without another invocation',async()=>{ const f=fixture();f.failAdmissionCodec=true;const engine=await f.open();engine.startRun(runInput);await engine.settle(); const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.outcome,'applied');assert.equal(request.phase,'waiting');assert.equal(engine.getSnapshot().runtime.runs.size,0); const config={enabled:true,modelId:'text-model',prompt:'Local next prompt',parameters:{},propagate:false};const edit=engine.editor.dispatch({type:'element.data',target,patch:{ai:config}});await engine.settle();f.finish('already generated'); const session=await engine.prepareRecovery();assert.deepEqual(session.acknowledgedRequestIds,[request.id]);assert.deepEqual(session.pendingOperationIds,edit.operationIds);assert.ok(session.conflict); const plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));f.failAdmissionCodec=false;await session.resolve(plan);await engine.settle();await f.clock.advance(0);await engine.settle(); const current=engine.getSnapshot();assert.equal(current.runtime.runs.size,1);assert.equal([...current.runtime.runs.values()][0].cursor,4);assert.equal([...current.runtime.runs.values()][0].status,'succeeded');assert.equal(current.document.elements.get('a').data.text,'already generated');assert.deepEqual(current.document.elements.get('a').data.ai,config); assert.equal(f.calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1);assert.equal(current.runtime.ledger.requests.get(request.id).body,request.body);assert.equal(current.runtime.ledger.requests.get(request.id).phase,'resolved'); await engine.close();const restored=await f.open();assert.equal(restored.getSnapshot().runtime.runs.size,1);await restored.close(); }); // 分别恢复已成功但尚未解释的取消和应用回执;恢复控制状态且不重发动作,应用后的本地编辑按当前 owner 同步。 test('saved successful cancel and apply receipts recover controls without sending the action again',async()=>{ for(const action of ['cancel','apply']){ const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();const runId=[...engine.getSnapshot().runtime.runs.keys()][0]; if(action==='apply'){f.finish('retained output',true);await f.clock.advance(0);await engine.settle();} f.failActionCodec=true;if(action==='cancel')engine.cancelRun(runId);else engine.applyRunOutput(runId,target,id(999));await engine.settle(); const request=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.'+action);assert.equal(request.outcome,'applied');assert.equal(request.phase,'waiting'); let edit;if(action==='apply'){edit=engine.editor.dispatch({type:'element.data',target,patch:{text:'later local'}});await engine.settle();f.move(child);} const session=await engine.prepareRecovery();assert.deepEqual(session.acknowledgedRequestIds,[request.id]); f.failActionCodec=false;if(edit){assert.ok(session.conflict);await session.resolve(session.plan(new Map([[edit.operationIds[0],'keep-local']])));}else await session.apply(); await engine.settle();await f.clock.advance(0);await engine.settle();const run=engine.getSnapshot().runtime.runs.get(runId); assert.equal(f.calls.filter(call=>call.method==='POST'&&call.path.endsWith('/'+action)).length,1);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).body,request.body);assert.equal(run.controlEpoch,1); if(action==='cancel')assert.equal(run.status,'cancelled');else{assert.ok(run.appliedOutputIds.includes(id(999)));assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'later local');assert.equal(engine.getSnapshot().document.elements.get('a').ownerCanvasId,child);const documentCall=f.calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).at(-1);assert.deepEqual(Object.keys(JSON.parse(documentCall.body).expected_versions),[child]);} await engine.close(); } }); // 实际 HTTP 传输适配器对三类动作都协商 canvas v2;POST 保留冻结体和幂等键,查询 GET 无请求体,取消不带 If-Match。 test('AI admission, cancel and apply negotiate canvas v2 through the shared HTTP transport',async()=>{ for(const action of ['start','cancel','apply']){ const f=fixture(),engine=await f.open(); try{ engine.startRun(runInput);await engine.settle();const runId=[...engine.getSnapshot().runtime.runs.keys()][0]; if(action==='cancel'){engine.cancelRun(runId);await engine.settle();} if(action==='apply'){f.finish('retained',true);await f.clock.advance(0);await engine.settle();engine.applyRunOutput(runId,target,id(999));await engine.settle();} const request=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.'+action),receipt=f.receipts.get(request.id),calls=[]; const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>true,fetch:async(url,input)=>{ calls.push({url,input}); if(input.headers.Accept!=='application/vnd.foundry.canvas+json;version=2')return Response.json({error:'canvas_schema_upgrade_required'},{status:409}); return Response.json(receipt,{status:action==='start'&&input.method==='POST'?202:200}); }}),port=createFoundryRuns({workspaceId:workspace,rootId:root,registry,transport}),signal=new AbortController().signal; assert.equal((await port.submit(request,signal)).outcome,'applied');assert.equal((await port.query(request,signal)).outcome,'applied'); assert.equal(calls[0].input.body,request.body);assert.equal(calls[0].input.headers['Idempotency-Key'],request.id); assert.equal(calls[0].input.headers['If-Match'],action==='cancel'?undefined:`"${request.expectedVersions[root]}"`); assert.equal(calls[1].input.method,'GET');assert.equal(calls[1].input.body,undefined);assert.equal(calls[1].input.headers['Idempotency-Key'],undefined); }finally{await engine.close();} } }); // 成功历史证据也必须绑定原请求、操作、任务、目标和版本;冻结计划可省略无关 canvas,但不能引入外来 canvas 或篡改参与版本。 test('historical AI success proofs bind immutable request, operation, run, target, version and frozen plan',async()=>{ for(const action of ['start','cancel','apply']){ const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();const runId=[...engine.getSnapshot().runtime.runs.keys()][0]; if(action==='cancel'){engine.cancelRun(runId);await engine.settle();} if(action==='apply'){f.finish('retained',true);await f.clock.advance(0);await engine.settle();engine.applyRunOutput(runId,target,id(999));await engine.settle();} const request=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.'+action),receipt=f.receipts.get(request.id),port=createFoundryRuns({workspaceId:workspace,rootId:root,registry,transport:f.transport}); const proof=port.verifyApplied(request,receipt);proof.model.assertCanonical(f.server()); if(action==='start'){const subset=structuredClone(receipt);delete subset.plan.frozen_versions[child];assert.ok(port.verifyApplied(request,subset).admission);const foreign=structuredClone(subset);foreign.plan.frozen_versions[id(9900)]=1;assert.throws(()=>port.verifyApplied(request,foreign),/run_identity_mismatch/);} const mutations=[body=>body.request_id=id(9991),body=>body.operation_id=id(9992),body=>body.action='different',body=>body.run_id=action==='start'?'invalid-run-id':id(9993)]; if(action==='start')mutations.push(body=>body.target_incarnation=2,body=>body.canvas_version++,body=>body.plan.frozen_versions[root]++); if(action==='apply')mutations.push(body=>body.target_incarnation=2,body=>body.canvas_version++,body=>body.output_id=id(9994),body=>body.board_id=child); for(const mutate of mutations){const invalid=structuredClone(receipt);mutate(invalid);assert.throws(()=>port.verifyApplied(request,invalid),/run_identity_mismatch|receipt/);} await engine.close(); } });