Reconcile AI output ownership without duplicating or rewinding elements
This commit is contained in:
@@ -209,15 +209,18 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const conflicts=new Map(previous.conflicts);
|
||||
try {
|
||||
for(const event of page.events) {
|
||||
if(event.seq<=current.cursor || event.type!=='step.applied')continue;
|
||||
if(event.seq<=current.cursor || event.type!=='step.applied'&&!(event.type==='step.output'&&event.model&&typeof event.model==='object'&&!Array.isArray(event.model)&&event.model.complete===true))continue;
|
||||
const canvas=event.board_id as Id,version=event.canvas_version as number;
|
||||
if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_run_response');
|
||||
// A later document receipt already includes this event; never rewind its participant.
|
||||
if(version<=(versions.get(canvas)??0))continue;
|
||||
if([...previous.ledger.requests.values()].some(request=>request.phase!=='resolved'&&request.canvasIds.includes(canvas)))throw fault('run_waiting_for_receipts');
|
||||
const model=event.model as {[key:string]:Json}|undefined,context=model?.complete===true,participants=context?Object.keys(model.versions as {[key:string]:Json}):[canvas];
|
||||
if([...previous.ledger.requests.values()].some(request=>request.phase!=='resolved'&&request.canvasIds.some(id=>participants.includes(id))))throw fault('run_waiting_for_receipts');
|
||||
if(!options.runs)throw fault('run_interpreter_unavailable');
|
||||
const snapshot={...root,runtime:{...previous,acknowledged,versions}};
|
||||
const proposal=options.runs.canonical(event,snapshot);acknowledged=proposal.document;versions=proposal.versions;
|
||||
const proposal=options.runs.canonical(event,snapshot);
|
||||
for(const record of previous.ledger.operations.values())if(record.phase!=='resolved')for(const change of record.intent.changes)if(change.kind==='field'&&(change.field==='geometry'||change.field==='path')){const before=acknowledged.elements.get(change.target.id),after=proposal.document.elements.get(change.target.id);if(before&&after&&before.ownerCanvasId!==after.ownerCanvasId)throw fault('ownership_conflict',[change.target.id]);}
|
||||
acknowledged=proposal.document;versions=proposal.versions;
|
||||
}
|
||||
const document=replayPending(acknowledged,previous.ledger,options.registry),runs=new Map(previous.runs);
|
||||
const appliedOutputIds=[...new Set([...(current.appliedOutputIds??[]),...page.events.filter(event=>event.type==='step.applied'&&typeof event.output_id==='string').map(event=>event.output_id as Id)])];
|
||||
|
||||
@@ -150,3 +150,24 @@ export function mergeFoundryModel(value: unknown, participants: readonly Id[], s
|
||||
const document = createDocument(previous.projectId, [...canvases.values()], [...elements.values()], tombstones); validateDocument(document, registry);
|
||||
return Object.freeze({ document, versions: readMap(serverVersions) });
|
||||
}
|
||||
|
||||
/** A relocated result carries a complete ownership context. Retain later ACKs
|
||||
* per canvas; validate the combined tree before publishing any ownership change. */
|
||||
export function mergeFoundryRunContext(value: unknown, snapshot: EngineSnapshot, registry: Registry, rootId: Id) {
|
||||
const chunk=modelChunk(value);
|
||||
if(!chunk.complete||chunk.projectId!==rootId)throw fault('invalid_run_context');
|
||||
const previous=snapshot.runtime.acknowledged,canvases=new Map(previous.canvases),elements=new Map(previous.elements),tombstones=new Map(previous.tombstones),serverVersions=new Map(snapshot.runtime.versions),replace=new Set<Id>();
|
||||
for(const [id,version] of chunk.versions)if(version>=(snapshot.runtime.versions.get(id)??0)){
|
||||
if(version===snapshot.runtime.versions.get(id)){
|
||||
const incoming=chunk.elements.filter(element=>element.ownerCanvasId===id).sort((a,b)=>a.id.localeCompare(b.id)),known=[...previous.elements.values()].filter(element=>element.ownerCanvasId===id).sort((a,b)=>a.id.localeCompare(b.id));
|
||||
if(!equal(chunk.canvases.find(canvas=>canvas.id===id),previous.canvases.get(id))||!equal(incoming,known))throw fault('same_version_context_conflict',[id]);
|
||||
}
|
||||
replace.add(id);serverVersions.set(id,version);canvases.delete(id);
|
||||
}
|
||||
for(const canvas of chunk.canvases)if(replace.has(canvas.id))canvases.set(canvas.id,canvas);
|
||||
for(const [id,element] of elements)if(replace.has(element.ownerCanvasId))elements.delete(id);
|
||||
for(const element of chunk.elements)if(replace.has(element.ownerCanvasId)){if(elements.has(element.id))throw fault('canonical_scope_mismatch',[element.id]);elements.set(element.id,element);}
|
||||
for(const [id,generation] of chunk.tombstones)tombstones.set(id,Math.max(tombstones.get(id)??0,generation));
|
||||
const document=createDocument(rootId,[...canvases.values()],[...elements.values()],tombstones);validateDocument(document,registry);
|
||||
return Object.freeze({document,versions:readMap(serverVersions)});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { validateRunInput } from '../canvas-runtime/runs.ts';
|
||||
import type { RunEvent, RunPage, RunPort, RunRecord, RunStatus, RunModel } from '../canvas-runtime/runs.ts';
|
||||
import type { FrozenRequest } from '../canvas-runtime/ledger.ts';
|
||||
import type { OperationStatus } from '../canvas-runtime/scheduler.ts';
|
||||
import { mergeFoundryModel, type ServiceTransport } from './foundry.ts';
|
||||
import { mergeFoundryModel, mergeFoundryRunContext, type ServiceTransport } from './foundry.ts';
|
||||
|
||||
const uuid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
|
||||
const statuses = new Set(['queued','running','succeeded','failed','conflict','cancelled','partially_succeeded']);
|
||||
@@ -101,7 +101,9 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr
|
||||
return{runId:request.runId,runStatus:body.run_status as RunStatus};
|
||||
}
|
||||
if(request.kind!=='run.apply'||body.node_id!==raw.node_id||body.output_id!==raw.output_id||body.board_id!==raw.target_canvas_id||body.target_incarnation!==raw.target_incarnation||body.document_format_version!==1)throw fault('run_identity_mismatch');
|
||||
const proposal=mergeFoundryModel(body.model,[raw.target_canvas_id as Id],snapshot,options.registry,options.rootId),target=proposal.document.elements.get(raw.node_id as Id),version=integer(body.canvas_version,1);
|
||||
const model=object(body.model),board=raw.target_canvas_id as Id;
|
||||
const scoped=model.complete===true?{...model,complete:false,canvases:array(model.canvases).filter(value=>object(value).id===board),elements:array(model.elements).filter(value=>object(value).ownerCanvasId===board),versions:{[board]:object(model.versions)[board]},tombstones:{},removedCanvasIds:[]}:model;
|
||||
const proposal=mergeFoundryModel(scoped,[board],snapshot,options.registry,options.rootId),target=proposal.document.elements.get(raw.node_id as Id),version=integer(body.canvas_version,1);
|
||||
if(proposal.versions.get(raw.target_canvas_id as Id)!==version||version!==request.expectedVersions[raw.target_canvas_id as Id]+1||target?.incarnation!==raw.target_incarnation||target?.ownerCanvasId!==raw.target_canvas_id)throw fault('run_identity_mismatch');
|
||||
return{runId:request.runId,outputId:raw.output_id as Id,proposal};
|
||||
},
|
||||
@@ -114,7 +116,10 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr
|
||||
if(event.run_id!==run.id||integer(event.seq,1)!==cursor+1||typeof event.type!=='string')throw fault('invalid_run_response');cursor++;
|
||||
if(event.type==='step.applied'||event.type==='step.output'){
|
||||
const step=steps.find(step=>step.step_id===event.step_id);
|
||||
if(!step||step.board_id!==event.board_id||step.node_id!==event.node_id||event.type==='step.applied'&&(step.target_incarnation!==event.target_incarnation||event.document_format_version!==1))throw fault('run_identity_mismatch');
|
||||
if(!step||step.node_id!==event.node_id)throw fault('run_identity_mismatch');
|
||||
const relocated=step.board_id!==event.board_id;
|
||||
if(relocated&&(!uuid.test(string(event.board_id))||event.origin_board_id!==step.board_id||event.canonical_root!==options.rootId||event.target_incarnation!==step.target_incarnation))throw fault('run_identity_mismatch');
|
||||
if(event.type==='step.applied'&&(step.target_incarnation!==event.target_incarnation||event.document_format_version!==1||relocated&&object(event.model).complete!==true))throw fault('run_identity_mismatch');
|
||||
}
|
||||
}
|
||||
if(integer(body.next_seq)!==cursor||events.length>100||body.has_more&&events.length===0)throw fault('invalid_run_response');
|
||||
@@ -122,8 +127,9 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr
|
||||
},
|
||||
canonical(event,snapshot){
|
||||
const board=string(event.board_id),version=integer(event.canvas_version,1),current=snapshot.runtime.acknowledged.elements.get(string(event.node_id));
|
||||
if(event.type!=='step.applied'||!current||current.ownerCanvasId!==board||current.incarnation!==event.target_incarnation)throw fault('run_identity_mismatch');
|
||||
const proposal=mergeFoundryModel(event.model,[board],snapshot,options.registry,options.rootId),target=proposal.document.elements.get(string(event.node_id));
|
||||
const context=object(event.model).complete===true;
|
||||
if(!['step.applied','step.output'].includes(event.type)||!current||current.incarnation!==event.target_incarnation||!context&¤t.ownerCanvasId!==board||context&&event.canonical_root!==options.rootId)throw fault('run_identity_mismatch');
|
||||
const proposal=context?mergeFoundryRunContext(event.model,snapshot,options.registry,options.rootId):mergeFoundryModel(event.model,[board],snapshot,options.registry,options.rootId),target=proposal.document.elements.get(string(event.node_id));
|
||||
if(proposal.versions.get(board)!==version||!target||target.ownerCanvasId!==board||target.incarnation!==event.target_incarnation)throw fault('run_identity_mismatch');
|
||||
return proposal;
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ import { setTimeout as pause } from 'node:timers/promises';
|
||||
import { createDocument, applyChanges } from '../../src/lib/canvas-core/index.ts';
|
||||
const source = resolve(import.meta.dirname, '../..'), directory = await mkdtemp(join(tmpdir(), 'endoleap-renderer-')), profile = await mkdtemp(join(tmpdir(), 'endoleap-renderer-browser-'));
|
||||
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||
const account = id(1), workspace = id(2), root = id(3), other = id(4), calls = [], uploads = [], documents = new Map(), versions = new Map([[root, 1], [other, 1]]), assets = new Map(), aiRuns = new Map();
|
||||
const account = id(1), workspace = id(2), root = id(3), other = id(4), partition = id(5), calls = [], uploads = [], documents = new Map(), versions = new Map([[root, 1], [other, 1]]), assets = new Map(), aiRuns = new Map();
|
||||
const node = (id, ownerCanvasId, x) => ({ id, ownerCanvasId, incarnation: 1, componentId: 'text', dataVersion: 1, structure: 'node', data: { title: 'Card ' + x, text: 'Editable content' }, geometry: { x, y: 150, width: 260, height: 200, rotation: 0 } });
|
||||
documents.set(root, createDocument(root, [{ id: root, parent: null }], [node('a', root, 80), node('b', root, 450), { id: 'edge', incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: root, source: { elementId: 'a' }, target: { elementId: 'b' }, data: {}, path: { type: 'bezier-chain', knots: [{ id: 'k', mode: 'corner', position: { x: 400, y: 250 }, in: { x: -20, y: 0 }, out: { x: 20, y: 0 } }] } }]));
|
||||
documents.set(other, createDocument(other, [{ id: other, parent: null }], []));
|
||||
@@ -17,7 +17,7 @@ const boards = [root, other].map((id, i) => ({ id, workspace_id: workspace, kind
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=', 'base64');
|
||||
let releaseApply;const applyGate=new Promise(resolve=>{releaseApply=resolve;});
|
||||
let releaseUpload; const uploadGate = new Promise(resolve => { releaseUpload = resolve; });
|
||||
const model = (document, complete = true) => ({ documentFormatVersion: 1, projectId: document.projectId, complete, canvases: [...document.canvases.values()], elements: [...document.elements.values()], tombstones: Object.fromEntries(document.tombstones), versions: Object.fromEntries([...document.canvases.keys()].map(id => [id, versions.get(id)])), removedCanvasIds: [] });
|
||||
const model = (document, complete = true, participants=[...document.canvases.keys()]) => ({ documentFormatVersion: 1, projectId: document.projectId, complete, canvases: [...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)), elements: [...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)), tombstones: Object.fromEntries(document.tombstones), versions: Object.fromEntries(participants.map(id => [id, versions.get(id)])), removedCanvasIds: [] });
|
||||
const upstream = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://fixture'), path = url.pathname, chunks = [];
|
||||
@@ -35,27 +35,27 @@ const upstream = createServer(async (request, response) => {
|
||||
if (write) {
|
||||
const batch = JSON.parse(bytes.toString()), previous = documents.get(write[1]);
|
||||
assert.equal(request.headers['x-expected-account'], account); assert.equal(request.headers['idempotency-key'], batch.request_id);
|
||||
assert.equal(batch.expected_versions[write[1]], versions.get(write[1]));
|
||||
for(const [id,version] of Object.entries(batch.expected_versions))assert.equal(version,versions.get(id));
|
||||
let next = previous; for (const operation of batch.operations) next = applyChanges(next, operation.changes);
|
||||
documents.set(write[1], next); versions.set(write[1], versions.get(write[1]) + 1);
|
||||
return reply(200, { protocol_version: 2, request_id: batch.request_id, status: 'applied', operation_ids: batch.operations.map(operation => operation.id), model: model(next, false) });
|
||||
documents.set(write[1], next);for(const id of Object.keys(batch.expected_versions))versions.set(id,versions.get(id)+1);
|
||||
return reply(200, { protocol_version: 2, request_id: batch.request_id, status: 'applied', operation_ids: batch.operations.map(operation => operation.id), model: model(next, false,Object.keys(batch.expected_versions)) });
|
||||
}
|
||||
if(path.endsWith('/canvas/ai/models'))return reply(200,{models:[{id:'fixture-text',label:'Fixture text',available:true,input_modalities:['text'],output_modalities:['text']}]});
|
||||
if(path.endsWith('/canvas/ai/runs')&&request.method==='POST'){
|
||||
const input=JSON.parse(bytes.toString());assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['if-match'],'"'+versions.get(root)+'"');assert.deepEqual(input.expected_versions,{[root]:versions.get(root)});
|
||||
const previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.ai',before:element.data.ai??null,beforePresent:element.data.ai!==undefined,after:{enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:input.parameters,propagate:input.propagate},afterPresent:true}]);documents.set(root,next);versions.set(root,versions.get(root)+1);
|
||||
const runId=id(700+aiRuns.size*10),plan={steps:[{step_id:'step-b',board_id:root,node_id:input.node_id,canonical_root:root,target_incarnation:input.target_incarnation,kind:'text',model_id:input.model_id,status:'queued',dependencies:[],static_inputs:[]}],edges:[],frozen_versions:{[root]:versions.get(root)}};
|
||||
const admitted={protocol_version:2,request_id:input.request_id,operation_id:input.operation_id,admission:'accepted',status:'queued',run_id:runId,plan,canvas_version:versions.get(root),document_format_version:1,target_incarnation:input.target_incarnation,model:model(next,false)};
|
||||
aiRuns.set(runId,{status:'queued',plan,admitted,events:[{seq:1,run_id:runId,type:'run.queued',status:'queued'},{seq:2,run_id:runId,type:'step.queued',step_id:'step-b',board_id:root,node_id:input.node_id,status:'queued'}]});return reply(202,admitted);
|
||||
const input=JSON.parse(bytes.toString()),owner=documents.get(root).elements.get(input.node_id).ownerCanvasId;assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['if-match'],'"'+versions.get(owner)+'"');assert.deepEqual(input.expected_versions,Object.fromEntries([...documents.get(root).canvases.keys()].map(id=>[id,versions.get(id)])));
|
||||
const previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.ai',before:element.data.ai??null,beforePresent:element.data.ai!==undefined,after:{enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:input.parameters,propagate:input.propagate},afterPresent:true}]);documents.set(root,next);versions.set(owner,versions.get(owner)+1);
|
||||
const runId=id(700+aiRuns.size*10),plan={steps:[{step_id:'step-b',board_id:owner,node_id:input.node_id,canonical_root:root,target_incarnation:input.target_incarnation,kind:'text',model_id:input.model_id,status:'queued',dependencies:[],static_inputs:[]}],edges:[],frozen_versions:Object.fromEntries([...next.canvases.keys()].map(id=>[id,versions.get(id)]))};
|
||||
const admitted={protocol_version:2,request_id:input.request_id,operation_id:input.operation_id,admission:'accepted',status:'queued',run_id:runId,plan,canvas_version:versions.get(owner),document_format_version:1,target_incarnation:input.target_incarnation,model:model(next,false,[owner])};
|
||||
aiRuns.set(runId,{status:'queued',plan,admitted,events:[{seq:1,run_id:runId,type:'run.queued',status:'queued'},{seq:2,run_id:runId,type:'step.queued',step_id:'step-b',board_id:owner,node_id:input.node_id,status:'queued'}]});return reply(202,admitted);
|
||||
}
|
||||
const aiAction=path.match(/\/canvas\/ai\/runs\/([^/]+)\/(cancel|apply)$/);
|
||||
if(aiAction){
|
||||
const run=aiRuns.get(aiAction[1]),input=JSON.parse(bytes.toString()),base={protocol_version:2,request_id:input.request_id,operation_id:input.operation_id,action:'run.'+aiAction[2],status:'applied',run_id:aiAction[1]};
|
||||
assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['x-expected-account'],account);
|
||||
if(aiAction[2]==='cancel'){run.status='cancelled';run.events.push({seq:run.events.length+1,run_id:aiAction[1],type:'run.completed',status:'cancelled'});return reply(200,{...base,run_status:'cancelled'});}
|
||||
assert.equal(input.target_canvas_id,root);assert.equal(input.target_incarnation,1);assert.equal(request.headers['if-match'],'"'+versions.get(root)+'"');
|
||||
const output=run.events.find(event=>event.type==='step.output'&&event.output.output_id===input.output_id).output,previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.text',before:element.data.text,after:output.text,beforePresent:true,afterPresent:true}]);documents.set(root,next);versions.set(root,versions.get(root)+1);
|
||||
const applied={...base,step_id:'step-b',board_id:root,node_id:input.node_id,output_id:input.output_id,document_format_version:1,target_incarnation:1,canvas_version:versions.get(root),model:model(next,false)};run.events.push({...applied,seq:run.events.length+1,type:'step.applied'});await applyGate;return reply(200,applied);
|
||||
const owner=documents.get(root).elements.get(input.node_id).ownerCanvasId;assert.equal(input.target_canvas_id,owner);assert.equal(input.target_incarnation,1);assert.equal(request.headers['if-match'],'"'+versions.get(owner)+'"');
|
||||
const output=run.events.find(event=>event.type==='step.output'&&event.output.output_id===input.output_id).output,previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.text',before:element.data.text,after:output.text,beforePresent:true,afterPresent:true}]);documents.set(root,next);versions.set(owner,versions.get(owner)+1);
|
||||
const applied={...base,step_id:'step-b',board_id:owner,origin_board_id:run.plan.steps[0].board_id,canonical_root:root,node_id:input.node_id,output_id:input.output_id,document_format_version:1,target_incarnation:1,canvas_version:versions.get(owner),model:model(next,false,[owner])};run.events.push({...applied,seq:run.events.length+1,type:'step.applied'});await applyGate;return reply(200,applied);
|
||||
}
|
||||
const aiPoll=path.match(/\/canvas\/ai\/runs\/([^/]+)$/);
|
||||
if(aiPoll){const run=aiRuns.get(aiPoll[1]),after=Number(url.searchParams.get('after_seq')||0),events=run.events.filter(event=>event.seq>after);return reply(200,{run_id:aiPoll[1],status:run.status,plan:run.plan,steps:[],events,next_seq:events.at(-1)?.seq??after,has_more:false,outputs:[]});}
|
||||
@@ -162,8 +162,10 @@ try {
|
||||
await until(`document.body.innerText.includes('已受理')`,'AI admission did not persist');assert.equal(aiRuns.size,1);
|
||||
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`);await until(`location.pathname.endsWith('${other}')&&document.querySelectorAll('.react-flow__node').length===0`,'AI view detach failed');
|
||||
const run=aiRuns.get(id(700)),beforeAI=documents.get(root),beforeNode=beforeAI.elements.get('b');
|
||||
const generated=applyChanges(beforeAI,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:beforeNode.data.text,after:'Generated while another canvas was open',beforePresent:true,afterPresent:true}]);documents.set(root,generated);versions.set(root,versions.get(root)+1);run.status='succeeded';
|
||||
run.events.push({seq:3,run_id:id(700),type:'step.output',step_id:'step-b',board_id:root,node_id:'b',output:{output_id:id(701),kind:'text',text:'Generated while another canvas was open'}},{seq:4,run_id:id(700),type:'step.applied',step_id:'step-b',board_id:root,node_id:'b',canvas_version:versions.get(root),document_format_version:1,target_incarnation:1,model:model(generated,false)},{seq:5,run_id:id(700),type:'run.completed',status:'succeeded'});
|
||||
const frame={...node('relocation-frame',root,30),componentId:'frame',geometry:{x:30,y:50,width:1000,height:600,rotation:0},data:{title:'Relocated partition',text:''}};
|
||||
const movedModel=applyChanges(beforeAI,[{kind:'element',id:frame.id,before:null,after:frame},{kind:'canvas',id:partition,before:null,after:{id:partition,parent:{canvasId:root,entryElementId:frame.id}}},{kind:'field',target:{id:'b',incarnation:1},field:'ownerCanvasId',before:root,after:partition},{kind:'field',target:{id:'b',incarnation:1},field:'geometry',before:beforeNode.geometry,after:{...beforeNode.geometry,x:beforeNode.geometry.x-54,y:beforeNode.geometry.y-86}}]);
|
||||
const generated=applyChanges(movedModel,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:beforeNode.data.text,after:'Generated while another canvas was open',beforePresent:true,afterPresent:true}]);documents.set(root,generated);versions.set(root,versions.get(root)+1);versions.set(partition,2);run.status='succeeded';
|
||||
run.events.push({seq:3,run_id:id(700),type:'step.output',step_id:'step-b',board_id:root,node_id:'b',output:{output_id:id(701),kind:'text',text:'Generated while another canvas was open'}},{seq:4,run_id:id(700),type:'step.applied',step_id:'step-b',board_id:partition,origin_board_id:root,canonical_root:root,node_id:'b',canvas_version:versions.get(partition),document_format_version:1,target_incarnation:1,model:model(generated,true)},{seq:5,run_id:id(700),type:'run.completed',status:'succeeded'});
|
||||
const pollsBefore=calls.filter(call=>call.path.includes('/ai/runs/')).length;
|
||||
for(let i=0;i<200&&calls.filter(call=>call.path.includes('/ai/runs/')).length===pollsBefore;i++)await pause(20);
|
||||
assert.ok(calls.filter(call=>call.path.includes('/ai/runs/')).length>pollsBefore,'view detach stopped persistent AI polling');await pause(200);
|
||||
@@ -171,32 +173,32 @@ try {
|
||||
await call('Page.reload');await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'other project reload failed');
|
||||
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`);await until(`!!document.querySelector('.react-flow__node[data-id=b]')&&document.querySelector('.react-flow__node[data-id=b] textarea')?.value==='Generated while another canvas was open'`,'AI output did not survive reload');
|
||||
await selectB();await click('AI 创作');await until(`document.body.innerText.includes('已更新当前元素')`,'durable RunRecord did not recover');await click('关闭 AI 工具');
|
||||
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1,'AI recovery invoked generation twice');assert.equal(documents.get(root).elements.get('b').geometry.width,300);
|
||||
console.log('PASS actual AI panel submits through the durable ledger, polls after navigation, preserves geometry, and restores its RunRecord after reload without re-execution');
|
||||
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1,'AI recovery invoked generation twice');assert.equal(documents.get(root).elements.get('b').geometry.width,300);assert.equal(documents.get(root).elements.get('b').ownerCanvasId,partition);assert.equal(await evaluate(`document.querySelectorAll('.react-flow__node[data-id=b]').length`),1);
|
||||
console.log('PASS actual AI panel follows a remote owner move into a new partition, polls after navigation, and restores one element/RunRecord after reload without re-execution');
|
||||
await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="执行 AI"]')&&!document.querySelector('[aria-label="执行 AI"]').disabled`,'AI action did not reopen');await click('执行 AI');await until(`!!document.querySelector('[aria-label="取消生成"]')`,'cancel control did not appear');await click('取消生成');await until(`document.body.innerText.includes('已取消生成')`,'durable cancellation did not finish');assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open');
|
||||
await until(`!document.querySelector('[aria-label="执行 AI"]').disabled`,'cancelled run did not release execution');await click('执行 AI');
|
||||
for(let i=0;i<100&&!aiRuns.has(id(720));i++)await pause(20);assert.ok(aiRuns.has(id(720)));
|
||||
const conflicted=aiRuns.get(id(720));conflicted.status='conflict';conflicted.events.push({seq:3,run_id:id(720),type:'step.output',step_id:'step-b',board_id:root,node_id:'b',output:{output_id:id(721),kind:'text',text:'Chosen conflict result'}},{seq:4,run_id:id(720),type:'step.conflict',step_id:'step-b',board_id:root,node_id:'b',status:'conflict',error:'target_changed'},{seq:5,run_id:id(720),type:'run.completed',status:'conflict'});
|
||||
const conflicted=aiRuns.get(id(720));conflicted.status='conflict';conflicted.events.push({seq:3,run_id:id(720),type:'step.output',step_id:'step-b',board_id:partition,node_id:'b',output:{output_id:id(721),kind:'text',text:'Chosen conflict result'}},{seq:4,run_id:id(720),type:'step.conflict',step_id:'step-b',board_id:partition,node_id:'b',status:'conflict',error:'target_changed'},{seq:5,run_id:id(720),type:'run.completed',status:'conflict'});
|
||||
await until(`[...document.querySelectorAll('button')].some(button=>button.textContent==='使用此结果替换当前内容')`,'conflict result did not expose explicit application');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent==='使用此结果替换当前内容').click()`);
|
||||
await until(`document.querySelector('.react-flow__node[data-id=b] textarea').value==='Chosen conflict result'`,'chosen output was not optimistic');await click('撤销');await until(`document.querySelector('.react-flow__node[data-id=b] textarea').value==='Generated while another canvas was open'`,'unknown apply could not be locally undone');releaseApply();
|
||||
for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Generated while another canvas was open';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open');await click('重做');
|
||||
for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Chosen conflict result';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Chosen conflict result');assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/cancel')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/apply')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,3);await click('关闭 AI 工具');
|
||||
console.log('PASS actual AI cancellation and optimistic conflict application use durable actions; undo/redo while awaiting acknowledgment never regenerate');
|
||||
await evaluate(`(()=>{const bytes=Uint8Array.from(atob('${png.toString('base64')}'),c=>c.charCodeAt(0));const transfer=new DataTransfer();transfer.items.add(new File([bytes],'pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||
await until(`document.querySelectorAll('.react-flow__node').length === 4 && [...document.querySelectorAll('[data-headless-canvas] img')].some(image=>image.getAttribute('src')?.startsWith('blob:'))`, 'pending image did not appear before upload');
|
||||
await until(`document.querySelectorAll('.react-flow__node').length === 5 && [...document.querySelectorAll('[data-headless-canvas] img')].some(image=>image.getAttribute('src')?.startsWith('blob:'))`, 'pending image did not appear before upload');
|
||||
const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-canvas-integration.png',Buffer.from(screenshot.data,'base64'));
|
||||
for (let i = 0; i < 100 && !uploads.length; i++) await pause(20);
|
||||
assert.equal(uploads.length, 1); assert.equal(documents.get(root).elements.size, 4);
|
||||
assert.equal(uploads.length, 1); assert.equal(documents.get(root).elements.size, 5);
|
||||
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`);
|
||||
await until(`location.pathname.endsWith('${other}') && !!document.querySelector('[data-headless-canvas]') && document.querySelectorAll('.react-flow__node').length === 0`, 'other board did not open');
|
||||
releaseUpload();
|
||||
for (let i = 0; i < 200 && documents.get(root).elements.size !== 5; i++) await pause(20);
|
||||
assert.equal(documents.get(root).elements.size, 5, 'view detach aborted the upload or document operation');
|
||||
for (let i = 0; i < 200 && documents.get(root).elements.size !== 6; i++) await pause(20);
|
||||
assert.equal(documents.get(root).elements.size, 6, 'view detach aborted the upload or document operation');
|
||||
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node").length'), 0, 'late result appeared on the wrong board');
|
||||
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`);
|
||||
await until('document.querySelectorAll(".react-flow__node").length === 4', 'returning project lost its engine');
|
||||
await until('document.querySelectorAll(".react-flow__node").length === 5', 'returning project lost its engine');
|
||||
assert.equal(uploads.length, 1);
|
||||
await call('Page.reload'); await until('document.querySelectorAll(".react-flow__node").length === 4', 'page reload did not recover the document');
|
||||
await call('Page.reload'); await until('document.querySelectorAll(".react-flow__node").length === 5', 'page reload did not recover the document');
|
||||
assert.equal(uploads.length, 1); assert.equal(calls.some(call => call.path.endsWith('/canvas')), false);
|
||||
console.log('PASS pending image displays before upload; navigation preserves work and fences late view updates; reload retains resource identity');
|
||||
console.log('PASS canvas floating controls remain visible while the sidebar is expanded');
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { createFoundryPersistence, createFoundryRuns, mergeFoundryRunContext } 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};
|
||||
@@ -18,6 +18,8 @@ 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 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)]))});
|
||||
const context=()=>({...partial([...document.canvases.keys()]),complete:true});
|
||||
const transport=async request=>{
|
||||
calls.push(request);
|
||||
if(request.path.endsWith('/model/operations')){
|
||||
@@ -25,7 +27,7 @@ function fixture(){
|
||||
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)};}
|
||||
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};
|
||||
@@ -37,9 +39,9 @@ function fixture(){
|
||||
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)};
|
||||
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));
|
||||
@@ -74,12 +76,13 @@ function fixture(){
|
||||
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)});},
|
||||
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);},
|
||||
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);}
|
||||
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: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))},
|
||||
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;
|
||||
@@ -230,3 +233,34 @@ test('AI action rejection proof must match request, operation, action and run; b
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user