Files
Endoleap/tests/headless/runs.test.mjs

382 lines
48 KiB
JavaScript

/**
* 运行任务从受理、轮询到取消及显式应用结果的引擎测试。真实 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.length<run.events.length,steps:[],outputs:run.outputs}};if(f.pollGate)await f.pollGate.promise;return response;
}
throw new Error('Unexpected '+request.path);
};
// 暴露故障开关与观测结果;open 每次建立新引擎,但复用同一内存服务和已落盘镜像。
const f={storage,clock,calls,runs,errors,receipts,transport,loseAdmission:false,
holdAdmission(){postGate=defer();return postGate;},holdDocument(){docGate=defer();return docGate;},
// 真实 runs 端口的 accept 可注入解释失败:即使已保存成功回执,也必须等能正确解释后才发布本地状态。
async open(){return openProjectEngine({scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1],[child,7]]),storage,clock,createId:()=>id(++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);
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();
// The server rejects its stale version first. Adopt an externally refreshed full base only after receipt resolution.
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 后退出,剩余请求完成时活跃数归零。
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。
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,也不能提前推进游标,下一轮新响应才消费取消事件。
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();
}
});