fix(canvas): negotiate v2 for headless AI requests

This commit is contained in:
2026-11-11 15:41:59 +08:00
parent 3ea1a33747
commit 96fc37c319
4 changed files with 28 additions and 7 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ import type { FrozenRequest } from '../canvas-runtime/ledger.ts';
import { projectBatch } from '../canvas-runtime/batch-projection.ts';
import type { OperationStatus } from '../canvas-runtime/scheduler.ts';
export type ServiceRequest = { readonly responseFormat?:'model-stream-v1'|'asset-bytes-v1'; readonly path: string; readonly method: 'GET' | 'POST'; readonly requestId?: Id; readonly expectedVersion?: number; readonly body?: string | FormData; readonly resourceId?: Id; readonly signal?: AbortSignal };
export type ServiceRequest = { readonly responseFormat?:'canvas-v2'|'model-stream-v1'|'asset-bytes-v1'; readonly path: string; readonly method: 'GET' | 'POST'; readonly requestId?: Id; readonly expectedVersion?: number; readonly body?: string | FormData; readonly resourceId?: Id; readonly signal?: AbortSignal };
export type ServiceResponse = { readonly status: number; readonly body: unknown; readonly bytes?:Blob; readonly stream?:AsyncIterable<Uint8Array>; readonly retryAfterMs?: number };
export type ServiceTransport = (request: ServiceRequest) => Promise<ServiceResponse>;
const uuid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
@@ -29,7 +29,7 @@ export function createFoundryTransport(options: { accountId: Id; isCurrentIdenti
if (!options.isCurrentIdentity()) throw fault('identity_changed');
if (input.expectedVersion !== undefined && (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1)) throw fault('invalid_server_version');
const response = await requestFetch(`${options.basePath ?? '/api/foundry/'}${input.path}`, { method: input.method, body: input.body, signal: input.signal, credentials: 'same-origin', cache: 'no-store', redirect: 'error', headers: {
Accept: input.responseFormat==='model-stream-v1'?modelStreamType:input.responseFormat==='asset-bytes-v1'?'image/*':'application/json', ...(input.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...(input.resourceId ? { 'X-Resource-Id': input.resourceId } : {}), 'X-Expected-Account': options.accountId, ...(input.requestId ? { 'Idempotency-Key': input.requestId } : {}), ...(input.expectedVersion !== undefined ? { 'If-Match': `"${input.expectedVersion}"` } : {}),
Accept: input.responseFormat==='canvas-v2'?'application/vnd.foundry.canvas+json;version=2':input.responseFormat==='model-stream-v1'?modelStreamType:input.responseFormat==='asset-bytes-v1'?'image/*':'application/json', ...(input.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...(input.resourceId ? { 'X-Resource-Id': input.resourceId } : {}), 'X-Expected-Account': options.accountId, ...(input.requestId ? { 'Idempotency-Key': input.requestId } : {}), ...(input.expectedVersion !== undefined ? { 'If-Match': `"${input.expectedVersion}"` } : {}),
} });
if (!options.isCurrentIdentity()) { await response.body?.cancel(); throw fault('identity_changed'); }
if(input.responseFormat==='asset-bytes-v1'&&response.status===200){
+3 -3
View File
@@ -69,7 +69,7 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr
async function execute(request: FrozenRequest,query: boolean,signal: AbortSignal): Promise<OperationStatus> {
try{
const raw=payload(request),suffix=request.kind==='run.start'?'runs':`runs/${encodeURIComponent(request.runId!)}/${request.kind==='run.cancel'?'cancel':'apply'}`;
const response=await options.transport({method:query?'GET':'POST',path:`${path(request.ownerCanvasId!)}/${query?`operations/${encodeURIComponent(request.id)}`:suffix}`,requestId:query?undefined:request.id,expectedVersion:query||request.kind==='run.cancel'?undefined:request.expectedVersions[request.kind==='run.apply'?raw.target_canvas_id as Id:request.ownerCanvasId!],body:query?undefined:request.body,signal});
const response=await options.transport({responseFormat:'canvas-v2',method:query?'GET':'POST',path:`${path(request.ownerCanvasId!)}/${query?`operations/${encodeURIComponent(request.id)}`:suffix}`,requestId:query?undefined:request.id,expectedVersion:query||request.kind==='run.cancel'?undefined:request.expectedVersions[request.kind==='run.apply'?raw.target_canvas_id as Id:request.ownerCanvasId!],body:query?undefined:request.body,signal});
const body=object(response.body),matching=body.protocol_version===2&&body.request_id===request.id;
if(matching&&body.operation_id===request.operationIds[0]&&body.action===request.kind&&(request.kind==='run.start'||body.run_id===request.runId)&&body.status==='not-applied'&&body.terminal===true&&validJson(body))return freeze({outcome:'not-applied',error:typeof body.error==='string'?body.error:'not_applied',canonical:JSON.parse(JSON.stringify(body)) as Json});
if(matching&&body.operation_id===request.operationIds[0]&&body.action===request.kind&&body.status==='applied'&&request.kind!=='run.start'&&response.status>=200&&response.status<300&&validJson(body))return freeze({outcome:'applied',canonical:JSON.parse(JSON.stringify(body)) as Json});
@@ -81,7 +81,7 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr
return Object.freeze({
async catalog(boardId,signal){
if(!uuid.test(boardId))throw fault('invalid_service_scope');
const response=await options.transport({method:'GET',path:`${path(boardId)}/models`,signal}),body=object(response.body);
const response=await options.transport({responseFormat:'canvas-v2',method:'GET',path:`${path(boardId)}/models`,signal}),body=object(response.body);
if(response.status!==200)throw fault(typeof body.error==='string'?body.error:'model_catalog_unavailable');
const models=array(body.models).map(value=>{const model=object(value);string(model.id);for(const key of ['output_modalities','input_modalities'])for(const modality of array(model[key]))string(modality);if(typeof model.available!=='boolean'||!validJson(model))throw fault('invalid_run_response');return JSON.parse(JSON.stringify(model)) as RunModel;});
return freeze({models,...(typeof body.unavailable_reason==='string'?{unavailableReason:body.unavailable_reason}:{})});
@@ -120,7 +120,7 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr
return{...parsed.action,...(parsed.rawModel?{proposal:mergeFoundryModel(parsed.rawModel,request.canvasIds,snapshot,options.registry,options.rootId)}:{})};
},
async poll(run,signal){
const response=await options.transport({method:'GET',path:`${path(run.boardId)}/runs/${encodeURIComponent(run.id)}?after_seq=${run.cursor}&limit=100`,signal}),body=object(response.body);
const response=await options.transport({responseFormat:'canvas-v2',method:'GET',path:`${path(run.boardId)}/runs/${encodeURIComponent(run.id)}?after_seq=${run.cursor}&limit=100`,signal}),body=object(response.body);
if(response.status!==200)throw fault(response.status===404?'run_receipt_expired':typeof body.error==='string'?body.error:'run_poll_failed');
if(!validJson(body)||body.run_id!==run.id||!statuses.has(string(body.status))||typeof body.has_more!=='boolean'||!equal(body.plan,run.plan))throw fault('invalid_run_response');
const steps=planSteps(run.plan,options.rootId),events=array(body.events).map(object);let cursor=run.cursor;
+1 -1
View File
@@ -20,7 +20,7 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
// A committed admission whose response was lost must be queried after reopen.
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');const admissionCount=posts('/ai/runs').length;
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);
assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/ai/operations/'+admission.id)));
const recovered=engine.getSnapshot().runtime.ledger.requests.get(admission.id);
+22 -1
View File
@@ -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, mergeFoundryRunContext } from '../../src/lib/canvas-service/index.ts';
import { createFoundryPersistence, createFoundryRuns, createFoundryTransport, 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};
@@ -293,6 +293,27 @@ test('saved successful cancel and apply receipts recover controls without sendin
}
});
test('AI admission, cancel and apply negotiate canvas v2 through the shared HTTP transport',async()=>{
for(const action of ['start','cancel','apply']){
const f=fixture(),engine=await f.open();
try{
engine.startRun(runInput);await engine.settle();const runId=[...engine.getSnapshot().runtime.runs.keys()][0];
if(action==='cancel'){engine.cancelRun(runId);await engine.settle();}
if(action==='apply'){f.finish('retained',true);await f.clock.advance(0);await engine.settle();engine.applyRunOutput(runId,target,id(999));await engine.settle();}
const request=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.'+action),receipt=f.receipts.get(request.id),calls=[];
const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>true,fetch:async(url,input)=>{
calls.push({url,input});
if(input.headers.Accept!=='application/vnd.foundry.canvas+json;version=2')return Response.json({error:'canvas_schema_upgrade_required'},{status:409});
return Response.json(receipt,{status:action==='start'&&input.method==='POST'?202:200});
}}),port=createFoundryRuns({workspaceId:workspace,rootId:root,registry,transport}),signal=new AbortController().signal;
assert.equal((await port.submit(request,signal)).outcome,'applied');assert.equal((await port.query(request,signal)).outcome,'applied');
assert.equal(calls[0].input.body,request.body);assert.equal(calls[0].input.headers['Idempotency-Key'],request.id);
assert.equal(calls[0].input.headers['If-Match'],action==='cancel'?undefined:`"${request.expectedVersions[root]}"`);
assert.equal(calls[1].input.method,'GET');assert.equal(calls[1].input.body,undefined);assert.equal(calls[1].input.headers['Idempotency-Key'],undefined);
}finally{await engine.close();}
}
});
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];