233 lines
28 KiB
JavaScript
233 lines
28 KiB
JavaScript
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 } from '../../src/lib/canvas-service/index.ts';
|
|
import { createNetworkGate } from '../../src/lib/canvas-runtime/network.ts';
|
|
|
|
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}});
|
|
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};
|
|
const defer=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};};
|
|
const tick=async()=>{for(let i=0;i<100;i++)await Promise.resolve();};
|
|
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};}
|
|
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;}
|
|
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=[];
|
|
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)}});
|
|
const transport=async request=>{
|
|
calls.push(request);
|
|
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:chunk(root)};}
|
|
receipts.set(request.requestId,structuredClone(body));
|
|
}
|
|
return{status:body.status==='applied'?200:412,body};
|
|
}
|
|
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{
|
|
assert.equal(input.target_canvas_id,root);assert.equal(input.target_incarnation,1);assert.equal(request.expectedVersion,versions.get(root));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(root,versions.get(root)+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:root,node_id:input.node_id,output_id:input.output_id,target_incarnation:1,document_format_version:1,canvas_version:versions.get(root),model:chunk(root)};
|
|
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};
|
|
}
|
|
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};
|
|
}
|
|
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'}};}
|
|
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);
|
|
};
|
|
const f={storage,clock,calls,runs,errors,receipts,transport,loseAdmission:false,
|
|
holdAdmission(){postGate=defer();return postGate;},holdDocument(){docGate=defer();return docGate;},
|
|
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:createFoundryRuns({workspaceId:workspace,rootId:root,registry,transport}),onError:error=>errors.push(error)});},
|
|
finish(text='generated',conflict=false){
|
|
const [runId,run]=[...runs].at(-1),element=document.elements.get('a');
|
|
if(!conflict){document=applyChanges(document,[{kind:'field',target,field:'data.text',before:element.data.text,after:text,beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+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:root,node_id:'a',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:root,node_id:'a',canvas_version:versions.get(root),target_incarnation:1,document_format_version:1,model:structuredClone(chunk(root))},
|
|
{seq:4,run_id:runId,type:'run.completed',status:run.status});
|
|
},server:()=>({document,versions}),
|
|
};return f;
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
|
|
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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
|
|
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();
|
|
});
|
|
|
|
|
|
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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
|
|
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();
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|