diff --git a/src/lib/canvas-service/foundry.ts b/src/lib/canvas-service/foundry.ts index ea30e4d..e70ceb6 100644 --- a/src/lib/canvas-service/foundry.ts +++ b/src/lib/canvas-service/foundry.ts @@ -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; readonly retryAfterMs?: number }; export type ServiceTransport = (request: ServiceRequest) => Promise; 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){ diff --git a/src/lib/canvas-service/runs.ts b/src/lib/canvas-service/runs.ts index e4483f0..e7c3fd6 100644 --- a/src/lib/canvas-service/runs.ts +++ b/src/lib/canvas-service/runs.ts @@ -69,7 +69,7 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr async function execute(request: FrozenRequest,query: boolean,signal: AbortSignal): Promise { 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; diff --git a/tests/headless/foundry-ai.integration.mjs b/tests/headless/foundry-ai.integration.mjs index 4373310..d0df87b 100644 --- a/tests/headless/foundry-ai.integration.mjs +++ b/tests/headless/foundry-ai.integration.mjs @@ -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); diff --git a/tests/headless/runs.test.mjs b/tests/headless/runs.test.mjs index 11140e7..382cb1c 100644 --- a/tests/headless/runs.test.mjs +++ b/tests/headless/runs.test.mjs @@ -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];