Replan reviewed field conflicts with durable audit and conditional history

This commit is contained in:
2026-11-08 01:56:21 +08:00
parent a0417b4811
commit 89b6f4a253
11 changed files with 453 additions and 29 deletions
@@ -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<ProjectionNode>();
const [showAI, setShowAI] = useState(false);
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
const [error, setError] = useState(""), [sources, setSources] = useState(new Map<string, string>());
const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
@@ -194,7 +195,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
<IconButton icon={Minus} label="缩小" onClick={() => flow.zoomOut()} /><IconButton icon={Plus} label="放大" onClick={() => flow.zoomIn()} /><IconButton icon={Maximize} label="适应画布" onClick={() => flow.fitView({ padding: .2 })} />
<IconButton icon={Download} label="导出图谱" onClick={() => { 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); }} />
</div><p role="status" style={{ fontSize: 11 }}>{blocked ? '有修改需要处理' : pending.length ? '正在同步…' : '已同步'}{blocked && <button onClick={() => void engine.retry().catch(e => setError(errorText(e)))}>重试</button>}</p></div>
</div><p role="status" style={{ fontSize: 11 }}>{blocked ? '有修改需要处理' : pending.length ? '正在同步…' : '已同步'}{blocked && <><button onClick={() => void engine.retry().catch(e => setError(errorText(e)))}>重试</button><button aria-label="处理同步修改" onClick={() => setShowRecovery(true)}>处理同步修改</button></>}</p></div>
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={view.tool === 'select'} onClick={() => engine.sessions.tool(viewId, 'select')} /><IconButton icon={Hand} label="抓手工具(H)" aria-pressed={view.tool === 'pan'} onClick={() => engine.sessions.tool(viewId, 'pan')} />
<IconButton icon={Sparkles} label="AI 创作" disabled={!aiElement} aria-pressed={showAI} onClick={() => setShowAI(value => !value)} />
@@ -202,6 +203,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
</div>
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onChange={event => { 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 && <HeadlessAIPanel key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
{showRecovery && <HeadlessRecoveryDialog engine={engine} close={() => setShowRecovery(false)} />}
{error && <div className={styles.error} role="alert">{error} <button onClick={() => setError('')}>关闭</button></div>}
</div>;
}
@@ -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 <span>未设置</span>;
const text=typeof value==='string'?value:JSON.stringify(value);
return text.length>240?<details><summary>{text.slice(0,240)}…(展开全文)</summary><pre style={{whiteSpace:'pre-wrap',overflowWrap:'anywhere'}}>{text}</pre></details>:<span>{text}</span>;
}
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<RecoverySession|undefined>(undefined),[session,setSession]=useState<RecoverySession>(),[records,setRecords]=useState<readonly OperationIntent[]>([]);
const [choices,setChoices]=useState(new Map<Id,RecoveryChoice>()),[plan,setPlan]=useState<ConflictPlan>(),[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 <Dialog open onOpenChange={open=>{if(!open)void cancel();}}><DialogContent aria-label="处理同步修改" style={{zIndex:300,maxHeight:'80vh',overflowY:'auto',background:'var(--background, #fffdf9)',color:'var(--foreground, #302d29)'}} onEscapeKeyDown={event=>{if(busy&&session)event.preventDefault();}}>
<DialogTitle>处理同步修改</DialogTitle>
<DialogDescription>检查期间暂停编辑。逐项选择要保留的修改,预览后再应用;原内容和撤销记录会保留备份。</DialogDescription>
{busy&&!session&&<p role="status">正在等待同步并读取最新内容…</p>}
{session&&records.length===0&&<p>没有待处理的本地修改,可以采用服务端最新内容。</p>}
{session&&records.map((intent,index)=><article key={intent.id} style={{borderBottom:'1px solid #ddd5ca',paddingBottom:12}}>
<strong>修改 {index+1}{intent.operationType==='run.start'?' · 尚未受理的生成':intent.operationType==='run.cancel'?' · 尚未确认的取消':''}</strong>
{intent.changes.map((change,changeIndex)=>{if(change.kind!=='field')return <p key={changeIndex}>包含元素或分区结构修改</p>;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<string,Json>|undefined)?.[change.field]:element?.data[key],proposed=plan?.document.elements.get(change.target.id),proposedValue=key===undefined?(proposed as unknown as Record<string,Json>|undefined)?.[change.field]:proposed?.data[key];return <div key={changeIndex} style={{fontSize:13,whiteSpace:'pre-wrap',overflowWrap:'anywhere',marginTop:8}}><span>{fieldLabel(change.field)}</span><div>服务端:{element?<PreviewValue value={value}/>:'元素已不存在'}</div><div>本地:{change.afterPresent===false?'移除此字段':<PreviewValue value={change.after}/>}</div>{plan&&<div>处理后:{proposed?<PreviewValue value={proposedValue}/>:'元素已不存在'}</div>}</div>;})}
<select aria-label={`处理第${index+1}项修改`} value={choices.get(intent.id)??''} disabled={busy||frozen} onChange={event=>{const next=new Map(choices);if(event.target.value)next.set(intent.id,event.target.value as RecoveryChoice);else next.delete(intent.id);setChoices(next);setPlan(undefined);setError('');}}>
<option value="">选择处理方式</option>{['document.change','run.apply'].includes(intent.operationType)&&<option value="keep-local">保留这项本地修改</option>}<option value="discard-local">放弃这项本地修改</option>
</select>
</article>)}
{plan&&<p role="status">方案已生成:保留 {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} 项。</p>}
{error&&<p role="alert">{error}</p>}
<DialogFooter><button disabled={busy&&!!session} onClick={()=>void cancel()}>{session?'取消并恢复编辑':'恢复编辑'}</button>
{session&&records.length>0&&<button aria-label="预览处理方案" disabled={busy||frozen||choices.size!==records.length} onClick={()=>{try{setPlan(session.plan(choices));setError('');}catch(error){setPlan(undefined);setError(message(error));}}}>预览处理方案</button>}
{session&&<button aria-label="应用处理方案" disabled={busy||records.length>0&&!plan} onClick={()=>void apply()}>{busy?'正在保存…':frozen?'重试保存处理结果':'应用处理方案'}</button>}
</DialogFooter>
</DialogContent></Dialog>;
}
+11 -2
View File
@@ -269,7 +269,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
finally { entered = false; }
},
/** Engine-only canonical proposal: validation and runtime state publish as one root. */
reconcile(document: Document, runtime: Runtime, expectedRootRevision: number): Result {
reconcile(document: Document, runtime: Runtime, expectedRootRevision: number, replacement?: { readonly history: HistoryState; readonly operations: ReadonlyMap<Id,OperationIntent> }): Result {
try { assertOpen(); } catch (error) { return reject(error); }
entered = true;
try {
@@ -277,8 +277,17 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if (document.projectId !== state.document.projectId) throw fault('project_mismatch');
const next = createDocument(document.projectId, [...document.canvases.values()], [...document.elements.values()], document.tombstones);
validateDocument(next, options.registry);
let nextSequence=sequence;
if(replacement){
const {history,operations}=replacement,ids=[...history.undo,...history.redo];
if(new Set(ids).size!==ids.length||ids.length!==history.entries.size||ids.some(id=>!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); }
+118
View File
@@ -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<string,Json>)[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<Id,RecoveryChoice>, 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<Id,OperationIntent|undefined>();
const starts=new Map<string,ReturnType<typeof value>>(),historyOwner=new Map<Id,Id>();
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'&&current.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'&&current.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<Id,'exists'>={};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<Id,OperationIntent|undefined>,starts:ReadonlyMap<string,ReturnType<typeof value>>):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<string,FieldChange>();for(const intent of intents)for(const change of intent.changes as readonly FieldChange[])keys.set(fieldKey(change),change);
const fields:FieldChange[]=[];
for(const [key,example]of keys){
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.length<oldPending.length||!equal(original.slice(prefix.length),oldPending))throw fault('recovery_history_mismatch',[entry.id]);
const start=starts.get(JSON.stringify([entry.id,key]))!,first=oldPending[0];
if(!start.missing&&equal(start.before,first.before)&&(start.beforePresent??true)===(first.beforePresent??true))fields.push(...prefix);
fields.push(...intents.flatMap(intent=>replacements.get(intent.id)?.changes as readonly FieldChange[]??[]).filter(change=>fieldKey(change)===key));
}
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)});
}
+2
View File
@@ -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<Id, string>;
readonly resources: ReadonlyMap<Id,ResourceRecord>;
readonly resourcePins: ReadonlyMap<Id,readonly Id[]>;
readonly recoveries: ReadonlyMap<Id,RecoveryAudit>;
};
export type EngineSnapshot = Snapshot<RuntimeState>;
export type CanonicalProposal = { readonly document: Document; readonly versions: ReadonlyMap<Id, number> };
+35 -11
View File
@@ -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<Id,RecoveryChoice>):ConflictPlan;
resolve(plan:ConflictPlan):Promise<Result>;
apply(): Promise<Result>;
cancel(): Promise<void>;
};
@@ -46,9 +49,9 @@ export async function openProjectEngine(options: EngineOptions) {
const fileWrites = new Map<Id, Blob>(), digestTasks = new Map<Id, Promise<void>>();
let staged: readonly ResourceRecord[] = [];
let applying: RunApplyInput | undefined;
let recovery: { controller: AbortController; release(): void } | undefined;
let recovery: { controller: AbortController; release(): void; cancel():Promise<void> } | 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<ConflictPlan>();
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<Id,RecoveryChoice>){
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<Result>{
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<Result>{
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;
+14 -1
View File
@@ -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<Record<Id, Presence>> };
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<Record<Id, Presence>> };
export type LedgerSnapshot = { readonly generation: number; readonly durableGeneration: number; readonly operations: ReadonlyMap<Id, OperationRecord>; readonly requests: ReadonlyMap<Id, FrozenRequest> };
/** 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]);
+31 -13
View File
@@ -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<Id,OperationIntent>):HistoryState {
const entries=table<HistoryEntry>(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<HistoryEntry>(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<OperationRecord>(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<Id>();
const recoveries=table<RecoveryAudit>(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<Id>();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<RunRecord>(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) }),
});
}
+26
View File
@@ -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); }
}
+136
View File
@@ -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();
});
+17
View File
@@ -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}));})()`);