diff --git a/src/components/canvas/headless-recovery-dialog.tsx b/src/components/canvas/headless-recovery-dialog.tsx
index c718679..91068c2 100644
--- a/src/components/canvas/headless-recovery-dialog.tsx
+++ b/src/components/canvas/headless-recovery-dialog.tsx
@@ -21,6 +21,8 @@ function message(error:unknown){
if(code==='stale_endpoint')return '连线所指元素已删除或进入新的生命周期,请重新选择端点。';
if(code==='unsupported_presentation_units'||code==='invalid_presentation_units')return '这个元素的展示单位暂时无法转换,原内容已保留,请确认展示格式后重试。';
if(code==='unsupported_coordinate_path')return '这条曲线使用未知坐标格式,当前无法安全转换归属,原路径仍已保留。';
+ if(code==='recovery_history_traversal_order')return '这些撤销或重做有先后顺序,不能跳过前一项继续处理后一项。请重新选择。';
+ if(code==='recovery_history_traversal_conflict')return '服务端内容与这项历史记录已有冲突,暂时无法安全调整撤销或重做;原内容和历史均已保留。';
if(code?.startsWith('recovery_history')||code==='recovery_record_replacement_not_supported')return '这批修改含结构或撤销操作,暂时无法自动处理。原内容和历史均已保留。';
if(code==='recovery_action_requires_new_command')return '生成任务需要重新手动发起。可以在这里放弃尚未执行的请求。';
if(code==='forbidden'||code==='unauthorized'||code==='identity_changed')return '当前账号无法读取或修改此画布,请确认权限后重试。';
@@ -66,13 +68,13 @@ export function HeadlessRecoveryDialog({engine,close}:{engine:ProjectEngine;clos
{!!session?.acknowledgedRequestIds.length&&
已找回 {session.acknowledgedRequestIds.length} 个成功回执,仅处理后续尚未同步的修改。
}
{session&&records.length===0&&没有待处理的本地修改,可以采用服务端最新内容。
}
{session&&records.map((intent,index)=>
- 修改 {index+1}{intent.operationType==='run.start'?' · 尚未受理的生成':intent.operationType==='run.cancel'?' · 尚未确认的取消':''}
+ 修改 {index+1}{intent.origin==='undo'?' · 未同步的撤销':intent.origin==='redo'?' · 未同步的重做':''}{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)}本地:{change.afterPresent===false?'移除此字段':
}{plan&&
}
;})}
)}
- {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} 项。
}
+ {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} 项,重做记录由 {engine.getSnapshot().history.redo.length} 项调整为 {plan.history.redo.length} 项。
}
{plan&&}
{error&&{error}
}
diff --git a/src/lib/canvas-runtime/change-rebase.ts b/src/lib/canvas-runtime/change-rebase.ts
index 1d4cd1b..096d37a 100644
--- a/src/lib/canvas-runtime/change-rebase.ts
+++ b/src/lib/canvas-runtime/change-rebase.ts
@@ -59,6 +59,29 @@ function movedNode(current:Extract,originalBef
return{geometry,presentation};
}
+/** Restore batches may have inverse storage order (canvas before entry). Only reorder adjacent creates, never move across an edit/delete. */
+function orderedCreates(changes:readonly Change[]):readonly Change[]{
+ const result:Change[]=[];
+ const creating=(change:Change)=>change.kind!=='field'&&!change.before&&!!change.after;
+ for(let offset=0;offset`${change.kind}:${(change as Extract).id}`),positions=new Map(keys.map((key,index)=>[key,index]));
+ if(positions.size!==batch.length)throw fault('recovery_creation_dependency_conflict');
+ const counts=batch.map(()=>0),waiting=new Map(),ready:number[]=[];
+ batch.forEach((change,index)=>{
+ const dependencies=new Set();
+ if(change.kind==='canvas'&&change.after?.parent){dependencies.add(`canvas:${change.after.parent.canvasId}`);dependencies.add(`element:${change.after.parent.entryElementId}`);}
+ if(change.kind==='element'&&change.after){dependencies.add(`canvas:${change.after.ownerCanvasId}`);if(change.after.structure==='connection'){dependencies.add(`element:${change.after.source.elementId}`);dependencies.add(`element:${change.after.target.elementId}`);}}
+ for(const key of dependencies){const dependency=positions.get(key);if(dependency===undefined)continue;counts[index]++;const list=waiting.get(dependency)??[];list.push(index);waiting.set(dependency,list);}
+ if(!counts[index])ready.push(index);
+ });
+ let emitted=0;for(let next=0;next(),deferredPaths:FieldChange[]=[];
@@ -66,11 +89,12 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In
function apply(change:Change){document=applyChanges(document,[change]);changes.push(freeze(change));}
function target(change:FieldChange){const current=document.elements.get(change.target.id),prior=context.before.get(change.target.id)??context.after.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)throw fault('stale_incarnation',[change.target.id]);if(prior&&(current.componentId!==prior.componentId||current.dataVersion!==prior.dataVersion||current.structure!==prior.structure))throw fault('recovery_target_type_changed',[current.id]);if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]);return current;}
function endpoint(id:Id){const current=document.elements.get(id),expected=context.after.get(id);if(!current||current.structure!=='node'||!expected||current.incarnation!==expected.incarnation)throw fault('stale_endpoint',[id]);}
- for(const original of intent.changes){
+ for(const original of orderedCreates(intent.changes)){
if(original.kind==='element'){
const current=document.elements.get(original.id);
if(original.before===null&&original.after){
- if(current||document.tombstones.has(original.id))throw fault('recovery_identity_collision',[original.id]);
+ const dead=document.tombstones.get(original.id),restoring=['undo','redo'].includes(intent.origin)&&dead!==undefined&&original.after.incarnation===dead+1;
+ if(current||dead!==undefined&&!restoring)throw fault('recovery_identity_collision',[original.id]);
if(registry.resolve(original.after)!=='known')throw fault('unsupported_component',[original.id]);
let after=original.after;
if(after.structure==='node'){const basis=context.transforms.get(after.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[after.id]);const destination=canvasTransform(document,after.ownerCanvasId),presentation=withUnitScale(after.data.presentation,unitScale(after.data.presentation)*basis.scale/destination.scale),data={...after.data};if(presentation===undefined)delete data.presentation;else data.presentation=presentation;after={...after,data,geometry:convertGeometry(after.geometry,basis,destination)};}
diff --git a/src/lib/canvas-runtime/conflicts.ts b/src/lib/canvas-runtime/conflicts.ts
index 8c888a7..c77a65c 100644
--- a/src/lib/canvas-runtime/conflicts.ts
+++ b/src/lib/canvas-runtime/conflicts.ts
@@ -5,6 +5,7 @@ import { equal, fault, freeze, readMap } from '../canvas-core/values.ts';
import type { CanonicalProposal, EngineSnapshot } from './contracts.ts';
import { createLedger, type Presence } from './ledger.ts';
import { intentContexts, rebaseIntent } from './change-rebase.ts';
+import { replanTraversals } from './traversal-recovery.ts';
import { operationCanvases, validateCanonicalRead } from './reconcile.ts';
import { referencedResources } from './resources.ts';
@@ -38,14 +39,14 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
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.length&&intent.origin!=='user')throw fault('recovery_history_traversal_not_supported',[intent.id]);
+ if(intent.changes.length&&!['user','undo','redo'].includes(intent.origin))throw fault('recovery_history_traversal_not_supported',[intent.id]);
const before=document,entryId=historyOwner.get(intent.id);
if(entryId&&!boundaries.has(entryId))boundaries.set(entryId,{before,after:before});
for(const original of intent.changes)if(original.kind==='field'){
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,true));
}
- if(choice==='keep-local')for(const change of intent.changes)if(change.kind==='canvas'&&!change.before&&canonical.versions.has(change.id))throw fault('recovery_identity_collision',[change.id]);
+ if(choice==='keep-local'&&intent.origin==='user')for(const change of intent.changes)if(change.kind==='canvas'&&!change.before&&canonical.versions.has(change.id))throw fault('recovery_identity_collision',[change.id]);
const rebased=choice==='keep-local'?rebaseIntent(document,intent,contexts.get(intent.id)!,registry):{document,changes:[]};
const changes=rebased.changes;document=rebased.document;
if(entryId)boundaries.get(entryId)!.after=document;
@@ -66,7 +67,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
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,boundaries,registry);
+ const pending=records.map(record=>record.intent),history=pending.some(intent=>['undo','redo'].includes(intent.origin)&&intent.changes.length)?replanTraversals(snapshot,pending,replacements,new Set(decisions.filter(decision=>decision.choice==='keep-local').map(decision=>decision.operationId)),boundaries,registry):rewriteHistory(snapshot,pending,replacements,starts,boundaries,registry);
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';else if(change.kind==='element')presence[change.id]??=change.before?'exists':'absent';
diff --git a/src/lib/canvas-runtime/traversal-recovery.ts b/src/lib/canvas-runtime/traversal-recovery.ts
new file mode 100644
index 0000000..9fbb9bd
--- /dev/null
+++ b/src/lib/canvas-runtime/traversal-recovery.ts
@@ -0,0 +1,38 @@
+import type { Document, HistoryState, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
+import type { EngineSnapshot } from './contracts.ts';
+import { invert } from '../canvas-core/document.ts';
+import { planHistoryChanges } from '../canvas-core/history.ts';
+import { fault, freeze, readMap } from '../canvas-core/values.ts';
+
+/** Rewind only proved local stack moves, then replay the explicitly retained traversals. Never publish the temporary cursor. */
+export function replanTraversals(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap,kept:ReadonlySet,boundaries:ReadonlyMap,registry:Registry):HistoryState {
+ const traversals=pending.filter(intent=>intent.changes.length);
+ if(traversals.some(intent=>!['undo','redo'].includes(intent.origin)))throw fault('recovery_history_mixed_traversal_not_supported');
+ const owner=new Map();for(const entry of snapshot.history.entries.values())for(const id of entry.operationIds)owner.set(id,entry.id);
+ const seen=new Set(),undo=[...snapshot.history.undo],redo=[...snapshot.history.redo],entries=new Map(snapshot.history.entries);
+ function entryId(intent:OperationIntent){const id=owner.get(intent.id);if(!id)throw fault('recovery_history_traversal_evidence_missing',[intent.id]);return id;}
+ function move(source:Id[],destination:Id[],id:Id){if(source.at(-1)!==id)throw fault('recovery_history_traversal_order',[id]);source.pop();destination.push(id);}
+ for(const intent of [...traversals].reverse()){
+ const id=entryId(intent);if(seen.has(id))throw fault('recovery_history_repeated_traversal_not_supported',[id]);seen.add(id);
+ if(intent.origin==='undo')move(redo,undo,id);else move(undo,redo,id);
+ }
+ for(const intent of traversals){
+ const id=entryId(intent),entry=entries.get(id)!,source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo;
+ if(source.at(-1)!==id)throw fault('recovery_history_traversal_order',[id]);
+ const replacement=replacements.get(intent.id),boundary=boundaries.get(id)!;
+ if(kept.has(intent.id)){
+ if(!replacement){source.pop();entries.delete(id);continue;} // No remaining local effect, retired with an audit rather than a fabricated redo.
+ const changes=intent.origin==='undo'?invert(replacement.changes):replacement.changes;
+ try{planHistoryChanges(boundary.after,intent.origin==='undo'?changes:invert(changes),registry);}
+ catch{throw fault('recovery_history_traversal_conflict',[id]);}
+ move(source,destination,id);
+ entries.set(id,freeze({...entry,changes,operationIds:entry.operationIds.map(operationId=>operationId===intent.id?replacement.id:operationId)}));
+ }else{
+ const changes=intent.origin==='undo'?invert(intent.changes):intent.changes;
+ try{planHistoryChanges(boundary.before,intent.origin==='undo'?invert(changes):changes,registry);}
+ catch{throw fault('recovery_history_traversal_conflict',[id]);}
+ entries.set(id,freeze({...entry,changes,operationIds:entry.operationIds.filter(operationId=>operationId!==intent.id)}));
+ }
+ }
+ return Object.freeze({undo:freeze(undo),redo:freeze(redo),entries:readMap(entries)});
+}
diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs
index 2bd8b72..faa6a7d 100644
--- a/tests/headless/browser-storage-cases.mjs
+++ b/tests/headless/browser-storage-cases.mjs
@@ -5,6 +5,22 @@ 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 traversalRecoveryCase(name,assert){
+ const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents());
+ const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:300,height:200,rotation:0},data:{title:id,text:''}});
+ const original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('anchor'),{...node('entry'),componentId:'frame'},node('member',child)]),storage=createBrowserStorage({name:`${name}-traversal`}),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:participants.filter(id=>!document.canvases.has(id)),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),'traversal restoration lost an archive version fence');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);engine.editor.dispatch({type:'element.remove',target:{id:'entry',incarnation:1}});await engine.settle();reject=true;const undo=engine.editor.history.undo();await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(undo.operationIds[0]));
+ document=applyChanges(document,[{kind:'field',target:{id:'anchor',incarnation:1},field:'data.title',before:'anchor',after:'remote',beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+1);
+ const session=await engine.prepareRecovery(),plan=session.plan(new Map([[undo.operationIds[0],'keep-local']]));reject=false;await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options);
+ assert(engine.getSnapshot().history.redo.length===1&&engine.getSnapshot().history.undo.length===0,'recovered undo did not retain its stack transition');assert(document.elements.get('member').incarnation===2,'restoration used the deleted incarnation');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'rejected undo bytes changed');
+ assert(engine.editor.history.redo().status==='applied','redo after recovered undo failed');await engine.settle();assert(!document.canvases.has(child),'redo did not archive the restored child');assert(engine.editor.history.undo().status==='applied','next lifecycle undo failed');await engine.settle();assert(document.elements.get('member').incarnation===3,'next undo did not advance incarnation');assert(document.elements.get('anchor').data.title==='remote','traversal recovery erased remote content');await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').incarnation===3&&engine.getSnapshot().history.redo.length===1,'restored document and history were not in one durable cut');assert(calls.filter(call=>call.method==='POST').length===5,'traversal recovery duplicated a write');
+ }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-traversal`);}
+}
+
async function mixedHistoryCase(name,assert){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents());
const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:300,height:200,rotation:0},data:{title:id,text:''}});
@@ -329,6 +345,8 @@ export async function run() {
results.push('scaled container units preserve nested transforms and typography through atomic protocol writes, IndexedDB reopening, undo and redo');
await mixedHistoryCase(name,assert);
results.push('mixed acknowledged entry/pending partition history persists one undo unit, preserves original request facts and restores fresh lifecycles after reopening');
+ await traversalRecoveryCase(name,assert);
+ results.push('rejected partition undo recovers archived scopes and history through IndexedDB; redo/undo advance lifecycles without repeating the old request');
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
index 6e5bab0..14ced2d 100644
--- a/tests/headless/conflicts.test.mjs
+++ b/tests/headless/conflicts.test.mjs
@@ -78,9 +78,9 @@ test('recovery publication failure keeps one plan and blocks sends until the exa
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()=>{
+test('incomplete choices and mixed user/traversal recovery 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();
+ assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();const undoSession=await engine.prepareRecovery();assert.throws(()=>undoSession.plan(choices(engine)),/recovery_history_mixed_traversal_not_supported/);await undoSession.cancel();await engine.close();
});
test('retirement rejects unknown work and incomplete dependency closures without mutation',()=>{
diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs
index 8780855..2633280 100644
--- a/tests/headless/renderer.integration.mjs
+++ b/tests/headless/renderer.integration.mjs
@@ -174,7 +174,11 @@ try {
console.log('PASS actual recovery dialog reviews remote/local content, recompiles with new identities, saves before dispatch and preserves the reviewed undo baseline');
rejectNextEdit=true;await click('添加文本卡片');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected creation did not expose recovery');const createdId=rejectedEdit.operations[0].changes.find(change=>change.kind==='element'&&change.before===null).id;
await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('创建元素')`,'creation review omitted the structural change');
- 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('[role=dialog]').innerText.includes('关联结构变更')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'structural plan did not expose related changes');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'structural review did not finish');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.ok(documents.get(root).elements.has(createdId));await click('撤销');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false);
+ 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('[role=dialog]').innerText.includes('关联结构变更')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'structural plan did not expose related changes');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'structural review did not finish');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.ok(documents.get(root).elements.has(createdId));rejectNextEdit=true;await click('撤销');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected undo did not expose recovery');
+ await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('未同步的撤销')`,'undo review did not identify the history action');assert.ok(await evaluate(`document.querySelector('select[aria-label="处理第1项修改"]').innerText.includes('保留这次撤销')`));
+ 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('[role=dialog]').innerText.includes('重做记录由')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'undo recovery omitted its redo stack');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'undo recovery did not finish');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false);
+ await click('重做');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.get(createdId).incarnation,2);await click('撤销');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false);
+ console.log('PASS actual rejected undo review labels the traversal, previews both stacks, replans deletion and retains redo with a new lifecycle');
console.log('PASS actual structural recovery dialog reviews a rejected creation and related changes, applies it with a new identity and retains undo');
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');
diff --git a/tests/headless/structure-recovery.test.mjs b/tests/headless/structure-recovery.test.mjs
index feb0cb9..5658953 100644
--- a/tests/headless/structure-recovery.test.mjs
+++ b/tests/headless/structure-recovery.test.mjs
@@ -233,3 +233,67 @@ test('a later confirmed group operation cannot be moved before an earlier reject
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('rejected undo and redo can be explicitly kept or discarded with the correct history stack',async()=>{
+ for(const direction of ['undo','redo'])for(const keep of [true,false]){
+ const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();
+ if(direction==='redo'){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}
+ f.reject=true;assert.equal(engine.editor.history[direction]().status,'applied');await engine.settle();const old=pending(engine)[0].intent;
+ f.remote([{kind:'field',target:target('a'),field:'data.text',before:'',after:'remote text',beforePresent:true,afterPresent:true}]);
+ const session=await engine.prepareRecovery(),plan=session.plan(new Map([[old.id,keep?'keep-local':'discard-local']]));f.reject=false;await session.resolve(plan);await engine.settle();
+ const undone=direction==='undo'?keep:!keep;assert.equal(f.server().elements.get('a').data.title,undone?'a':'edited');assert.equal(engine.getSnapshot().history.redo.length,undone?1:0);assert.equal(engine.getSnapshot().history.undo.length,undone?0:1);
+ assert.equal(engine.getSnapshot().runtime.ledger.operations.get(old.id).resolution,keep?'superseded':'discarded');assert.equal(f.server().elements.get('a').data.text,'remote text');
+ assert.equal(engine.editor.history[undone?'redo':'undo']().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,undone?'edited':'a');await engine.close();
+ }
+});
+
+test('a kept rejected undo uses the reviewed remote value as its redo baseline',async()=>{
+ const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
+ f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');
+ assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');await engine.close();
+});
+
+test('a rejected undo of a confirmed partition deletion restores only the proved next lifecycle',async()=>{
+ const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('f')});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
+ const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('f').incarnation,2);assert.equal(f.server().elements.get('c').incarnation,2);assert.equal(f.server().canvases.get('child').parent.entryElementId,'f');
+ assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('child'),false);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('f').incarnation,3);await engine.close();
+});
+
+test('a rejected redo after confirmed undo can restore creation with the archived canvas version',async()=>{
+ const f=fixture(),engine=await f.open();f.reject=false;engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})});tx.dispatch({type:'canvas.create',canvas:{id:'inside',parent:{canvasId:'root',entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member','inside')});});await engine.settle();engine.editor.history.undo();await engine.settle();f.reject=true;engine.editor.history.redo();await engine.settle();
+ const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('member').incarnation,2);assert.ok(f.calls.at(-1).expectedVersions.inside>0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('inside'),false);await engine.close();
+});
+
+test('multiple rejected undos retain stack order and cannot skip a discarded top traversal',async()=>{
+ const f=fixture(),engine=await f.open();f.reject=false;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;engine.editor.history.undo();engine.editor.history.undo();await engine.settle();
+ const ids=pending(engine).map(record=>record.intent.id),session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(new Map([[ids[0],'discard-local'],[ids[1],'keep-local']])),/recovery_history_traversal_order/);assert.equal(engine.getSnapshot(),before);
+ const plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(engine.getSnapshot().history.redo.length,2);
+ engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('a').data.title,'first');engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('b').data.title,'second');await engine.close();
+});
+
+test('a failed restoration cannot claim a remotely advanced tombstone and leaves its original history intact',async()=>{
+ const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
+ f.remote([{kind:'element',id:'a',before:null,after:{...node('a'),incarnation:2}},{kind:'element',id:'a',before:{...node('a'),incarnation:2},after:null}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_identity_collision/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
+});
+
+
+test('an already satisfied kept undo retires only its empty contribution with an audit',async()=>{
+ const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();const original=pending(engine)[0].intent;
+ f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'a',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));assert.equal(plan.history.entries.size,0);assert.equal(plan.operationIds.length,0);assert.equal(plan.audit.decisions[0].resolution,'satisfied');await session.resolve(plan);assert.equal(engine.getSnapshot().runtime.ledger.operations.get(original.id).resolution,'satisfied');await engine.close();
+});
+
+test('discarding a rejected traversal never broadens its old inverse over a remote field conflict',async()=>{
+ const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
+ f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local']))),/recovery_history_traversal_conflict/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
+});
+
+test('mixed user and traversal recovery and repeated traversals require additional history evidence',async()=>{
+ for(const mode of ['mixed','repeat','orphan']){
+ const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;
+ if(mode==='mixed')engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'pending'}});
+ engine.editor.history.undo();if(mode==='repeat')engine.editor.history.redo();
+ if(mode==='orphan'){engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'later confirmed'}});}
+ await engine.settle();const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
+ }
+});