import type { Document, Id, Registry, Result, RunInput, RunApplyInput, RunCancelInput, Json, OperationIntent, Data } from '../canvas-core/types.ts'; import { createEditor, type EditorOptions, type Transaction } from '../canvas-core/editor.ts'; import { createDocument } from '../canvas-core/document.ts'; import { createSessions } from '../canvas-core/gestures.ts'; import { equal, fault, freeze, readMap, reject } from '../canvas-core/values.ts'; import type { EngineSnapshot, PersistencePort, RuntimeState } from './contracts.ts'; import { createRunTracker, validateRunInput, runContainsTarget, type RunPort, type RunRecord, type RunPage } from './runs.ts'; import { createNetworkGate } from './network.ts'; import { createLedger, type FrozenRequest } from './ledger.ts'; import { operationCanvases, replayPending } from './reconcile.ts'; import { decodeRecovery, encodeRecovery } from './recovery.ts'; import { createScheduler, type Clock, type OperationStatus } from './scheduler.ts'; import { conflictsWith, maxResourceBytes, operationFootprint, referencedResources, type AssetPort, type ResourceRecord, type ResourceHandle, type StagedFile } from './resources.ts'; import type { StoragePort, StorageScope } from './storage.ts'; export type EngineOptions = { scope: StorageScope; registry: Registry; document: Document; versions: ReadonlyMap; storage: StoragePort; persistence: PersistencePort; assets?: AssetPort; runs?: RunPort; createId?: () => Id; historyLimit?: number; policy?: EditorOptions['policy']; traversalPolicy?: EditorOptions['traversalPolicy']; clock?: Clock; onError?: (error: unknown) => void; }; /** One project owner. Detaching a view does not close this engine or abort persistent work. */ export async function openProjectEngine(options: EngineOptions) { if (options.scope.projectId !== options.document.projectId) throw fault('project_mismatch'); const leader = await options.storage.claim(options.scope); try { const image = await options.storage.recover(leader), recovered = image ? decodeRecovery(image, options.registry) : undefined; if (recovered && recovered.document.projectId !== options.scope.projectId) throw fault('project_mismatch'); for (const canvas of options.document.canvases.keys()) if (!Number.isSafeInteger(options.versions.get(canvas)) || options.versions.get(canvas)! < 1) throw fault('missing_server_version', [canvas]); let closing = false, closed = false, durableRootRevision = image?.rootRevision ?? -1; const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Error reporting cannot alter state. */ } }; const createId = options.createId ?? (() => crypto.randomUUID()); const network = createNetworkGate(2); const runClock: Clock = options.clock ?? { now: () => Date.now(), setTimer: (fn, delay) => setTimeout(fn, delay), clearTimer: handle => clearTimeout(handle as ReturnType) }; const displays = new Set<() => void>(); const fileWrites = new Map(), digestTasks = new Map>(); let staged: readonly ResourceRecord[] = []; let applying: RunApplyInput | undefined; const initial: RuntimeState = recovered?.runtime ?? Object.freeze({ runs: readMap(new Map()), acknowledged: createDocument(options.document.projectId, [...options.document.canvases.values()], [...options.document.elements.values()], options.document.tombstones), versions: readMap(options.versions), ledger: createLedger().getSnapshot(), receipts: readMap(new Map()), conflicts: readMap(new Map()), resources: readMap(new Map()), resourcePins: readMap(new Map()) }); const editor = createEditor({ registry: options.registry, document: options.document, recovery: recovered, runtime: initial, createId: options.createId, historyLimit: options.historyLimit, policy(command, document) { if (closing || closed) return { enabled: false, code: 'engine_closing', targets: [] }; return options.policy?.(command, document) ?? { enabled: true }; }, traversalPolicy(entry, direction) { return closing || closed ? { enabled: false, code: 'engine_closing', targets: [] } : options.traversalPolicy?.(entry, direction) ?? { enabled: true }; }, onListenerError: report, prepareIntent: intent => applying && intent.origin==='user' ? {...intent,operationType:'run.apply',action:applying} : intent, nextOperationSequence(runtime, fallback) { let next = fallback; for (const record of runtime.ledger.operations.values()) next = Math.max(next, record.intent.sequence + 1); return next + staged.length; }, reduceRuntime(previous, next, commit) { if (!commit.operationIds.length) return previous; const ledger = createLedger(previous.ledger), resources = new Map(previous.resources); const firstSequence = next.operations.get(commit.operationIds[0])!.sequence - staged.length; staged.forEach((resource, index) => { if (resources.has(resource.id) || ledger.getSnapshot().operations.has(resource.operationId)) throw fault('resource_identity_conflict', [resource.id]); resources.set(resource.id, resource); ledger.add(freeze({ id: resource.operationId, operationType: 'asset.upload', operationSchemaVersion: 1, sequence: firstSequence + index, origin: 'user', canvasIds: [], changes: [], dependencies: [], resourceId: resource.id }), {}); ledger.block(resource.operationId, ['waiting_digest']); }); for (const id of commit.operationIds) { const intent = next.operations.get(id)!, footprint = operationFootprint(intent, next.document); const dependencies = new Set(intent.dependencies); for (const record of ledger.getSnapshot().operations.values()) if (record.phase !== 'resolved' && (record.intent.operationType === 'run.start' || ['document.change','run.apply'].includes(record.intent.operationType) && conflictsWith(operationFootprint(record.intent, next.document), footprint))) dependencies.add(record.intent.id); for (const id of referencedResources(intent.changes, options.registry)) { const resource = resources.get(id); if (resource && !resource.stored) dependencies.add(resource.operationId); } const basePresence: Record = Object.create(null); for (const change of intent.changes) if (change.kind === 'element') basePresence[change.id] = change.before ? 'exists' : 'absent'; else if (change.kind === 'field') basePresence[change.target.id] ??= 'exists'; ledger.add(freeze({ ...intent, dependencies: [...dependencies] }), basePresence); } return Object.freeze({ ...previous, resources: readMap(resources), ledger: ledger.getSnapshot() }); }, }); const pending = new Map(); if (!recovered) pending.set(editor.getSnapshot().rootRevision, editor.getSnapshot()); let writing: Promise | undefined, scheduled = false; function persist(): Promise { if (writing) return writing; const task = (async () => { while (pending.size) { const [revision, snapshot] = pending.entries().next().value!; const files = [...fileWrites].filter(([id]) => snapshot.runtime.resources.has(id)).map(([id, bytes]) => ({ id, bytes })); const receipt = await options.storage.commit(leader, { image: encodeRecovery(snapshot), files }); if (receipt.durableRootRevision !== revision) throw fault('invalid_durable_receipt'); durableRootRevision = revision; pending.delete(revision); for (const file of files) fileWrites.delete(file.id); if (editor.getSnapshot().runtime.ledger.durableGeneration < snapshot.runtime.ledger.generation) mutate(ledger => ledger.markDurable(snapshot.runtime.ledger.generation)); } })(); writing = task.finally(() => { writing = undefined; }); return writing; } function mutate(reduce: (ledger: ReturnType) => void) { const result = editor.updateRuntime(previous => { const ledger = createLedger(previous.ledger); reduce(ledger); return Object.freeze({ ...previous, ledger: ledger.getSnapshot() }); }); if (result.status === 'rejected') throw fault(result.code, result.targets); } function complete(request: FrozenRequest, status: OperationStatus): { readonly blocked: true } | void { const root = editor.getSnapshot(), previous = root.runtime, ledger = createLedger(previous.ledger); if (request.phase === 'resolved') return; if (status.outcome !== 'applied') { const result=editor.updateRuntime(runtime=>{const ledger=createLedger(runtime.ledger),receipts=new Map(runtime.receipts);ledger.result(request.id,status.outcome,status.error);if(status.outcome==='not-applied'&&['run.start','run.cancel'].includes(request.kind??''))ledger.resolveNoEffect(request.id);if(status.canonical!==undefined)receipts.set(request.id,freeze(status.canonical));return Object.freeze({...runtime,ledger:ledger.getSnapshot(),receipts:readMap(receipts)});},root.rootRevision); if(result.status==='rejected')throw fault(result.code,result.targets);return; } if(request.kind==='run.cancel'){ const receipts=new Map(previous.receipts);if(status.canonical!==undefined)receipts.set(request.id,freeze(status.canonical)); try{ if(!options.runs)throw fault('run_interpreter_unavailable'); const accepted=options.runs.acceptAction(request,status,root),run=previous.runs.get(accepted.runId);if(!run||!accepted.runStatus)throw fault('run_identity_mismatch'); ledger.result(request.id,'applied');const runs=new Map(previous.runs);runs.set(run.id,freeze({...run,controlEpoch:(run.controlEpoch??0)+1,status:accepted.runStatus,hasMore:true,blocked:false,error:undefined,nextPollAt:runClock.now()})); const result=editor.updateRuntime(()=>Object.freeze({...previous,runs:readMap(runs),receipts:readMap(receipts),ledger:ledger.getSnapshot()}),root.rootRevision);if(result.status==='rejected')throw fault(result.code,result.targets); queueMicrotask(()=>runTracker.notify([run.id]));return; }catch(error){ledger.result(request.id,'applied',(error as Error).message,false);const result=editor.updateRuntime(()=>Object.freeze({...previous,receipts:readMap(receipts),ledger:ledger.getSnapshot()}),root.rootRevision);if(result.status==='rejected')throw fault(result.code,result.targets);report(error);return{blocked:true};} } if (request.kind === 'asset.upload') { const receipts = new Map(previous.receipts); if (status.canonical !== undefined) receipts.set(request.id, freeze(status.canonical)); try { const intent = previous.ledger.operations.get(request.operationIds[0])?.intent; if (intent?.operationType !== 'asset.upload' || !options.assets) throw fault('asset_interpreter_unavailable'); const resource = previous.resources.get(intent.resourceId); if (!resource) throw fault('resource_missing'); const stored = freeze(options.assets.accept(request, status, resource)); if (stored.id !== resource.id || stored.bytes !== resource.byteLength) throw fault('asset_receipt_mismatch'); ledger.result(request.id, 'applied'); const resources = new Map(previous.resources); resources.set(resource.id, freeze({ ...resource, stored })); const result = editor.updateRuntime(() => Object.freeze({ ...previous, receipts: readMap(receipts), resources: readMap(resources), ledger: ledger.getSnapshot() }), root.rootRevision); if (result.status === 'rejected') throw fault(result.code, result.targets); return; } catch (error) { ledger.result(request.id, 'applied', (error as Error).message, false); const result = editor.updateRuntime(() => Object.freeze({ ...previous, receipts: readMap(receipts), ledger: ledger.getSnapshot() }), root.rootRevision); if (result.status === 'rejected') throw fault(result.code, result.targets); report(error); return { blocked: true }; } } const receipts = new Map(previous.receipts), conflicts = new Map(previous.conflicts); if (status.canonical !== undefined) receipts.set(request.id, freeze(status.canonical)); try { const action = request.kind==='run.apply' ? options.runs?.acceptAction(request,status,root) : undefined; if(request.kind==='run.apply'&&!action?.proposal)throw fault('run_interpreter_unavailable'); const admission = request.kind === 'run.start' ? options.runs?.accept(request, status, root) : undefined; if (request.kind === 'run.start' && !admission) throw fault('run_interpreter_unavailable'); const proposal = action?.proposal ?? admission?.proposal ?? options.persistence.canonical(request, status, root); const writtenCanvases = admission ? [request.ownerCanvasId!] : request.canvasIds; if (proposal.document.projectId !== root.document.projectId) throw fault('project_mismatch'); for (const canvas of writtenCanvases) { const version = proposal.versions.get(canvas); if (!version || version <= request.expectedVersions[canvas] || version < (previous.versions.get(canvas) ?? 0)) throw fault('stale_canonical_version', [canvas]); } // A codec cannot roll back an unrelated canvas while reconciling this request. for (const [canvas, version] of previous.versions) if (!writtenCanvases.includes(canvas)) { if (proposal.versions.get(canvas) !== version || !equal(proposal.document.canvases.get(canvas), previous.acknowledged.canvases.get(canvas)) || !equal([...proposal.document.elements.values()].filter(element => element.ownerCanvasId === canvas), [...previous.acknowledged.elements.values()].filter(element => element.ownerCanvasId === canvas))) throw fault('canonical_scope_mismatch', [canvas]); } ledger.result(request.id, 'applied'); const acknowledged = createDocument(proposal.document.projectId, [...proposal.document.canvases.values()], [...proposal.document.elements.values()], proposal.document.tombstones); const document = replayPending(acknowledged, ledger.getSnapshot(), options.registry); conflicts.delete(request.id); const runs = new Map(previous.runs); if (admission) { const existing = runs.get(admission.run.id); if (existing && (existing.requestId !== request.id || existing.operationId !== admission.run.operationId)) throw fault('run_identity_mismatch'); if (!existing) runs.set(admission.run.id, admission.run); } if(action){const run=runs.get(action.runId);if(!run)throw fault('run_identity_mismatch');runs.set(run.id,freeze({...run,controlEpoch:(run.controlEpoch??0)+1,appliedOutputIds:[...new Set([...(run.appliedOutputIds??[]),action.outputId!])],hasMore:true,blocked:false,error:undefined,pendingPage:undefined,nextPollAt:runClock.now()}));} const runtime: RuntimeState = Object.freeze({ ...previous, runs: readMap(runs), ledger: ledger.getSnapshot(), acknowledged, versions: readMap(proposal.versions), receipts: readMap(receipts), conflicts: readMap(conflicts) }); const result = editor.reconcile(document, runtime, root.rootRevision); if (result.status === 'rejected') throw fault(result.code, result.targets); if(action)queueMicrotask(()=>runTracker.notify([action.runId])); } catch (error) { // Preserve both the known receipt and the user's working document. No rollback or lane release. const waiting = createLedger(previous.ledger); waiting.result(request.id, 'applied', (error as Error).message, false); conflicts.set(request.id, (error as Error).message || 'reconcile_conflict'); const result = editor.updateRuntime(() => Object.freeze({ ...previous, ledger: waiting.getSnapshot(), receipts: readMap(receipts), conflicts: readMap(conflicts) }), root.rootRevision); if (result.status === 'rejected') throw fault(result.code, result.targets); report(error); return { blocked: true }; } } const scheduler = createScheduler({ getLedger: () => editor.getSnapshot().runtime.ledger, mutate, persist, createId: options.createId ?? (() => crypto.randomUUID()), participants: intent => operationCanvases(intent, editor.getSnapshot().document), compile(intent, id) { if (intent.operationType === 'document.change') return options.persistence.compile(intent, id, editor.getSnapshot()); if (['run.start','run.cancel','run.apply'].includes(intent.operationType)) { if (!options.runs) throw fault('run_interpreter_unavailable');return options.runs.compile(intent,id,editor.getSnapshot()); } if(intent.operationType!=='asset.upload')throw fault('operation_interpreter_unavailable'); const resource = editor.getSnapshot().runtime.resources.get(intent.resourceId); if (!options.assets || !resource?.digest) throw fault('asset_not_ready'); return { kind: 'asset.upload', protocolVersion: 2, canvasIds: [], expectedVersions: {}, payload: { protocol_version: 2, request_id: id, resource_id: resource.id, operation_id: resource.operationId, sha256: resource.digest, byte_length: resource.byteLength, name: resource.name, mime_type: resource.mimeType } }; }, async submit(request, signal) { if (['run.start','run.cancel','run.apply'].includes(request.kind??'')) return options.runs ? network.run(signal, () => options.runs!.submit(request,signal)) : { outcome: 'unresolved', blocked: true, error: 'run_interpreter_unavailable' }; if (request.kind !== 'asset.upload') return network.run(signal, () => options.persistence.submit(request, signal)); if (!options.assets) return { outcome: 'unresolved', blocked: true, error: 'asset_interpreter_unavailable' }; const payload = JSON.parse(request.body), bytes = await options.storage.readFile(leader, payload.resource_id); if (!bytes || bytes.size !== payload.byte_length || await digest(bytes) !== payload.sha256) return { outcome: 'unresolved', blocked: true, error: 'resource_bytes_unavailable' }; return network.run(signal, () => options.assets!.upload(request, bytes, signal)); }, query: (request, signal) => network.run(signal, () => ['run.start','run.cancel','run.apply'].includes(request.kind??'') ? options.runs?.query(request,signal) ?? Promise.resolve({ outcome: 'unresolved', blocked: true, error: 'run_interpreter_unavailable' }) : request.kind === 'asset.upload' ? options.assets?.query(request, signal) ?? Promise.resolve({ outcome: 'unresolved', blocked: true, error: 'asset_interpreter_unavailable' }) : options.persistence.query(request, signal)), supportsRequest: request => ['run.start','run.cancel','run.apply'].includes(request.kind??'') ? !!options.runs && request.protocolVersion === 2 : request.kind === 'asset.upload' ? !!options.assets && request.protocolVersion === 2 : options.persistence.supportsRequest?.(request) ?? request.protocolVersion === 1, complete, onError: report, clock: options.clock, }); function updateRun(id: Id, reduce: (run: RunRecord) => RunRecord) { const result = editor.updateRuntime(previous => { const current = previous.runs.get(id);if(!current)throw fault('run_missing');const runs = new Map(previous.runs);runs.set(id,freeze(reduce(current)));return Object.freeze({...previous,runs:readMap(runs)}); }); if(result.status==='rejected')throw fault(result.code,result.targets); } function acceptRunPage(run: RunRecord, page: RunPage) { const root=editor.getSnapshot(),previous=root.runtime,current=previous.runs.get(run.id); if(!current || current.requestId!==run.requestId)throw fault('run_identity_mismatch'); if((current.controlEpoch??0)!==(run.controlEpoch??0))return; if(current.cursor!==run.cursor)throw fault('run_identity_mismatch'); const outputEvents=new Map(current.outputEvents.map(event=>[event.step_id,event])); for(const event of page.events)if(event.type==='step.output')outputEvents.set(event.step_id,event); let acknowledged=previous.acknowledged,versions=previous.versions; const conflicts=new Map(previous.conflicts); try { for(const event of page.events) { 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; 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); 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)])]; runs.set(run.id,freeze({...current,appliedOutputIds,cursor:page.next,status:page.status,hasMore:page.hasMore,outputs:page.outputs,outputEvents:[...outputEvents.values()],steps:page.steps,error:page.error,blocked:false,pendingPage:undefined})); conflicts.delete(`run:${run.id}`); const result=editor.reconcile(document,Object.freeze({...previous,acknowledged,versions:readMap(versions),runs:readMap(runs),conflicts:readMap(conflicts)}),root.rootRevision); if(result.status==='rejected')throw fault(result.code,result.targets); } catch(error) { const code=(error as Error).message; // Save the page as evidence without overwriting local content or advancing the cursor. const result=editor.updateRuntime(runtime=>{const runs=new Map(runtime.runs);runs.set(run.id,freeze({...current,outputs:page.outputs,outputEvents:[...outputEvents.values()],steps:page.steps,pendingPage:page,error:code,blocked:code!=='run_waiting_for_receipts'}));if(code!=='run_waiting_for_receipts')conflicts.set(`run:${run.id}`,code);return Object.freeze({...runtime,runs:readMap(runs),conflicts:readMap(conflicts)});},root.rootRevision); if(result.status==='rejected')throw fault(result.code,result.targets); if(code!=='run_waiting_for_receipts')report(error); } } const runTracker=createRunTracker({clock:runClock,getRuns:()=>editor.getSnapshot().runtime.runs,persist,update:updateRun,accept:acceptRunPage, poll:(run,signal)=>options.runs ? network.run(signal,()=>options.runs!.poll(run,signal)) : Promise.reject(fault('run_interpreter_unavailable')),onError:report}); const trackedRuns=new Set(initial.runs.keys()); const sessions = createSessions(editor); const unsubscribe = editor.subscribe((commit, snapshot) => { pending.set(snapshot.rootRevision, snapshot); const addedRuns=[...snapshot.runtime.runs.keys()].filter(id=>!trackedRuns.has(id));for(const id of addedRuns)trackedRuns.add(id); if(addedRuns.length)queueMicrotask(()=>runTracker.notify(addedRuns)); if (!scheduled) { scheduled = true; queueMicrotask(() => { scheduled = false; void persist().catch(report); }); } // Intents already exist in this root; notification only schedules their IDs. const operationIds = [...commit.operationIds, ...staged.map(resource => resource.operationId)]; queueMicrotask(() => { if (!closing && !closed) { scheduler.notify(operationIds); if (operationIds.length) prepareDigests(); } }); }); async function digest(bytes: Blob) { return [...new Uint8Array(await crypto.subtle.digest('SHA-256', await bytes.arrayBuffer()))].map(value => value.toString(16).padStart(2, '0')).join(''); } function prepareDigests() { if (closing || closed) return; for (const resource of editor.getSnapshot().runtime.resources.values()) { if (digestTasks.size >= 2) break; if (resource.digest || digestTasks.has(resource.id)) continue; const operation = editor.getSnapshot().runtime.ledger.operations.get(resource.operationId); if (operation?.phase !== 'blocked' || !operation.blockers.includes('waiting_digest')) continue; const task = Promise.resolve().then(async () => { const bytes = fileWrites.get(resource.id) ?? await options.storage.readFile(leader, resource.id); if (!bytes || bytes.size !== resource.byteLength) throw fault('resource_bytes_unavailable', [resource.id]); const hash = await digest(bytes); if (closing || closed) return; const result = editor.updateRuntime(previous => { if (previous.resources.get(resource.id) !== resource) throw fault('resource_identity_conflict', [resource.id]); const resources = new Map(previous.resources), ledger = createLedger(previous.ledger); resources.set(resource.id, freeze({ ...resource, digest: hash })); ledger.block(resource.operationId, []); return Object.freeze({ ...previous, resources: readMap(resources), ledger: ledger.getSnapshot() }); }); if (result.status === 'rejected') throw fault(result.code, result.targets); scheduler.notify([resource.operationId]); }).catch(error => { if (!closing && !closed) { mutate(ledger => ledger.block(resource.operationId, ['resource_bytes_unavailable'])); report(error); } }).finally(() => { digestTasks.delete(resource.id); prepareDigests(); }); digestTasks.set(resource.id, task); } } function queueRunAction(operationType:'run.cancel'|'run.apply',action:RunCancelInput|RunApplyInput):Result { const root=editor.getSnapshot();let sequence=1;for(const record of root.runtime.ledger.operations.values())sequence=Math.max(sequence,record.intent.sequence+1); const id=createId(),intent={id,operationType,action,operationSchemaVersion:1,sequence,origin:'user',canvasIds:operationType==='run.apply'?[(action as RunApplyInput).targetCanvasId]:[],changes:[],dependencies:operationType==='run.cancel'?[]:[...root.runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved').map(record=>record.intent.id)} as OperationIntent; const result=editor.updateRuntime(previous=>{const ledger=createLedger(previous.ledger);ledger.add(freeze(intent),{});return Object.freeze({...previous,ledger:ledger.getSnapshot()});},root.rootRevision); if(result.status==='rejected')return result;queueMicrotask(()=>{if(!closing&&!closed)scheduler.notify([id]);});return{status:'applied',revision:root.revision,operationIds:[id]}; } scheduler.start(); prepareDigests(); runTracker.notify([...initial.runs.keys()]); return Object.freeze({ editor, sessions, models(boardId: Id, signal: AbortSignal) { if(closing||closed||!options.runs)return Promise.reject(fault('run_interpreter_unavailable')); if(!editor.getSnapshot().document.canvases.has(boardId))return Promise.reject(fault('canvas_missing')); return network.run(signal,()=>options.runs!.catalog(boardId,signal)); }, cancelRun(runId:Id):Result { try{ if(closing||closed)throw fault('engine_closing');if(!options.runs)throw fault('run_interpreter_unavailable'); const root=editor.getSnapshot(),run=root.runtime.runs.get(runId);if(!run)throw fault('run_missing'); const allowed=options.policy?.({type:'element.data',target:run.target,patch:{}},root.document);if(allowed&&!allowed.enabled)throw fault(allowed.code,allowed.targets); if([...root.runtime.ledger.operations.values()].some(record=>record.phase!=='resolved'&&record.intent.operationType==='run.cancel'&&record.intent.action.runId===runId))throw fault('run_action_pending'); return queueRunAction('run.cancel',{runId,boardId:run.boardId}); }catch(error){return reject(error);} }, applyRunOutput(runId:Id,target:RunApplyInput['target'],outputId:Id):Result { try{ if(closing||closed)throw fault('engine_closing');if(!options.runs)throw fault('run_interpreter_unavailable');if(applying)throw fault('reentrant_dispatch'); const root=editor.getSnapshot(),run=root.runtime.runs.get(runId),element=root.document.elements.get(target.id); if(!run||run.blocked||!runContainsTarget(run,target)||!element||element.incarnation!==target.incarnation)throw fault('target_changed'); if((run.appliedOutputIds??[]).includes(outputId))throw fault('output_already_applied'); if([...root.runtime.ledger.operations.values()].some(record=>record.phase!=='resolved'&&(record.intent.operationType==='run.apply'||record.intent.operationType==='run.cancel')&&record.intent.action.runId===runId))throw fault('run_action_pending'); const event=run.outputEvents.find(event=>event.node_id===target.id&&event.output&&typeof event.output==='object'&&!Array.isArray(event.output)&&event.output.output_id===outputId),output=event?.output as {[key:string]:Json}|undefined; if(!output||run.status!=='conflict')throw fault('output_not_applicable'); const patch:Data|undefined=output.kind==='text'&&typeof output.text==='string'?{text:output.text}:output.kind==='image'&&typeof output.src==='string'&&typeof output.assetId==='string'?{src:output.src,assetId:output.assetId}:undefined;if(!patch)throw fault('invalid_run_output'); const action:RunApplyInput={runId,boardId:run.boardId,targetCanvasId:element.ownerCanvasId,target,outputId};applying=action; const result=editor.dispatch({type:'element.data',target,patch},{label:'Apply generated result'}); if(result.status==='rejected'||result.operationIds.length)return result; return queueRunAction('run.apply',action); }catch(error){return reject(error);}finally{applying=undefined;} }, startRun(input: RunInput): Result { try { if(closing||closed)throw fault('engine_closing'); if(!options.runs)throw fault('run_interpreter_unavailable'); validateRunInput(input); const root=editor.getSnapshot(),element=root.document.elements.get(input.target.id); if(!element||element.incarnation!==input.target.incarnation||element.ownerCanvasId!==input.boardId||element.structure!=='node'||options.registry.resolve(element)!=='known'||!['text','plain-text','script','image','plain-image'].includes(element.componentId))throw fault('target_changed'); const allowed=editor.can({type:'element.data',target:input.target,patch:{ai:{enabled:true,modelId:input.modelId,prompt:input.prompt,parameters:input.parameters,propagate:input.propagate}}}); if(!allowed.enabled)throw fault(allowed.code,allowed.targets); if([...root.runtime.ledger.operations.values()].some(record=>record.intent.operationType==='run.start'&&record.phase!=='resolved'&&record.intent.run.target.id===input.target.id))throw fault('node_busy'); if([...root.runtime.runs.values()].some(run=>runContainsTarget(run,input.target)&&(run.hasMore||run.pendingPage||['queued','running'].includes(run.status))))throw fault('node_busy'); const id=createId(),dependencies=[...root.runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved').map(record=>record.intent.id); let sequence=1;for(const record of root.runtime.ledger.operations.values())sequence=Math.max(sequence,record.intent.sequence+1); const result=editor.updateRuntime(previous=>{const ledger=createLedger(previous.ledger);ledger.add(freeze({id,operationType:'run.start',operationSchemaVersion:1,sequence,origin:'user',canvasIds:[...root.document.canvases.keys()].sort(),changes:[],dependencies,run:JSON.parse(JSON.stringify(input))}),{});return Object.freeze({...previous,ledger:ledger.getSnapshot()});},root.rootRevision); if(result.status==='rejected')return result; queueMicrotask(()=>{if(!closing&&!closed)scheduler.notify([id]);}); return{status:'applied',revision:root.revision,operationIds:[id]}; }catch(error){return reject(error);} }, refresh(document: Document, versions: ReadonlyMap): Result { const root = editor.getSnapshot(), previous = root.runtime; try { if (closing || closed) throw fault('engine_closing'); if (document.projectId !== root.document.projectId) throw fault('project_mismatch'); if ([...previous.ledger.requests.values()].some(request => request.phase !== 'resolved')) throw fault('refresh_waiting_for_receipts'); for (const canvas of document.canvases.keys()) if (!Number.isSafeInteger(versions.get(canvas)) || versions.get(canvas)! < 1) throw fault('missing_server_version', [canvas]); for (const [id, version] of previous.versions) if ((versions.get(id) ?? -1) < version) throw fault('stale_canonical_version', [id]); if (equal([...previous.versions], [...versions]) && equal([...previous.acknowledged.canvases], [...document.canvases]) && equal([...previous.acknowledged.elements], [...document.elements]) && equal([...previous.acknowledged.tombstones], [...document.tombstones])) return { status: 'applied', revision: root.revision, operationIds: [] }; const acknowledged = createDocument(document.projectId, [...document.canvases.values()], [...document.elements.values()], document.tombstones); const working = replayPending(acknowledged, previous.ledger, options.registry); const conflicts = new Map(previous.conflicts); conflicts.delete('refresh'); return editor.reconcile(working, Object.freeze({ ...previous, acknowledged, versions: readMap(versions), conflicts: readMap(conflicts) }), root.rootRevision); } catch (error) { const result = reject(error); if (result.code !== 'refresh_waiting_for_receipts' && result.code !== 'engine_closing') editor.updateRuntime(previous => { const conflicts = new Map(previous.conflicts); conflicts.set('refresh', result.code); return Object.freeze({ ...previous, conflicts: readMap(conflicts) }); }); return result; } }, stageFiles(files: readonly StagedFile[], callback: (resources: readonly ResourceHandle[], transaction: Transaction) => unknown) { if (closing || closed) throw fault('engine_closing'); if (staged.length) throw fault('reentrant_dispatch'); if (!options.assets) throw fault('asset_interpreter_unavailable'); const resources = files.map(file => { if (!(file.bytes instanceof Blob) || !file.bytes.size || file.bytes.size > maxResourceBytes || !file.bytes.type.startsWith('image/') || typeof file.name !== 'string' || !file.name) throw fault('invalid_staged_file'); return freeze({ id: createId(), operationId: createId(), name: file.name, mimeType: file.bytes.type, byteLength: file.bytes.size }); }); if (new Set(resources.flatMap(resource => [resource.id, resource.operationId])).size !== resources.length * 2 || resources.some(resource => editor.getSnapshot().runtime.resources.has(resource.id) || fileWrites.has(resource.id))) throw fault('resource_identity_conflict'); staged = resources; resources.forEach((resource, index) => fileWrites.set(resource.id, files[index].bytes)); try { const handles = freeze(resources.map(resource => ({ id: resource.id, src: options.assets!.sourceURL(resource.id) }))); const result = editor.transaction(tx => callback(handles, tx), { label: 'Add images' }); if (result.status === 'rejected' || !result.operationIds.length) for (const resource of resources) fileWrites.delete(resource.id); return result; } finally { staged = []; for (const resource of resources) if (!editor.getSnapshot().runtime.resources.has(resource.id)) fileWrites.delete(resource.id); } }, async displayResource(id: Id) { if (closing || closed) throw fault('engine_closing'); const bytes = fileWrites.get(id) ?? await options.storage.readFile(leader, id); if (closing || closed) throw fault('engine_closing'); if (!bytes && !options.assets) throw fault('resource_missing', [id]); const url = bytes ? URL.createObjectURL(bytes) : options.assets!.displayURL(id); let released = false; const release = () => { if (!released) { released = true; displays.delete(release); if (bytes) URL.revokeObjectURL(url); } }; displays.add(release); return Object.freeze({ url, release }); }, getSnapshot: editor.getSnapshot, persist, getDurability: () => Object.freeze({ rootRevision: editor.getSnapshot().rootRevision, durableRootRevision }), retry: async () => { await scheduler.resume();await runTracker.retry(); }, async settle() { await Promise.resolve(); while (digestTasks.size) await Promise.all([...digestTasks.values()]); await scheduler.settle(); await runTracker.settle(); await persist(); }, async close() { if (closed) return; closing = true; sessions.dispose(); scheduler.stop(); runTracker.stop(); for (const release of [...displays]) release(); // Failure keeps the leader/recovery material available for an explicit retry of close. await persist(); unsubscribe(); editor.dispose(); await leader.release(); closed = true; }, }); } catch (error) { await leader.release(); throw error; } }