From c83641417ba7c7f549fe1214f221c9bff4cfd4d6 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 13:52:23 +0800 Subject: [PATCH] docs(runtime): explain mixed history recovery planning lifecycle --- .../history-journal-recovery.ts | 56 ++++++++++++++----- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/src/lib/canvas-runtime/history-journal-recovery.ts b/src/lib/canvas-runtime/history-journal-recovery.ts index 4f5acd1..ff15634 100644 --- a/src/lib/canvas-runtime/history-journal-recovery.ts +++ b/src/lib/canvas-runtime/history-journal-recovery.ts @@ -15,7 +15,11 @@ import type { EngineSnapshot } from './contracts.ts'; import { planCreationSize, validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts'; /** Native history events are reconstructed from evidence; all cursors remain private until the recovery root is accepted. */ -/** 创建一次恢复会话,要求有效 history 容量和完整事件证据;返回 project→accept→finish/evidence 协作接口。 */ +/** 一次同步恢复规划会话:从已验证 snapshot/pending 取事件,逆向条件回放历史元数据得到 baseline。 + * 不逆向执行文档 changes 或访问服务;document 的 fresh 重规划由 project/rebaseIntent 处理。 + * 调用方按事件顺序为每项 project→分配替代操作→accept,最后 finish→evidence,并同一个恢复 Root 采纳。 + * snapshot/registry 沿用不可变输入;方法会改变私有游标/history,失败不自动回滚或封闭会话,应丢弃本次规划对象。 + * limit 约束重建 user 贡献时的 undo 容量,不代表所有外部事件证据都可截断;无异步预算/网络/持久化。 */ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:readonly OperationIntent[],registry:Registry,limit:number){ if(!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_history_limit'); // historyEventSource 可能提供 adoption 虚拟事件;给每项补私有 before/after 以便核对后续取舍。 @@ -31,15 +35,19 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea const baseline=history,replayEvents:HistoryReplayEvent[]=[],adoptionRevision=snapshot.rootRevision+1; /** 记录私有 history 的一次真实变化;关联操作即使 no-op 也留证据,调整事件不造 operation。 */ function recordStep(before:HistoryState,entryId:Id|undefined,operationId?:Id){const transition=describeHistoryTransition(before,history,adoptionRevision,entryId,!!operationId);if(transition)replayEvents.push(operationId?freeze({kind:'operation' as const,operationId,transition}):freeze({kind:'adjustment' as const,transition}));} - // next 是严格事件游标;touched 区分本轮已审阅项,retired 解释被清redo/容量淘汰或无效果移除的项。 + // events 保存证据中的原始 before/after,history 是随取舍变化的新候选,不能混成同一份状态。 + // next 指下个事件;touched 标本轮审阅项,retired 解释清 redo/容量淘汰/无效果删除;remap/sizeHistory 仅服务当前 project→accept。 let next=0;const touched=new Set(),retired=new Set();let remap:((change:Change)=>Change)|undefined; let sizeHistory: HistoryState | undefined; /** 捕获双栈数组和 entry 表成员为新只读 history,不暴露私有可变容器。 */ const state=(undo:readonly Id[],redo:readonly Id[],entries:ReadonlyMap)=>Object.freeze({undo:freeze([...undo]),redo:freeze([...redo]),entries:readMap(entries)}); - /** 当前处理的 intent 必须恰好位于 next;不能按网络返回先后跳过或交换历史。 */ + /** 只按下个 record 的 intent.id 检查位置,不逐字段比对外来 intent;协调器须传同快照中受信意图。 + * 不能按网络返回顺序跳过/交换,adjustment 和已结算事件由 advance 先消费。 */ const eventFor=(intent:OperationIntent)=>{const event=events[next];if(!event||event.record?.intent.id!==intent.id)throw fault('recovery_history_event_order',[intent.id]);return event;}; - /** 处理一个已有分组项的确认前缀:纯字段仅保留 fresh 仍含其最终效果的字段链。 - * 几何/path 要求 owner 未变;结构性前缀必须可完整逆向执行,否则拒绝猜测部分历史。 */ + /** 审阅已有分组项:纯字段按 target ID/代次/field 的最后 after 判断 fresh 是否还保留该贡献。 + * 最终值/存在性匹配才保留该字段的原整条链,不将每条都压成一次最后值;不匹配就移除此字段贡献。 + * geometry/path 的 owner 还须与原 ACK 一致;含 owner/端点或整记录的前缀必须可完整逆向规划。 + * 只返回 changes,不移动文档;不在这里恢复被远端覆盖的旧字段值。 */ function reviewPrefix(entry:HistoryEntry,document:Document):readonly Change[]{ const prefix=entry.changes; if(prefix.every(change=>change.kind==='field'&&!['ownerCanvasId','source','target'].includes(change.field))){ @@ -50,14 +58,17 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea } try{planHistoryChanges(document,invert(prefix),registry);}catch{throw fault('recovery_history_prefix_conflict',[entry.id]);}return prefix; } - /** 丢弃一个未保留的 user 贡献时,仍审查同 entry 中已确认的旧前缀,不能连旧贡献一起删除。 */ + /** 无 replacement 的 user 贡献不追加新 changes;首次触及且还在 undo 的旧项才审阅原前缀。 + * 空前缀退休并从两栈移除,非空则保留;无 entryId(history=false)、已 touched 或不在 undo 时不重复处理。 */ function skipUser(intent:OperationIntent,document:Document){ const event=eventFor(intent),id=event.transition.entryId;if(!id)return;const entry=history.entries.get(id); if(!entry){retired.add(id);return;} if(touched.has(id)||!history.undo.includes(id))return; const changes=reviewPrefix(entry,document),entries=new Map(history.entries);if(changes.length)entries.set(id,freeze({...entry,changes}));else{entries.delete(id);retired.add(id);}history=state(history.undo.filter(key=>entries.has(key)),history.redo.filter(key=>entries.has(key)),entries); } - /** 加入保留/已确认的 user 贡献,沿用原 entry 元数据并维护分组顺序、清redo和容量淘汰。 */ + /** 以原事件 after 的 label/group 等元数据加入贡献,append 必须与原有分组/栈顶关系相容。 + * 先清 redo 并标 retired;pending 首次触及旧项才审阅 fresh 前缀,随后追加本轮 changes/operationId。 + * 新项压 undo,超过 limit 从栈底淘汰;已退休原组可按证据重新建立,不借旧 ID 静默改写任意已有项。 */ function addUser(intent:OperationIntent,changes:readonly Change[],operationId:Id,pending:boolean,document?:Document){ const event=eventFor(intent),id=event.transition.entryId;if(!id)return; const original=event.after!.entries.get(id);if(!original)throw fault('recovery_history_transition_conflict',[id]); @@ -77,7 +88,9 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea for(const patch of event.transition.entries){const current=entries.get(patch.id),original=before.entries.get(patch.id),desired=after.entries.get(patch.id);if(equal(current,desired))continue;if(!original||!equal(current,original))throw fault('recovery_history_adjustment_conflict',[patch.id]);if(desired)entries.set(patch.id,desired);else{entries.delete(patch.id);retired.add(patch.id);}} history=state(history.undo.filter(id=>entries.has(id)),history.redo.filter(id=>entries.has(id)),entries); } - /** 先消费下一个待审阅意图之前的已确认/调整事件;碰到另一 pending 不能越过它自动处理。 */ + /** 消费目标 pending 之前(或 finish 时剩余)的 adjustment 与已有结果事件,逐个 recordStep/推进 next。 + * 带 record 者不得是另一个 pending,resolution 只接受 applied/elided;不在此独立校验 phase/服务回执真实性。 + * 默认尺寸、user、undo/redo 分别走专用逻辑,其它 origin 明确拒绝;不会重发这些已结算操作。 */ function advance(intent?:OperationIntent){ while(next>(); /** 仅同 ID 且代次确实改变时登记,不能把两个不同元素意外映为同一生命周期。 */ @@ -99,7 +114,8 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea /** 仅重写 field target 与整元素 before/after 代次,不修改 canvas 记录或冻结服务请求。 */ return(change:Change):Change=>{const record=(value:T|null)=>value?{...value,incarnation:maps.get(value.id)?.get(value.incarnation)??value.incarnation}:null;if(change.kind==='field')return freeze({...change,target:record(change.target)!});if(change.kind==='element')return freeze({...change,before:record(change.before),after:record(change.after)});return change;}; } - /** 已 applied 的 undo/redo 是事实:只有当前审阅前缀仍表示完全相同效果才允许移动栈。 */ + /** 已有 applied/elided 结果的遍历必须命中当前栈顶,且原事件前后变化与审阅后的正向效果精确对应。 + * 只重建历史移动/关联并为其它项重映代次,不执行已结算文档变化,也不改旧 operation/request。 */ function addConfirmedTraversal(intent:OperationIntent){ const event=eventFor(intent),id=event.transition.entryId,undo=[...history.undo],redo=[...history.redo],source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo; if(!id||source.at(-1)!==id)throw fault('recovery_history_traversal_order',id?[id]:[]); @@ -110,7 +126,8 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea const entries=new Map(history.entries),map=incarnationRemap(before,after);for(const[otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(map)})); entries.set(id,freeze({...entry,changes,operationIds:[...entry.operationIds,intent.id]}));source.pop();destination.push(id);history=state(undo,redo,entries); } - /** 已确认默认尺寸效果只修原 entry/后续条件,不移动双栈;当前历史必须仍匹配原前置证据。 */ + /** 已结算默认尺寸使用原 transition 的 entry changes 修正,要求该事件两栈不动、当前 changes/label/group 仍匹配。 + * 不再次运行尺寸文档命令;只给所属创建项追加逻辑操作关联,其它被修正项保留各自关联。 */ function addConfirmedSize(intent: OperationIntent) { validateCreationSizeEffect(intent.historyEffect); const event = eventFor(intent), entries = new Map(history.entries); @@ -123,7 +140,10 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea history = state(history.undo, history.redo, entries); } return{ - /** 为当前 intent 计算文档候选,不立即移动对应新历史;accept 在替代操作身份确定后接续。 */ + /** 先 advance 结算前置事件,再为当前 pending 返回 fresh 文档候选;自身对应的历史贡献等 accept。 + * 尺寸专用 planner 暂存 sizeHistory;touched 遍历按审阅后的 entry 重新规划,其它 keep 意图走 rebaseIntent。 + * 丢弃返回零文档变化但仍可能校验原遍历可行性;空 changes 直接返回,不消费事件游标。 + * 此调用可能已经改变前置私有 history,不能当无状态 query,也不能并发交错不同项的 project/accept。 */ project(document:Document,intent:OperationIntent,context:IntentContext,keep:boolean){ if(!intent.changes.length)return{document,changes:[] as readonly Change[]};advance(intent);const event=eventFor(intent),id=event.transition.entryId;remap=undefined;sizeHistory=undefined; // 尺寸效果通过专用 planner 重算 history;keep=false 不发布旧自动尺寸。 @@ -150,7 +170,10 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea // 普通保留意图经显式审阅后重新规划;丢弃返回零文档变化,不自行伪造成功回执。 return keep?rebaseIntent(document,intent,context,registry):{document,changes:[] as readonly Change[]}; }, - /** 接受 project 的取舍/替代操作,更新私有双栈并记 replay;每项完成后 next 单调前进。 */ + /** 在协调器确定 replacement 后,按当前 project 结果更新私有历史、记 replay 并将 next 加一。 + * replacement/keep/before/after 须与此前 project 配对;接口不独立比对该语义或自动生成新 ID/账本记录。 + * user 按贡献维护组,尺寸采用暂存计划,keep 遍历有剩余效果才移到另一栈,否则退休该项。 + * 不发布 Root、不持久化审计;调用失败后不要继续使用可能已部分推进的同一规划会话。 */ accept(intent:OperationIntent,replacement:OperationIntent|undefined,keep:boolean,before:Document,after:Document){ if(!intent.changes.length)return;const previous=history,event=eventFor(intent),id=event.transition.entryId; if (intent.historyEffect) { @@ -174,9 +197,12 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea } recordStep(previous,id,replacement?.id);next++; }, - /** 消费剩余已确认事件后返回最终候选 history;未审阅 pending 仍会被 advance 拒绝。 */ + /** 消费剩余已结算/调整事件并返回候选 history;未处理 pending 会拒绝,不静默跳过。 + * 不设置 finished 锁或自动保存,协调器仍需整体校验并原子采纳文档/历史/账本。 */ finish(){advance();return history;}, - /** 输出原 history→baseline 的 anchor 及有序增量,和本次 adoption 一起持久化以支持再次重开。 */ + /** 完成 finish 后作为最后步骤输出证据:原 history→baseline 的 anchor,加本次有序元数据增量。 + * 所有 transition 绑定 snapshot.rootRevision+1,不表示多次服务写入或已持久化成功。 + * freeze 直接包含 replayEvents 数组,会冻结该会话的事件缓冲;提前调用后不能再向它追加规划事件。 */ evidence():HistoryReplay{return freeze({version:1 as const,anchor:describeHistoryTransition(snapshot.history,baseline,adoptionRevision,undefined,true)!,events:replayEvents});}, }; }