docs(canvas): explain receipt coordination run events and image preparation
This commit is contained in:
@@ -509,13 +509,18 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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);
|
||||
}
|
||||
/** 网络结果的统一协调入口。先记录请求是否生效,再按请求种类解释回执。
|
||||
* applied 只证明服务端生效;仍需验证身份/版本并重放本地未决操作,才能发布新的 ACK 和 working。
|
||||
* 协调失败保留原回执与原工作文档,标为“已生效但未协调”,不当作可重新提交的新请求。 */
|
||||
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;
|
||||
}
|
||||
// 取消是控制结果,不直接用响应替换文档;提高 controlEpoch 使在途旧轮询失效,并继续读尾事件。
|
||||
if(request.kind==='run.cancel'){
|
||||
const receipts=new Map(previous.receipts);if(status.canonical!==undefined)receipts.set(request.id,freeze(status.canonical));
|
||||
try{
|
||||
@@ -526,6 +531,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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};}
|
||||
}
|
||||
// 上传回执只更新资源已存储事实;资源 ID 和原字节长度必须与冻结请求关联的记录一致。
|
||||
if (request.kind === 'asset.upload') {
|
||||
const receipts = new Map(previous.receipts); if (status.canonical !== undefined) receipts.set(request.id, freeze(status.canonical));
|
||||
try {
|
||||
@@ -533,6 +539,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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');
|
||||
// 更细的协议、摘要和来源校验由 assets.accept 实现,engine 再核对核心关联身份。
|
||||
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');
|
||||
@@ -541,6 +548,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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);
|
||||
@@ -550,13 +558,16 @@ 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 {
|
||||
// 应用输出、启动生成分别使用 run 端口的文档提案,其它文档请求交给 persistence 解释。
|
||||
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);
|
||||
// 生成受理以 ownerCanvasId 为写入范围;普通批次使用冻结请求的参与画布集合。
|
||||
const writtenCanvases = admission ? [request.ownerCanvasId!] : request.canvasIds;
|
||||
if (proposal.document.projectId !== root.document.projectId) throw fault('project_mismatch');
|
||||
// 本次写入版本必须超过请求基线,且不落后于当前 ACK;迟到响应不能倒退服务端版本。
|
||||
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]);
|
||||
@@ -566,31 +577,38 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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');
|
||||
// 先把回执模型复制为独立 ACK,再只重放仍未解决的本地意图,保留用户随后做出的编辑。
|
||||
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);
|
||||
// 重复受理回执可复用同一任务,禁止同 run ID 指向另一请求/操作。
|
||||
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);
|
||||
}
|
||||
// 成功应用输出后记录输出身份并提升控制代次,丢弃旧 pendingPage,重新读取任务最新尾部。
|
||||
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 appliedVersions=readMap(proposal.versions);let resolvedReceipts=receipts;
|
||||
// 仅对文档请求的候选成功结果尝试压缩;压缩失败保留原证据,最终发布失败也沿用原回执。
|
||||
if(request.kind==='document.change'&&status.canonical!==undefined&&options.persistence.compactResolvedReceipt){
|
||||
try{
|
||||
const compact=options.persistence.compactResolvedReceipt(ledger.getSnapshot().requests.get(request.id)!,status.canonical,Object.freeze({document:acknowledged,versions:appliedVersions}));
|
||||
if(!validJson(compact))throw fault('invalid_receipt_compaction');
|
||||
// 压缩结果先验证 JSON,再复制并冻结,避免服务适配器后续修改已经采用的回执摘要。
|
||||
if(compact!==status.canonical){const owned=freeze(clone(compact));if(!validJson(owned))throw fault('invalid_receipt_compaction');resolvedReceipts=new Map(receipts);resolvedReceipts.set(request.id,owned);}
|
||||
}catch(error){report(error);}
|
||||
}
|
||||
// Keep the original map intact for a rejected final publication below.
|
||||
const runtime: RuntimeState = Object.freeze({ ...previous, runs: readMap(runs), ledger: ledger.getSnapshot(), acknowledged, versions: appliedVersions, receipts: readMap(resolvedReceipts), conflicts: readMap(conflicts) });
|
||||
// loading v3+ 允许已就绪范围的 ACK 更新;只在这次同步 reconcile 内打开受控发布窗口。
|
||||
const oldAccepting=acceptingLoading;acceptingLoading=oldAccepting||(previous.loading?.version??0)>=3;let result:Result;try{result=editor.reconcile(document, runtime, root.rootRevision);}finally{acceptingLoading=oldAccepting;}
|
||||
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.
|
||||
// 从旧账本另起失败分支,避免候选 ledger 中的成功推进泄漏;保留参与画布占用等待恢复。
|
||||
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);
|
||||
@@ -598,24 +616,34 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
report(error); return { blocked: true };
|
||||
}
|
||||
}
|
||||
// scheduler 只挑选和执行账本事实;注入的 persistThrough 保证请求身份及前置根先可靠保存。
|
||||
// admit 另检查导入 seal、加载覆盖和加载完成保存屏障;v1/2 加载的整体暂停由下方 hold 实现。
|
||||
const scheduler = createScheduler({ admit:intent=>{const root=editor.getSnapshot();if(!importsMaySend(root.runtime.imports,durableImportSeals))return false;if(root.runtime.loading&&root.runtime.loading.version<3)return true;if(closing||closed||loadingCompletionRevision!==undefined&&durableRootRevision<loadingCompletionRevision)return false;return (root.runtime.loading?.version===6&&intent.operationType==='asset.upload'||loadingCanSend(root.runtime,operationCanvases(intent,root.document),root.document))&&(!root.runtime.loading||durableRootRevision>=root.rootRevision);},getLedger: () => editor.getSnapshot().runtime.ledger, mutate, persist:persistThrough,
|
||||
createId: options.createId ?? (() => crypto.randomUUID()),
|
||||
/** 计算实际写入画布,自动尺寸补写还需验证 historyEffect 的版本与结构。 */
|
||||
participants: intent => { if(intent.historyEffect)validateCreationSizeEffect(intent.historyEffect);return 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()); }
|
||||
// 上传请求使用原文件 SHA/字节数与资源身份;摘要未就绪就拒绝编译,不能上传预览图冒充原件。
|
||||
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 } };
|
||||
},
|
||||
/** 用 ACK 与账本证据限制可消除批次的边界,避免把 possibly-applied 意图当作从未发送。 */
|
||||
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,
|
||||
/** 所有服务共用网络容量;上传先从可靠本地存储取字节并核对冻结长度/摘要。
|
||||
* 不能用 fileWrites 里的尚未保存文件绕过持久化屏障,也不为重试重新生成请求身份。 */
|
||||
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));
|
||||
@@ -624,58 +652,81 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
if (!bytes || bytes.size !== payload.byte_length || await digest(bytes,signal) !== payload.sha256) return { outcome: 'unresolved', blocked: true, error: 'resource_bytes_unavailable' };
|
||||
return network.run(signal, () => options.assets!.upload(request, bytes, signal));
|
||||
},
|
||||
// 不确定结果查询沿用原 FrozenRequest;端口缺失返回阻塞,不能改用另一解释器猜测结果。
|
||||
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)),
|
||||
// 恢复旧请求时先检查服务/协议可解释性;文档端口未声明能力时仅采用旧版 v1 默认。
|
||||
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,
|
||||
});
|
||||
// 旧加载协议尚不能证明局部写入安全,先整体 hold;加载完成根保存后才释放。
|
||||
if(initial.loading&&initial.loading.version<3)loadingRelease=scheduler.hold();
|
||||
/** 把完整读取合入渐进模型。fresh 表示新的权威读取;否则是当前来源流的最终补全。
|
||||
* 对照来源令牌、版本和所有权证据校验,再合成 ACK 并重放本地意图,一次发布退出 loading。
|
||||
* 这里只记录完成 revision,真正解除发送屏障要等 writePrefix 确认该根可靠保存。 */
|
||||
function completeLoading(document:Document,versions:ReadonlyMap<Id,number>,settings:{fresh?:boolean;sourceToken?:string}={}):Result{
|
||||
try{assertWritable(false);const root=editor.getSnapshot(),previous=root.runtime;if(!previous.loading)throw fault('loading_not_active');
|
||||
// 新权威读取不能越过结果未确认的请求,否则无法区分读取包含了哪些本地写入。
|
||||
if(settings.fresh&&previous.loading.version>=3&&[...previous.ledger.requests.values()].some(request=>request.phase!=='resolved'))throw fault('loading_waiting_for_receipts');
|
||||
// 相同画布版本必须对应相同所有权;已完整读过的画布内容也不能在不升版本时变化。
|
||||
if(settings.fresh&&((previous.loading.version===4||previous.loading.version===5||previous.loading.version===6))){const complete=new Set(loadingQueryCoverage(root).completeCanvasIds),capture=previous.loadingRead!;
|
||||
for(const [id,owner]of Object.entries(previous.loading.elementOwners))if(versions.get(owner)===capture.versions.get(owner)&&document.elements.get(id)?.ownerCanvasId!==owner)throw fault('canonical_version_mismatch',[owner]);
|
||||
for(const element of document.elements.values()){if(versions.get(element.ownerCanvasId)===capture.versions.get(element.ownerCanvasId)&&previous.loading.elementOwners[element.id]!==element.ownerCanvasId)throw fault('canonical_version_mismatch',[element.ownerCanvasId]);if(complete.has(element.ownerCanvasId)&&versions.get(element.ownerCanvasId)===previous.versions.get(element.ownerCanvasId)&&!equal(element,previous.acknowledged.elements.get(element.id)))throw fault('canonical_version_mismatch',[element.ownerCanvasId]);}}
|
||||
// 当前流按原始 loadingRead 证明扩展;fresh 则按当前 ACK 和更严格的权威读取规则校验。
|
||||
const source=!settings.fresh&&previous.loading.version>=3?previous.loadingRead:undefined;validateLoadingCompletion(source?.document??previous.acknowledged,source?.versions??previous.versions,previous.loading,document,versions,options.registry,settings.fresh,settings.sourceToken);
|
||||
// 当前流尾部可能早于加载期间的 ACK,mergeLoadingRead 保留已经确认的编辑。
|
||||
const proposal=!settings.fresh?mergeLoadingRead(previous,{document,versions},options.registry):{document,versions};
|
||||
const acknowledged=createDocument(document.projectId,[...proposal.document.canvases.values()],[...proposal.document.elements.values()],proposal.document.tombstones),working=replayPending(acknowledged,previous.ledger,options.registry),conflicts=new Map(previous.conflicts);conflicts.delete('loading');conflicts.delete('refresh');
|
||||
// 最终 working 仍叠加 pending 操作;清除加载诊断与状态不等于丢弃用户排队中的编辑。
|
||||
acceptingLoading=true;const result=editor.reconcile(working,Object.freeze({...previous,loading:undefined,loadingRead:undefined,acknowledged,versions:readMap(proposal.versions),conflicts:readMap(conflicts)}),root.rootRevision);
|
||||
if(result.status==='applied')loadingCompletionRevision=editor.getSnapshot().rootRevision;return result;
|
||||
}catch(error){const result=reject(error);if(editor.getSnapshot().runtime.loading&&!['engine_closing','identity_changed','recovery_in_progress','loading_not_active','loading_waiting_for_receipts'].includes(result.code))editor.updateRuntime(previous=>{const conflicts=new Map(previous.conflicts);conflicts.set('loading',result.code);return Object.freeze({...previous,conflicts:readMap(conflicts)});});return result;}finally{acceptingLoading=false;}
|
||||
}
|
||||
/** 通过 editor 更新单个 RunRecord;run 必须仍存在,归约产物冻结后与其它 runtime 状态同根发布。 */
|
||||
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);
|
||||
}
|
||||
/** 协调一页生成事件:校验任务/游标/控制代次,依次采用新的模型事件,最后与游标一起发布。
|
||||
* 有未确认写入或模型冲突时保留 pendingPage 和输出预览,working、ACK 与游标保持原值。 */
|
||||
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) {
|
||||
// 只消费游标之后的模型事件:step.applied 或附带 complete 模型的 step.output。
|
||||
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');
|
||||
// 同页前一个事件已更新 ACK/版本,后一个事件的解释应看到该临时累计结果。
|
||||
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;
|
||||
}
|
||||
// 页内全部模型事件通过后,再把本地未决操作叠回;文档与 RunRecord 的 next 游标一起发布。
|
||||
const document=replayPending(acknowledged,previous.ledger,options.registry),runs=new Map(previous.runs);
|
||||
// 应用输出的身份去重保存,避免 UI 将已经应用的输出再次视为待应用。
|
||||
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);
|
||||
@@ -683,34 +734,48 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
if(code!=='run_waiting_for_receipts')report(error);
|
||||
}
|
||||
}
|
||||
// 轮询器读根中的任务事实,状态更新仍回到 editor;读取与提交共享 network gate 和持久化入口。
|
||||
const runTracker=createRunTracker({clock:runClock,getRuns:()=>editor.getSnapshot().runtime.runs,persist:persistThrough,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 unsubscribeSizing = sessions.subscribe(() => { if (!sessions.getSnapshot().gesture) queueDefaultSizes(); });
|
||||
/** 每次成功发布后登记保存义务,再安排后台任务;通知只传现有操作 ID,不另造一份操作状态。 */
|
||||
const unsubscribe = editor.subscribe((commit, snapshot) => {
|
||||
pending.set(snapshot.rootRevision, snapshot);notifyDurability();
|
||||
// 新受理任务在后续微任务启动追尾,让本次根发布和监听通知先完成。
|
||||
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));
|
||||
// 合并同一轮事件中的保存触发;pending 仍逐根保留,合并的是调度而非快照证据。
|
||||
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(); }prepareImages(); });
|
||||
});
|
||||
/** 统一摘要入口,继承 sourceDigest 的注入选择与项目生命周期中止。 */
|
||||
async function digest(bytes:Blob,signal?:AbortSignal){return sourceDigest(bytes,signal);}
|
||||
/** 最多并发两个原文件摘要。只发现 waiting_digest 的上传事实,成功原子写摘要并解除阻塞。
|
||||
* 失败保留可识别原因并退出自动发现条件,等待显式 retry,而非无限计算同一损坏文件。 */
|
||||
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 => {
|
||||
// 等待散列时资源可能变更/退休,重新核对身份和内容属性,不把迟到摘要写给另一份文件。
|
||||
const current=previous.resources.get(resource.id);if(!current||current.operationId!==resource.operationId||current.byteLength!==resource.byteLength||current.mimeType!==resource.mimeType||current.digest!==undefined&¤t.digest!==hash||current.retirement?.purgedAt!==undefined)throw fault('resource_identity_conflict',[resource.id]);
|
||||
const resources = new Map(previous.resources), ledger = createLedger(previous.ledger);
|
||||
resources.set(resource.id, freeze({ ...current, digest: hash })); ledger.block(resource.operationId, []);
|
||||
@@ -722,29 +787,37 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
digestTasks.set(resource.id, task);
|
||||
}
|
||||
}
|
||||
/** 并发两项图片尺寸准备,与上传摘要分别调度。优先解码本地原件,无字节时可用已存服务端尺寸。
|
||||
* image 状态先写成 pending,完成时只更新仍指向同一 image 记录的资源,失败不会覆盖后来重试。 */
|
||||
function prepareImages(){
|
||||
queueDefaultSizes();
|
||||
if(closing||closed||recovery||!options.images)return;
|
||||
for(const resource of editor.getSnapshot().runtime.resources.values()){
|
||||
if(imageTasks.size>=2)break;
|
||||
if(imageTasks.has(resource.id)||resource.image&&(resource.image.version!==1||resource.image.status!=='pending')||resource.retirement?.purgedAt!==undefined)continue;
|
||||
// 先发布可恢复 pending,再由发布通知进入实际解码,避免隐形在途任务没有状态依据。
|
||||
if(!resource.image){editor.updateRuntime(previous=>{const current=previous.resources.get(resource.id);if(!current||current.image)return previous;const resources=new Map(previous.resources);resources.set(resource.id,freeze({...current,image:{version:1 as const,status:'pending' as const}}));return Object.freeze({...previous,resources:readMap(resources)});});return;}
|
||||
const task=(async()=>{
|
||||
const bytes=fileWrites.get(resource.id)??await options.storage.readFile(leader,resource.id);
|
||||
if(bytes&&bytes.size!==resource.byteLength||!bytes&&!resource.stored)throw fault('resource_bytes_unavailable',[resource.id]);
|
||||
// 上传完成并不一定保留本地字节;服务端回执的尺寸可作为无本地解码时的来源。
|
||||
const size=bytes?await options.images!.decode(bytes,digestLifecycle.signal):resource.stored;
|
||||
// 拒绝非法尺寸,也在 await 后复查关闭/恢复会话,防止迟到结果越过生命周期边界。
|
||||
if(!size||!validImageDimensions(size))throw fault('invalid_image_dimensions');if(closing||closed||recovery)return;
|
||||
const result=editor.updateRuntime(previous=>{const current=previous.resources.get(resource.id);if(!current||current.operationId!==resource.operationId||current.image!==resource.image||current.retirement?.purgedAt!==undefined)return previous;const resources=new Map(previous.resources);resources.set(resource.id,freeze({...current,image:{version:1 as const,status:'ready' as const,width:size.width,height:size.height,source:bytes?'decode' as const:'server' as const}}));return Object.freeze({...previous,resources:readMap(resources)});});
|
||||
if(result.status==='rejected')throw fault(result.code,result.targets);
|
||||
})().catch(error=>{if(closing||closed||recovery)return;const code=(error as{code?:string}).code??'image_decode_failed';editor.updateRuntime(previous=>{const current=previous.resources.get(resource.id);if(!current||current.operationId!==resource.operationId||current.image!==resource.image)return previous;const resources=new Map(previous.resources);resources.set(resource.id,freeze({...current,image:{version:1 as const,status:'failed' as const,code}}));return Object.freeze({...previous,resources:readMap(resources)});});}).finally(()=>{imageTasks.delete(resource.id);prepareImages();});imageTasks.set(resource.id,task);
|
||||
}
|
||||
}
|
||||
/** 将多个尺寸补写通知合并为一次宏任务;不是网络重试时刻,也不忙等尺寸解码。 */
|
||||
function queueDefaultSizes() {
|
||||
if (sizingTask || closing || closed || recovery || ![...editor.getSnapshot().runtime.resources.values()].some(resource => resource.sizing?.version === 1 && resource.sizing.grants.some(grant => grant.status === 'pending'))) return;
|
||||
// A scheduling yield, not a logical retry deadline: avoid a self-extending
|
||||
// microtask chain when one resource supplies many newly created elements.
|
||||
sizingTask = new Promise(resolve => { setTimeout(() => { sizingTask = undefined; try { prepareDefaultSizes(); } catch (error) { report(error); } finally { resolve(); } }, 0); });
|
||||
}
|
||||
/** 一次最多处理一项创建尺寸授权。仅在无手势/无恢复时,把原始比例适配到当前宽度和单位约束。
|
||||
* resolveCreationSize 验证目标代次与创建历史,补写同一历史单元;不能改写任意已编辑对象。 */
|
||||
function prepareDefaultSizes() {
|
||||
if (closing || closed || recovery || sessions.getSnapshot().gesture) return;
|
||||
const root = editor.getSnapshot();
|
||||
@@ -756,11 +829,14 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
if (grant.status !== 'pending') continue;
|
||||
const element = root.document.elements.get(grant.target.id);
|
||||
if (element?.structure !== 'node') continue;
|
||||
// 模型几何使用 presentation 对应单位;先在模型单位算约束,调用接口前再换算为显示单位。
|
||||
const units = unitScale(element.data.presentation), min = units, max = 100000 * units;
|
||||
// 尽量维持当前宽度,同时约束双边最大值及最小值;极端比例不可满足时由下方拒绝。
|
||||
const scale = Math.min(element.geometry.width / size.width, max / size.width, max / size.height), grow = Math.max(scale, min / size.width, min / size.height);
|
||||
const width = size.width * grow, height = size.height * grow;
|
||||
const result = !Number.isFinite(width) || !Number.isFinite(height) || width > max || height > max ? reject(fault('image_fit_constraint'))
|
||||
: editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId: grant.entryId, target: grant.target, size: { width: width / units, height: height / units } }, root.rootRevision);
|
||||
// 无新增操作也要结清 grant;拒绝则标 blocked 防止每次根发布重复触发相同错误。
|
||||
if (result.status === 'rejected' || !result.operationIds.length) {
|
||||
editor.updateRuntime(previous => {
|
||||
const current = previous.resources.get(id);
|
||||
@@ -775,6 +851,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 显式重试把 blocked grant 恢复为 pending,不改已应用项;重新排一次尺寸检查。 */
|
||||
function retryDefaultSizes() {
|
||||
editor.updateRuntime(previous => {
|
||||
const resources = new Map(previous.resources); let changed = false;
|
||||
@@ -785,6 +862,9 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
});
|
||||
queueDefaultSizes();
|
||||
}
|
||||
/** 将没有文档 changes 的取消/应用动作登记为逻辑操作。调用方先做业务校验,
|
||||
* apply 等待所有旧未决操作;cancel 不添加这些依赖,以便及时控制服务端任务。
|
||||
* 只更新账本,返回的 revision 仍是当前文档 revision,发送由后续调度完成。 */
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user