From 89b6f4a25397f6ed3f6eef3806986d2ee14c4a8f Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 01:56:21 +0800 Subject: [PATCH] Replan reviewed field conflicts with durable audit and conditional history --- .../canvas/headless-graph-canvas.tsx | 6 +- .../canvas/headless-recovery-dialog.tsx | 59 ++++++++ src/lib/canvas-core/editor.ts | 13 +- src/lib/canvas-runtime/conflicts.ts | 118 +++++++++++++++ src/lib/canvas-runtime/contracts.ts | 2 + src/lib/canvas-runtime/engine.ts | 46 ++++-- src/lib/canvas-runtime/ledger.ts | 15 +- src/lib/canvas-runtime/recovery.ts | 44 ++++-- tests/headless/browser-storage-cases.mjs | 26 ++++ tests/headless/conflicts.test.mjs | 136 ++++++++++++++++++ tests/headless/renderer.integration.mjs | 17 +++ 11 files changed, 453 insertions(+), 29 deletions(-) create mode 100644 src/components/canvas/headless-recovery-dialog.tsx create mode 100644 src/lib/canvas-runtime/conflicts.ts create mode 100644 tests/headless/conflicts.test.mjs diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 9786505..1793c55 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -16,6 +16,7 @@ import { guardEditingLease } from "./editing-lease"; import { NodeEditing } from "./transformable-node"; import { EdgeEditing } from "./edge-editing"; import { HeadlessAIPanel } from "./headless-ai-panel"; +import { HeadlessRecoveryDialog } from "./headless-recovery-dialog"; import { CanvasEdge } from "./canvas-edge"; import { MaterialNode } from "./material-node"; import { PlainTextNode } from "./plain-text-node"; @@ -37,7 +38,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B const liveView = sessions.views.get(viewId), lastView = useRef(liveView); if (liveView) lastView.current = liveView; const view = liveView ?? lastView.current!, flow = useReactFlow(); - const [showAI, setShowAI] = useState(false); + const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false); const [error, setError] = useState(""), [sources, setSources] = useState(new Map()); const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map } | null>(null); const measurements = useRef(new Map()); @@ -194,7 +195,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B report(engine.editor.history.undo())} /> report(engine.editor.history.redo())} /> flow.zoomOut()} /> flow.zoomIn()} /> flow.fitView({ padding: .2 })} /> { const snapshot = engine.getSnapshot(), url = URL.createObjectURL(new Blob([JSON.stringify({ documentFormatVersion: 1, projectId: snapshot.document.projectId, canvases: [...snapshot.document.canvases.values()], elements: [...snapshot.document.elements.values()], tombstones: Object.fromEntries(snapshot.document.tombstones) })], { type: 'application/json' })); const a = window.document.createElement('a'); a.href = url; a.download = board.title + '.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }} /> -

{blocked ? '有修改需要处理' : pending.length ? '正在同步…' : '已同步'}{blocked && }

+

{blocked ? '有修改需要处理' : pending.length ? '正在同步…' : '已同步'}{blocked && <>}

engine.sessions.tool(viewId, 'select')} /> engine.sessions.tool(viewId, 'pan')} /> setShowAI(value => !value)} /> @@ -202,6 +203,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
{ const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} /> {showAI && aiElement && setShowAI(false)} />} + {showRecovery && setShowRecovery(false)} />} {error &&
{error}
} ; } diff --git a/src/components/canvas/headless-recovery-dialog.tsx b/src/components/canvas/headless-recovery-dialog.tsx new file mode 100644 index 0000000..ac9505e --- /dev/null +++ b/src/components/canvas/headless-recovery-dialog.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect, useRef, useState } from 'react'; +import type { Id, Json, OperationIntent } from '@/lib/canvas-core/types'; +import type { ProjectEngine } from '@/lib/canvas-runtime/workspace'; +import type { RecoverySession } from '@/lib/canvas-runtime/engine'; +import type { ConflictPlan, RecoveryChoice } from '@/lib/canvas-runtime/conflicts'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogTitle } from '@/components/ui/dialog'; + +function message(error:unknown){ + const code=(error as {code?:string;message?:string})?.code??(error as Error)?.message; + if(code==='recovery_waiting_for_receipts')return '还有请求正在确认。请关闭此窗口,重试同步后再处理。'; + if(code==='recovery_in_progress')return '上一次处理尚未结束。可先恢复编辑,再重新检查。'; + if(code==='recovery_choices_incomplete')return '请为每一项修改选择处理方式。'; + if(code==='stale_incarnation')return '服务端已删除或替换了这个元素。可以放弃这项本地修改。'; + if(code==='ownership_conflict')return '元素已移入其它分区,当前坐标需要重新确认,暂时无法直接保留。'; + if(code?.startsWith('recovery_history')||code==='recovery_structure_not_supported')return '这批修改含结构或撤销操作,暂时无法自动处理。原内容和历史均已保留。'; + if(code==='recovery_action_requires_new_command')return '生成任务需要重新手动发起。可以在这里放弃尚未执行的请求。'; + if(code==='forbidden'||code==='unauthorized'||code==='identity_changed')return '当前账号无法读取或修改此画布,请确认权限后重试。'; + return '处理未完成,当前修改仍已保留。请重试,或恢复编辑后稍后处理。'; +} +function PreviewValue({value}:{value:Json|undefined}){ + if(value===undefined)return 未设置; + const text=typeof value==='string'?value:JSON.stringify(value); + return text.length>240?
{text.slice(0,240)}…(展开全文)
{text}
:{text}; +} +const fieldLabel=(field:string)=>({'data.title':'标题','data.text':'正文',geometry:'位置与尺寸',path:'连线路径'}[field]??(field.startsWith('data.')?'内容字段':'元素关系')); + +export function HeadlessRecoveryDialog({engine,close}:{engine:ProjectEngine;close():void}){ + const owner=useRef(undefined),[session,setSession]=useState(),[records,setRecords]=useState([]); + const [choices,setChoices]=useState(new Map()),[plan,setPlan]=useState(),[error,setError]=useState(''),[busy,setBusy]=useState(true),[frozen,setFrozen]=useState(false); + useEffect(()=>{ + let live=true; + Promise.resolve().then(()=>live?engine.prepareRecovery():undefined).then(async session=>{if(!session)return;if(!live){await session.cancel();return;}owner.current=session;setSession(session);setRecords([...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload').sort((a,b)=>a.intent.sequence-b.intent.sequence).map(record=>record.intent));}).catch(error=>{if(live)setError(message(error));}).finally(()=>{if(live)setBusy(false);}); + return()=>{live=false;void owner.current?.cancel().catch(()=>{/* Engine retains the failed durable barrier; retry/cancelRecovery can finish it. */});}; + },[engine]); + async function cancel(){if(busy&&session)return;setBusy(true);try{await engine.cancelRecovery();close();}catch(error){setError(message(error));}finally{setBusy(false);}} + async function apply(){if(!session||busy)return;setBusy(true);setError('');setFrozen(true);try{const result=plan?await session.resolve(plan):await session.apply();if(result.status==='rejected'){setFrozen(false);setError(message({code:result.code}));return;}close();}catch(error){setError(message(error));}finally{setBusy(false);}} + const canonical=session?.canonical.document; + return {if(!open)void cancel();}}>{if(busy&&session)event.preventDefault();}}> + 处理同步修改 + 检查期间暂停编辑。逐项选择要保留的修改,预览后再应用;原内容和撤销记录会保留备份。 + {busy&&!session&&

正在等待同步并读取最新内容…

} + {session&&records.length===0&&

没有待处理的本地修改,可以采用服务端最新内容。

} + {session&&records.map((intent,index)=>
+ 修改 {index+1}{intent.operationType==='run.start'?' · 尚未受理的生成':intent.operationType==='run.cancel'?' · 尚未确认的取消':''} + {intent.changes.map((change,changeIndex)=>{if(change.kind!=='field')return

包含元素或分区结构修改

;const element=canonical?.elements.get(change.target.id),key=change.field.startsWith('data.')?change.field.slice(5):undefined,value=key===undefined?(element as unknown as Record|undefined)?.[change.field]:element?.data[key],proposed=plan?.document.elements.get(change.target.id),proposedValue=key===undefined?(proposed as unknown as Record|undefined)?.[change.field]:proposed?.data[key];return
{fieldLabel(change.field)}
服务端:{element?:'元素已不存在'}
本地:{change.afterPresent===false?'移除此字段':}
{plan&&
处理后:{proposed?:'元素已不存在'}
}
;})} + +
)} + {plan&&

方案已生成:保留 {plan.audit.decisions.filter(item=>item.choice==='keep-local').length} 项,放弃 {plan.audit.decisions.filter(item=>item.choice==='discard-local').length} 项。撤销记录由 {engine.getSnapshot().history.undo.length} 项调整为 {plan.history.undo.length} 项。

} + {error&&

{error}

} + + {session&&records.length>0&&} + {session&&} + +
; +} diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index 0a288b1..64ead8c 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -269,7 +269,7 @@ export function createEditor(options: EditorOptions }): Result { try { assertOpen(); } catch (error) { return reject(error); } entered = true; try { @@ -277,8 +277,17 @@ export function createEditor(options: EditorOptions!history.entries.has(id)))throw fault('invalid_history'); + for(const [id,intent]of state.operations)if(!equal(intent,operations.get(id)))throw fault('operation_identity_conflict',[id]); + for(const [id,intent]of operations){if(id!==intent.id||!Number.isSafeInteger(intent.sequence)||intent.sequence<1)throw fault('invalid_operation_sequence');nextSequence=Math.max(nextSequence,intent.sequence);} + for(const [id,entry]of history.entries)if(entry.id!==id||entry.operationIds.some(operationId=>!operations.has(operationId)))throw fault('invalid_history'); + } const changed = !documentsEqual(next, state.document); - state = Object.freeze({ ...state, document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 }); + state = Object.freeze({ ...state, ...(replacement?{history:replacement.history,operations:replacement.operations}:{}), document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 }); + sequence=nextSequence; emit(freeze({ revision: state.revision, origin: 'ack', changes: [], operationIds: [] })); return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) }); } catch (error) { return reject(error); } diff --git a/src/lib/canvas-runtime/conflicts.ts b/src/lib/canvas-runtime/conflicts.ts new file mode 100644 index 0000000..be17c67 --- /dev/null +++ b/src/lib/canvas-runtime/conflicts.ts @@ -0,0 +1,118 @@ +import type { Change, Document, FieldChange, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts'; +import { applyChanges, validateDocument } from '../canvas-core/document.ts'; +import { equal, fault, freeze, readMap } from '../canvas-core/values.ts'; +import type { CanonicalProposal, EngineSnapshot } from './contracts.ts'; +import { createLedger } from './ledger.ts'; +import { operationCanvases, validateCanonicalRead } from './reconcile.ts'; +import { referencedResources } from './resources.ts'; + +export type RecoveryChoice = 'keep-local' | 'discard-local'; +export type RecoveryDecision = { readonly operationId: Id; readonly choice: RecoveryChoice; readonly replacementId?: Id; readonly resolution: 'superseded' | 'discarded' | 'satisfied' }; +export type RecoveryAudit = { readonly id: Id; readonly formatVersion: 1; readonly baseRootRevision: number; readonly decisions: readonly RecoveryDecision[]; readonly backup: Json }; +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[] }; +const fieldKey = (change: FieldChange) => JSON.stringify([change.target.id,change.target.incarnation,change.field]); +function value(document: Document, change: FieldChange, allowMissing=false): {before:Json;beforePresent?:boolean;missing?:boolean} { + const element=document.elements.get(change.target.id); + if(!element||element.incarnation!==change.target.incarnation){if(allowMissing)return{before:null,beforePresent:false,missing:true};throw fault('stale_incarnation',[change.target.id]);} + const key=change.field.startsWith('data.')?change.field.slice(5):undefined; + return {before:(key===undefined?(element as unknown as Record)[change.field]:element.data[key]??null),beforePresent:key===undefined?undefined:Object.hasOwn(element.data,key)}; +} +const documentValue = (document: Document) => ({projectId:document.projectId,canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:[...document.tombstones]}); + +/** Pure proposal. Explicit choices cover the complete pending dependency closure; no key/body is reused. */ +export function planConflictRecovery(snapshot: EngineSnapshot, canonical: CanonicalProposal, choices: ReadonlyMap, registry: Registry, createId:()=>Id): ConflictPlan { + 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'); + const records=[...snapshot.runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload').sort((a,b)=>a.intent.sequence-b.intent.sequence); + if(!records.length)throw fault('recovery_nothing_to_resolve'); + for(const record of records)for(const dependency of record.intent.dependencies)if((snapshot.runtime.ledger.operations.get(dependency)?.intent.sequence??Infinity)>=record.intent.sequence)throw fault('recovery_dependency_order',[record.intent.id]); + if(choices.size!==records.length||records.some(record=>!['keep-local','discard-local'].includes(choices.get(record.intent.id)??'')))throw fault('recovery_choices_incomplete'); + const used=new Set([...snapshot.operations.keys(),...snapshot.runtime.ledger.operations.keys(),...snapshot.runtime.ledger.requests.keys(),...snapshot.history.entries.keys(),...(snapshot.runtime.recoveries?.keys()??[])]); + function unique(){const id=createId();if(!id||used.has(id))throw fault('duplicate_operation_id');used.add(id);return id;} + const id=unique(),ledger=createLedger(snapshot.runtime.ledger),operations=new Map(snapshot.operations),decisions:RecoveryDecision[]=[],replacements=new Map(); + const starts=new Map>(),historyOwner=new Map(); + 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);} + let document=canonical.document,sequence=0;for(const record of snapshot.runtime.ledger.operations.values())sequence=Math.max(sequence,record.intent.sequence); + for(const record of records){ + const intent=record.intent,choice=choices.get(intent.id)!; + if(intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[intent.id]); + if(intent.changes.some(change=>change.kind!=='field'))throw fault('recovery_structure_not_supported',[intent.id]); + if(intent.changes.length&&intent.origin!=='user')throw fault('recovery_history_traversal_not_supported',[intent.id]); + const changes:FieldChange[]=[],before=document; + for(const original of intent.changes as readonly FieldChange[]){ + const owner=historyOwner.get(intent.id),key=owner?JSON.stringify([owner,fieldKey(original)]):undefined; + if(key&&!starts.has(key))starts.set(key,value(document,original,choice==='discard-local')); + if(choice==='discard-local')continue; + const current=document.elements.get(original.target.id),prior=snapshot.runtime.acknowledged.elements.get(original.target.id); + if(!current||current.incarnation!==original.target.incarnation)throw fault('stale_incarnation',[original.target.id]); + if(prior&&(prior.componentId!==current.componentId||prior.dataVersion!==current.dataVersion||prior.structure!==current.structure))throw fault('recovery_target_type_changed',[current.id]); + if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]); + if(original.field.startsWith('data.')&&!registry.supports(current,'content.edit')||original.field==='path'&&!registry.supports(current,'connection.edit'))throw fault('unsupported_capability',[current.id]); + if((original.field==='geometry'||original.field==='path')&&prior?.ownerCanvasId!==current.ownerCanvasId)throw fault('ownership_conflict',[current.id]); + if(original.field==='ownerCanvasId'||original.field==='source'||original.field==='target')throw fault('recovery_structure_not_supported',[intent.id]); + const actual=value(document,original); + let desired=original.after; + if(original.field==='geometry'&¤t.structure==='node'){ + const before=original.before as unknown as typeof current.geometry,after=original.after as unknown as typeof current.geometry; + desired={...current.geometry,...Object.fromEntries((['x','y','width','height','rotation'] as const).filter(key=>before[key]!==after[key]).map(key=>[key,after[key]]))}; + } + const change=freeze({...original,...actual,after:desired}); + if(change.field==='geometry'&¤t.structure==='node'){const next=change.after as unknown as typeof current.geometry;for(const [keys,capability] of [[['x','y'],'geometry.move'],[['width','height'],'geometry.resize'],[['rotation'],'geometry.rotate']] as const)if(keys.some(key=>current.geometry[key]!==next[key])&&!registry.supports(current,capability))throw fault('unsupported_capability',[current.id]);} + if(equal(actual.before,change.after)&&(actual.beforePresent??true)===(change.afterPresent??true))continue; + document=applyChanges(document,[change]);changes.push(change); + } + validateDocument(document,registry); + let replacement:OperationIntent|undefined; + if(choice==='keep-local'){ + if(intent.operationType!=='document.change'&&intent.operationType!=='run.apply')throw fault('recovery_action_requires_new_command',[intent.id]); + if(changes.length){ + 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); + for(const previous of replacements.values())if(previous)dependencySet.add(previous.id); + const replacementId=unique(); + const basic={...intent,id:replacementId,sequence:++sequence,changes:freeze(changes),dependencies:[...dependencySet],canvasIds:operationCanvases({...intent,canvasIds:[],changes},before)}; + replacement=intent.operationType==='run.apply'?freeze({...basic,operationType:'run.apply',action:{...intent.action,targetCanvasId:document.elements.get(intent.action.target.id)!.ownerCanvasId}}) as OperationIntent:freeze(basic); + operations.set(replacement.id,replacement); + } + } + replacements.set(intent.id,replacement); + decisions.push(freeze({operationId:intent.id,choice,resolution:choice==='discard-local'?'discarded':replacement?'superseded':'satisfied',...(replacement?{replacementId:replacement.id}:{})})); + } + const history=rewriteHistory(snapshot,records.map(record=>record.intent),replacements,starts); + ledger.retire(id,decisions); + for(const replacement of replacements.values())if(replacement){ + const presence:Record={};for(const change of replacement.changes)if(change.kind==='field')presence[change.target.id]='exists'; + ledger.add(replacement,presence); + } + // Conservative pins include the archived document/history and unresolved opaque local resources. + 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); + 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 audit=freeze({id,formatVersion:1 as const,baseRootRevision:snapshot.rootRevision,decisions,backup}); + 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]:[]))}); +} + +function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap,starts:ReadonlyMap>):HistoryState { + const entries=new Map(snapshot.history.entries),affected=new Set(pending.map(intent=>intent.id)); + for(const entry of snapshot.history.entries.values()){ + const intents=pending.filter(intent=>entry.operationIds.includes(intent.id));if(!intents.length)continue; + if(!snapshot.history.undo.includes(entry.id)||entry.operationIds.some(id=>snapshot.operations.get(id)?.origin!=='user'))throw fault('recovery_history_traversal_not_supported',[entry.id]); + const keys=new Map();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){ + if(entry.changes.some(change=>change.kind!=='field'&&change.id===example.target.id||change.kind==='field'&&change.target.id===example.target.id&&change.field==='ownerCanvasId'))throw fault('recovery_history_structure_not_supported',[entry.id]); + const original=entry.changes.filter((change):change is FieldChange=>change.kind==='field'&&fieldKey(change)===key); + const oldPending=intents.flatMap(intent=>intent.changes as readonly FieldChange[]).filter(change=>fieldKey(change)===key); + const prefix=original.slice(0,original.length-oldPending.length); + if(original.lengthreplacements.get(intent.id)?.changes as readonly FieldChange[]??[]).filter(change=>fieldKey(change)===key)); + } + 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); + } + return Object.freeze({undo:Object.freeze(snapshot.history.undo.filter(id=>entries.has(id))),redo:snapshot.history.redo,entries:readMap(entries)}); +} diff --git a/src/lib/canvas-runtime/contracts.ts b/src/lib/canvas-runtime/contracts.ts index 132ceb7..81310d8 100644 --- a/src/lib/canvas-runtime/contracts.ts +++ b/src/lib/canvas-runtime/contracts.ts @@ -3,6 +3,7 @@ import type { LedgerSnapshot, FrozenRequest } from './ledger.ts'; import type { CompiledRequest, OperationStatus } from './scheduler.ts'; import type { RunRecord } from './runs.ts'; import type { ResourceRecord } from './resources.ts'; +import type { RecoveryAudit } from './conflicts.ts'; export type RuntimeState = { readonly ledger: LedgerSnapshot; @@ -13,6 +14,7 @@ export type RuntimeState = { readonly conflicts: ReadonlyMap; readonly resources: ReadonlyMap; readonly resourcePins: ReadonlyMap; + readonly recoveries: ReadonlyMap; }; export type EngineSnapshot = Snapshot; export type CanonicalProposal = { readonly document: Document; readonly versions: ReadonlyMap }; diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index 8079c21..732faf1 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -10,6 +10,7 @@ import { createLedger, type FrozenRequest } from './ledger.ts'; import { operationCanvases, replayPending, validateCanonicalRead } from './reconcile.ts'; import { decodeRecovery, encodeRecovery } from './recovery.ts'; import { createScheduler, type Clock, type OperationStatus } from './scheduler.ts'; +import { planConflictRecovery, type ConflictPlan, type RecoveryChoice } from './conflicts.ts'; import { conflictsWith, maxResourceBytes, operationFootprint, referencedResources, type AssetPort, type ResourceRecord, type ResourceHandle, type StagedFile } from './resources.ts'; import type { StoragePort, StorageScope } from './storage.ts'; @@ -25,6 +26,8 @@ export type RecoverySession = { readonly pendingOperationIds: readonly Id[]; /** A conflict is diagnostic only: the existing document/history/requests remain intact. */ readonly conflict?: { readonly code: string; readonly targets: readonly Id[] }; + plan(choices:ReadonlyMap):ConflictPlan; + resolve(plan:ConflictPlan):Promise; apply(): Promise; cancel(): Promise; }; @@ -46,9 +49,9 @@ export async function openProjectEngine(options: EngineOptions) { const fileWrites = new Map(), digestTasks = new Map>(); let staged: readonly ResourceRecord[] = []; let applying: RunApplyInput | undefined; - let recovery: { controller: AbortController; release(): void } | undefined; + let recovery: { controller: AbortController; release(): void; cancel():Promise } | undefined; const assertWritable = () => { if(closing||closed)throw fault('engine_closing');if(recovery)throw fault('recovery_in_progress'); }; - 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 initial: RuntimeState = recovered?.runtime ?? Object.freeze({ recoveries:readMap(new Map()), runs: readMap(new Map()), acknowledged: createDocument(options.document.projectId, [...options.document.canvases.values()], [...options.document.elements.values()], options.document.tombstones), versions: readMap(options.versions), ledger: createLedger().getSnapshot(), receipts: readMap(new Map()), conflicts: readMap(new Map()), resources: readMap(new Map()), resourcePins: readMap(new Map()) }); const editor = createEditor({ registry: options.registry, document: options.document, recovery: recovered, runtime: initial, createId: options.createId, historyLimit: options.historyLimit, policy(command, document) { if (closing || closed || recovery) return { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] }; return options.policy?.(command, document) ?? { enabled: true }; }, traversalPolicy(entry, direction) { return closing || closed || recovery ? { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] } : options.traversalPolicy?.(entry, direction) ?? { enabled: true }; }, @@ -299,14 +302,14 @@ export async function openProjectEngine(options: EngineOptions) { assertWritable(); if(!options.persistence.read)throw fault('canonical_reader_unavailable'); const releaseScheduler=scheduler.hold(),releaseRuns=runTracker.hold(),controller=new AbortController(); - const active={controller,release(){if(recovery!==active)return;recovery=undefined;releaseRuns();releaseScheduler();}}; + const active={controller,release(){if(recovery!==active)return;recovery=undefined;releaseRuns();releaseScheduler();},async cancel(){controller.abort();active.release();}}; recovery=active; sessions.cancel('recovery'); const assertCurrent=()=>{if(closing||closed)throw fault('engine_closing');if(recovery!==active||controller.signal.aborted)throw fault('recovery_session_closed');}; try { // Both holds are acquired before awaiting either lane; already admitted callbacks finish normally. - await Promise.all([scheduler.settle(),runTracker.settle()]); - while(digestTasks.size)await Promise.all([...digestTasks.values()]); + await Promise.all([scheduler.settle(),runTracker.settle()]);assertCurrent(); + while(digestTasks.size){await Promise.all([...digestTasks.values()]);assertCurrent();} await persist();assertCurrent(); const root=editor.getSnapshot(),previous=root.runtime; const unresolved=[...previous.ledger.requests.values()].filter(request=>request.phase!=='resolved'); @@ -318,24 +321,44 @@ export async function openProjectEngine(options: EngineOptions) { const canonical=Object.freeze({document:acknowledged,versions:readMap(loaded.versions)}); let working:Document|undefined,conflict:RecoverySession['conflict']; try{working=replayPending(acknowledged,previous.ledger,options.registry);}catch(error){const rejected=reject(error);conflict=freeze({code:rejected.code,targets:rejected.targets});} - let committed=false; + let committed:ConflictPlan|'refresh'|undefined,committedIds:readonly Id[]=[]; + const plans=new WeakSet(); + const finish=async()=>{await persist();assertCurrent();scheduler.notify(committedIds);active.release();return{status:'applied' as const,revision:editor.getSnapshot().revision,operationIds:committedIds};}; + active.cancel=async()=>{if(recovery!==active)return;if(committed){await finish();return;}active.release();}; return Object.freeze({baseRootRevision:root.rootRevision,canonical, pendingOperationIds:Object.freeze([...previous.ledger.operations.values()].filter(record=>record.phase!=='resolved').map(record=>record.intent.id)), ...(conflict?{conflict}:{}), + plan(choices:ReadonlyMap){ + assertCurrent();if(committed)throw fault('recovery_already_committed');if(editor.getSnapshot().rootRevision!==root.rootRevision)throw fault('stale_plan'); + const plan=planConflictRecovery(root,canonical,choices,options.registry,createId);plans.add(plan);return plan; + }, + async resolve(plan:ConflictPlan):Promise{ + assertCurrent();if(!plans.has(plan))throw fault('invalid_recovery_plan');if(committed&&committed!==plan)throw fault('recovery_already_committed'); + if(!committed){ + const permission=options.policy?.({type:'changes.apply',changes:[]},plan.document);if(permission&&!permission.enabled)return{status:'rejected',code:permission.code,targets:permission.targets}; + for(const id of plan.operationIds){const intent=plan.operations.get(id)!;const allowed=options.policy?.({type:'changes.apply',changes:intent.changes},plan.document);if(allowed&&!allowed.enabled)return{status:'rejected',code:allowed.code,targets:allowed.targets}; + if(intent.operationType==='run.apply'){const run=previous.runs.get(intent.action.runId);if(!run||run.blocked||run.status!=='conflict'||!runContainsTarget(run,intent.action.target))return{status:'rejected',code:'output_not_applicable',targets:[intent.action.target.id]};}} + const recoveries=new Map(previous.recoveries),resourcePins=new Map(previous.resourcePins),conflicts=new Map(previous.conflicts);recoveries.set(plan.id,plan.audit);resourcePins.set(`recovery:${plan.id}`,plan.resourceIds);conflicts.delete('refresh'); + for(const decision of plan.audit.decisions){const requestId=previous.ledger.operations.get(decision.operationId)?.requestId;if(requestId)conflicts.delete(requestId);} + const result=editor.reconcile(plan.document,Object.freeze({...previous,ledger:plan.ledger,acknowledged,versions:canonical.versions,recoveries:readMap(recoveries),resourcePins:readMap(resourcePins),conflicts:readMap(conflicts)}),root.rootRevision,plan); + if(result.status==='rejected')return result;committed=plan;committedIds=plan.operationIds; + } + return finish(); + }, async apply():Promise{ assertCurrent(); + if(committed&&committed!=='refresh')throw fault('recovery_already_committed'); if(conflict)return{status:'rejected',...conflict}; if(!committed){ const conflicts=new Map(previous.conflicts);conflicts.delete('refresh'); const result=editor.reconcile(working!,Object.freeze({...previous,acknowledged,versions:canonical.versions,conflicts:readMap(conflicts)}),root.rootRevision); if(result.status==='rejected')return result; - committed=true; + committed='refresh'; } // Persistence failure retains the hold; apply/cancel can retry this barrier without republishing. - await persist();assertCurrent();active.release(); - return{status:'applied',revision:editor.getSnapshot().revision,operationIds:[]}; + return finish(); }, - async cancel(){if(recovery!==active)return;if(committed)await persist();active.release();}, + cancel:()=>active.cancel(), }); }catch(error){active.release();throw error;} }, @@ -433,8 +456,9 @@ export async function openProjectEngine(options: EngineOptions) { displays.add(release); return Object.freeze({ url, release }); }, getSnapshot: editor.getSnapshot, persist, + cancelRecovery:async()=>{await recovery?.cancel();}, getDurability: () => Object.freeze({ rootRevision: editor.getSnapshot().rootRevision, durableRootRevision }), - retry: async () => { assertWritable();await scheduler.resume();await runTracker.retry(); }, + retry: async () => { if(recovery)await recovery.cancel();assertWritable();await scheduler.resume();await runTracker.retry(); }, async settle() { await Promise.resolve(); while (digestTasks.size) await Promise.all([...digestTasks.values()]); await scheduler.settle(); await runTracker.settle(); await persist(); }, async close() { if (closed) return; diff --git a/src/lib/canvas-runtime/ledger.ts b/src/lib/canvas-runtime/ledger.ts index 96f3a46..2229ebe 100644 --- a/src/lib/canvas-runtime/ledger.ts +++ b/src/lib/canvas-runtime/ledger.ts @@ -12,7 +12,7 @@ export type FrozenRequest = { 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 resolution?: 'applied' | 'not-applied'; readonly basePresence: Readonly> }; +export type OperationRecord = { readonly intent: OperationIntent; readonly phase: 'pending' | 'frozen' | 'resolved' | 'blocked'; readonly requestId?: Id; readonly blockers: readonly string[]; readonly resolution?: 'applied' | 'not-applied' | 'superseded' | 'discarded' | 'satisfied'; readonly recoveryId?: Id; readonly replacementId?: Id; readonly basePresence: Readonly> }; export type LedgerSnapshot = { readonly generation: number; readonly durableGeneration: number; readonly operations: ReadonlyMap; readonly requests: ReadonlyMap }; /** A field-safe compressor for a wholly unsent segment. Frozen records never enter here. */ @@ -108,6 +108,19 @@ export function createLedger(initial?: LedgerSnapshot) { for(const operationId of request.operationIds)operations.set(operationId,freeze({...operations.get(operationId)!,phase:'resolved',resolution:'not-applied'})); changed(); }, + /** Local retirement is not an applied receipt. Validate the complete closure before mutating. */ + retire(recoveryId:Id,decisions:readonly {operationId:Id;resolution:'superseded'|'discarded'|'satisfied';replacementId?:Id}[]) { + const ids=new Set(decisions.map(decision=>decision.operationId)); + if(!recoveryId||ids.size!==decisions.length||new Set(decisions.flatMap(decision=>decision.replacementId?[decision.replacementId]:[])).size!==decisions.filter(decision=>decision.replacementId).length)throw fault('invalid_recovery_decisions'); + for(const decision of decisions){ + const record=operations.get(decision.operationId),request=record?.requestId?requests.get(record.requestId):undefined; + if(!record||record.phase==='resolved'||record.intent.operationType==='asset.upload'||record.requestId&&(!request||request.phase!=='resolved'||request.outcome!=='not-applied'))throw fault('recovery_operation_not_safe',[decision.operationId]); + if(!['superseded','discarded','satisfied'].includes(decision.resolution)||(decision.resolution==='superseded')!==!!decision.replacementId||decision.replacementId&&(operations.has(decision.replacementId)||ids.has(decision.replacementId)))throw fault('invalid_recovery_decisions'); + } + for(const record of operations.values())if(record.phase!=='resolved'&&!ids.has(record.intent.id)&&record.intent.dependencies.some(id=>ids.has(id)))throw fault('recovery_dependency_closure',[record.intent.id]); + for(const decision of decisions)operations.set(decision.operationId,freeze({...operations.get(decision.operationId)!,phase:'resolved',resolution:decision.resolution,recoveryId,...(decision.replacementId?{replacementId:decision.replacementId}:{})})); + if(decisions.length)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]); diff --git a/src/lib/canvas-runtime/recovery.ts b/src/lib/canvas-runtime/recovery.ts index bae8c2f..9bf2748 100644 --- a/src/lib/canvas-runtime/recovery.ts +++ b/src/lib/canvas-runtime/recovery.ts @@ -1,4 +1,5 @@ -import type { Change, CoreSnapshot, Document, HistoryEntry, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts'; +import type { Change, CoreSnapshot, Document, HistoryEntry, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts'; +import type { RecoveryAudit } from './conflicts.ts'; import { createDocument, validateDocument } from '../canvas-core/document.ts'; import { fault, freeze, readMap, validJson, equal } from '../canvas-core/values.ts'; import type { EngineSnapshot, RuntimeState } from './contracts.ts'; @@ -14,7 +15,7 @@ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage { document: documentValue(snapshot.document), history: { undo: snapshot.history.undo, redo: snapshot.history.redo, entries: [...snapshot.history.entries] }, operations: [...snapshot.operations], - runtime: { runs: [...snapshot.runtime.runs], resources: [...snapshot.runtime.resources], resourcePins: [...snapshot.runtime.resourcePins], acknowledged: documentValue(snapshot.runtime.acknowledged), versions: [...snapshot.runtime.versions], receipts: [...snapshot.runtime.receipts], conflicts: [...snapshot.runtime.conflicts], ledger: { ...snapshot.runtime.ledger, operations: [...snapshot.runtime.ledger.operations], requests: [...snapshot.runtime.ledger.requests] } }, + runtime: { recoveries:[...(snapshot.runtime.recoveries??[])], runs: [...snapshot.runtime.runs], resources: [...snapshot.runtime.resources], resourcePins: [...snapshot.runtime.resourcePins], acknowledged: documentValue(snapshot.runtime.acknowledged), versions: [...snapshot.runtime.versions], receipts: [...snapshot.runtime.receipts], conflicts: [...snapshot.runtime.conflicts], ledger: { ...snapshot.runtime.ledger, operations: [...snapshot.runtime.ledger.operations], requests: [...snapshot.runtime.ledger.requests] } }, })) as Json; if (!validJson(payload)) throw fault('invalid_recovery_payload'); return freeze({ formatVersion: 1, rootRevision: snapshot.rootRevision, localRevision: snapshot.revision, payload }); @@ -67,24 +68,23 @@ function intent(value: unknown, id: Id): OperationIntent { strings(raw.canvasIds); strings(raw.dependencies); changes(raw.changes); return freeze(value as OperationIntent); } +function readHistory(history:ObjectValue,operations:ReadonlyMap):HistoryState { + const entries=table(history.entries,(value,id)=>{const raw=object(value);if(string(raw.id)!==id||typeof raw.label!=='string')throw fault('corrupt_recovery_image');changes(raw.changes);for(const operationId of strings(raw.operationIds))if(!operations.has(operationId))throw fault('corrupt_recovery_image');if(raw.groupId!==undefined)string(raw.groupId);return freeze(value as HistoryEntry);}); + const undo=strings(history.undo),redo=strings(history.redo),ids=[...undo,...redo]; + if(new Set(ids).size!==ids.length||ids.length!==entries.size||ids.some(id=>!entries.has(id)))throw fault('corrupt_recovery_image'); + return Object.freeze({undo:freeze(undo),redo:freeze(redo),entries}); +} export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSnapshot & { readonly rootRevision: number; readonly runtime: RuntimeState } { if (image.formatVersion !== 1) throw fault('unsupported_recovery_version'); integer(image.rootRevision); integer(image.localRevision); if (image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image'); // Own the parsed records; mutating a caller's RecoveryImage must not mutate live state. const raw = object(JSON.parse(JSON.stringify(image.payload))), document = readDocument(raw.document, registry), history = object(raw.history), runtime = object(raw.runtime), ledger = object(runtime.ledger); - const operations = table(raw.operations, intent), historyEntries = table(history.entries, (value, id) => { - const raw = object(value); if (string(raw.id) !== id || typeof raw.label !== 'string') throw fault('corrupt_recovery_image'); - changes(raw.changes); for (const operationId of strings(raw.operationIds)) if (!operations.has(operationId)) throw fault('corrupt_recovery_image'); - if (raw.groupId !== undefined) string(raw.groupId); - return freeze(value as HistoryEntry); - }); - const undo = strings(history.undo), redo = strings(history.redo), stackIds = [...undo, ...redo]; - if (new Set(stackIds).size !== stackIds.length || stackIds.length !== historyEntries.size || stackIds.some(id => !historyEntries.has(id))) throw fault('corrupt_recovery_image'); + const operations = table(raw.operations, intent),restoredHistory=readHistory(history,operations); const records = table(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'||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'); + if(raw.resolution!==undefined&&!['applied','not-applied','superseded','discarded','satisfied'].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'||record.intent.operationType==='run.apply'&&record.intent.changes.length>0)).length) throw fault('corrupt_recovery_image'); @@ -151,6 +151,24 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn }); for (const record of records.values()) if (record.intent.operationType === 'asset.upload' && resources.get(record.intent.resourceId)?.operationId !== record.intent.id) throw fault('corrupt_recovery_image'); const resourcePins = table(runtime.resourcePins ?? [], strings); + const replacementOwners=new Set(); + const recoveries=table(runtime.recoveries??[],(value,id)=>{ + const raw=object(value),backup=object(raw.backup);if(string(raw.id)!==id||raw.formatVersion!==1||integer(raw.baseRootRevision)>=image.rootRevision)throw fault('corrupt_recovery_image'); + const working=readDocument(backup.document,registry),base=readDocument(backup.acknowledged,registry),versions=table(backup.versions,value=>integer(value,1)); + if(working.projectId!==document.projectId||base.projectId!==document.projectId||[...base.canvases.keys()].some(id=>!versions.has(id)))throw fault('corrupt_recovery_image'); + readHistory(object(backup.history),operations);if(!equal(strings(backup.resourceIds),resourcePins.get(`recovery:${id}`)))throw fault('corrupt_recovery_image'); + const ids=new Set();for(const value of array(raw.decisions)){const decision=object(value),operationId=string(decision.operationId),record=records.get(operationId),request=record?.requestId?requests.get(record.requestId):undefined; + if(ids.has(operationId)||!record||record.phase!=='resolved'||record.recoveryId!==id||record.resolution!==decision.resolution||record.replacementId!==decision.replacementId||record.intent.operationType==='asset.upload'||record.requestId&&(!request||request.phase!=='resolved'||request.outcome!=='not-applied'))throw fault('corrupt_recovery_image');ids.add(operationId); + if(!['keep-local','discard-local'].includes(string(decision.choice))||(decision.choice==='discard-local')!==(decision.resolution==='discarded'))throw fault('corrupt_recovery_image'); + if(decision.resolution==='superseded'){const replacementId=string(decision.replacementId),replacement=records.get(replacementId);if(replacementOwners.has(replacementId))throw fault('corrupt_recovery_image');replacementOwners.add(replacementId);if(!replacement||replacement.intent.sequence<=record.intent.sequence||replacement.intent.operationType!==record.intent.operationType)throw fault('corrupt_recovery_image');} + else if(!['discarded','satisfied'].includes(string(decision.resolution))||decision.replacementId!==undefined)throw fault('corrupt_recovery_image'); + } + if(!resourcePins.has(`recovery:${id}`))throw fault('corrupt_recovery_image');return freeze(value as RecoveryAudit); + }); + for(const record of records.values()){ + if(['superseded','discarded','satisfied'].includes(record.resolution??'')){const audit=record.recoveryId?recoveries.get(record.recoveryId):undefined;if(!audit||!audit.decisions.some(decision=>decision.operationId===record.intent.id))throw fault('corrupt_recovery_image');} + else if(record.recoveryId!==undefined||record.replacementId!==undefined)throw fault('corrupt_recovery_image'); + } const runs=table(runtime.runs ?? [],(value,id)=>{ 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'); @@ -167,7 +185,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn return freeze({...value as RunRecord,outputEvents:array(raw.outputEvents ?? []) as never}); }); 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, conflicts: table(runtime.conflicts, string) }), + return Object.freeze({ revision: image.localRevision, rootRevision: image.rootRevision, document, history:restoredHistory, operations, + runtime: Object.freeze({ recoveries, runs, resources, resourcePins, ledger: restoredLedger, acknowledged, versions, receipts, conflicts: table(runtime.conflicts, string) }), }); } diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index e8d8ef2..672e632 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -5,6 +5,30 @@ import { createFoundryRuns } from '../../src/lib/canvas-service/runs.ts'; import { createFoundryAssets } from '../../src/lib/canvas-service/assets.ts'; import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts'; +async function conflictReplanCase(name,assert){ + const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1}; + const element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:'a',text:'original'}}; + const original=createDocument(root,[{id:root,parent:null}],[element]),storage=createBrowserStorage({name:`${name}-conflict-replan`}),calls=[],receipts=new Map();let server=original,version=1,engine,reject=true,lose=true; + const model=complete=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:{},versions:{[root]:version}}); + const transport=async request=>{ + calls.push(request);if(request.method==='GET')return{status:200,body:request.path.endsWith('/model')?{model:model(true)}:receipts.get(request.path.split('/').at(-1))}; + const body=JSON.parse(request.body);if(reject){reject=false;server=applyChanges(server,[{kind:'field',target,field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);version++;return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};} + assert(body.expected_versions[root]===version,'replanned write did not use reviewed version');for(const operation of body.operations)server=applyChanges(server,operation.changes);version++; + const receipt={protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)};receipts.set(request.requestId,receipt);return lose?{status:502,body:{error:'lost_response'}}:{status:200,body:receipt}; + }; + const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; + try{ + engine=await openProjectEngine(options);const edit=engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle(); + const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0],session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle(); + const replacement=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.id!==old.id);assert(replacement&&replacement.outcome==='unresolved','lost replacement ACK was guessed successful');await engine.close();lose=false; + engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}}); + for(let i=0;i<100&&engine.getSnapshot().runtime.ledger.requests.get(replacement.id)?.phase!=='resolved';i++)await new Promise(resolve=>setTimeout(resolve,10));await engine.settle(); + const restored=engine.getSnapshot();assert(restored.runtime.recoveries.has(plan.id),'recovery audit disappeared');assert(restored.runtime.ledger.requests.get(old.id).body===old.body&&restored.runtime.ledger.requests.get(old.id).outcome==='not-applied','old rejected request was rewritten'); + assert(restored.runtime.ledger.requests.get(replacement.id).body===replacement.body,'replacement identity changed after reopening');assert(calls.filter(call=>call.method==='POST').length===2,'recovery repeated a server write'); + assert(engine.editor.history.undo().status==='applied','replanned history did not restore');await engine.settle();assert(engine.getSnapshot().document.elements.get('a').data.title==='remote','undo did not restore the reviewed server value'); + }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-conflict-replan`);} +} + async function canonicalRefreshCase(name,assert){ const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()); const target={id:'a',incarnation:1},element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:'a',text:'original'}}; @@ -230,6 +254,8 @@ export async function run() { results.push('AI admission and RunRecord recover through real IndexedDB; cursor and generated content share a durable cut without a second invocation'); await canonicalRefreshCase(name,assert); results.push('quiescent canonical refresh preserves original requests and history; the new base/document/version cut survives IndexedDB reopening'); + await conflictReplanCase(name,assert); + results.push('conflict replan persists its audit/history/new identity; a lost replacement ACK recovers by the exact key and undo restores the reviewed server value'); return results; } finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); } } diff --git a/tests/headless/conflicts.test.mjs b/tests/headless/conflicts.test.mjs new file mode 100644 index 0000000..6e5bab0 --- /dev/null +++ b/tests/headless/conflicts.test.mjs @@ -0,0 +1,136 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRegistry,builtinComponents,createDocument,applyChanges } from '../../src/lib/canvas-core/index.ts'; +import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts'; +import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts'; +import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts'; + +const registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1}; +const element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'canvas',data:{title:'a',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}; +const original=createDocument('project',[{id:'canvas',parent:null}],[element]); +const tick=async()=>{for(let i=0;i<40;i++)await Promise.resolve();}; +function fixture(){ + let server=original,version=1,image,leader;const calls=[]; + const f={reject:true,broken:false,calls,storage:{async claim(scope){if(leader)throw Error('occupied');return leader={scope,fence:1,active:true,async release(){leader=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(owner,batch){if(f.broken)throw Error('quota');assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}},port:{compile(intent,requestId,snapshot){return{canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)])),payload:{intent,requestId}};},async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const{intent}=JSON.parse(request.body);assert.equal(request.expectedVersions.canvas,version);server=applyChanges(server,intent.changes);version++;return{outcome:'applied',canonical:{requestId:request.id}};},async query(){throw Error('unexpected query');},canonical(){return{document:server,versions:new Map([['canvas',version]])};},async read(){return{document:server,versions:new Map([['canvas',version]])};}},remote(patch){const before=server.elements.get('a');server=applyChanges(server,Object.entries(patch).map(([key,after])=>({kind:'field',target,field:`data.${key}`,before:before.data[key]??null,beforePresent:Object.hasOwn(before.data,key),after,afterPresent:true})));version++;},remoteGeometry(patch){const before=server.elements.get('a').geometry;server=applyChanges(server,[{kind:'field',target,field:'geometry',before,after:{...before,...patch}}]);version++;},setServer(document){server=document;version++;},image:()=>structuredClone(image),async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:f.storage,persistence:f.port});}}; + return f; +} +const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload'); +const choices=(engine,choice='keep-local')=>new Map(pending(engine).map(record=>[record.intent.id,choice])); +const title=(engine,value,settings)=>engine.editor.dispatch({type:'element.data',target,patch:{title:value}},settings); + +test('keep-local freezes a new conditional intent/key, retains the old rejection, and undo restores the reviewed remote value',async()=>{ + const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote',text:'untouched remote'}); + const before=engine.getSnapshot(),old=[...before.runtime.ledger.requests.values()][0],session=await engine.prepareRecovery(),plan=session.plan(choices(engine)); + assert.equal(engine.getSnapshot(),before);assert.equal(plan.document.elements.get('a').data.title,'local');assert.equal(plan.document.elements.get('a').data.text,'untouched remote'); + const replacement=plan.operations.get(plan.operationIds[0]);assert.notEqual(replacement.id,old.operationIds[0]);assert.equal(replacement.changes[0].before,'remote'); + assert.equal(plan.ledger.requests.get(old.id).body,old.body);assert.equal(plan.ledger.operations.get(old.operationIds[0]).resolution,'superseded'); + f.reject=false;assert.equal((await session.resolve(plan)).status,'applied');await engine.settle(); + assert.equal(f.calls.length,2);assert.notEqual(f.calls[1].id,old.id);assert.equal(f.calls[1].expectedVersions.canvas,2); + const current=engine.getSnapshot();assert.equal(current.runtime.recoveries.size,1);assert.equal(current.runtime.ledger.requests.get(old.id).outcome,'not-applied'); + assert.equal(current.history.undo.length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle(); + assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'remote');assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'untouched remote'); + assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'local'); + const restored=decodeRecovery(f.image(),registry);assert.equal(restored.runtime.recoveries.size,1);assert.equal(restored.runtime.ledger.requests.get(old.id).body,old.body); + await engine.close(); +}); + +test('discarding a middle edit preserves the remaining undo chain and archives the old history',async()=>{ + const f=fixture(),engine=await f.open();title(engine,'one');await engine.settle();title(engine,'two');title(engine,'three');await engine.settle();f.remote({title:'remote'}); + const oldHistory=engine.getSnapshot().history,ids=pending(engine).map(record=>record.intent.id),session=await engine.prepareRecovery(),selected=new Map([[ids[0],'keep-local'],[ids[1],'discard-local'],[ids[2],'keep-local']]),plan=session.plan(selected); + assert.equal(plan.history.undo.length,2);assert.equal(plan.audit.backup.history.undo.length,3); + const last=plan.operations.get(plan.operationIds.at(-1));assert.ok(last.dependencies.includes(plan.operationIds[0]));assert.ok(!last.dependencies.includes(ids[1])); + f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'one'); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'remote'); + assert.equal(oldHistory.undo.length,3);assert.equal(engine.getSnapshot().runtime.recoveries.get(plan.id).backup.document.elements[0].data.title,'three');await engine.close(); +}); + +test('typing groups preserve an acknowledged prefix unless the server changed that field',async()=>{ + for(const remote of [false,true]){ + const f=fixture(),engine=await f.open();f.reject=false;title(engine,'acknowledged',{groupId:'typing'});await engine.settle();f.reject=true;title(engine,'pending',{groupId:'typing'});await engine.settle(); + if(remote)f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine)); + assert.equal(plan.history.undo.length,1);f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle(); + assert.equal(engine.getSnapshot().document.elements.get('a').data.title,remote?'remote':'a');await engine.close(); + } +}); + +test('discarding only a pending group suffix retains applied edits and other fields',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;title(engine,'acknowledged',{groupId:'typing'});await engine.settle();f.reject=true;title(engine,'pending',{groupId:'typing'});await engine.settle(); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));assert.equal(plan.operationIds.length,0);assert.equal(plan.history.undo.length,1); + await session.resolve(plan);f.reject=false;assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'a');await engine.close(); +}); + +test('geometry recovery changes only locally touched axes and undo retains remote geometry',async()=>{ + const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.geometry',target,patch:{width:200}});await engine.settle();f.remoteGeometry({x:300,rotation:45}); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));assert.deepEqual(plan.document.elements.get('a').geometry,{x:300,y:0,width:200,height:100,rotation:45}); + f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.deepEqual(engine.getSnapshot().document.elements.get('a').geometry,{x:300,y:0,width:100,height:100,rotation:45});await engine.close(); +}); + +test('a deleted target may be discarded but cannot be resurrected by keep-local',async()=>{ + const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.setServer(applyChanges(original,[{kind:'element',id:'a',before:element,after:null}])); + const session=await engine.prepareRecovery();assert.throws(()=>session.plan(choices(engine)),/stale_incarnation/);const plan=session.plan(choices(engine,'discard-local')); + assert.equal(plan.history.undo.length,0);await session.resolve(plan);assert.equal(engine.getSnapshot().document.elements.has('a'),false);assert.equal(engine.getSnapshot().document.tombstones.get('a'),1);await engine.close(); +}); + +test('recovery publication failure keeps one plan and blocks sends until the exact cut becomes durable',async()=>{ + const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine)); + f.reject=false;f.broken=true;await assert.rejects(session.resolve(plan),/quota/);const revision=engine.getSnapshot().rootRevision;assert.equal(f.calls.length,1); + await assert.rejects(session.apply(),/recovery_already_committed/);await assert.rejects(session.resolve({...plan}),/invalid_recovery_plan/); + f.broken=false;await session.resolve(plan);await engine.settle();assert.equal(f.calls.length,2);assert.ok(engine.getDurability().durableRootRevision>revision);assert.equal(engine.getSnapshot().runtime.recoveries.size,1);await engine.close(); +}); + +test('incomplete choices and pending history traversal require explicit handling',async()=>{ + const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();const session=await engine.prepareRecovery();assert.throws(()=>session.plan(new Map()),/recovery_choices_incomplete/);await session.cancel(); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();const undoSession=await engine.prepareRecovery();assert.throws(()=>undoSession.plan(choices(engine)),/recovery_history_traversal_not_supported/);await undoSession.cancel();await engine.close(); +}); + +test('retirement rejects unknown work and incomplete dependency closures without mutation',()=>{ + const ledger=createLedger(),intent=(id,dependencies=[])=>({id,operationType:'document.change',operationSchemaVersion:1,sequence:id==='a'?1:2,origin:'user',canvasIds:['canvas'],changes:[],dependencies}); + ledger.add(intent('a'),{});ledger.add(intent('b',['a']),{});const before=ledger.getSnapshot();assert.throws(()=>ledger.retire('recovery',[{operationId:'a',resolution:'discarded'}]),/recovery_dependency_closure/);assert.equal(ledger.getSnapshot(),before); + const request=ledger.freeze({id:'request',operationIds:['a'],canvasIds:['canvas'],expectedVersions:{canvas:1},payload:{}});ledger.markDispatch(request.id);const frozen=ledger.getSnapshot(); + assert.throws(()=>ledger.retire('recovery',[{operationId:'a',resolution:'discarded'},{operationId:'b',resolution:'discarded'}]),/recovery_operation_not_safe/);assert.equal(ledger.getSnapshot(),frozen); +}); + +test('owner changes reject old geometry; pending paid actions require an explicit discard and never auto-restart',async()=>{ + const {planConflictRecovery}=await import('../../src/lib/canvas-runtime/conflicts.ts'); + const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.geometry',target,patch:{width:200}});await engine.settle(); + const frame={...element,id:'frame',componentId:'frame'},moved=createDocument('project',[{id:'canvas',parent:null},{id:'child',parent:{canvasId:'canvas',entryElementId:'frame'}}],[frame,{...element,ownerCanvasId:'child'}]); + assert.throws(()=>planConflictRecovery(engine.getSnapshot(),{document:moved,versions:new Map([['canvas',2],['child',1]])},choices(engine),registry,()=>crypto.randomUUID()),/ownership_conflict/); + const parent=pending(engine)[0].intent.id;engine.editor.updateRuntime(runtime=>{const ledger=createLedger(runtime.ledger);ledger.add({id:'paid-start',operationType:'run.start',operationSchemaVersion:1,sequence:2,origin:'user',canvasIds:['canvas'],changes:[],dependencies:[parent],run:{target,boardId:'canvas',modelId:'model',prompt:'Generate',propagate:false,parameters:{}}},{});return{...runtime,ledger:ledger.getSnapshot()};}); + const session=await engine.prepareRecovery();assert.throws(()=>session.plan(choices(engine)),/recovery_action_requires_new_command/);const selected=choices(engine);selected.set('paid-start','discard-local');const plan=session.plan(selected); + assert.equal(plan.ledger.operations.get('paid-start').resolution,'discarded');assert.ok(plan.operationIds.every(id=>plan.operations.get(id).operationType==='document.change'));await session.cancel();await engine.close(); +}); + +test('recovery audits and replacement links are validated when reopening and the owner can finish a detached durable barrier',async()=>{ + const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine)); + f.broken=true;await assert.rejects(session.resolve(plan),/quota/);f.broken=false;f.reject=false;await engine.cancelRecovery();await engine.settle(); + const image=f.image();assert.equal(decodeRecovery(image,registry).runtime.recoveries.size,1); + for(const mutate of[ + image=>image.payload.runtime.recoveries.splice(0), + image=>image.payload.runtime.resourcePins.splice(0), + image=>image.payload.runtime.recoveries[0][1].decisions[0].replacementId='missing', + image=>image.payload.runtime.recoveries[0][1].backup.history.undo.push('missing'), + image=>{const old=image.payload.runtime.recoveries[0][1].decisions[0].operationId;image.payload.runtime.ledger.operations.find(([id])=>id===old)[1].resolution='applied';}, + ]){const broken=structuredClone(image);mutate(broken);assert.throws(()=>decodeRecovery(broken,registry),/corrupt_recovery_image/);} + await engine.close(); +}); + +test('a remote value already satisfying the local choice retires the intent without claiming a server write or adding undo',async()=>{ + const f=fixture(),engine=await f.open();title(engine,'same');await engine.settle();f.remote({title:'same'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine)); + assert.equal(plan.operationIds.length,0);assert.equal(plan.history.undo.length,0);assert.equal(plan.audit.decisions[0].resolution,'satisfied');await session.resolve(plan); + assert.equal(f.calls.length,1);assert.equal([...engine.getSnapshot().runtime.ledger.requests.values()][0].outcome,'not-applied');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,1);await engine.close(); +}); + +test('discarding a field in a mixed typing group keeps the unrelated acknowledged field undoable',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target,patch:{text:'acknowledged text'}},{groupId:'mixed'});await engine.settle();f.reject=true;title(engine,'pending title',{groupId:'mixed'});await engine.settle();f.remote({title:'remote title'}); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));assert.equal(plan.history.undo.length,1);await session.resolve(plan);f.reject=false; + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'remote title');assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'');await engine.close(); +}); + +test('discard-only resolution rechecks current identity and a cancelled read cannot later create a session',async()=>{ + const f=fixture();let allowed=true,release,signal;const engine=await f.open();title(engine,'local');await engine.settle(); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));await session.cancel(); + const actualRead=f.port.read;f.port.read=async input=>{signal=input;await new Promise(resolve=>{release=resolve;});return actualRead(input);};const preparing=engine.prepareRecovery();await tick();await engine.cancelRecovery();assert.equal(signal.aborted,true);release();await assert.rejects(preparing,/recovery_session_closed/);assert.equal(engine.editor.can({type:'element.data',target,patch:{title:'more'}}).enabled,true);await engine.close(); + // The policy is evaluated again even when all chosen operations will be discarded. + const other=fixture(),guarded=await openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:other.storage,persistence:other.port,policy:()=>allowed?{enabled:true}:{enabled:false,code:'identity_changed',targets:[]}}); + title(guarded,'local');await guarded.settle();const review=await guarded.prepareRecovery(),discard=review.plan(choices(guarded,'discard-local'));allowed=false;assert.equal((await review.resolve(discard)).code,'identity_changed');assert.equal(guarded.getSnapshot().runtime.recoveries.size,0);await review.cancel();await guarded.close(); +}); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index fd96dcb..fb93c98 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -18,6 +18,7 @@ const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR let releaseApply;const applyGate=new Promise(resolve=>{releaseApply=resolve;}); let releaseUpload; const uploadGate = new Promise(resolve => { releaseUpload = resolve; }); const model = (document, complete = true, participants=[...document.canvases.keys()]) => ({ documentFormatVersion: 1, projectId: document.projectId, complete, canvases: [...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)), elements: [...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)), tombstones: Object.fromEntries(document.tombstones), versions: Object.fromEntries(participants.map(id => [id, versions.get(id)])), removedCanvasIds: [] }); +let rejectNextEdit=false,rejectedEdit; const upstream = createServer(async (request, response) => { try { const url = new URL(request.url, 'http://fixture'), path = url.pathname, chunks = []; @@ -35,6 +36,7 @@ const upstream = createServer(async (request, response) => { if (write) { const batch = JSON.parse(bytes.toString()), previous = documents.get(write[1]); assert.equal(request.headers['x-expected-account'], account); assert.equal(request.headers['idempotency-key'], batch.request_id); + if(rejectNextEdit){rejectNextEdit=false;rejectedEdit=batch;const before=previous.elements.get('b');documents.set(write[1],applyChanges(previous,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:before.data.text,after:'Remote reviewed',beforePresent:true,afterPresent:true}]));versions.set(root,versions.get(root)+1);return reply(409,{protocol_version:2,request_id:batch.request_id,status:'not-applied',terminal:true,error:'version_conflict'});} for(const [id,version] of Object.entries(batch.expected_versions))assert.equal(version,versions.get(id)); let next = previous; for (const operation of batch.operations) next = applyChanges(next, operation.changes); documents.set(write[1], next);for(const id of Object.keys(batch.expected_versions))versions.set(id,versions.get(id)+1); @@ -155,6 +157,21 @@ try { assert.deepEqual(documents.get(root).elements.get('edge').path.knots[0].position,{x:440,y:265},JSON.stringify({inputs:await evaluate('window.inputEvents'),text:await evaluate('document.body.innerText'),writes:calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).map(call=>JSON.parse(call.bytes.toString()).operations)})); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length,beforeCurve+1); console.log('PASS actual GraphPage chooses canonical editor; create/undo/redo, drag/resize/curve final coordinates, Escape and IME text history publish correctly'); + await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50); + const recoveryPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]').getBoundingClientRect();return {x:r.x+50,y:r.y+30}})()`); + await call('Input.dispatchMouseEvent',{type:'mousePressed',...recoveryPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...recoveryPoint,button:'left',buttons:0,clickCount:2}); + await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'recovery edit did not open'); + rejectNextEdit=true;await call('Input.insertText',{text:'Local recovery'});await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected content did not expose recovery'); + const localRecoveryText=await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value`),rejectedId=rejectedEdit.request_id; + await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')`,'recovery choices did not load'); + assert.ok(await evaluate(`document.querySelector('[role=dialog]').innerText.includes('Remote reviewed')`),'review omitted the server value'); + await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`); + await click('预览处理方案');await until(`!document.querySelector('[aria-label="应用处理方案"]').disabled`,'reviewed recovery plan did not become applicable');await click('应用处理方案'); + await until(`!document.querySelector('[role=dialog]')`,'recovery dialog did not close after persistence'); + for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!==localRecoveryText;i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,localRecoveryText); + const recompiled=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).at(-1),recompiledBody=JSON.parse(recompiled.bytes.toString());assert.notEqual(recompiledBody.request_id,rejectedId);assert.notEqual(recompiledBody.operations[0].id,rejectedEdit.operations[0].id);assert.equal(recompiledBody.operations[0].changes[0].before,'Remote reviewed'); + await click('撤销');for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!=='Remote reviewed';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Remote reviewed'); + console.log('PASS actual recovery dialog reviews remote/local content, recompiles with new identities, saves before dispatch and preserves the reviewed undo baseline'); const selectB=async()=>{await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height-30}})()`);await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI tool did not accept the selected element');}; await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="AI 模型"] option[value="fixture-text"]')`,'AI catalog did not load through the host'); await evaluate(`(()=>{const input=document.querySelector('[aria-label="AI 指令"]');Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'Summarize this content');input.dispatchEvent(new Event('input',{bubbles:true}));})()`);