docs(runtime): explain conflict recovery coordination and audit
This commit is contained in:
@@ -26,7 +26,8 @@ export type RecoveryChoice = 'keep-local' | 'discard-local';
|
||||
export type RecoveryDecision = { readonly operationId: Id; readonly choice: RecoveryChoice; readonly replacementId?: Id; readonly resolution: 'superseded' | 'discarded' | 'satisfied' };
|
||||
/** 恢复审计记录:绑定规划时的根 revision,保留备份及可用的历史重放证据。 */
|
||||
export type RecoveryAudit = { readonly id: Id; readonly formatVersion: 1; readonly baseRootRevision: number; readonly decisions: readonly RecoveryDecision[]; readonly backup: Json; readonly historyTransition?:HistoryTransition; readonly historyReplay?:HistoryReplay };
|
||||
/** 待一次性发布的候选根及引用资源;baseRootRevision 供提交方拒绝过时方案。 */
|
||||
/** 待采纳的文档/历史/操作/账本候选及审计,非完整 EngineSnapshot;引擎还需结合 canonical 版本等事实发布。
|
||||
* baseRootRevision 用于拒绝过时方案,resourceIds 是本次已知保留集合,operationIds 只列新替代操作。 */
|
||||
export type ConflictPlan = { readonly id: Id; readonly baseRootRevision: number; readonly document: Document; readonly history: HistoryState; readonly operations: EngineSnapshot['operations']; readonly ledger: EngineSnapshot['runtime']['ledger']; readonly audit: RecoveryAudit; readonly resourceIds: readonly Id[]; readonly operationIds: readonly Id[] };
|
||||
/** 字段身份包括元素代次,避免把删除后重建的同 ID 元素算成同一字段历史。 */
|
||||
const fieldKey = (change: FieldChange) => JSON.stringify([change.target.id,change.target.incarnation,change.field]);
|
||||
@@ -41,8 +42,11 @@ function value(document: Document, change: FieldChange, allowMissing=false): {be
|
||||
/** 把文档 Map 转成备份用的 JSON 形状;序列化的是数据,不是运行时方法或索引。 */
|
||||
const documentValue = (document: Document) => ({projectId:document.projectId,canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:[...document.tombstones]});
|
||||
|
||||
/** 纯规划入口:先校验权威读取与回执已全部落定,再按操作顺序投影用户决策。
|
||||
* 不复用旧请求键/请求体;未知结果尚未确认时拒绝规划,防止重复执行已经生效的修改。 */
|
||||
/** 在不可变 snapshot 上规划恢复:先验 canonical 单调性/完整性,并要求 ledger 中请求 phase 全为 resolved。
|
||||
* 不自己查询回执、认证来源或等待磁盘;canonical/账本已捕获且可信由 engine/端口负责。
|
||||
* 取全部非 resolved 且非 asset.upload 意图,要求 choices 精确覆盖每项;依次重规划、重建 history、退休旧项并加入替代项。
|
||||
* 不发布/持久化/提交请求;createId 可消耗外部编号,失败不会回滚该回调副作用。
|
||||
* 小输入/模型按既有不可变合同使用,备份 JSON 编码与全部恢复规划同步完成,没有时间片/容量预算。 */
|
||||
export function planConflictRecovery(snapshot: EngineSnapshot, canonical: CanonicalProposal, choices: ReadonlyMap<Id,RecoveryChoice>, registry: Registry, createId:()=>Id,historyLimit=100): ConflictPlan {
|
||||
if(snapshot.runtime.loading)validateLoadingCompletion(snapshot.runtime.acknowledged,snapshot.runtime.versions,snapshot.runtime.loading,canonical.document,canonical.versions,registry,true);else validateCanonicalRead(snapshot.runtime.acknowledged,snapshot.runtime.versions,canonical.document,canonical.versions,registry);
|
||||
if([...snapshot.runtime.ledger.requests.values()].some(request=>request.phase!=='resolved'))throw fault('recovery_waiting_for_receipts');
|
||||
@@ -59,7 +63,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
// 边界记录历史单元恢复前后的文档;starts 保存首次触及字段时的基线,供混合 ACK 历史核对。
|
||||
const boundaries=new Map<Id,{before:Document;after:Document}>(),starts=new Map<string,ReturnType<typeof value>>(),historyOwner=new Map<Id,Id>(),contexts=intentContexts(snapshot.document,records.map(record=>record.intent));
|
||||
for(const entry of snapshot.history.entries.values())for(const operationId of entry.operationIds){if(historyOwner.has(operationId))throw fault('recovery_history_ambiguous');historyOwner.set(operationId,entry.id);}
|
||||
// 有历史转移证据或之前已采用重放的操作时,优先用日志恢复保持 undo/redo 的语义。
|
||||
// 全部 content 有原生 transition、某个遍历有 transition,或命中已有 adoption 时,进入严格日志恢复。
|
||||
// 这只是分支选择,不证明证据齐全/可回放;journal 构造和后续 project/accept 仍可拒绝不完整/矛盾记录。
|
||||
const adopted=new Set([...snapshot.runtime.recoveries.values()].filter(audit=>audit.historyReplay!==undefined).flatMap(audit=>audit.decisions.flatMap(decision=>decision.replacementId?[decision.replacementId]:[])));
|
||||
const content=records.filter(record=>record.intent.changes.length),hasEvidence=content.length>0&&content.every(record=>record.intent.historyTransition)||records.some(record=>['undo','redo'].includes(record.intent.origin)&&record.intent.historyTransition)||content.some(record=>adopted.has(record.intent.id));
|
||||
const journal=hasEvidence?createHistoryJournalRecovery(snapshot,records.map(record=>record.intent),registry,historyLimit):undefined;
|
||||
@@ -78,7 +83,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
}
|
||||
// 用户新建 canvas 的身份若已被服务端占用,不能悄悄视为同一对象。
|
||||
if(choice==='keep-local'&&intent.origin==='user')for(const change of intent.changes)if(change.kind==='canvas'&&!change.before&&canonical.versions.has(change.id))throw fault('recovery_identity_collision',[change.id]);
|
||||
// keep-local 在新基线上重新计算改动;discard-local 不施加该意图,日志仍记录该决定。
|
||||
// 每项边界 before→after 反映此前全部审阅结果;keep 在该新候选上规划,discard 不施加原文档变化。
|
||||
// journal.project 还可能先推进已有结算历史;没有 journal 时直接走 rebaseIntent,不能并行按同一旧基线处理。
|
||||
const rebased=journal?journal.project(document,intent,contexts.get(intent.id)!,choice==='keep-local'):choice==='keep-local'?rebaseIntent(document,intent,contexts.get(intent.id)!,registry):{document,changes:[]};
|
||||
const changes=rebased.changes;document=rebased.document;
|
||||
if(entryId)boundaries.get(entryId)!.after=document;
|
||||
@@ -87,7 +93,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
// 生成启动、取消等动作需重新发起命令;冲突审阅不能顺手重新执行可能付费的任务。
|
||||
if(intent.operationType!=='document.change'&&intent.operationType!=='run.apply')throw fault('recovery_action_requires_new_command',[intent.id]);
|
||||
if(changes.length){
|
||||
// 依赖替换为对应的新操作,丢弃或已满足的依赖不再排队;再串联此前所有替代操作。
|
||||
// 原依赖若已审阅:改指对应 replacement,或在丢弃/已满足时移除;未审阅的既有依赖保留原 ID。
|
||||
// 再依赖所有此前新 replacement,保证后项看到前项的串行效果;新 sequence 从账本最大值后递增。
|
||||
const dependencyIds=intent.dependencies.flatMap(dependency=>replacements.has(dependency)?replacements.get(dependency)?[replacements.get(dependency)!.id]:[]:[dependency]);
|
||||
// New dependencies reference new logical facts; a discarded parent never silently starts a paid run.
|
||||
const dependencySet=new Set(dependencyIds);
|
||||
@@ -102,6 +109,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
}
|
||||
journal?.accept(intent,replacement,choice==='keep-local',before,document);
|
||||
replacements.set(intent.id,replacement);
|
||||
// satisfied 只表示这次规划无需新 changes;不是在此收到另一份服务器 applied 回执。
|
||||
decisions.push(freeze({operationId:intent.id,choice,resolution:choice==='discard-local'?'discarded':replacement?'superseded':'satisfied',...(replacement?{replacementId:replacement.id}:{})}));
|
||||
}
|
||||
// 根据现有证据选日志重放、撤销/重做重规划或兼容历史重写,拒绝无法证明安全的路径。
|
||||
@@ -113,20 +121,24 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
ledger.add(replacement,presence);
|
||||
}
|
||||
const historyReplay=journal?.evidence();
|
||||
// Conservative pins include the archived document/history and unresolved opaque local resources.
|
||||
// 已知资源保留集合:先纳入本地 resources 全部 ID,再补原/替代 changes 与历史 replay 中可解释引用。
|
||||
// 不在此推断 opaque payload 的所有私有引用,也不把资源 ID 清单当作已保存 Blob 字节的证明。
|
||||
const resourceIds=new Set(snapshot.runtime.resources.keys());
|
||||
for(const record of records)for(const resource of referencedResources(record.intent.changes,registry))resourceIds.add(resource);
|
||||
for(const replacement of replacements.values())if(replacement)for(const resource of referencedResources(replacement.changes,registry))resourceIds.add(resource);
|
||||
if(historyReplay)for(const transition of [historyReplay.anchor,...historyReplay.events.map(event=>event.transition)])for(const resource of referencedResources(historyTransitionChanges(transition),registry))resourceIds.add(resource);
|
||||
// 同时备份旧 working、ACK 基线和历史;资源 pin 保护恢复证据,防止审阅后被清理。
|
||||
// backup 捕获旧 working/ACK/版本/history 和资源 ID;不嵌入 Blob、服务回执或完整 workspace 快照。
|
||||
// 顶层 audit transition 描述旧→新历史,journal evidence 另解释内部事件;两者都须与恢复 Root 同次保存。
|
||||
const backup=JSON.parse(JSON.stringify({document:documentValue(snapshot.document),acknowledged:documentValue(snapshot.runtime.acknowledged),versions:[...snapshot.runtime.versions],resourceIds:[...resourceIds],history:{undo:snapshot.history.undo,redo:snapshot.history.redo,entries:[...snapshot.history.entries]}})) as Json;
|
||||
const historyTransition=describeHistoryTransition(snapshot.history,history,snapshot.rootRevision+1,undefined,!!historyReplay);
|
||||
const audit=freeze({id,formatVersion:1 as const,baseRootRevision:snapshot.rootRevision,decisions,backup,...(historyTransition?{historyTransition}:{}),...(historyReplay?{historyReplay}:{})});
|
||||
return Object.freeze({id,baseRootRevision:snapshot.rootRevision,document,history,operations:readMap(operations),ledger:ledger.getSnapshot(),audit,resourceIds:Object.freeze([...resourceIds]),operationIds:Object.freeze([...replacements.values()].flatMap(intent=>intent?[intent.id]:[]))});
|
||||
}
|
||||
|
||||
/** 无完整日志证据时的兼容历史重写。仅支持能够证明前缀/字段基线的撤销栈项,
|
||||
* 不推测交错 ACK、结构变更或不支持的历史遍历;无法验证时抛错让整个规划保持未发布。 */
|
||||
/** 无 journal 的兼容分支,仅重写受本批 pending 影响的 undo 项,且其关联操作都必须是已知 user origin。
|
||||
* 全项未决时直接用 replacements 拼回;混合已确认贡献时,结构项要求确认贡献为完整前缀,纯字段按身份键分别审阅。
|
||||
* 不生成完整事件 replay,不重规划 redo 或任意交错遍历;不满足证据条件即拒绝本次方案,交外层保持未发布。
|
||||
* 返回私有 entry 表/undo 候选,未影响 entry 与 redo 数组沿用不可变快照引用。 */
|
||||
function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap<Id,OperationIntent|undefined>,starts:ReadonlyMap<string,ReturnType<typeof value>>,boundaries:ReadonlyMap<Id,{before:Document;after:Document}>,registry:Registry):HistoryState {
|
||||
const entries=new Map(snapshot.history.entries),affected=new Set(pending.map(intent=>intent.id));
|
||||
for(const entry of snapshot.history.entries.values()){
|
||||
@@ -155,7 +167,8 @@ function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent
|
||||
if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id);
|
||||
continue;
|
||||
}
|
||||
// 纯字段混合历史按“目标代次 + 字段”分组;确认部分是否保留取决于恢复起点的值和存在性。
|
||||
// 纯字段按 ID/代次/field 分组,先验证该字段历史尾部确为 oldPending,再分离已确认 prefix。
|
||||
// starts 记录本次审阅首次触及该字段的 fresh 值/存在性;只在它仍等于旧 pending 的 before 时保留确认前缀。
|
||||
const keys=new Map<string,FieldChange>();for(const intent of intents)for(const change of intent.changes as readonly FieldChange[])keys.set(fieldKey(change),change);
|
||||
const fields:FieldChange[]=[];
|
||||
for(const [key,example]of keys){
|
||||
@@ -169,6 +182,7 @@ function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent
|
||||
if(!start.missing&&equal(start.before,first.before)&&(start.beforePresent??true)===(first.beforePresent??true))fields.push(...prefix);
|
||||
fields.push(...intents.flatMap(intent=>replacements.get(intent.id)?.changes as readonly FieldChange[]??[]).filter(change=>fieldKey(change)===key));
|
||||
}
|
||||
// 未触及字段保留;重建字段链追加到后面。此分支已排除 owner/端点等结构变化,不用该方式重排结构依赖。
|
||||
const changes:Change[]=[...entry.changes.filter(change=>change.kind!=='field'||!keys.has(fieldKey(change))),...fields];
|
||||
const operationIds=entry.operationIds.flatMap(operationId=>affected.has(operationId)?replacements.get(operationId)?[replacements.get(operationId)!.id]:[]:[operationId]);
|
||||
if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id);
|
||||
|
||||
Reference in New Issue
Block a user