From d99b6951cfdcfa97a3cfa6bcf0f6840647715b25 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 03:35:58 +0800 Subject: [PATCH] Recover user groups across nonprefix acknowledgments and discarded entry births --- src/lib/canvas-runtime/conflicts.ts | 3 +- .../history-journal-recovery.ts | 35 ++++++++++++------- tests/headless/browser-storage-cases.mjs | 16 +++++++++ tests/headless/structure-recovery.test.mjs | 30 +++++++++++++--- 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/src/lib/canvas-runtime/conflicts.ts b/src/lib/canvas-runtime/conflicts.ts index ad72cc9..33e3907 100644 --- a/src/lib/canvas-runtime/conflicts.ts +++ b/src/lib/canvas-runtime/conflicts.ts @@ -37,7 +37,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni const id=unique(),ledger=createLedger(snapshot.runtime.ledger),operations=new Map(snapshot.operations),decisions:RecoveryDecision[]=[],replacements=new Map(); const boundaries=new Map(),starts=new Map>(),historyOwner=new Map(),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);} - const journal=records.some(record=>['undo','redo'].includes(record.intent.origin)&&record.intent.historyTransition)?createHistoryJournalRecovery(snapshot,records.map(record=>record.intent),registry,historyLimit):undefined; + 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); + const journal=hasEvidence?createHistoryJournalRecovery(snapshot,records.map(record=>record.intent),registry,historyLimit):undefined; 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)!; diff --git a/src/lib/canvas-runtime/history-journal-recovery.ts b/src/lib/canvas-runtime/history-journal-recovery.ts index 67aca91..4c670a1 100644 --- a/src/lib/canvas-runtime/history-journal-recovery.ts +++ b/src/lib/canvas-runtime/history-journal-recovery.ts @@ -18,24 +18,33 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea let history=snapshot.history; try{for(const event of [...events].reverse()){event.after=history;history=applyHistoryTransition(history,event.transition,'reverse');event.before=history;}} catch{throw fault('recovery_history_transition_conflict');} - let next=0;const touched=new Set();let remap:((change:Change)=>Change)|undefined; + let next=0;const touched=new Set(),retired=new Set();let remap:((change:Change)=>Change)|undefined; const state=(undo:readonly Id[],redo:readonly Id[],entries:ReadonlyMap)=>Object.freeze({undo:freeze([...undo]),redo:freeze([...redo]),entries:readMap(entries)}); 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;}; + 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))){ + const fields=prefix as readonly FieldChange[],last=new Map();const key=(change:FieldChange)=>JSON.stringify([change.target.id,change.target.incarnation,change.field]);for(const change of fields)last.set(key(change),change); + for(const change of fields)if(['geometry','path'].includes(change.field)&&document.elements.get(change.target.id)?.ownerCanvasId!==snapshot.runtime.acknowledged.elements.get(change.target.id)?.ownerCanvasId)throw fault('recovery_history_prefix_conflict',[entry.id]); + const keep=new Set([...last].filter(([,change])=>{const target=document.elements.get(change.target.id);if(target?.incarnation!==change.target.incarnation)return false;const field=change.field.startsWith('data.')?change.field.slice(5):undefined,value=field===undefined?(target as unknown as Record)[change.field]:target.data[field]??null;return equal(value,change.after)&&(field===undefined||Object.hasOwn(target.data,field)===(change.afterPresent??true));}).map(([key])=>key));return fields.filter(change=>keep.has(key(change))); + } + try{planHistoryChanges(document,invert(prefix),registry);}catch{throw fault('recovery_history_prefix_conflict',[entry.id]);}return prefix; + } + 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); + } 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]); const entries=new Map(history.entries),undo=[...history.undo];for(const removed of history.redo)entries.delete(removed); - const prior=entries.get(id),appending=event.before!.entries.has(id); - if(appending&&(!prior||undo.at(-1)!==id)||!appending&&prior)throw fault('recovery_history_traversal_order',[id]); - let prefix=prior?.changes??[]; - if(pending&&prior&&!touched.has(id)&&document){ - if(prefix.every(change=>change.kind==='field')){ - const fields=prefix as readonly FieldChange[],last=new Map();const key=(change:FieldChange)=>JSON.stringify([change.target.id,change.target.incarnation,change.field]);for(const change of fields)last.set(key(change),change); - const keep=new Set([...last].filter(([,change])=>{const target=document.elements.get(change.target.id);if(target?.incarnation!==change.target.incarnation)return false;const field=change.field.startsWith('data.')?change.field.slice(5):undefined,value=field===undefined?(target as unknown as Record)[change.field]:target.data[field]??null;return equal(value,change.after)&&(field===undefined||Object.hasOwn(target.data,field)===(change.afterPresent??true));}).map(([key])=>key));prefix=fields.filter(change=>keep.has(key(change))); - }else try{planHistoryChanges(document,invert(prefix),registry);}catch{throw fault('recovery_history_prefix_conflict',[id]);} - } + const prior=entries.get(id),originalAppend=event.before!.entries.has(id),appending=!!prior; + if(originalAppend&&(!prior&&!retired.has(id)||prior&&undo.at(-1)!==id)||!originalAppend&&prior)throw fault('recovery_history_traversal_order',[id]); + const prefix=pending&&prior&&!touched.has(id)&&document?reviewPrefix(prior,document):prior?.changes??[]; const {changes:_changes,operationIds:_operations,...metadata}=original; - entries.set(id,freeze({...metadata,changes:[...prefix,...changes],operationIds:[...(prior?.operationIds??[]),operationId]}));if(!appending)undo.push(id);while(undo.length>limit)entries.delete(undo.shift()!);history=state(undo,[],entries);if(pending)touched.add(id); + entries.set(id,freeze({...metadata,changes:[...prefix,...changes],operationIds:[...(prior?.operationIds??[]),operationId]}));if(!appending)undo.push(id);while(undo.length>limit)entries.delete(undo.shift()!);history=state(undo,[],entries);retired.delete(id);if(pending)touched.add(id); } function advance(intent?:OperationIntent){ while(next({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('a'),{...node('entry'),componentId:'frame'},node('c',child)]),database=`${name}-user-journal-${keep}`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=true,engine; + const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],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)]))}); + const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'group recovery lost its version condition');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};}; + const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; + try{ + engine=await openProjectEngine(options);const first=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'pending first'}},{groupId:'shared'});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0];reject=false;const confirmed=engine.editor.dispatch({type:'element.data',target:{id:'c',incarnation:1},patch:{text:'confirmed later'}},{groupId:'shared'});await engine.settle();await engine.close();engine=await openProjectEngine(options); + const session=await engine.prepareRecovery(),plan=session.plan(new Map([[first.operationIds[0],keep?'keep-local':'discard-local']]));await session.resolve(plan);await engine.settle();assert(calls.filter(call=>call.method==='POST'&&JSON.parse(call.body).operations.some(operation=>operation.changes.some(change=>change.kind==='field'&&change.target.id==='c'))).length===1,'confirmed group contribution was sent again');await engine.close();engine=await openProjectEngine(options); + const history=engine.getSnapshot().history;assert(history.undo.length===1&&history.entries.values().next().value.operationIds.includes(confirmed.operationIds[0]),'group lost its confirmed logical fact');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'group replan changed rejected bytes');assert(document.elements.get('a').data.text===(keep?'pending first':''),'group prefix choice was ignored');assert(engine.editor.history.undo().status==='applied','group undo failed');await engine.settle();assert(document.elements.get('a').data.text===''&&document.elements.get('c').data.text==='','group undo did not restore both selected contributions');assert(engine.editor.history.redo().status==='applied','group redo failed');await engine.settle();assert(document.elements.get('a').data.text===(keep?'pending first':'')&&document.elements.get('c').data.text==='confirmed later','group redo lost selected content');assert(calls.filter(call=>call.method==='POST').length===(keep?5:4),'group recovery repeated a write'); + }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);} + } +} + async function journalRecoveryCase(name,assert){ const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('a'),{...node('entry'),componentId:'frame'},node('c',child)]),storage=createBrowserStorage({name:`${name}-journal-recovery`}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=false,engine; const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],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)]))}); @@ -372,6 +386,8 @@ export async function run() { results.push('history transition evidence survives redo invalidation and IndexedDB reopening, reconstructs prior stacks without document mutation and never enters service payloads'); await journalRecoveryCase(name,assert); results.push('native history events recover a rejected undo after a later confirmed branch cleared redo; IndexedDB reopening retains the branch identity and sends its content once'); + await userJournalRecoveryCase(name,assert); + results.push('pure user group recovery preserves a later acknowledged contribution across keep/discard, IndexedDB reopening and atomic undo/redo without retransmission'); return results; } finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); } } diff --git a/tests/headless/structure-recovery.test.mjs b/tests/headless/structure-recovery.test.mjs index a71c603..a378b4c 100644 --- a/tests/headless/structure-recovery.test.mjs +++ b/tests/headless/structure-recovery.test.mjs @@ -228,11 +228,13 @@ test('mixed structural history cannot silently delete remote additions or claim }); -test('a later confirmed group operation cannot be moved before an earlier rejected structural operation',async()=>{ - const f=fixture(),submit=f.port.submit;f.port.submit=async request=>{const intent=JSON.parse(request.body).intent;f.reject=intent.changes.some(change=>change.kind==='element'&&change.id==='a');return submit(request);}; - const engine=await f.open();engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'out-of-order'});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'confirmed later'}},{groupId:'out-of-order'});await engine.settle(); - assert.equal(f.server().elements.get('c').data.title,'confirmed later');assert.equal(pending(engine).length,1); - const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history_nonprefix_ack/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close(); +test('a later confirmed group operation retains its fact while a rejected structural predecessor is kept or discarded',async()=>{ + for(const keep of [true,false]){ + const f=fixture(),submit=f.port.submit;let deny=true;f.port.submit=async request=>{const intent=JSON.parse(request.body).intent;f.reject=deny&&intent.changes.some(change=>change.kind==='element'&&change.id==='a');return submit(request);}; + const engine=await f.open(),remove=engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'out-of-order'});await engine.settle();const confirmed=engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'confirmed later'}},{groupId:'out-of-order'});await engine.settle(); + assert.equal(f.server().elements.get('c').data.title,'confirmed later');assert.equal(pending(engine).length,1);const session=await engine.prepareRecovery(),plan=session.plan(new Map([[remove.operationIds[0],keep?'keep-local':'discard-local']]));assert.equal(plan.history.undo.length,1);assert.ok(plan.history.entries.values().next().value.operationIds.includes(confirmed.operationIds[0]));deny=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.has('a'),!keep);assert.equal(f.server().elements.get('c').data.title,'confirmed later');assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.changes.some(change=>change.kind==='field'&&change.target.id==='c')).length,1); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').incarnation,keep?2:1);assert.equal(f.server().elements.get('c').data.title,'c');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('a'),!keep);assert.equal(f.server().elements.get('c').data.title,'confirmed later');await engine.close(); + } }); @@ -346,3 +348,21 @@ test('native event recovery refuses future, missing or unproved legacy transitio test('history event replay uses the engine capacity when discarding undo retains an extra prior entry',async()=>{ const f=fixture(),engine=await f.open({historyLimit:2});f.reject=false;const first=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'first'}});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'second'}});await engine.settle();f.reject=true;const undo=engine.editor.history.undo();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'branch'}});await engine.settle();const session=await engine.prepareRecovery(),selected=choices(engine);selected.set(undo.operationIds[0],'discard-local');const plan=session.plan(selected);assert.equal(plan.history.undo.length,2);assert.equal(plan.history.entries.has(first.historyEntryId),false);f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'first');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('c').data.title,'c');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(engine.editor.history.undo().code,'history_empty');await engine.close(); }); + + +test('grouped user edits rebuild after discarding or satisfying the original entry birth',async()=>{ + for(const firstChoice of ['keep-local','discard-local','satisfied'])for(const secondKeep of [true,false]){ + const f=fixture(),engine=await f.open(),first=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'one'}},{groupId:'typing'});await engine.settle();const second=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'two'}},{groupId:'typing'});await engine.settle();if(firstChoice==='satisfied')f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'one',beforePresent:true,afterPresent:true}]); + const session=await engine.prepareRecovery(),plan=session.plan(new Map([[first.operationIds[0],firstChoice==='discard-local'?'discard-local':'keep-local'],[second.operationIds[0],secondKeep?'keep-local':'discard-local']]));f.reject=false;await session.resolve(plan);await engine.settle();const retained=secondKeep||firstChoice==='keep-local';assert.equal(engine.getSnapshot().history.undo.length,retained?1:0);assert.equal(f.server().elements.get('a').data.title,secondKeep?'two':firstChoice==='discard-local'?'a':'one'); + if(retained){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,firstChoice==='satisfied'?'one':'a');}await engine.close(); + } +}); + +test('discarding a group suffix removes only the acknowledged field contribution already overwritten remotely',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack',text:'keep history'}},{groupId:'typing'});await engine.settle();f.reject=true;const edit=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'typing'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'ack',after:'remote',beforePresent:true,afterPresent:true}]); + const session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'discard-local']]));await session.resolve(plan);f.reject=false;assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');assert.equal(f.server().elements.get('a').data.text,'');await engine.close(); +}); + +test('a historical geometry prefix cannot silently use coordinates from an externally changed owner',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.geometry',target:target('a'),patch:{width:200}},{groupId:'geometry'});await engine.settle();f.reject=true;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'geometry'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'ownerCanvasId',before:'root',after:'child'}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history_prefix_conflict/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close(); +});