Replan reviewed field conflicts with durable audit and conditional history
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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'&¤t.structure==='node'){
|
||||
const before=original.before as unknown as typeof current.geometry,after=original.after as unknown as typeof current.geometry;
|
||||
desired={...current.geometry,...Object.fromEntries((['x','y','width','height','rotation'] as const).filter(key=>before[key]!==after[key]).map(key=>[key,after[key]]))};
|
||||
}
|
||||
const change=freeze({...original,...actual,after:desired});
|
||||
if(change.field==='geometry'&¤t.structure==='node'){const next=change.after as unknown as typeof current.geometry;for(const [keys,capability] of [[['x','y'],'geometry.move'],[['width','height'],'geometry.resize'],[['rotation'],'geometry.rotate']] as const)if(keys.some(key=>current.geometry[key]!==next[key])&&!registry.supports(current,capability))throw fault('unsupported_capability',[current.id]);}
|
||||
if(equal(actual.before,change.after)&&(actual.beforePresent??true)===(change.afterPresent??true))continue;
|
||||
document=applyChanges(document,[change]);changes.push(change);
|
||||
}
|
||||
validateDocument(document,registry);
|
||||
let replacement:OperationIntent|undefined;
|
||||
if(choice==='keep-local'){
|
||||
if(intent.operationType!=='document.change'&&intent.operationType!=='run.apply')throw fault('recovery_action_requires_new_command',[intent.id]);
|
||||
if(changes.length){
|
||||
const dependencyIds=intent.dependencies.flatMap(dependency=>replacements.has(dependency)?replacements.get(dependency)?[replacements.get(dependency)!.id]:[]:[dependency]);
|
||||
// New dependencies reference new logical facts; a discarded parent never silently starts a paid run.
|
||||
const dependencySet=new Set(dependencyIds);
|
||||
for(const previous of replacements.values())if(previous)dependencySet.add(previous.id);
|
||||
const replacementId=unique();
|
||||
const basic={...intent,id:replacementId,sequence:++sequence,changes:freeze(changes),dependencies:[...dependencySet],canvasIds:operationCanvases({...intent,canvasIds:[],changes},before)};
|
||||
replacement=intent.operationType==='run.apply'?freeze({...basic,operationType:'run.apply',action:{...intent.action,targetCanvasId:document.elements.get(intent.action.target.id)!.ownerCanvasId}}) as OperationIntent:freeze(basic);
|
||||
operations.set(replacement.id,replacement);
|
||||
}
|
||||
}
|
||||
replacements.set(intent.id,replacement);
|
||||
decisions.push(freeze({operationId:intent.id,choice,resolution:choice==='discard-local'?'discarded':replacement?'superseded':'satisfied',...(replacement?{replacementId:replacement.id}:{})}));
|
||||
}
|
||||
const history=rewriteHistory(snapshot,records.map(record=>record.intent),replacements,starts);
|
||||
ledger.retire(id,decisions);
|
||||
for(const replacement of replacements.values())if(replacement){
|
||||
const presence:Record<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)});
|
||||
}
|
||||
@@ -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> };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}));})()`);
|
||||
|
||||
Reference in New Issue
Block a user