583 lines
62 KiB
TypeScript
583 lines
62 KiB
TypeScript
import { encodeCopySnapshot, decodeCopySnapshot, copyRegistrySignature } from './copy-snapshot.ts';
|
|
import { planDuplicateAsync } from './duplicate.ts';
|
|
import { encodeDuplicateSource, prepareDuplicateImport, assertDuplicateExternal } from './import-duplicate.ts';
|
|
import { createImportJob, createImportRunner, createNdjsonImportSource, type ImportJob } from './imports.ts';
|
|
import { assertNativeImportDependencies, assertNativeImportTarget, prepareNativeImport } from './import-native.ts';
|
|
import { elisionBatchLimit, findElision } from './elision.ts';
|
|
import type { Document, Id, Registry, Result, RunInput, RunApplyInput, RunCancelInput, Target, Point, 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 { clone, equal, fault, freeze, readMap, reject } from '../canvas-core/values.ts';
|
|
import type { CanonicalProposal, 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, validateCanonicalRead } from './reconcile.ts';
|
|
import { decodeRecovery, encodeRecovery } from './recovery.ts';
|
|
import { createScheduler, type Clock, type OperationStatus } from './scheduler.ts';
|
|
import { planConflictRecovery, type ConflictPlan, type RecoveryChoice } from './conflicts.ts';
|
|
import { stageAppliedRecovery } from './applied-recovery.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<Id, number>;
|
|
storage: StoragePort; persistence: PersistencePort; assets?: AssetPort; runs?: RunPort; createId?: () => Id;
|
|
historyLimit?: number; policy?: EditorOptions['policy']; traversalPolicy?: EditorOptions['traversalPolicy']; clock?: Clock;
|
|
onError?: (error: unknown) => void;
|
|
};
|
|
export type RecoverySession = {
|
|
readonly baseRootRevision: number;
|
|
readonly canonical: CanonicalProposal;
|
|
readonly pendingOperationIds: readonly Id[];
|
|
readonly acknowledgedRequestIds: readonly Id[];
|
|
/** A conflict is diagnostic only: the existing document/history/requests remain intact. */
|
|
readonly conflict?: { readonly code: string; readonly targets: readonly Id[] };
|
|
plan(choices:ReadonlyMap<Id,RecoveryChoice>):ConflictPlan;
|
|
resolve(plan:ConflictPlan):Promise<Result>;
|
|
apply(): Promise<Result>;
|
|
cancel(): Promise<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<typeof setTimeout>) };
|
|
const displays = new Set<() => void>();
|
|
const fileWrites = new Map<Id, Blob>(), digestTasks = new Map<Id, Promise<void>>();
|
|
let staged: readonly ResourceRecord[] = [];
|
|
let applying: RunApplyInput | undefined;
|
|
let importing: { job: ImportJob; expectedGeneration: number } | undefined;
|
|
const preparationControllers=new Map<Id,AbortController>();
|
|
const copyPreparations=new Map<AbortController,Promise<Id>>();
|
|
const importRunners=new Map<Id,ReturnType<typeof createImportRunner>>(),importLoads=new Map<Id,Promise<void>>(),importHolds=new Map<symbol,Map<Id,()=>void>>();
|
|
let recovery: { controller: AbortController; release(): void; cancel():Promise<void> } | undefined;
|
|
const assertWritable = () => { if(closing||closed)throw fault('engine_closing');if(recovery)throw fault('recovery_in_progress'); };
|
|
const initial: RuntimeState = recovered?.runtime ?? Object.freeze({ imports:readMap(new Map()), recoveries:readMap(new Map()), 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 || recovery) return { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] }; return options.policy?.(command, document) ?? { enabled: true }; },
|
|
traversalPolicy(entry, direction) { return closing || closed || recovery ? { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', 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<Id, 'absent' | 'exists'> = 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);
|
|
}
|
|
const imports=new Map(previous.imports??[]);
|
|
if(importing){if(imports.get(importing.job.id)?.generation!==importing.expectedGeneration)throw fault('stale_import_job');imports.set(importing.job.id,importing.job);}
|
|
return Object.freeze({ ...previous, imports:readMap(imports), resources: readMap(resources), ledger: ledger.getSnapshot() });
|
|
},
|
|
});
|
|
const pending = new Map<number, EngineSnapshot>();
|
|
if (!recovered) pending.set(editor.getSnapshot().rootRevision, editor.getSnapshot());
|
|
let writing: Promise<void> | undefined, scheduled = false, persistenceFailed = false;
|
|
function persist(): Promise<void> {
|
|
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)||[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.fileId===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));
|
|
}
|
|
persistenceFailed = false;
|
|
})();
|
|
writing = task.catch(error => { persistenceFailed = true; throw error; }).finally(() => { writing = undefined; });
|
|
return writing;
|
|
}
|
|
function mutate(reduce: (ledger: ReturnType<typeof createLedger>) => 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 } };
|
|
},
|
|
batchLimit(intents) { const snapshot = editor.getSnapshot(); return elisionBatchLimit(intents, snapshot.runtime.acknowledged, snapshot.runtime.ledger); },
|
|
elide(intents) {
|
|
const snapshot = editor.getSnapshot(), proof = findElision(intents, snapshot.runtime.acknowledged, snapshot.runtime.ledger);
|
|
if (!proof) return [];
|
|
mutate(ledger => ledger.elide(proof)); return proof.operationIds;
|
|
},
|
|
compileBatch: options.persistence.compileBatch ? (intents, id) => options.persistence.compileBatch!(intents, id, editor.getSnapshot()) : undefined,
|
|
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]};
|
|
}
|
|
function getImport(id:Id){const job=editor.getSnapshot().runtime.imports?.get(id);if(!job)throw fault('import_missing',[id]);return job;}
|
|
function publishImport(job:ImportJob,expectedGeneration:number){
|
|
assertWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
|
|
const result=editor.updateRuntime(previous=>{const imports=new Map(previous.imports??[]);if(imports.get(job.id)?.generation!==expectedGeneration)throw fault('stale_import_job');imports.set(job.id,job.version===2?job:freeze({...job,version:2 as const}));return Object.freeze({...previous,imports:readMap(imports)});});if(result.status==='rejected')throw fault(result.code,result.targets);
|
|
}
|
|
function holdImports(){const token=Symbol(),releases=new Map<Id,()=>void>();importHolds.set(token,releases);for(const controller of preparationControllers.values())controller.abort();for(const[id,runner]of importRunners)releases.set(id,runner.hold());return()=>{if(!importHolds.delete(token))return;for(const release of releases.values())release();if(!closing&&!closed)for(const job of editor.getSnapshot().runtime.imports?.values()??[])if(['running','preparing'].includes(job.status)&&!importRunners.has(job.id))void loadImport(job.id);};}
|
|
async function materializeCopy(id:Id,bytes:Blob,controller:AbortController){
|
|
const admitted=getImport(id),preparation=admitted.preparation;
|
|
if(!preparation||admitted.source?.kind!=='duplicate-snapshot')throw fault('invalid_copy_preparation');
|
|
const check=()=>{const job=getImport(id);if(controller.signal.aborted||closing||closed||importHolds.size||job.status!=='preparing'||job.sourceFingerprint!==admitted.sourceFingerprint)throw fault('copy_aborted');return job;};
|
|
await persist();check();const document=await decodeCopySnapshot(bytes,options.scope.projectId,options.registry,controller.signal);
|
|
if(copyRegistrySignature(document,options.registry)!==preparation.registrySignature)throw fault('copy_interpreter_changed');
|
|
const allocationIds=[...preparation.allocationIds];let cursor=0;
|
|
const plan=await planDuplicateAsync(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId:()=>{const index=cursor++;if(index<allocationIds.length)return allocationIds[index];const id=createId();allocationIds.push(id);return id;}},{signal:controller.signal,checkpoint:async steps=>{
|
|
const job=check(),previous=job.preparation!;
|
|
if(steps<=previous.steps&&allocationIds.length===previous.allocationIds.length)return;
|
|
publishImport(freeze({...job,generation:job.generation+1,preparation:{...previous,steps:Math.max(previous.steps,steps),allocationIds:[...allocationIds]}}),job.generation);await persist();check();
|
|
}});
|
|
check();if(cursor!==allocationIds.length)throw fault('copy_interpreter_changed');
|
|
const encoded=await encodeDuplicateSource(document,options.registry,plan,controller.signal),job=check();assertWritable();assertNativeImportTarget(job,editor.getSnapshot().document);
|
|
const permission=options.policy?.({type:'changes.apply',changes:plan.changes},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
|
|
const fileId=createId(),runtime=editor.getSnapshot().runtime;if(!fileId||fileWrites.has(fileId)||runtime.resources.has(fileId)||[...(runtime.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict');
|
|
const stableIds=Object.fromEntries([...Object.entries(plan.elementIds).map(([old,id])=>[`element:${old}`,id]),...Object.entries(plan.canvasIds).map(([old,id])=>[`canvas:${old}`,id])]);
|
|
const next={...job,generation:job.generation+1,status:'running' as const,stableIds,sourceFingerprint:`local-file:${fileId}:${encoded.size}`,source:{...job.source!,kind:'duplicate-ndjson' as const,fileId,byteLength:encoded.size,copy:{sourceRevision:job.source!.copy!.sourceRevision,roots:plan.roots}}};delete next.preparation;delete next.error;
|
|
fileWrites.set(fileId,encoded);try{publishImport(freeze(next),job.generation);}catch(error){fileWrites.delete(fileId);throw error;}
|
|
return{job:getImport(id),bytes:encoded};
|
|
}
|
|
function loadImport(id:Id):Promise<void>{
|
|
if(importLoads.has(id))return importLoads.get(id)!;
|
|
if(importRunners.has(id)){void importRunners.get(id)!.start();return Promise.resolve();}
|
|
let expectedFingerprint=getImport(id).sourceFingerprint;
|
|
const preparationController=new AbortController();if(getImport(id).source?.kind==='duplicate-snapshot')preparationControllers.set(id,preparationController);
|
|
const task=(async()=>{
|
|
let job=getImport(id),source=job.source;if(!source||!['native-ndjson','duplicate-ndjson','duplicate-snapshot'].includes(source.kind)||job.parserVersion!==1)throw fault('import_interpreter_unavailable');
|
|
let bytes=fileWrites.get(source.fileId)??await options.storage.readFile(leader,source.fileId);
|
|
if(closing||closed)return;
|
|
const latest=getImport(id);if(latest.sourceFingerprint!==job.sourceFingerprint||latest.source?.fileId!==source.fileId||['completed','partial','cancelled'].includes(latest.status))return;
|
|
if(!bytes)throw fault('import_source_unavailable');if(bytes.size!==source.byteLength)throw fault('import_source_changed');
|
|
if(source.kind==='duplicate-snapshot'){if(preparationController.signal.aborted||importHolds.size||latest.status!=='preparing')return;const materialized=await materializeCopy(id,bytes,preparationController);job=materialized.job;source=job.source!;bytes=materialized.bytes;expectedFingerprint=job.sourceFingerprint;}
|
|
const runner=createImportRunner({optimisticLocal:true,awaitDurabilityBudget:()=>persistenceFailed||pending.size>=32?persist():undefined,source:createNdjsonImportSource({bytes,fingerprint:job.sourceFingerprint}),getJob:()=>getImport(id),createId,
|
|
publish:publishImport,prepare:(line,job,reserve)=>job.source?.kind==='duplicate-ndjson'?prepareDuplicateImport(line,job,reserve,options.registry):prepareNativeImport(line,job,reserve,editor.getSnapshot().document,options.registry),persist,onError:report,
|
|
commit(units,next,expected){
|
|
assertWritable();if(importing)throw fault('reentrant_dispatch');const current=getImport(id);if(current.generation!==expected)throw fault('stale_import_job');
|
|
try{assertNativeImportTarget(current,editor.getSnapshot().document);for(const unit of units){assertNativeImportDependencies(current,editor.getSnapshot().document,unit.dependencies);if(current.source?.kind==='duplicate-ndjson')assertDuplicateExternal({cursor:0,nextCursor:1,key:unit.key,value:unit.guard},current,editor.getSnapshot().document,options.registry);}}catch(error){return reject(error);}
|
|
importing={job:next,expectedGeneration:expected};
|
|
try{return editor.transaction(tx=>{for(const unit of units)for(const change of unit.changes){if(change.kind==='element'&&change.before===null&&change.after)tx.dispatch({type:'element.create',element:change.after});else if(change.kind==='canvas'&&change.before===null&&change.after)tx.dispatch({type:'canvas.create',canvas:change.after});else throw fault('invalid_native_import_unit');}},{label:current.source?.kind==='duplicate-ndjson'?'Duplicate selection':'Import canvas content',groupId:`import:${id}`});}finally{importing=undefined;}
|
|
},
|
|
});
|
|
importRunners.set(id,runner);for(const releases of importHolds.values())releases.set(id,runner.hold());
|
|
if(getImport(id).status==='running')void runner.start();
|
|
})().catch(error=>{if(preparationController.signal.aborted)return;if(!closing&&!closed){try{const job=getImport(id);if(job.sourceFingerprint!==expectedFingerprint||['completed','partial','cancelled','paused'].includes(job.status))return;publishImport(freeze({...job,generation:job.generation+1,status:'paused',error:(error as {code?:string}).code??'import_failed'}),job.generation);}catch{/* Recovery/identity fences retain the original job. */}report(error);}}).finally(()=>{preparationControllers.delete(id);importLoads.delete(id);});
|
|
importLoads.set(id,task);return task;
|
|
}
|
|
const imports=Object.freeze({
|
|
getJobs:()=>readMap(editor.getSnapshot().runtime.imports??new Map()),
|
|
startNdjson(bytes:Blob,targetCanvasId:Id):Id{
|
|
assertWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);const canvas=editor.getSnapshot().document.canvases.get(targetCanvasId);if(!canvas)throw fault('canvas_not_loaded',[targetCanvasId]);if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source');
|
|
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||fileWrites.has(fileId)||[...(current.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict');
|
|
const entry=canvas.parent?editor.getSnapshot().document.elements.get(canvas.parent.entryElementId)!:undefined;
|
|
const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{kind:'native-ndjson',name:((bytes as Blob&{name?:string}).name||'画布数据').slice(0,255),fileId,byteLength:bytes.size,targetCanvasId,...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'running' as const});
|
|
fileWrites.set(fileId,bytes);
|
|
const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});});
|
|
if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id;
|
|
},
|
|
async duplicate(selection:readonly Target[],settings:{readonly worldOffset?:Point;readonly signal?:AbortSignal;readonly viewCanvasId?:Id}={}):Promise<Id>{
|
|
assertWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);const captured=editor.getSnapshot(),target=settings.viewCanvasId??(selection[0]?captured.document.elements.get(selection[0].id)?.ownerCanvasId:undefined),signal=settings.signal,controller=new AbortController(),abort=()=>controller.abort();
|
|
const targets=freeze(clone(selection)),offset=freeze(clone(settings.worldOffset??{x:32,y:32}));
|
|
for(const selected of targets){const element=captured.document.elements.get(selected.id);if(!element||element.incarnation!==selected.incarnation)throw fault('stale_incarnation',[selected.id]);}if(![offset.x,offset.y].every(Number.isFinite))throw fault('invalid_copy_offset');
|
|
signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)controller.abort();
|
|
const task=(async()=>{
|
|
if(!target||!targets.length)throw fault('empty_copy_selection');
|
|
const bytes=await encodeCopySnapshot(captured.document,controller.signal);assertWritable();if(controller.signal.aborted)throw fault('copy_aborted');
|
|
const canvas=captured.document.canvases.get(target);if(!canvas)throw fault('canvas_not_loaded',[target]);
|
|
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;
|
|
if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||fileWrites.has(fileId)||[...(current.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict');
|
|
const entry=canvas.parent?captured.document.elements.get(canvas.parent.entryElementId)!:undefined;
|
|
const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{kind:'duplicate-snapshot',name:'复制选中内容',fileId,byteLength:bytes.size,targetCanvasId:target,copy:{sourceRevision:captured.revision,roots:[]},...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'preparing' as const,preparation:{version:1 as const,registrySignature:copyRegistrySignature(captured.document,options.registry),selection:targets,worldOffset:offset,allocationIds:[],steps:0}});
|
|
fileWrites.set(fileId,bytes);const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});});
|
|
if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id;
|
|
})();
|
|
copyPreparations.set(controller,task);void task.finally(()=>{copyPreparations.delete(controller);signal?.removeEventListener('abort',abort);}).catch(()=>{});return task;
|
|
},
|
|
async pause(id:Id){assertWritable();const runner=importRunners.get(id);if(runner)await runner.pause();else{const job=getImport(id);if(['running','preparing'].includes(job.status)){preparationControllers.get(id)?.abort();publishImport(freeze({...job,generation:job.generation+1,status:'paused'}),job.generation);await persist();}}},
|
|
async resume(id:Id){assertWritable();const job=getImport(id);if(['completed','partial','cancelled'].includes(job.status))return;if(['running','preparing'].includes(job.status)){await loadImport(id);return;}await importLoads.get(id);await importRunners.get(id)?.settle();await persist();await scheduler.resume();assertWritable();const current=getImport(id);if(current.status!=='paused')return;const next={...current,generation:current.generation+1,status:current.source?.kind==='duplicate-snapshot'?'preparing' as const:'running' as const};delete next.error;publishImport(freeze(next),current.generation);await loadImport(id);},
|
|
async cancel(id:Id){assertWritable();const runner=importRunners.get(id);if(runner)await runner.cancel();else{const job=getImport(id);if(['running','preparing','paused'].includes(job.status)){preparationControllers.get(id)?.abort();const next={...job,generation:job.generation+1,status:'cancelled' as const};delete next.error;publishImport(freeze(next),job.generation);await persist();}}},
|
|
});
|
|
scheduler.start(); prepareDigests(); runTracker.notify([...initial.runs.keys()]);
|
|
for(const job of initial.imports?.values()??[])if(['running','preparing'].includes(job.status))void loadImport(job.id);
|
|
return Object.freeze({
|
|
editor, sessions, imports,
|
|
/** Read/preview under a scheduling hold. No request is rewritten or declared resolved here. */
|
|
async prepareRecovery(): Promise<RecoverySession> {
|
|
assertWritable();
|
|
if(!options.persistence.read)throw fault('canonical_reader_unavailable');
|
|
const releaseScheduler=scheduler.hold(),releaseRuns=runTracker.hold(),releaseImports=holdImports(),controller=new AbortController();
|
|
const active={controller,release(){if(recovery!==active)return;recovery=undefined;releaseRuns();releaseScheduler();releaseImports();},async cancel(){controller.abort();active.release();}};
|
|
recovery=active;
|
|
sessions.cancel('recovery');
|
|
const assertCurrent=()=>{if(closing||closed)throw fault('engine_closing');if(recovery!==active||controller.signal.aborted)throw fault('recovery_session_closed');};
|
|
try {
|
|
// Both holds are acquired before awaiting either lane; already admitted callbacks finish normally.
|
|
await Promise.all([...importLoads.values()]);assertCurrent();await Promise.all([scheduler.settle(),runTracker.settle(),...[...importRunners.values()].map(runner=>runner.settle())]);assertCurrent();
|
|
while(digestTasks.size){await Promise.all([...digestTasks.values()]);assertCurrent();}
|
|
await persist();assertCurrent();
|
|
const staged=stageAppliedRecovery(editor.getSnapshot(),{persistence:options.persistence,runs:options.runs,assets:options.assets,now:runClock.now()});
|
|
const root=staged.snapshot,previous=root.runtime;
|
|
const loaded=await network.run(controller.signal,()=>options.persistence.read!(controller.signal));assertCurrent();
|
|
if(editor.getSnapshot().rootRevision!==root.rootRevision)throw fault('stale_plan');
|
|
validateCanonicalRead(previous.acknowledged,previous.versions,loaded.document,loaded.versions,options.registry);
|
|
const acknowledged=createDocument(loaded.document.projectId,[...loaded.document.canvases.values()],[...loaded.document.elements.values()],loaded.document.tombstones);
|
|
const canonical=Object.freeze({document:acknowledged,versions:readMap(loaded.versions)});staged.assertCanonical(canonical);
|
|
let working:Document|undefined,conflict:RecoverySession['conflict'];
|
|
try{working=replayPending(acknowledged,previous.ledger,options.registry);}catch(error){const rejected=reject(error);conflict=freeze({code:rejected.code,targets:rejected.targets});}
|
|
let committed:ConflictPlan|'refresh'|undefined,committedIds:readonly Id[]=[];
|
|
const plans=new WeakSet<ConflictPlan>();
|
|
const finish=async()=>{await persist();assertCurrent();await scheduler.resume();assertCurrent();scheduler.notify(committedIds);runTracker.notify(staged.runIds);active.release();return{status:'applied' as const,revision:editor.getSnapshot().revision,operationIds:committedIds};};
|
|
active.cancel=async()=>{if(recovery!==active)return;if(committed){await finish();return;}active.release();};
|
|
return Object.freeze({baseRootRevision:root.rootRevision,canonical,acknowledgedRequestIds:staged.requestIds,
|
|
pendingOperationIds:Object.freeze([...previous.ledger.operations.values()].filter(record=>record.phase!=='resolved').map(record=>record.intent.id)),
|
|
...(conflict?{conflict}:{}),
|
|
plan(choices:ReadonlyMap<Id,RecoveryChoice>){
|
|
assertCurrent();if(committed)throw fault('recovery_already_committed');if(editor.getSnapshot().rootRevision!==root.rootRevision)throw fault('stale_plan');
|
|
const plan=planConflictRecovery(root,canonical,choices,options.registry,createId,options.historyLimit??100);plans.add(plan);return plan;
|
|
},
|
|
async resolve(plan:ConflictPlan):Promise<Result>{
|
|
assertCurrent();if(!plans.has(plan))throw fault('invalid_recovery_plan');if(committed&&committed!==plan)throw fault('recovery_already_committed');
|
|
if(!committed){
|
|
const permission=options.policy?.({type:'changes.apply',changes:[]},plan.document);if(permission&&!permission.enabled)return{status:'rejected',code:permission.code,targets:permission.targets};
|
|
for(const id of plan.operationIds){const intent=plan.operations.get(id)!;const allowed=options.policy?.({type:'changes.apply',changes:intent.changes},plan.document);if(allowed&&!allowed.enabled)return{status:'rejected',code:allowed.code,targets:allowed.targets};
|
|
if(intent.operationType==='run.apply'){const run=previous.runs.get(intent.action.runId);if(!run||run.blocked||run.status!=='conflict'||!runContainsTarget(run,intent.action.target))return{status:'rejected',code:'output_not_applicable',targets:[intent.action.target.id]};}}
|
|
const recoveries=new Map(previous.recoveries),resourcePins=new Map(previous.resourcePins),conflicts=new Map(previous.conflicts);recoveries.set(plan.id,plan.audit);resourcePins.set(`recovery:${plan.id}`,plan.resourceIds);conflicts.delete('refresh');
|
|
for(const decision of plan.audit.decisions){const requestId=previous.ledger.operations.get(decision.operationId)?.requestId;if(requestId)conflicts.delete(requestId);}
|
|
const result=editor.reconcile(plan.document,Object.freeze({...previous,ledger:plan.ledger,acknowledged,versions:canonical.versions,recoveries:readMap(recoveries),resourcePins:readMap(resourcePins),conflicts:readMap(conflicts)}),root.rootRevision,plan);
|
|
if(result.status==='rejected')return result;committed=plan;committedIds=plan.operationIds;
|
|
}
|
|
return finish();
|
|
},
|
|
async apply():Promise<Result>{
|
|
assertCurrent();
|
|
if(committed&&committed!=='refresh')throw fault('recovery_already_committed');
|
|
if(conflict)return{status:'rejected',...conflict};
|
|
if(!committed){
|
|
const conflicts=new Map(previous.conflicts);conflicts.delete('refresh');
|
|
const result=editor.reconcile(working!,Object.freeze({...previous,acknowledged,versions:canonical.versions,conflicts:readMap(conflicts)}),root.rootRevision);
|
|
if(result.status==='rejected')return result;
|
|
committed='refresh';
|
|
}
|
|
// Persistence failure retains the hold; apply/cancel can retry this barrier without republishing.
|
|
return finish();
|
|
},
|
|
cancel:()=>active.cancel(),
|
|
});
|
|
}catch(error){active.release();throw error;}
|
|
},
|
|
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{
|
|
assertWritable();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{
|
|
assertWritable();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 {
|
|
assertWritable();
|
|
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<Id, number>): Result {
|
|
const root = editor.getSnapshot(), previous = root.runtime;
|
|
try {
|
|
assertWritable();
|
|
if ([...previous.ledger.requests.values()].some(request => request.phase !== 'resolved')) throw fault('refresh_waiting_for_receipts');
|
|
validateCanonicalRead(previous.acknowledged,previous.versions,document,versions,options.registry);
|
|
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 (!['refresh_waiting_for_receipts','engine_closing','recovery_in_progress'].includes(result.code)) 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) {
|
|
assertWritable();
|
|
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) || [...(editor.getSnapshot().runtime.imports?.values()??[])].some(job=>job.source?.fileId===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,
|
|
cancelRecovery:async()=>{await recovery?.cancel();},
|
|
getDurability: () => Object.freeze({ rootRevision: editor.getSnapshot().rootRevision, durableRootRevision }),
|
|
retry: async () => { if(recovery)await recovery.cancel();assertWritable();await scheduler.resume();await runTracker.retry(); },
|
|
async settle() { await Promise.resolve();await Promise.allSettled([...copyPreparations.values()]);while(importLoads.size)await Promise.all([...importLoads.values()]);await Promise.all([...importRunners.values()].map(runner=>runner.settle())); while (digestTasks.size) await Promise.all([...digestTasks.values()]); await scheduler.settle(); await runTracker.settle(); await persist(); },
|
|
async close() {
|
|
if (closed) return;
|
|
closing = true;for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop();
|
|
await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);
|
|
recovery?.controller.abort();recovery?.release();
|
|
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; }
|
|
}
|