Files
Endoleap/src/lib/canvas-service/foundry.ts
T

264 lines
25 KiB
TypeScript

import { modelStreamType } from './model-stream.ts';
import type { CanvasRecord, Document, ElementRecord, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
import { createDocument, validateDocument } from '../canvas-core/document.ts';
import { equal, fault, freeze, readMap, validJson } from '../canvas-core/values.ts';
import type { AppliedModelProof, CanonicalReadResult, EngineSnapshot, PersistencePort } from '../canvas-runtime/contracts.ts';
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 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;
const record = (value: unknown): Record<string, unknown> => { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('invalid_canonical_response'); return value as Record<string, unknown>; };
const list = (value: unknown): unknown[] => { if (!Array.isArray(value)) throw fault('invalid_canonical_response'); return value; };
const id = (value: unknown): string => { if (typeof value !== 'string' || !value) throw fault('invalid_canonical_response'); return value; };
function versions(value: unknown, minimum = 1): ReadonlyMap<Id, number> {
const result = new Map<Id, number>();
for (const [key, version] of Object.entries(record(value))) { if (!uuid.test(key) || typeof version !== 'number' || !Number.isSafeInteger(version) || version < minimum) throw fault('invalid_canonical_version'); result.set(key, version); }
return readMap(result);
}
const pathFor = (workspace: Id, canvas: Id) => `workspaces/${encodeURIComponent(workspace)}/materials/${encodeURIComponent(canvas)}/canvas/model`;
/** Credentials stay in the host/session. Every write additionally binds the expected account. */
export function createFoundryTransport(options: { accountId: Id; isCurrentIdentity(): boolean; fetch?: typeof fetch; basePath?: string }): ServiceTransport {
if (!uuid.test(options.accountId)) throw fault('invalid_account_identity');
const requestFetch = options.fetch ?? globalThis.fetch;
return async input => {
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}"` } : {}),
} });
if (!options.isCurrentIdentity()) { await response.body?.cancel(); throw fault('identity_changed'); }
if(input.responseFormat==='asset-bytes-v1'&&response.status===200){
const maximum=20*1024*1024,length=response.headers.get('content-length');
if(length!==null&&(!/^\d+$/.test(length)||Number(length)>maximum)){await response.body?.cancel();throw fault('asset_byte_budget_exceeded');}
if(!response.body)throw fault('asset_bytes_unavailable');
const reader=response.body.getReader(),chunks:Uint8Array<ArrayBuffer>[]= [];let size=0;
const check=()=>{input.signal?.throwIfAborted();if(!options.isCurrentIdentity())throw fault('identity_changed');};
const abort=()=>{void reader.cancel().catch(()=>{});};input.signal?.addEventListener('abort',abort,{once:true});
try{while(true){check();const next=await reader.read();check();if(next.done)break;size+=next.value.byteLength;if(size>maximum)throw fault('asset_byte_budget_exceeded');chunks.push(next.value);}}
finally{input.signal?.removeEventListener('abort',abort);await reader.cancel().catch(()=>{});reader.releaseLock();}
return{status:200,body:null,bytes:new Blob(chunks,{type:response.headers.get('content-type')?.split(';')[0].trim()??''})};
}
if(input.responseFormat==='model-stream-v1'&&response.status===200&&response.headers.get('content-type')?.split(';')[0].trim()==='application/vnd.foundry.canvas-model+ndjson'){
if(!/;\s*version=1(?:;|$)/.test(response.headers.get('content-type')??'')){await response.body?.cancel();throw fault('unsupported_model_stream');}
if(!response.body)throw fault('incomplete_model_stream');
const body=response.body;
async function* stream(){
const reader=body.getReader(),abort=()=>{void reader.cancel().catch(()=>{});};input.signal?.addEventListener('abort',abort,{once:true});
try{while(true){input.signal?.throwIfAborted();if(!options.isCurrentIdentity())throw fault('identity_changed');const next=await reader.read();input.signal?.throwIfAborted();if(!options.isCurrentIdentity())throw fault('identity_changed');if(next.done)break;yield next.value;}}
finally{input.signal?.removeEventListener('abort',abort);await reader.cancel().catch(()=>{});reader.releaseLock();}
}
return{status:response.status,body:null,stream:stream()};
}
let body: unknown;
try { body = await response.json(); } catch { throw fault('invalid_canonical_response'); }
if (!options.isCurrentIdentity()) throw fault('identity_changed');
const retry = response.headers.get('retry-after'), seconds = retry === null ? NaN : Number(retry);
return { status: response.status, body, ...(Number.isFinite(seconds) && seconds >= 0 ? { retryAfterMs: seconds * 1000 } : {}) };
};
}
type ModelChunk = { projectId: Id; complete: boolean; canvases: readonly CanvasRecord[]; elements: readonly ElementRecord[]; tombstones: ReadonlyMap<Id, number>; versions: ReadonlyMap<Id, number>; removed: readonly Id[] };
function modelChunk(value: unknown): ModelChunk {
if (!validJson(value)) throw fault('invalid_canonical_response');
const raw = record(JSON.parse(JSON.stringify(value)));
if (raw.documentFormatVersion !== 1) throw fault('unsupported_document_format');
const projectId = id(raw.projectId);
if (!uuid.test(projectId) || typeof raw.complete !== 'boolean') throw fault('invalid_canonical_response');
const canvases = list(raw.canvases) as CanvasRecord[], elements = list(raw.elements) as ElementRecord[], removed = list(raw.removedCanvasIds).map(id), serverVersions = versions(raw.versions), tombstones = new Map<Id, number>();
const canvasIds = new Set<Id>(), elementIds = new Set<Id>();
for (const canvas of canvases) {
record(canvas);
if (!uuid.test(canvas.id) || canvasIds.has(canvas.id) || !serverVersions.has(canvas.id)) throw fault('invalid_canonical_response');
canvasIds.add(canvas.id);
}
for (const element of elements) {
record(element);
if (!element.id || elementIds.has(element.id) || !canvasIds.has(element.ownerCanvasId)) throw fault('invalid_canonical_response');
elementIds.add(element.id);
}
for (const [key, value] of Object.entries(record(raw.tombstones))) { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) throw fault('invalid_canonical_response'); tombstones.set(key, value); }
if (new Set(removed).size !== removed.length || removed.some(value => canvasIds.has(value) || !serverVersions.has(value)) || serverVersions.size !== canvasIds.size + removed.length) throw fault('invalid_canonical_response');
return freeze({ projectId, complete: raw.complete, canvases, elements, tombstones: readMap(tombstones), versions: serverVersions, removed });
}
export type ModelLoad = CanonicalReadResult;
export async function loadFoundryModel(transport: ServiceTransport, workspaceId: Id, canvasId: Id, registry: Registry, signal?: AbortSignal): Promise<ModelLoad> {
const response = await transport({ method: 'GET', path: pathFor(workspaceId, canvasId), signal }), body = record(response.body);
if (response.status === 409 && body.error === 'canonical_migration_required') {
if (body.document_format_version !== 1) throw fault('unsupported_document_format');
const rootId = id(body.root_id); if (!uuid.test(rootId)) throw fault('invalid_canonical_response');
const sourceVersions=versions(body.source_versions);if(!sourceVersions.has(canvasId)||!sourceVersions.has(rootId))throw fault('project_mismatch');
return freeze({ status: 'migration-required', rootId, sourceVersions });
}
if (response.status !== 200) throw fault(typeof body.error === 'string' ? body.error : 'model_load_failed');
const chunk = modelChunk(body.model);
if (!chunk.complete) throw fault('incomplete_initial_model');
const document = createDocument(chunk.projectId, chunk.canvases, chunk.elements, chunk.tombstones); validateDocument(document, registry);
if(!document.canvases.has(canvasId))throw fault('project_mismatch');
return Object.freeze({ status: 'ready', document, versions: chunk.versions });
}
function statusFor(request: FrozenRequest, response: ServiceResponse, query: boolean): OperationStatus {
const body = record(response.body), error = typeof body.error === 'string' ? body.error : undefined;
const matching = body.protocol_version === 2 && body.request_id === request.id;
if (matching && body.status === 'applied' && response.status >= 200 && response.status < 300) {
const covered = list(body.operation_ids).map(id);
if (covered.length !== request.operationIds.length || new Set(covered).size !== covered.length || covered.some(id => !request.operationIds.includes(id))) throw fault('receipt_coverage_mismatch');
if (!validJson(body)) throw fault('invalid_canonical_response');
return freeze({ outcome: 'applied', canonical: JSON.parse(JSON.stringify(body)) as Json });
}
if (matching && body.status === 'accepted' && response.status >= 200 && response.status < 300) return { outcome: 'accepted', retryAfterMs: response.retryAfterMs };
if (matching && body.status === 'not-applied' && body.terminal === true) return { outcome: 'not-applied', error: error ?? 'not_applied' };
if (query && response.status === 404 && body.status === 'unknown' && body.request_id === request.id) return { outcome: 'unresolved', error: 'operation_receipt_unknown', retryAfterMs: response.retryAfterMs };
if (response.status >= 400 && response.status < 500) return { outcome: 'unresolved', blocked: true, error: error ?? 'protocol_rejected' };
return { outcome: 'unresolved', error: error ?? 'transport_unknown', retryAfterMs: response.retryAfterMs };
}
function validateFrozen(request: FrozenRequest) {
let body: Record<string, unknown>;
try { body = record(JSON.parse(request.body)); } catch { throw fault('invalid_frozen_request'); }
const operations = list(body.operations).map(value => id(record(value).id));
if (request.protocolVersion !== 2 || body.protocol_version !== 2 || body.request_id !== request.id || !equal(body.expected_versions, request.expectedVersions) || !equal(Object.keys(request.expectedVersions).sort(), [...request.canvasIds].sort()) || !equal([...operations].sort(), [...request.operationIds].sort())) throw fault('frozen_request_contract_mismatch');
}
/** Operations/conditional fields are sent; full snapshots are only reads and ACK participant views. */
export type FoundryResolvedReceiptSummary={readonly localReceiptFormat:'foundry.reconciled-document';readonly localReceiptVersion:1;readonly protocol_version:2;readonly request_id:Id;readonly status:'applied';readonly operation_ids:readonly Id[];readonly projectId:Id;readonly versions:Readonly<Record<Id,number>>};
const receiptKeys=new Set(['protocol_version','request_id','status','operation_ids','model']);
const modelKeys=new Set(['documentFormatVersion','projectId','complete','canvases','elements','tombstones','versions','removedCanvasIds']);
const elementKeys=new Set(['id','incarnation','ownerCanvasId','componentId','dataVersion','structure','geometry','data','source','target','path']);
function hasExtraKeys(value:Record<string,unknown>,known:ReadonlySet<string>){for(const key in value)if(Object.hasOwn(value,key)&&!known.has(key))return true;return false;}
function assertRawReceipt(value:unknown){if(value&&typeof value==='object'&&!Array.isArray(value)&&(value as Record<string,unknown>).localReceiptFormat==='foundry.reconciled-document'&&!Object.hasOwn(value,'model')){if((value as Record<string,unknown>).localReceiptVersion!==1)throw fault('receipt_summary_interpreter_unavailable');throw fault('receipt_already_reconciled');}}
export function createFoundryPersistence(options: { workspaceId: Id; rootId: Id; registry: Registry; transport: ServiceTransport;resolvedReceiptPolicy?:'raw'|'summary' }): PersistencePort {
if (!uuid.test(options.workspaceId) || !uuid.test(options.rootId)) throw fault('invalid_service_scope');
if(options.resolvedReceiptPolicy!==undefined&&!['raw','summary'].includes(options.resolvedReceiptPolicy))throw fault('invalid_receipt_policy');
const path = pathFor(options.workspaceId, options.rootId);
async function execute(request: FrozenRequest, query: boolean, signal: AbortSignal): Promise<OperationStatus> {
try {
validateFrozen(request);
const response = await options.transport({ method: query ? 'GET' : 'POST', path: `${path}/operations${query ? `/${encodeURIComponent(request.id)}` : ''}`, requestId: query ? undefined : request.id, body: query ? undefined : request.body, signal });
return statusFor(request, response, query);
} catch (error) {
const code = (error as { code?: string }).code;
return { outcome: 'unresolved', error: code ?? 'transport_unknown', ...(code && code !== 'invalid_canonical_response' ? { blocked: true } : {}) };
}
}
function compileBatch(intents: readonly OperationIntent[], requestId: Id, snapshot: EngineSnapshot) {
if (!intents.length || intents.length > 64 || snapshot.document.projectId !== options.rootId || !uuid.test(requestId) || new Set(intents.map(intent => intent.id)).size !== intents.length) throw fault('operation_interpreter_unavailable');
for (const intent of intents) if (!uuid.test(intent.id) || intent.operationType !== 'document.change' || intent.operationSchemaVersion !== 1) throw fault('operation_interpreter_unavailable');
if (intents.some((intent, index) => index > 0 && intent.sequence <= intents[index - 1].sequence)) throw fault('invalid_operation_order');
const canvasIds = [...new Set(intents.flatMap(intent => intent.canvasIds))].sort(), expectedVersions: Record<Id, number> = {};
for (const canvas of canvasIds) {
const version = snapshot.runtime.versions.get(canvas);
if (version === undefined && !intents.some(intent => intent.changes.some(change => change.kind === 'canvas' && change.id === canvas && change.before === null && change.after))) throw fault('missing_server_version', [canvas]);
expectedVersions[canvas] = version ?? 0;
}
// The body is the durable coverage mapping. Never replace logical IDs with a synthetic batch ID.
const payload = JSON.parse(JSON.stringify({ protocol_version: 2, request_id: requestId, expected_versions: expectedVersions, operations: projectBatch(intents, snapshot.runtime.acknowledged, snapshot.runtime.ledger, canvasIds) })) as Json;
if (!validJson(payload)) throw fault('invalid_operation_payload');
return freeze({ protocolVersion: 2, canvasIds, expectedVersions, payload });
}
return Object.freeze({
supportsRequest: (request: FrozenRequest) => request.protocolVersion === 2,
...(options.resolvedReceiptPolicy==='summary'?{compactResolvedReceipt(request:FrozenRequest,receipt:Json,applied:import('../canvas-runtime/contracts.ts').CanonicalProposal):Json{
if(request.kind!=='document.change'||request.protocolVersion!==2||request.phase!=='resolved'||request.outcome!=='applied')return receipt;
validateFrozen(request);const body=record(receipt);
if(hasExtraKeys(body,receiptKeys))return receipt;
if(body.protocol_version!==2)return receipt;
if(body.request_id!==request.id||body.status!=='applied')throw fault('receipt_identity_mismatch');
const covered=list(body.operation_ids).map(id);if(covered.length!==request.operationIds.length||new Set(covered).size!==covered.length||covered.some(id=>!request.operationIds.includes(id)))throw fault('receipt_coverage_mismatch');
const model=record(body.model);
if(hasExtraKeys(model,modelKeys)||model.documentFormatVersion!==1||model.projectId!==options.rootId||model.complete!==false)return receipt;
const resultVersions=versions(model.versions);
if(resultVersions.size!==request.canvasIds.length||request.canvasIds.some(id=>!resultVersions.has(id)||(resultVersions.get(id)??0)<=request.expectedVersions[id]||resultVersions.get(id)!==applied.versions.get(id)))return receipt;
for(const value of list(model.elements)){const element=record(value);if(options.registry.resolve(element as unknown as ElementRecord)!=='known'||hasExtraKeys(element,elementKeys))return receipt;}
const canvasKeys=new Set(['id','parent']),parentKeys=new Set(['canvasId','entryElementId']);
for(const value of list(model.canvases)){const canvas=record(value);if(hasExtraKeys(canvas,canvasKeys)||canvas.parent!==null&&hasExtraKeys(record(canvas.parent),parentKeys))return receipt;}
return freeze({localReceiptFormat:'foundry.reconciled-document',localReceiptVersion:1,protocol_version:2,request_id:request.id,status:'applied',operation_ids:covered,projectId:options.rootId,versions:Object.fromEntries(resultVersions)});
}}:{}),
verifyApplied(request:FrozenRequest,receipt:Json){
assertRawReceipt(receipt);
validateFrozen(request);
if(statusFor(request,{status:200,body:receipt},false).outcome!=='applied')throw fault('receipt_identity_mismatch');
const proof=verifyFoundryModelProof(record(receipt).model,request.canvasIds,options.rootId);
for(const [canvas,version]of proof.versions)if(version!==request.expectedVersions[canvas]+1)throw fault('receipt_version_mismatch',[canvas]);
return proof;
},
async read(signal: AbortSignal) {
const loaded=await loadFoundryModel(options.transport,options.workspaceId,options.rootId,options.registry,signal);
if(loaded.status!=='ready')throw fault('canonical_migration_required');
if(loaded.document.projectId!==options.rootId)throw fault('project_mismatch');
return loaded;
},
compile: (intent: OperationIntent, requestId: Id, snapshot: EngineSnapshot) => compileBatch([intent], requestId, snapshot),
compileBatch,
submit: (request: FrozenRequest, signal: AbortSignal) => execute(request, false, signal),
query: (request: FrozenRequest, signal: AbortSignal) => execute(request, true, signal),
canonical(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot) {
assertRawReceipt(status.canonical);const body = record(status.canonical);
if (body.request_id !== request.id || body.protocol_version !== 2) throw fault('receipt_identity_mismatch');
return mergeFoundryModel(body.model, request.canvasIds, snapshot, options.registry, options.rootId);
},
});
}
/** Verify coverage independently of the current ACK graph. Only a complete fresh model is adopted. */
export function verifyFoundryModelProof(value:unknown,participants:readonly Id[],rootId:Id):AppliedModelProof {
const chunk=modelChunk(value);
if(chunk.projectId!==rootId||chunk.complete||chunk.versions.size!==participants.length||participants.some(id=>!chunk.versions.has(id)))throw fault('receipt_coverage_mismatch');
return Object.freeze({versions:chunk.versions,assertCanonical(proposal){
if(proposal.document.projectId!==rootId)throw fault('project_mismatch');
const unchanged=new Set<Id>(),canvases=new Map(chunk.canvases.map(canvas=>[canvas.id,canvas]));
for(const [canvas,version]of chunk.versions){
const fresh=proposal.versions.get(canvas);if(fresh===undefined||fresh<version)throw fault('recovery_read_behind_receipt',[canvas]);
if(fresh!==version)continue;
if(!equal(canvases.get(canvas),proposal.document.canvases.get(canvas)))throw fault('canonical_version_mismatch',[canvas]);
unchanged.add(canvas);
}
const expected=new Map(chunk.elements.filter(element=>unchanged.has(element.ownerCanvasId)).map(element=>[element.id,element]));
let count=0;for(const element of proposal.document.elements.values())if(unchanged.has(element.ownerCanvasId)){count++;if(!equal(expected.get(element.id),element))throw fault('canonical_version_mismatch',[element.ownerCanvasId]);}
if(count!==expected.size)throw fault('canonical_version_mismatch');
for(const [id,incarnation]of chunk.tombstones)if((proposal.document.tombstones.get(id)??0)<incarnation)throw fault('stale_canonical_lifecycle',[id]);
}});
}
/** Shared participant replacement for document receipts and canonical run events. */
export function mergeFoundryModel(value: unknown, participants: readonly Id[], snapshot: EngineSnapshot, registry: Registry, rootId: Id) {
const chunk = modelChunk(value);
if (chunk.projectId !== rootId || chunk.complete || chunk.versions.size !== participants.length || participants.some(id => !chunk.versions.has(id))) throw fault('receipt_coverage_mismatch');
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);
for (const canvas of participants) canvases.delete(canvas);
for (const canvas of chunk.canvases) canvases.set(canvas.id, canvas);
for (const [id, element] of elements) if (participants.includes(element.ownerCanvasId)) elements.delete(id);
for (const element of chunk.elements) { 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));
for (const [id, version] of chunk.versions) serverVersions.set(id, version);
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)});
}