Add durable AI cancellation and undoable output application
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowUp, Sparkles, X } from "lucide-react";
|
||||
import { ArrowUp, Sparkles, Square, X } from "lucide-react";
|
||||
import type { ElementRecord, Json, Result } from "@/lib/canvas-core/types";
|
||||
import { runContainsTarget } from "@/lib/canvas-runtime/runs";
|
||||
import type { RunModel } from "@/lib/canvas-runtime/runs";
|
||||
@@ -20,8 +20,11 @@ export function HeadlessAIPanel({ engine, element, close }: { engine: ProjectEng
|
||||
const image = ['image','plain-image'].includes(element.componentId);
|
||||
const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element));
|
||||
const run = relevant.at(-1);
|
||||
const pending = [...root.runtime.ledger.operations.values()].filter(record => record.intent.operationType === 'run.start' && record.intent.run.target.id === element.id && record.intent.run.target.incarnation === element.incarnation && record.phase !== 'resolved');
|
||||
const busy = pending.length > 0 || !!run && (run.hasMore || !!run.pendingPage || ['queued','running'].includes(run.status));
|
||||
const starts = [...root.runtime.ledger.operations.values()].filter(record => record.intent.operationType === 'run.start' && record.intent.run.target.id === element.id && record.intent.run.target.incarnation === element.incarnation);
|
||||
const pending=starts.filter(record=>record.phase!=='resolved'), rejectedStart=starts.at(-1)?.resolution==='not-applied'?starts.at(-1):undefined;
|
||||
const actions=[...root.runtime.ledger.operations.values()].filter(record=>(record.intent.operationType==='run.cancel'||record.intent.operationType==='run.apply')&&record.intent.action.runId===run?.id&&record.phase!=='resolved');
|
||||
const cancelling=actions.some(record=>record.intent.operationType==='run.cancel'),applying=actions.some(record=>record.intent.operationType==='run.apply');
|
||||
const busy = pending.length > 0 || actions.length > 0 || !!run && (run.hasMore || !!run.pendingPage || ['queued','running'].includes(run.status));
|
||||
const outputEvent = run?.outputEvents.filter(event => event.node_id === element.id).at(-1), output = object(outputEvent?.output);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();setLoading(true);setError('');
|
||||
@@ -38,16 +41,18 @@ export function HeadlessAIPanel({ engine, element, close }: { engine: ProjectEng
|
||||
if(!model || busy)return true;
|
||||
return check(engine.editor.dispatch({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{ai:{enabled:true,modelId:model,prompt,propagate,parameters:{}}}},{label:'Configure generation'}));
|
||||
}
|
||||
const message = pending.length ? pending.some(record => record.phase === 'blocked') ? '生成受理需要处理' : '正在等待同步并受理生成…' : run?.blocked ? '生成结果已保留,当前编辑需要协调' : run?.error ? '任务跟踪需要处理,可重试' : run?.hasMore ? '正在接收生成结果…' : run ? ({queued:'已受理,等待生成',running:'正在生成…',succeeded:'已更新当前元素',failed:'生成未完成,原内容已保留',conflict:'内容已改变,生成结果已保留',cancelled:'已取消生成',partially_succeeded:'部分步骤已完成'}[run.status]) : '结果将显示在当前元素';
|
||||
const appliedOutput=typeof output.output_id==='string'&&run?.appliedOutputIds?.includes(output.output_id);
|
||||
const message = cancelling ? '取消请求已保存,等待确认…' : applying ? '正在应用生成结果…' : rejectedStart ? `生成未受理:${rejectedStart.blockers.join('、')}` : appliedOutput ? '此生成结果已应用' : pending.length ? pending.some(record => record.phase === 'blocked') ? '生成受理需要处理' : '正在等待同步并受理生成…' : run?.blocked ? '生成结果已保留,当前编辑需要协调' : run?.error ? '任务跟踪需要处理,可重试' : run?.hasMore ? '正在同步任务状态…' : run ? ({queued:'已受理,等待生成',running:'正在生成…',succeeded:'已更新当前元素',failed:'生成未完成,原内容已保留',conflict:'内容已改变,生成结果已保留',cancelled:'已取消生成',partially_succeeded:'部分步骤已完成'}[run.status]) : '结果将显示在当前元素';
|
||||
return <section className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具" style={{position:'absolute',right:24,bottom:90,zIndex:110,maxWidth:'calc(100% - 48px)',maxHeight:'70%',overflow:'auto'}} onPointerDown={event=>event.stopPropagation()} onKeyDown={event=>event.stopPropagation()}>
|
||||
<header><Sparkles size={14}/><strong>{image ? '图像创作' : '文本创作'}</strong><span>{element.data.title as string}</span><button aria-label="关闭 AI 工具" onClick={close}><X size={14}/></button></header>
|
||||
<textarea aria-label="AI 指令" placeholder="希望如何处理当前内容?" maxLength={16000} disabled={busy} value={prompt} onChange={event=>setPrompt(event.target.value)} onBlur={save}/>
|
||||
<footer><select aria-label="AI 模型" value={model} disabled={loading || busy} onChange={event=>setModel(event.target.value)}>{!model && <option value="">{loading ? '正在读取模型…' : '暂无可用模型'}</option>}{models.map(model=><option key={model.id} value={model.id} disabled={!model.available}>{model.label || model.display_name || model.id}</option>)}</select>
|
||||
<label><input type="checkbox" aria-label="继续向下传播" checked={propagate} disabled={busy} onChange={event=>setPropagate(event.target.checked)}/>继续向下传播</label>
|
||||
<button aria-label="执行 AI" title="在当前元素生成结果" disabled={busy || loading || !prompt.trim() || !models.some(item=>item.id===model && item.available)} onClick={()=>{setError('');if(save())check(engine.startRun({target:{id:element.id,incarnation:element.incarnation},boardId:element.ownerCanvasId,modelId:model,prompt,parameters:{},propagate}));}}><ArrowUp size={18}/></button>
|
||||
{run && ['queued','running'].includes(run.status) && <button aria-label="取消生成" disabled={cancelling} onClick={()=>check(engine.cancelRun(run.id))}><Square size={15}/></button>}
|
||||
</footer>
|
||||
<p role="status">{message}</p>
|
||||
{(run?.blocked || run?.status==='conflict') && <div className={styles.aiConflict}>{typeof output.text==='string' && <pre>{output.text}</pre>}{typeof output.src==='string' && <img src={managedImageSource(output.src)} alt="保留的生成结果"/>}</div>}
|
||||
{(run?.blocked || run?.status==='conflict') && <div className={styles.aiConflict}>{typeof output.text==='string' && <pre>{output.text}</pre>}{typeof output.src==='string' && <img src={managedImageSource(output.src)} alt="保留的生成结果"/>}{run?.status==='conflict'&&!run.blocked&&!run.hasMore&&!appliedOutput&&typeof output.output_id==='string'&&<button disabled={actions.length>0} onClick={()=>check(engine.applyRunOutput(run.id,{id:element.id,incarnation:element.incarnation},output.output_id as string))}>使用此结果替换当前内容</button>}</div>}
|
||||
{(run?.blocked || run?.error || pending.some(record=>record.phase==='blocked')) && <button onClick={()=>void engine.retry().catch(error=>setError(errorText(error)))}>重试任务跟踪</button>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</section>;
|
||||
|
||||
@@ -12,6 +12,8 @@ export type EditorOptions<Runtime = undefined> = {
|
||||
runtime?: Runtime;
|
||||
/** Installed by the engine. Pure preparation: an error rejects the entire commit. */
|
||||
reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime;
|
||||
/** Engine may attach transport semantics without changing identity, order or document changes. */
|
||||
prepareIntent?: (intent: OperationIntent, runtime: Runtime) => OperationIntent;
|
||||
nextOperationSequence?: (runtime: Runtime, fallback: number) => number;
|
||||
};
|
||||
export type Transaction = { dispatch(command: Command): Availability; getDocument(): Document };
|
||||
@@ -152,7 +154,9 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
}
|
||||
}
|
||||
const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: nextSequence, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [] });
|
||||
operations.set(intent.id, intent);
|
||||
const prepared=options.prepareIntent?.(intent,state.runtime) ?? intent;
|
||||
if(prepared.operationType==='run.apply'&&!validJson(prepared.action)||!['document.change','run.apply'].includes(prepared.operationType)||['id','operationSchemaVersion','sequence','origin','canvasIds','changes','dependencies'].some(key=>!equal((prepared as unknown as Record<string,unknown>)[key],(intent as unknown as Record<string,unknown>)[key])))throw fault('invalid_prepared_intent');
|
||||
operations.set(intent.id, freeze(clone(prepared)));
|
||||
}
|
||||
if (historyEntryId) {
|
||||
const entries = new Map(history.entries), entry = entries.get(historyEntryId)!;
|
||||
|
||||
@@ -36,7 +36,9 @@ export type HistoryEntry = { readonly id: Id; readonly label: string; readonly c
|
||||
export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap<Id, HistoryEntry> };
|
||||
type IntentBase = { readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] };
|
||||
export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean };
|
||||
export type OperationIntent = IntentBase & ({ readonly operationType: 'document.change' } | { readonly operationType: 'asset.upload'; readonly resourceId: Id } | { readonly operationType: 'run.start'; readonly run: RunInput });
|
||||
export type RunCancelInput = { readonly runId: Id; readonly boardId: Id };
|
||||
export type RunApplyInput = RunCancelInput & { readonly targetCanvasId: Id; readonly target: Target; readonly outputId: Id };
|
||||
export type OperationIntent = IntentBase & ({ readonly operationType: 'document.change' } | { readonly operationType: 'asset.upload'; readonly resourceId: Id } | { readonly operationType: 'run.start'; readonly run: RunInput } | { readonly operationType: 'run.cancel'; readonly action: RunCancelInput } | { readonly operationType: 'run.apply'; readonly action: RunApplyInput });
|
||||
export type Commit = { readonly revision: number; readonly origin: Origin; readonly changes: readonly Change[]; readonly operationIds: readonly Id[] };
|
||||
export type CoreSnapshot = { readonly revision: number; readonly document: Document; readonly history: HistoryState; readonly operations: ReadonlyMap<Id, OperationIntent> };
|
||||
/** One publication boundary. Runtime metadata changes do not dirty the document revision. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Document, Id, Registry, Result, RunInput } from '../canvas-core/types.ts';
|
||||
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';
|
||||
@@ -36,11 +36,13 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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;
|
||||
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;
|
||||
@@ -55,7 +57,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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' || record.intent.operationType === 'document.change' && conflictsWith(operationFootprint(record.intent, next.document), footprint))) dependencies.add(record.intent.id);
|
||||
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);
|
||||
@@ -93,7 +95,20 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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') { mutate(draft => draft.result(request.id, status.outcome, status.error)); 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 {
|
||||
@@ -118,9 +133,11 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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 = admission?.proposal ?? options.persistence.canonical(request, status, root);
|
||||
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) {
|
||||
@@ -141,9 +158,11 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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);
|
||||
@@ -158,21 +177,22 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
participants: intent => operationCanvases(intent, editor.getSnapshot().document),
|
||||
compile(intent, id) {
|
||||
if (intent.operationType === 'document.change') return options.persistence.compile(intent, id, editor.getSnapshot());
|
||||
if (intent.operationType === 'run.start') { if (!options.runs) throw fault('run_interpreter_unavailable');return options.runs.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 (request.kind === 'run.start') return options.runs ? network.run(signal, () => options.runs!.submit(request,signal)) : { outcome: 'unresolved', blocked: true, error: 'run_interpreter_unavailable' };
|
||||
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, () => request.kind === 'run.start' ? 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 => request.kind === 'run.start' ? !!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,
|
||||
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)}); });
|
||||
@@ -180,7 +200,9 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
}
|
||||
function acceptRunPage(run: RunRecord, page: RunPage) {
|
||||
const root=editor.getSnapshot(),previous=root.runtime,current=previous.runs.get(run.id);
|
||||
if(!current || current.cursor!==run.cursor || current.requestId!==run.requestId)throw fault('run_identity_mismatch');
|
||||
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;
|
||||
@@ -198,7 +220,8 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const proposal=options.runs.canonical(event,snapshot);acknowledged=proposal.document;versions=proposal.versions;
|
||||
}
|
||||
const document=replayPending(acknowledged,previous.ledger,options.registry),runs=new Map(previous.runs);
|
||||
runs.set(run.id,freeze({...current,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}));
|
||||
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);
|
||||
@@ -248,6 +271,12 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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,
|
||||
@@ -256,6 +285,31 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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');
|
||||
|
||||
@@ -6,13 +6,13 @@ export type RequestPhase = 'frozen' | 'dispatch-marked' | 'waiting' | 'resolved'
|
||||
export type Outcome = 'unresolved' | 'accepted' | 'applied' | 'not-applied';
|
||||
export type FrozenRequest = {
|
||||
readonly kind?: OperationIntent['operationType'];
|
||||
readonly ownerCanvasId?: Id;
|
||||
readonly ownerCanvasId?: Id; readonly runId?: Id;
|
||||
readonly id: Id; readonly protocolVersion: number; readonly generation: number; readonly body: string;
|
||||
readonly operationIds: readonly Id[]; readonly canvasIds: readonly Id[]; readonly expectedVersions: Readonly<Record<Id, number>>;
|
||||
readonly phase: RequestPhase; readonly outcome: Outcome; readonly error?: string; readonly attempt: number;
|
||||
readonly retryAt?: number; readonly retryAction?: 'submit' | 'query';
|
||||
};
|
||||
export type OperationRecord = { readonly intent: OperationIntent; readonly phase: 'pending' | 'frozen' | 'resolved' | 'blocked'; readonly requestId?: Id; readonly blockers: readonly string[]; readonly basePresence: Readonly<Record<Id, Presence>> };
|
||||
export type OperationRecord = { readonly intent: OperationIntent; readonly phase: 'pending' | 'frozen' | 'resolved' | 'blocked'; readonly requestId?: Id; readonly blockers: readonly string[]; readonly resolution?: 'applied' | 'not-applied'; readonly basePresence: Readonly<Record<Id, Presence>> };
|
||||
export type LedgerSnapshot = { readonly generation: number; readonly durableGeneration: number; readonly operations: ReadonlyMap<Id, OperationRecord>; readonly requests: ReadonlyMap<Id, FrozenRequest> };
|
||||
|
||||
/** A field-safe compressor for a wholly unsent segment. Frozen records never enter here. */
|
||||
@@ -74,15 +74,16 @@ export function createLedger(initial?: LedgerSnapshot) {
|
||||
return !!operation && operation.phase === 'pending' && !operation.blockers.length && operation.intent.dependencies.every(dependency => operations.get(dependency)?.phase === 'resolved');
|
||||
},
|
||||
block(id: Id, reasons: readonly string[]) { const operation = operations.get(id); if (!operation || !['pending', 'blocked'].includes(operation.phase)) return; operations.set(id, freeze({ ...operation, phase: reasons.length ? 'blocked' : 'pending', blockers: [...reasons] })); changed(); },
|
||||
freeze(input: { id: Id; kind?: FrozenRequest['kind']; ownerCanvasId?: Id; protocolVersion?: number; operationIds: readonly Id[]; canvasIds: readonly Id[]; expectedVersions: Readonly<Record<Id, number>>; payload: Json }) {
|
||||
if (!input.id || requests.has(input.id) || !Number.isSafeInteger(input.protocolVersion ?? 1) || (input.protocolVersion ?? 1) < 1 || !input.operationIds.length || (!input.canvasIds.length && input.kind!=='asset.upload') || !validJson(input.payload) || new Set(input.operationIds).size !== input.operationIds.length || new Set(input.canvasIds).size !== input.canvasIds.length || Object.keys(input.expectedVersions).length !== input.canvasIds.length || input.canvasIds.some(id => !id || !Number.isSafeInteger(input.expectedVersions[id]) || input.expectedVersions[id] < 0)) throw fault('invalid_frozen_request');
|
||||
freeze(input: { id: Id; kind?: FrozenRequest['kind']; ownerCanvasId?: Id; runId?: Id; protocolVersion?: number; operationIds: readonly Id[]; canvasIds: readonly Id[]; expectedVersions: Readonly<Record<Id, number>>; payload: Json }) {
|
||||
if (!input.id || requests.has(input.id) || !Number.isSafeInteger(input.protocolVersion ?? 1) || (input.protocolVersion ?? 1) < 1 || !input.operationIds.length || (!input.canvasIds.length && input.kind!=='asset.upload' && input.kind!=='run.cancel') || !validJson(input.payload) || new Set(input.operationIds).size !== input.operationIds.length || new Set(input.canvasIds).size !== input.canvasIds.length || Object.keys(input.expectedVersions).length !== input.canvasIds.length || input.canvasIds.some(id => !id || !Number.isSafeInteger(input.expectedVersions[id]) || input.expectedVersions[id] < 0)) throw fault('invalid_frozen_request');
|
||||
if (input.kind === 'run.start' && (!input.ownerCanvasId || !input.canvasIds.includes(input.ownerCanvasId))) throw fault('invalid_run_request');
|
||||
if ((input.kind === 'run.cancel' || input.kind === 'run.apply') && (!input.ownerCanvasId || !input.runId)) throw fault('invalid_run_request');
|
||||
for (const id of input.operationIds) {
|
||||
const operation = operations.get(id);
|
||||
if ((input.kind ?? 'document.change') !== operation?.intent.operationType) throw fault('request_kind_mismatch',[id]);
|
||||
if (!operation || operation.phase !== 'pending' || operation.blockers.length || operation.intent.dependencies.some(dependency => operations.get(dependency)?.phase !== 'resolved' && !input.operationIds.includes(dependency))) throw fault('operation_not_ready', [id]);
|
||||
}
|
||||
const request: FrozenRequest = freeze({ id: input.id, kind: input.kind ?? 'document.change', ...(input.ownerCanvasId ? { ownerCanvasId: input.ownerCanvasId } : {}), protocolVersion: input.protocolVersion ?? 1, generation: generation + 1, body: JSON.stringify(input.payload), operationIds: [...input.operationIds], canvasIds: [...input.canvasIds].sort(), expectedVersions: { ...input.expectedVersions }, phase: 'frozen', outcome: 'unresolved', attempt: 0 });
|
||||
const request: FrozenRequest = freeze({ id: input.id, kind: input.kind ?? 'document.change', ...(input.ownerCanvasId ? { ownerCanvasId: input.ownerCanvasId } : {}), ...(input.runId ? { runId: input.runId } : {}), protocolVersion: input.protocolVersion ?? 1, generation: generation + 1, body: JSON.stringify(input.payload), operationIds: [...input.operationIds], canvasIds: [...input.canvasIds].sort(), expectedVersions: { ...input.expectedVersions }, phase: 'frozen', outcome: 'unresolved', attempt: 0 });
|
||||
requests.set(request.id, request);
|
||||
for (const id of input.operationIds) operations.set(id, freeze({ ...operations.get(id)!, phase: 'frozen', requestId: request.id }));
|
||||
changed(); return request;
|
||||
@@ -97,10 +98,16 @@ export function createLedger(initial?: LedgerSnapshot) {
|
||||
if ((request.phase === 'resolved' || request.outcome === 'applied') && request.outcome !== outcome) throw fault('receipt_conflict', [id]);
|
||||
const phase = (outcome === 'applied' && reconciled) || outcome === 'not-applied' ? 'resolved' : 'waiting';
|
||||
requests.set(id, freeze({ ...request, phase, outcome, error, retryAt: undefined, retryAction: undefined }));
|
||||
if (outcome === 'applied' && reconciled) for (const operationId of request.operationIds) operations.set(operationId, freeze({ ...operations.get(operationId)!, phase: 'resolved' }));
|
||||
if (outcome === 'applied' && reconciled) for (const operationId of request.operationIds) operations.set(operationId, freeze({ ...operations.get(operationId)!, phase: 'resolved', resolution: 'applied' }));
|
||||
if (outcome === 'not-applied') for (const operationId of request.operationIds) operations.set(operationId, freeze({ ...operations.get(operationId)!, phase: 'blocked', blockers: [error || 'not_applied'] }));
|
||||
changed();
|
||||
},
|
||||
resolveNoEffect(id: Id) {
|
||||
const request=requireRequest(id);
|
||||
if(request.outcome!=='not-applied'||!['run.start','run.cancel'].includes(request.kind??''))throw fault('invalid_no_effect_resolution');
|
||||
for(const operationId of request.operationIds)operations.set(operationId,freeze({...operations.get(operationId)!,phase:'resolved',resolution:'not-applied'}));
|
||||
changed();
|
||||
},
|
||||
scheduleRetry(id: Id, dueAt: number, action: 'submit' | 'query') {
|
||||
const request = requireRequest(id);
|
||||
if (request.phase === 'resolved' || !Number.isFinite(dueAt)) throw fault('invalid_retry', [id]);
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { LedgerSnapshot } from './ledger.ts';
|
||||
|
||||
/** The footprint follows ownership; geometry does not decide which canvas owns an object. */
|
||||
export function operationCanvases(intent: OperationIntent, document: Document): readonly Id[] {
|
||||
if(intent.operationType==='asset.upload')return[];
|
||||
if(intent.operationType==='asset.upload'||intent.operationType==='run.cancel')return[];
|
||||
if (intent.canvasIds?.length) return intent.canvasIds;
|
||||
const ids = new Set<Id>();
|
||||
for (const change of intent.changes) {
|
||||
@@ -30,7 +30,7 @@ export function operationCanvases(intent: OperationIntent, document: Document):
|
||||
export function replayPending(base: Document, ledger: LedgerSnapshot, registry: Registry): Document {
|
||||
let document = base;
|
||||
for (const record of [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence)) {
|
||||
if (record.phase === 'resolved' || record.intent.operationType!=='document.change') continue;
|
||||
if (record.phase === 'resolved' || !['document.change','run.apply'].includes(record.intent.operationType)) continue;
|
||||
for (const change of record.intent.changes) {
|
||||
if (satisfied(document, change)) continue;
|
||||
document = applyChanges(document, [change]);
|
||||
|
||||
@@ -58,6 +58,11 @@ function intent(value: unknown, id: Id): OperationIntent {
|
||||
const kind = string(raw.operationType);
|
||||
if (kind === 'asset.upload') { string(raw.resourceId); if (array(raw.changes).length || array(raw.canvasIds).length) throw fault('corrupt_recovery_image'); }
|
||||
if (kind === 'run.start') { validateRunInput(raw.run as never);if(array(raw.changes).length || !strings(raw.canvasIds).includes(string(object(raw.run).boardId)))throw fault('corrupt_recovery_image'); }
|
||||
if(kind==='run.cancel'||kind==='run.apply'){
|
||||
const action=object(raw.action);string(action.runId);string(action.boardId);
|
||||
if(kind==='run.cancel'){if(array(raw.changes).length||array(raw.canvasIds).length)throw fault('corrupt_recovery_image');}
|
||||
else {const target=object(action.target);string(target.id);integer(target.incarnation,1);string(action.outputId);if(!strings(raw.canvasIds).includes(string(action.targetCanvasId)))throw fault('corrupt_recovery_image');}
|
||||
}
|
||||
integer(raw.operationSchemaVersion, 1); integer(raw.sequence, 1); string(raw.origin);
|
||||
strings(raw.canvasIds); strings(raw.dependencies); changes(raw.changes);
|
||||
return freeze(value as OperationIntent);
|
||||
@@ -78,10 +83,11 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
if (new Set(stackIds).size !== stackIds.length || stackIds.length !== historyEntries.size || stackIds.some(id => !historyEntries.has(id))) throw fault('corrupt_recovery_image');
|
||||
const records = table<OperationRecord>(ledger.operations, (value, id) => {
|
||||
const raw = object(value); intent(raw.intent, id); strings(raw.blockers); object(raw.basePresence);
|
||||
if ((object(raw.intent).operationType === 'document.change' && !operations.has(id)) || !['pending', 'frozen', 'resolved', 'blocked'].includes(string(raw.phase))) throw fault('corrupt_recovery_image');
|
||||
if (((object(raw.intent).operationType === 'document.change'||object(raw.intent).operationType==='run.apply'&&array(object(raw.intent).changes).length>0) && !operations.has(id)) || !['pending', 'frozen', 'resolved', 'blocked'].includes(string(raw.phase))) throw fault('corrupt_recovery_image');
|
||||
if(raw.resolution!==undefined&&!['applied','not-applied'].includes(string(raw.resolution)))throw fault('corrupt_recovery_image');
|
||||
return freeze(value as OperationRecord);
|
||||
});
|
||||
if (operations.size !== [...records.values()].filter(record => record.intent.operationType === 'document.change').length) throw fault('corrupt_recovery_image');
|
||||
if (operations.size !== [...records.values()].filter(record => (record.intent.operationType === 'document.change'||record.intent.operationType==='run.apply'&&record.intent.changes.length>0)).length) throw fault('corrupt_recovery_image');
|
||||
// Validate the dependency DAG iteratively, including missing dependencies, without recursion overflow.
|
||||
const counts = new Map<Id, number>(), dependents = new Map<Id, Id[]>(), ready: Id[] = [];
|
||||
for (const [id, record] of records) {
|
||||
@@ -107,11 +113,26 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
let body:ObjectValue;try{body=object(JSON.parse(raw.body as string));}catch{throw fault('corrupt_recovery_image');}
|
||||
if(raw.ownerCanvasId!==operation.run.boardId||body.protocol_version!==2||body.request_id!==id||body.operation_id!==operation.id||body.node_id!==operation.run.target.id||body.target_incarnation!==operation.run.target.incarnation||body.model_id!==operation.run.modelId||body.prompt!==operation.run.prompt||body.propagate!==operation.run.propagate||!equal(body.parameters,operation.run.parameters)||!equal(body.expected_versions,raw.expectedVersions)||!equal([...strings(raw.canvasIds)].sort(),[...operation.canvasIds].sort()))throw fault('corrupt_recovery_image');
|
||||
}
|
||||
if(raw.kind==='run.cancel'||raw.kind==='run.apply'){
|
||||
if(array(raw.operationIds).length!==1)throw fault('corrupt_recovery_image');const operation=records.get(string(array(raw.operationIds)[0]))!.intent;
|
||||
if(operation.operationType!=='run.cancel'&&operation.operationType!=='run.apply')throw fault('corrupt_recovery_image');
|
||||
const body=object(JSON.parse(raw.body as string));
|
||||
if(raw.runId!==operation.action.runId||raw.ownerCanvasId!==operation.action.boardId||body.protocol_version!==2||body.request_id!==id||body.operation_id!==operation.id)throw fault('corrupt_recovery_image');
|
||||
if(operation.operationType==='run.cancel'){if(array(raw.canvasIds).length||Object.keys(object(raw.expectedVersions)).length)throw fault('corrupt_recovery_image');}
|
||||
else if(body.target_canvas_id!==operation.action.targetCanvasId||body.target_incarnation!==operation.action.target.incarnation||body.node_id!==operation.action.target.id||body.output_id!==operation.action.outputId||!equal([...strings(raw.canvasIds)].sort(),[...operation.canvasIds].sort()))throw fault('corrupt_recovery_image');
|
||||
}
|
||||
if (raw.retryAt !== undefined && (typeof raw.retryAt !== 'number' || !Number.isFinite(raw.retryAt))) throw fault('corrupt_recovery_image');
|
||||
if (raw.retryAction !== undefined && !['submit', 'query'].includes(string(raw.retryAction))) throw fault('corrupt_recovery_image');
|
||||
return freeze(value as FrozenRequest);
|
||||
});
|
||||
for (const record of records.values()) if (record.requestId && !requests.has(record.requestId) || record.phase === 'frozen' && !record.requestId) throw fault('corrupt_recovery_image');
|
||||
const receipts=table(runtime.receipts,value=>freeze(value as Json));
|
||||
for(const record of records.values())if(record.resolution==='not-applied'){
|
||||
const request=record.requestId?requests.get(record.requestId):undefined;
|
||||
if(!['run.start','run.cancel'].includes(record.intent.operationType)||record.phase!=='resolved'||request?.outcome!=='not-applied'||request.phase!=='resolved')throw fault('corrupt_recovery_image');
|
||||
const proof=object(receipts.get(request.id));
|
||||
if(proof.protocol_version!==2||proof.request_id!==request.id||proof.operation_id!==record.intent.id||proof.action!==record.intent.operationType||proof.status!=='not-applied'||proof.terminal!==true)throw fault('corrupt_recovery_image');
|
||||
}
|
||||
const generation = integer(ledger.generation), durableGeneration = integer(ledger.durableGeneration);
|
||||
if (durableGeneration > generation) throw fault('corrupt_recovery_image');
|
||||
const acknowledged = readDocument(runtime.acknowledged, registry);
|
||||
@@ -134,6 +155,8 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
const raw=object(value),operation=records.get(string(raw.operationId)),request=requests.get(string(raw.requestId)),target=object(raw.target);
|
||||
if(string(raw.id)!==id||operation?.intent.operationType!=='run.start'||operation.requestId!==raw.requestId||request?.kind!=='run.start'||request.outcome!=='applied'||operation.intent.run.boardId!==string(raw.boardId)||operation.intent.run.target.id!==string(target.id)||operation.intent.run.target.incarnation!==integer(target.incarnation,1))throw fault('corrupt_recovery_image');
|
||||
if(!['queued','running','succeeded','failed','conflict','cancelled','partially_succeeded'].includes(string(raw.status))||typeof raw.hasMore!=='boolean')throw fault('corrupt_recovery_image');
|
||||
if(raw.controlEpoch!==undefined)integer(raw.controlEpoch);
|
||||
if(raw.appliedOutputIds!==undefined)strings(raw.appliedOutputIds);
|
||||
integer(raw.cursor);integer(raw.attempt);object(raw.plan);array(raw.outputs);array(raw.steps);array(raw.outputEvents ?? []);
|
||||
if(raw.blocked!==undefined&&typeof raw.blocked!=='boolean')throw fault('corrupt_recovery_image');
|
||||
if(raw.error!==undefined)string(raw.error);
|
||||
@@ -145,6 +168,6 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
});
|
||||
const restoredLedger: LedgerSnapshot = Object.freeze({ generation, durableGeneration, operations: records, requests });
|
||||
return Object.freeze({ revision: image.localRevision, rootRevision: image.rootRevision, document, history: Object.freeze({ undo: freeze(undo), redo: freeze(redo), entries: historyEntries }), operations,
|
||||
runtime: Object.freeze({ runs, resources, resourcePins, ledger: restoredLedger, acknowledged, versions, receipts: table(runtime.receipts, value => freeze(value as Json)), conflicts: table(runtime.conflicts, string) }),
|
||||
runtime: Object.freeze({ runs, resources, resourcePins, ledger: restoredLedger, acknowledged, versions, receipts, conflicts: table(runtime.conflicts, string) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export type RunRecord = {
|
||||
readonly id: Id; readonly operationId: Id; readonly requestId: Id; readonly boardId: Id; readonly target: Target;
|
||||
readonly plan: Json; readonly status: RunStatus; readonly cursor: number; readonly hasMore: boolean;
|
||||
readonly outputs: readonly Json[]; readonly outputEvents: readonly RunEvent[]; readonly steps: readonly Json[]; readonly attempt: number;
|
||||
readonly nextPollAt?: number; readonly error?: string; readonly blocked?: boolean; readonly pendingPage?: RunPage;
|
||||
readonly controlEpoch?: number; readonly appliedOutputIds?: readonly Id[]; readonly nextPollAt?: number; readonly error?: string; readonly blocked?: boolean; readonly pendingPage?: RunPage;
|
||||
};
|
||||
export type RunPort = {
|
||||
catalog(boardId: Id, signal: AbortSignal): Promise<RunCatalog>;
|
||||
@@ -26,6 +26,7 @@ export type RunPort = {
|
||||
submit(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
|
||||
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
|
||||
accept(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot): { readonly run: RunRecord; readonly proposal: CanonicalProposal };
|
||||
acceptAction(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot): { readonly runId: Id; readonly runStatus?: RunStatus; readonly outputId?: Id; readonly proposal?: CanonicalProposal };
|
||||
poll(run: RunRecord, signal: AbortSignal): Promise<RunPage>;
|
||||
canonical(event: RunEvent, snapshot: EngineSnapshot): CanonicalProposal;
|
||||
};
|
||||
@@ -87,6 +88,8 @@ export function createRunTracker(options: {
|
||||
if(!stopped && !latest.blocked)notify([run.id]);
|
||||
} catch(error) {
|
||||
if(stopped)return;
|
||||
const current=options.getRuns().get(run.id);
|
||||
if(current && (current.controlEpoch??0)!==(run.controlEpoch??0)){notify([run.id]);return;}
|
||||
const code=(error as {code?:string}).code ?? (error as Error).message ?? 'run_poll_failed';
|
||||
const blocked=['forbidden','unauthorized','identity_changed','invalid_run_response','run_identity_mismatch','run_receipt_expired','run_interpreter_unavailable'].includes(code);
|
||||
options.update(run.id,current=>({...current,error:code,blocked,attempt:current.attempt+1,nextPollAt:options.clock.now()+Math.min(30000,500*2**Math.min(current.attempt,6))}));
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createLedger, type FrozenRequest, type LedgerSnapshot, type Outcome } f
|
||||
import { createDeque, createMinHeap } from './structures.ts';
|
||||
|
||||
export type OperationStatus = { readonly outcome: Outcome; readonly error?: string; readonly retryAfterMs?: number; readonly canonical?: Json; readonly blocked?: boolean };
|
||||
export type CompiledRequest = { readonly kind?: FrozenRequest['kind']; readonly ownerCanvasId?: Id; readonly protocolVersion?: number; readonly payload: Json; readonly canvasIds: readonly Id[]; readonly expectedVersions: Readonly<Record<Id, number>> };
|
||||
export type CompiledRequest = { readonly kind?: FrozenRequest['kind']; readonly ownerCanvasId?: Id; readonly runId?: Id; readonly protocolVersion?: number; readonly payload: Json; readonly canvasIds: readonly Id[]; readonly expectedVersions: Readonly<Record<Id, number>> };
|
||||
export type Clock = { now(): number; setTimer(callback: () => void, delay: number): unknown; clearTimer(handle: unknown): void };
|
||||
const browserClock: Clock = { now: () => Date.now(), setTimer: (fn, delay) => setTimeout(fn, delay), clearTimer: handle => clearTimeout(handle as ReturnType<typeof setTimeout>) };
|
||||
type Ledger = ReturnType<typeof createLedger>;
|
||||
@@ -149,7 +149,7 @@ export function createScheduler(options: SchedulerOptions) {
|
||||
const set = laneWaiters.get(record.intent.operationType) ?? new Set<Id>(); set.add(id); laneWaiters.set(record.intent.operationType, set); continue;
|
||||
}
|
||||
const canvases = [...new Set(options.participants(record.intent))].sort();
|
||||
if (!canvases.length && record.intent.operationType !== 'asset.upload') throw fault('missing_participants');
|
||||
if (!canvases.length && record.intent.operationType !== 'asset.upload' && record.intent.operationType !== 'run.cancel') throw fault('missing_participants');
|
||||
const occupied = canvases.filter(canvas => claims.has(canvas));
|
||||
if (occupied.length) {
|
||||
for (const canvas of occupied) { const set = waiters.get(canvas) ?? new Set<Id>(); set.add(id); waiters.set(canvas, set); }
|
||||
|
||||
@@ -15,9 +15,20 @@ const integer = (value: unknown, min=0): number => { if (typeof value!=='number'
|
||||
|
||||
function payload(request: FrozenRequest) {
|
||||
const raw=object(JSON.parse(request.body));
|
||||
if(!request.ownerCanvasId||!uuid.test(request.ownerCanvasId)||request.kind!=='run.start'||request.protocolVersion!==2||!uuid.test(request.id)||raw.protocol_version!==2||raw.request_id!==request.id||request.operationIds.length!==1||raw.operation_id!==request.operationIds[0]||!uuid.test(string(raw.operation_id))||!request.ownerCanvasId||!request.canvasIds.includes(request.ownerCanvasId)||!equal(raw.expected_versions,request.expectedVersions)||!equal(Object.keys(request.expectedVersions).sort(),[...request.canvasIds].sort()))throw fault('invalid_run_request');
|
||||
for(const version of Object.values(request.expectedVersions))integer(version,1);
|
||||
validateRunInput({target:{id:string(raw.node_id),incarnation:integer(raw.target_incarnation,1)},boardId:request.ownerCanvasId,modelId:string(raw.model_id),prompt:string(raw.prompt),parameters:object(raw.parameters) as RunInput['parameters'],propagate:raw.propagate as boolean});
|
||||
if(!request.ownerCanvasId||!uuid.test(request.ownerCanvasId)||!['run.start','run.cancel','run.apply'].includes(request.kind??'')||request.protocolVersion!==2||!uuid.test(request.id)||raw.protocol_version!==2||raw.request_id!==request.id||request.operationIds.length!==1||raw.operation_id!==request.operationIds[0]||!uuid.test(string(raw.operation_id)))throw fault('invalid_run_request');
|
||||
if(request.kind==='run.cancel'){
|
||||
if(!request.runId||!uuid.test(request.runId)||request.canvasIds.length||Object.keys(request.expectedVersions).length)throw fault('invalid_run_request');
|
||||
}else{
|
||||
if(!equal(raw.expected_versions??{[string(raw.target_canvas_id)]:request.expectedVersions[string(raw.target_canvas_id)]},request.expectedVersions)||!equal(Object.keys(request.expectedVersions).sort(),[...request.canvasIds].sort()))throw fault('invalid_run_request');
|
||||
for(const version of Object.values(request.expectedVersions))integer(version,1);
|
||||
if(request.kind==='run.start'){
|
||||
if(!request.canvasIds.includes(request.ownerCanvasId))throw fault('invalid_run_request');
|
||||
validateRunInput({target:{id:string(raw.node_id),incarnation:integer(raw.target_incarnation,1)},boardId:request.ownerCanvasId,modelId:string(raw.model_id),prompt:string(raw.prompt),parameters:object(raw.parameters) as RunInput['parameters'],propagate:raw.propagate as boolean});
|
||||
}else{
|
||||
if(!request.runId||!uuid.test(request.runId)||!uuid.test(string(raw.output_id))||!uuid.test(string(raw.target_canvas_id))||request.canvasIds.length!==1||request.canvasIds[0]!==raw.target_canvas_id)throw fault('invalid_run_request');
|
||||
string(raw.node_id);integer(raw.target_incarnation,1);
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
function planSteps(plan: unknown, rootId: Id) {
|
||||
@@ -32,10 +43,12 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr
|
||||
const path=(board: Id)=>`workspaces/${encodeURIComponent(options.workspaceId)}/materials/${encodeURIComponent(board)}/canvas/ai`;
|
||||
async function execute(request: FrozenRequest,query: boolean,signal: AbortSignal): Promise<OperationStatus> {
|
||||
try{
|
||||
payload(request);
|
||||
const response=await options.transport({method:query?'GET':'POST',path:`${path(request.ownerCanvasId!)}/${query?`operations/${encodeURIComponent(request.id)}`:'runs'}`,requestId:query?undefined:request.id,expectedVersion:query?undefined:request.expectedVersions[request.ownerCanvasId!],body:query?undefined:request.body,signal});
|
||||
const raw=payload(request),suffix=request.kind==='run.start'?'runs':`runs/${encodeURIComponent(request.runId!)}/${request.kind==='run.cancel'?'cancel':'apply'}`;
|
||||
const response=await options.transport({method:query?'GET':'POST',path:`${path(request.ownerCanvasId!)}/${query?`operations/${encodeURIComponent(request.id)}`:suffix}`,requestId:query?undefined:request.id,expectedVersion:query||request.kind==='run.cancel'?undefined:request.expectedVersions[request.kind==='run.apply'?raw.target_canvas_id as Id:request.ownerCanvasId!],body:query?undefined:request.body,signal});
|
||||
const body=object(response.body),matching=body.protocol_version===2&&body.request_id===request.id;
|
||||
if(matching&&body.operation_id===request.operationIds[0]&&body.admission==='accepted'&&response.status>=200&&response.status<300&&validJson(body))return freeze({outcome:'applied',canonical:JSON.parse(JSON.stringify(body)) as Json});
|
||||
if(matching&&body.operation_id===request.operationIds[0]&&body.action===request.kind&&(request.kind==='run.start'||body.run_id===request.runId)&&body.status==='not-applied'&&body.terminal===true&&validJson(body))return freeze({outcome:'not-applied',error:typeof body.error==='string'?body.error:'not_applied',canonical:JSON.parse(JSON.stringify(body)) as Json});
|
||||
if(matching&&body.operation_id===request.operationIds[0]&&body.action===request.kind&&body.status==='applied'&&request.kind!=='run.start'&&response.status>=200&&response.status<300&&validJson(body))return freeze({outcome:'applied',canonical:JSON.parse(JSON.stringify(body)) as Json});
|
||||
if(request.kind==='run.start'&&(body.action===undefined||body.action==='run.start')&&matching&&body.operation_id===request.operationIds[0]&&body.admission==='accepted'&&response.status>=200&&response.status<300&&validJson(body))return freeze({outcome:'applied',canonical:JSON.parse(JSON.stringify(body)) as Json});
|
||||
if(query&&matching&&response.status===404&&body.status==='unknown')return{outcome:'unresolved',error:'ai_admission_unknown',retryAfterMs:response.retryAfterMs};
|
||||
return{outcome:'unresolved',error:typeof body.error==='string'?body.error:'ai_admission_unknown',retryAfterMs:response.retryAfterMs,...(response.status>=400&&response.status<500?{blocked:true}:{})};
|
||||
}catch(error){const code=(error as {code?:string}).code;return{outcome:'unresolved',error:code??'ai_transport_unknown',...(code?{blocked:true}:{})};}
|
||||
@@ -49,6 +62,16 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr
|
||||
return freeze({models,...(typeof body.unavailable_reason==='string'?{unavailableReason:body.unavailable_reason}:{})});
|
||||
},
|
||||
compile(intent,requestId,snapshot){
|
||||
if(intent.operationType==='run.cancel'||intent.operationType==='run.apply'){
|
||||
const action=intent.action,run=snapshot.runtime.runs.get(action.runId);
|
||||
if(!run||run.boardId!==action.boardId||!uuid.test(intent.id)||!uuid.test(requestId))throw fault('run_identity_mismatch');
|
||||
const base={protocol_version:2,request_id:requestId,operation_id:intent.id};
|
||||
if(intent.operationType==='run.cancel')return freeze({kind:'run.cancel',ownerCanvasId:run.boardId,runId:run.id,protocolVersion:2,canvasIds:[],expectedVersions:{},payload:base});
|
||||
const target=snapshot.runtime.acknowledged.elements.get(intent.action.target.id);
|
||||
if(!target||target.incarnation!==intent.action.target.incarnation||target.ownerCanvasId!==intent.action.targetCanvasId)throw fault('target_changed');
|
||||
const canvas=target.ownerCanvasId,expectedVersions={[canvas]:integer(snapshot.runtime.versions.get(canvas),1)};
|
||||
return freeze({kind:'run.apply',ownerCanvasId:run.boardId,runId:run.id,protocolVersion:2,canvasIds:[canvas],expectedVersions,payload:{...base,node_id:target.id,target_incarnation:target.incarnation,target_canvas_id:canvas,output_id:intent.action.outputId}});
|
||||
}
|
||||
if(intent.operationType!=='run.start'||intent.operationSchemaVersion!==1||!uuid.test(intent.id)||!uuid.test(requestId)||snapshot.document.projectId!==options.rootId)throw fault('operation_interpreter_unavailable');
|
||||
validateRunInput(intent.run);
|
||||
const before=snapshot.runtime.acknowledged,element=before.elements.get(intent.run.target.id);
|
||||
@@ -62,7 +85,7 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr
|
||||
submit:(request,signal)=>execute(request,false,signal),query:(request,signal)=>execute(request,true,signal),
|
||||
accept(request,status,snapshot){
|
||||
const raw=payload(request),body=object(status.canonical),runId=string(body.run_id),board=request.ownerCanvasId!;
|
||||
if(status.outcome!=='applied'||body.protocol_version!==2||body.request_id!==request.id||body.operation_id!==raw.operation_id||body.admission!=='accepted'||body.status!=='queued'||!uuid.test(runId)||body.target_incarnation!==raw.target_incarnation||body.document_format_version!==1||body.canvas_version!==request.expectedVersions[board]+1)throw fault('run_identity_mismatch');
|
||||
if(status.outcome!=='applied'||body.protocol_version!==2||body.request_id!==request.id||body.operation_id!==raw.operation_id||body.admission!=='accepted'||body.action!==undefined&&body.action!=='run.start'||body.status!=='queued'||!uuid.test(runId)||body.target_incarnation!==raw.target_incarnation||body.document_format_version!==1||body.canvas_version!==request.expectedVersions[board]+1)throw fault('run_identity_mismatch');
|
||||
const steps=planSteps(body.plan,options.rootId);
|
||||
if(!steps.some(step=>step.board_id===board&&step.node_id===raw.node_id&&step.target_incarnation===raw.target_incarnation)||steps.some(step=>!request.canvasIds.includes(step.board_id as Id)))throw fault('run_identity_mismatch');
|
||||
const proposal=mergeFoundryModel(body.model,[board],snapshot,options.registry,options.rootId),target=proposal.document.elements.get(raw.node_id as string);
|
||||
@@ -70,6 +93,18 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr
|
||||
const run:RunRecord=freeze({id:runId,requestId:request.id,operationId:request.operationIds[0],boardId:board,target:{id:target.id,incarnation:target.incarnation},plan:body.plan as Json,status:'queued',cursor:0,hasMore:true,steps:[],outputs:[],outputEvents:[],attempt:0});
|
||||
return{run,proposal};
|
||||
},
|
||||
acceptAction(request,status,snapshot){
|
||||
const raw=payload(request),body=object(status.canonical);
|
||||
if(!request.runId||status.outcome!=='applied'||body.protocol_version!==2||body.request_id!==request.id||body.operation_id!==request.operationIds[0]||body.run_id!==request.runId||body.action!==request.kind||body.status!=='applied')throw fault('run_identity_mismatch');
|
||||
if(request.kind==='run.cancel'){
|
||||
if(!statuses.has(string(body.run_status)))throw fault('invalid_run_response');
|
||||
return{runId:request.runId,runStatus:body.run_status as RunStatus};
|
||||
}
|
||||
if(request.kind!=='run.apply'||body.node_id!==raw.node_id||body.output_id!==raw.output_id||body.board_id!==raw.target_canvas_id||body.target_incarnation!==raw.target_incarnation||body.document_format_version!==1)throw fault('run_identity_mismatch');
|
||||
const proposal=mergeFoundryModel(body.model,[raw.target_canvas_id as Id],snapshot,options.registry,options.rootId),target=proposal.document.elements.get(raw.node_id as Id),version=integer(body.canvas_version,1);
|
||||
if(proposal.versions.get(raw.target_canvas_id as Id)!==version||version!==request.expectedVersions[raw.target_canvas_id as Id]+1||target?.incarnation!==raw.target_incarnation||target?.ownerCanvasId!==raw.target_canvas_id)throw fault('run_identity_mismatch');
|
||||
return{runId:request.runId,outputId:raw.output_id as Id,proposal};
|
||||
},
|
||||
async poll(run,signal){
|
||||
const response=await options.transport({method:'GET',path:`${path(run.boardId)}/runs/${encodeURIComponent(run.id)}?after_seq=${run.cursor}&limit=100`,signal}),body=object(response.body);
|
||||
if(response.status!==200)throw fault(response.status===404?'run_receipt_expired':typeof body.error==='string'?body.error:'run_poll_failed');
|
||||
|
||||
Reference in New Issue
Block a user