docs(tests): explain AI run admission recovery controls and output ownership
This commit is contained in:
@@ -1,24 +1,40 @@
|
||||
/** Remote authenticated HTTP/DB integration. Only the model output is a fixture. */
|
||||
/**
|
||||
* 由 Foundry 远程集成入口调用的 AI 场景:HTTP、回执查询和数据库链路真实运行,模型输出使用 fixture。
|
||||
* open、transport、资源端口、故障注入和时钟均由调用方提供;本文件不会独立启动服务或读取登录凭据。
|
||||
* 依次验证丢失受理响应、取消恢复、生成期间的内容冲突,以及受管图片和复制复用资源。
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID,createHash} from 'node:crypto';
|
||||
|
||||
// 组织整套有状态集成场景并返回已通过的检查标记;任一断言失败都进入 finally 关闭引擎。
|
||||
export async function verifyFoundryAI({open,transport,rootId,imageId,assets,calls,tick,readDisplay,advance,loseAdmission,loseAction}){
|
||||
// 使用调用方准备的 workspace 和根 material 定位测试服务,不自行创建项目或切换身份。
|
||||
const path=`workspaces/${process.env.FOUNDRY_TEST_WORKSPACE}/materials/${rootId}/canvas/ai`;
|
||||
// 通过真实 HTTP 操作 fixture 执行屏障和计数器;每次调用有 15 秒超时且要求 200。
|
||||
const control=async action=>{const response=await transport({method:'POST',path:path+'/fixture/'+action,signal:AbortSignal.timeout(15_000)});assert.equal(response.status,200,action+': '+JSON.stringify(response.body));return response.body;};
|
||||
// 从共享调用记录筛选某类 POST,用次数与冻结请求体判断恢复是否意外重复了原动作。
|
||||
const posts=suffix=>calls.filter(call=>call.method==='POST'&&call.path.endsWith(suffix));
|
||||
// 保存当前引擎实例;重开时替换该引用,后续断言读取恢复后的同一项目。
|
||||
let engine=await open();const signal=new AbortController().signal;
|
||||
// 正常关闭释放资源与存储占用,推进调用方时钟后重新打开并等待恢复流程收敛。
|
||||
const reopen=async()=>{await engine.close();advance();engine=await open();await engine.settle();};
|
||||
// 显式 retry 后排空调用方可推进的轮询定时器;最多 100 轮,防止轮询异常令远程检查无限等待。
|
||||
const poll=async()=>{await engine.retry();for(let turns=0;turns<100;turns++){if(!tick())return;await engine.settle();}assert.fail('AI timer drain exceeded its bound');};
|
||||
try{
|
||||
// 先确认测试服务提供文本和图片 fixture 模型;缺少能力即失败,不降级为本地假结果。
|
||||
const catalog=await engine.models(rootId,signal);assert.ok(catalog.models.some(model=>model.id==='fixture-text'));assert.ok(catalog.models.some(model=>model.id==='fixture-image'));
|
||||
// 创建独立文本目标,附带 extension 字段;结果合并必须保留未知扩展及用户修改的几何。
|
||||
const target={id:randomUUID(),incarnation:1};
|
||||
assert.equal(engine.editor.dispatch({type:'element.create',element:{id:target.id,incarnation:1,ownerCanvasId:rootId,componentId:'text',dataVersion:1,structure:'node',data:{title:'Real AI adapter',text:'before generation',extension:{keep:true}},geometry:{x:0,y:0,width:100,height:80,rotation:0}}}).status,'applied');await engine.settle();
|
||||
const input={target,boardId:rootId,modelId:'fixture-text',prompt:'Fixture generation',parameters:{},propagate:false};
|
||||
// 统一断言 startRun 在本地获准并等待受理链收敛;此时并不代表模型已经执行完毕。
|
||||
const begin=async value=>{const result=engine.startRun(value);assert.equal(result.status,'applied',JSON.stringify(result));await engine.settle();return result;};
|
||||
// 通过受理操作 ID 找回对应 RunRecord,避免假设任务 ID 等于操作 ID。
|
||||
const byOperation=id=>[...engine.getSnapshot().runtime.runs.values()].find(run=>run.operationId===id);
|
||||
// 记录本场景开始前的调用基数,末尾核对生成次数和图片复制是否引入额外上传。
|
||||
const initialStarts=posts('/ai/runs').length,initialUploads=posts('/assets').length;
|
||||
|
||||
// A committed admission whose response was lost must be queried after reopen.
|
||||
// 服务端已提交受理后丢弃响应:重开必须查询原请求,保持原 body,不能第二次启动生成。
|
||||
loseAdmission();const first=await begin(input),admission=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.start'&&request.operationIds.includes(first.operationIds[0]));
|
||||
assert.equal(admission.phase,'waiting');assert.equal(posts('/ai/runs').at(-1).response?.httpStatus,202,'must lose a committed admission, not a rejection');const admissionCount=posts('/ai/runs').length;
|
||||
await reopen();assert.equal(posts('/ai/runs').length,admissionCount);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(admission.id).body,admission.body);
|
||||
@@ -26,6 +42,7 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
|
||||
const recovered=engine.getSnapshot().runtime.ledger.requests.get(admission.id);
|
||||
assert.equal(recovered.phase,'resolved','AI admission recovery: '+JSON.stringify({phase:recovered.phase,error:recovered.error,requestId:admission.id,operationId:first.operationIds[0],responses:calls.filter(call=>call.path.endsWith('/ai/runs')||call.path.endsWith('/ai/operations/'+admission.id)).map(call=>({method:call.method,response:call.response}))}));
|
||||
const firstRun=byOperation(first.operationIds[0]);assert.ok(firstRun,'resolved admission must publish a RunRecord');assert.equal(firstRun.status,'queued');
|
||||
// 受理恢复后先移动元素再执行生成;服务结果只能更新文字,必须保留这次移动及扩展数据。
|
||||
assert.equal(engine.editor.dispatch({type:'element.geometry',target,patch:{x:77}}).status,'applied');await engine.settle();
|
||||
await control('execute');await poll();
|
||||
const generatedText=engine.getSnapshot().runtime.runs.get(firstRun.id);
|
||||
@@ -33,7 +50,7 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
|
||||
assert.equal(engine.getSnapshot().document.elements.get(target.id).data.text,'headless fixture output');assert.equal(engine.getSnapshot().document.elements.get(target.id).geometry.x,77);
|
||||
assert.deepEqual(engine.getSnapshot().document.elements.get(target.id).data.extension,{keep:true});
|
||||
|
||||
// Queued cancellation is durable and does not execute the generation transport.
|
||||
// 排队任务取消的响应丢失后查询恢复;执行次数不增加,并排空取消事件游标后才开启下一任务。
|
||||
const second=await begin({...input,prompt:'Fixture cancellation'}),secondRun=byOperation(second.operationIds[0]),beforeCancel=(await control('stats')).executions;
|
||||
loseAction();assert.equal(engine.cancelRun(secondRun.id).status,'applied');await engine.settle();
|
||||
const cancellation=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.cancel'&&request.runId===secondRun.id);assert.equal(cancellation.phase,'waiting');const cancellationCount=posts('/cancel').length;
|
||||
@@ -41,16 +58,19 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
|
||||
await poll();assert.equal(engine.getSnapshot().runtime.runs.get(secondRun.id).hasMore,false,'drain the cancelled run event cursor before another admission');
|
||||
assert.equal((await control('stats')).executions,beforeCancel);
|
||||
|
||||
// Edit while the provider is held, then retain and explicitly apply its output.
|
||||
// 用服务端屏障挂起 provider,期间提交用户文字;结果发生冲突后保留输出,等待用户显式选择应用。
|
||||
const third=await begin({...input,prompt:'Fixture conflict'}),thirdRun=byOperation(third.operationIds[0]);await control('arm');
|
||||
// 并行启动执行并把错误转成结果对象,确保解除屏障后能统一等待和报告,避免未处理的 Promise 拒绝。
|
||||
const executing=control('execute').then(value=>({value}),error=>({error}));
|
||||
try{
|
||||
await control('wait');
|
||||
assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'user edit while executing'}}).status,'applied');await engine.settle();
|
||||
// 无论中途断言是否通过,都先解除 provider 屏障,避免测试留下永久挂起的执行。
|
||||
}finally{await control('release');}
|
||||
const completed=await executing;if(completed.error)throw completed.error;await poll();
|
||||
const conflict=engine.getSnapshot().runtime.runs.get(thirdRun.id);assert.equal(conflict.status,'conflict');assert.equal(engine.getSnapshot().document.elements.get(target.id).data.text,'user edit while executing');
|
||||
const outputId=conflict.outputEvents[0]?.output.output_id;assert.equal(typeof outputId,'string');
|
||||
// 再次模拟成功动作丢响应;先撤销再重开,通过查询确认原应用,随后同步撤销,redo 只恢复已有内容。
|
||||
loseAction();assert.equal(engine.applyRunOutput(conflict.id,target,outputId).status,'applied');await engine.settle();
|
||||
const application=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.apply'&&request.runId===conflict.id);assert.equal(application.phase,'waiting');const applyCount=posts('/apply').length;
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();await reopen();
|
||||
@@ -59,16 +79,21 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
|
||||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(target.id).data.text,'headless fixture output');
|
||||
assert.equal(posts('/apply').length,applyCount);
|
||||
|
||||
// Managed image results are readable, and copying shares bytes without a run.
|
||||
// 图片结果必须可从资源权威端和显示路径读取;复制元素共享 assetId,不复制任务或重新上传字节。
|
||||
const image=engine.getSnapshot().document.elements.get(imageId),oldAsset=image.data.assetId;
|
||||
const fourth=await begin({target:{id:image.id,incarnation:image.incarnation},boardId:rootId,modelId:'fixture-image',prompt:'Fixture image',parameters:{},propagate:false});
|
||||
const fourthRun=byOperation(fourth.operationIds[0]);await control('execute');await poll();
|
||||
assert.equal(engine.getSnapshot().runtime.runs.get(fourthRun.id).status,'succeeded');const generated=engine.getSnapshot().document.elements.get(imageId);assert.notEqual(generated.data.assetId,oldAsset);
|
||||
// 分别取得资源元数据、原始字节和显示句柄;后续核对 PNG 签名、SHA-256、尺寸和可显示内容。
|
||||
const metadata=await assets.authority.resolve(generated.data.assetId,signal),original=await assets.authority.read(generated.data.assetId,signal),display=await engine.displayResource(generated.data.assetId);
|
||||
// 显示句柄是带引用的资源,即使字节或元数据断言失败也必须 release。
|
||||
try{const bytes=Buffer.from(await original.arrayBuffer());assert.deepEqual([...bytes.subarray(0,8)],[137,80,78,71,13,10,26,10]);assert.equal(createHash('sha256').update(bytes).digest('hex'),metadata.digest);assert.equal(metadata.asset.width,3);assert.equal(metadata.asset.height,2);assert.ok((await readDisplay(display.url)).length>0);}finally{display.release();}
|
||||
// 深复制生成后的图片,等待复制作业完成后检查稳定 ID 映射;复用资源不应增加 RunRecord。
|
||||
const runCount=engine.getSnapshot().runtime.runs.size,copyId=await engine.imports.duplicate([{id:generated.id,incarnation:generated.incarnation}],{viewCanvasId:rootId});await engine.settle();
|
||||
const copy=engine.imports.getJobs().get(copyId);assert.equal(copy.status,'completed');assert.equal(engine.getSnapshot().document.elements.get(copy.stableIds['element:'+generated.id]).data.assetId,generated.data.assetId);
|
||||
assert.equal(engine.getSnapshot().runtime.runs.size,runCount);assert.equal(posts('/ai/runs').length,initialStarts+4);assert.equal(posts('/assets').length,initialUploads);assert.equal((await control('stats')).executions,3);
|
||||
// 只有前面全部断言通过才返回这些标记;它们描述本次远程调用结果,不代表其他入口都已覆盖。
|
||||
return {runtimeAI:true,aiLostAdmission:true,aiCancelReconciled:true,aiConflictApplyUndo:true,aiManagedImage:true,aiCopyNoRun:true,aiExecutionCount:3};
|
||||
// 成功或失败都关闭最新引擎实例,释放本次打开的运行时资源。
|
||||
}finally{await engine.close();}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
/**
|
||||
* 运行任务轮询器的暂停与终止测试。hold 只阻止下一次轮询,允许已发出的响应更新并保存游标。
|
||||
* 可控 Promise 与假时钟分别控制响应和后续调度;这里不连接网络或真实存储。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRunTracker } from '../../src/lib/canvas-runtime/runs.ts';
|
||||
|
||||
// 推进有限轮微任务,让轮询链执行;不会推进假时钟或主动触发未来的定时器。
|
||||
const tick = async () => { for (let index=0;index<30;index++)await Promise.resolve(); };
|
||||
// 由测试决定首个轮询何时返回,用来把 hold 放在请求已发出但响应尚未完成的位置。
|
||||
const deferred = () => { let resolve;const promise=new Promise(yes=>{resolve=yes;});return{promise,resolve}; };
|
||||
// 搭建一个运行中的任务:首轮响应可挂起,后续响应直接完成,保存记录用于核对落盘游标。
|
||||
function fixture() {
|
||||
let now=0,next=0;const timers=new Map(),runs=new Map([['run',{id:'run',status:'running',cursor:0,hasMore:true,attempt:0}]]),calls=[],saved=[],response=deferred();
|
||||
const tracker=createRunTracker({clock:{now:()=>now,setTimer(callback,delay){const id=++next;timers.set(id,{callback,due:now+delay});return id;},clearTimer:id=>timers.delete(id)},getRuns:()=>runs,async persist(){saved.push(structuredClone([...runs]));},async poll(run){calls.push(run);return calls.length===1?response.promise:{status:'succeeded',events:[],next:2,hasMore:false,outputs:[],steps:[]};},accept(run,page){runs.set(run.id,{...runs.get(run.id),status:page.status,cursor:page.next,hasMore:page.hasMore});},update(id,reduce){runs.set(id,reduce(runs.get(id)));},onError(error){throw error;}});
|
||||
return{tracker,runs,calls,saved,response,timers,async advance(ms){now+=ms;for(const[id,timer]of[...timers])if(timer.due<=now){timers.delete(id);timer.callback();}await tick();}};
|
||||
}
|
||||
|
||||
// 两个 hold 共同阻止新轮询;在途响应仍保存 cursor=1,重复释放同一 hold 无效,最后释放后从该游标继续。
|
||||
test('run holds preserve a completed cursor and notifications without launching another poll',async()=>{
|
||||
const f=fixture();f.tracker.notify(['run']);await f.advance(0);assert.equal(f.calls.length,1);
|
||||
const releaseA=f.tracker.hold(),releaseB=f.tracker.hold();
|
||||
@@ -21,6 +29,7 @@ test('run holds preserve a completed cursor and notifications without launching
|
||||
f.tracker.stop();
|
||||
});
|
||||
|
||||
// stop 是终态:即使先前有待处理通知,随后释放 hold 也不能恢复定时器或请求,关闭后不再允许申请 hold。
|
||||
test('releasing a run hold after stop cannot restart a poll',async()=>{
|
||||
const f=fixture(),release=f.tracker.hold();f.tracker.notify(['run']);f.tracker.stop();release();await f.advance(10000);
|
||||
assert.equal(f.calls.length,0);assert.equal(f.timers.size,0);assert.throws(()=>f.tracker.hold(),/run_tracker_closed/);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 运行任务从受理、轮询到取消及显式应用结果的引擎测试。真实 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';
|
||||
@@ -6,23 +11,37 @@ 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);
|
||||
@@ -33,6 +52,7 @@ function fixture(){
|
||||
}
|
||||
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){
|
||||
@@ -50,6 +70,7 @@ function fixture(){
|
||||
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){
|
||||
@@ -66,7 +87,9 @@ function fixture(){
|
||||
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);
|
||||
@@ -74,10 +97,14 @@ function fixture(){
|
||||
}
|
||||
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);}
|
||||
@@ -89,6 +116,7 @@ function fixture(){
|
||||
};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'}});
|
||||
@@ -114,6 +142,7 @@ test('AI admission orders prior and later edits, persists its job, and does not
|
||||
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();
|
||||
@@ -124,6 +153,7 @@ test('lost AI admission response recovers its original request and run without a
|
||||
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();
|
||||
@@ -135,6 +165,7 @@ test('run event waits for outstanding receipts and retains a content conflict, o
|
||||
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();
|
||||
@@ -145,6 +176,7 @@ test('older run event cannot rewind a later canonical document receipt',async()=
|
||||
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);
|
||||
@@ -153,6 +185,7 @@ test('run durability failure prevents admission and close fences late poll respo
|
||||
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--;});
|
||||
@@ -161,6 +194,7 @@ test('transport gate bounds combined run/read/write capacity and drops aborted w
|
||||
});
|
||||
|
||||
|
||||
// 篡改受理响应的 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;}});
|
||||
@@ -170,6 +204,7 @@ test('malformed admission identity keeps its receipt and claims until the origin
|
||||
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;
|
||||
@@ -182,6 +217,7 @@ test('foreign event identity and corrupted persisted run bindings cannot overwri
|
||||
});
|
||||
|
||||
|
||||
// 即使事件声明更高 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;
|
||||
@@ -191,6 +227,7 @@ test('even a higher-version malformed event cannot restore an old incarnation',a
|
||||
});
|
||||
|
||||
|
||||
// 受理拒绝的响应丢失后先保持排序阻塞;查到绑定原请求的 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;
|
||||
@@ -199,6 +236,7 @@ test('queryable zero-effect admission rejection releases ordering without discar
|
||||
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);
|
||||
@@ -206,6 +244,7 @@ test('durable cancellation survives response loss and does not create content hi
|
||||
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();
|
||||
@@ -215,6 +254,7 @@ test('explicit output application is immediate and undoable while unknown; recov
|
||||
});
|
||||
|
||||
|
||||
// 取消确认推进 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);
|
||||
@@ -222,6 +262,7 @@ test('a poll captured before cancellation cannot regress the acknowledged contro
|
||||
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']){
|
||||
@@ -236,29 +277,34 @@ test('AI action rejection proof must match request, operation, action and run; b
|
||||
});
|
||||
|
||||
|
||||
// 任务结果跟随同 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}};
|
||||
@@ -266,6 +312,7 @@ test('complete run context retains later unrelated canvas ACKs and rejects chang
|
||||
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);
|
||||
@@ -277,6 +324,7 @@ test('a saved successful AI admission recovers its RunRecord and later local con
|
||||
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];
|
||||
@@ -293,6 +341,7 @@ test('saved successful cancel and apply receipts recover controls without sendin
|
||||
}
|
||||
});
|
||||
|
||||
// 实际 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();
|
||||
@@ -314,6 +363,7 @@ test('AI admission, cancel and apply negotiate canvas v2 through the shared HTTP
|
||||
}
|
||||
});
|
||||
|
||||
// 成功历史证据也必须绑定原请求、操作、任务、目标和版本;冻结计划可省略无关 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];
|
||||
|
||||
Reference in New Issue
Block a user