From d702240240597511879174f0a9f585978c09e4a2 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 02:29:51 +0800 Subject: [PATCH] Replan structural edits with shared coordinates and lifecycle-safe history --- .../canvas/headless-recovery-dialog.tsx | 26 +++- src/lib/canvas-bindings/projection.ts | 26 +--- src/lib/canvas-core/coordinates.ts | 49 ++++++++ src/lib/canvas-core/editor.ts | 59 +++++---- src/lib/canvas-core/index.ts | 2 + src/lib/canvas-core/structure.ts | 31 +++++ src/lib/canvas-core/types.ts | 1 + src/lib/canvas-runtime/change-rebase.ts | 114 ++++++++++++++++++ src/lib/canvas-runtime/conflicts.ts | 46 +++---- src/lib/canvas-runtime/reconcile.ts | 5 +- tests/headless/browser-storage-cases.mjs | 13 ++ tests/headless/renderer.integration.mjs | 4 + tests/headless/structure-recovery.test.mjs | 103 ++++++++++++++++ 13 files changed, 403 insertions(+), 76 deletions(-) create mode 100644 src/lib/canvas-core/coordinates.ts create mode 100644 src/lib/canvas-core/structure.ts create mode 100644 src/lib/canvas-runtime/change-rebase.ts create mode 100644 tests/headless/structure-recovery.test.mjs diff --git a/src/components/canvas/headless-recovery-dialog.tsx b/src/components/canvas/headless-recovery-dialog.tsx index 7a92634..a27862e 100644 --- a/src/components/canvas/headless-recovery-dialog.tsx +++ b/src/components/canvas/headless-recovery-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState } from 'react'; -import type { Id, Json, OperationIntent } from '@/lib/canvas-core/types'; +import type { Change, Document, 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'; @@ -17,7 +17,11 @@ function message(error:unknown){ if(code==='stale_incarnation')return '服务端已删除或替换了这个元素。可以放弃这项本地修改。'; if(code==='operation_context_changed')return '元素所属分区已改变,请重新预览这项修改。'; if(code==='ownership_conflict')return '元素已移入其它分区,当前坐标需要重新确认,暂时无法直接保留。'; - if(code?.startsWith('recovery_history')||code==='recovery_structure_not_supported')return '这批修改含结构或撤销操作,暂时无法自动处理。原内容和历史均已保留。'; + if(code==='recovery_identity_collision')return '这个身份已在服务端使用,不能覆盖另一份内容。可以放弃本地创建,或重新创建一个元素。'; + if(code==='stale_endpoint')return '连线所指元素已删除或进入新的生命周期,请重新选择端点。'; + if(code==='container_scale_conversion_required')return '这个分区包含子内容,跨不同缩放空间的整体转换尚未支持,原内容仍已保留。'; + if(code==='unsupported_coordinate_path')return '这条曲线使用未知坐标格式,当前无法安全转换归属,原路径仍已保留。'; + if(code?.startsWith('recovery_history')||code==='recovery_record_replacement_not_supported')return '这批修改含结构或撤销操作,暂时无法自动处理。原内容和历史均已保留。'; if(code==='recovery_action_requires_new_command')return '生成任务需要重新手动发起。可以在这里放弃尚未执行的请求。'; if(code==='forbidden'||code==='unauthorized'||code==='identity_changed')return '当前账号无法读取或修改此画布,请确认权限后重试。'; return '处理未完成,当前修改仍已保留。请重试,或恢复编辑后稍后处理。'; @@ -29,6 +33,21 @@ function PreviewValue({value}:{value:Json|undefined}){ } const fieldLabel=(field:string)=>({'data.title':'标题','data.text':'正文',geometry:'位置与尺寸',path:'连线路径'}[field]??(field.startsWith('data.')?'内容字段':'元素关系')); +function StructureReview({change,document}:{change:Exclude;document:Document}){ + const current=change.kind==='element'?document.elements.get(change.id):document.canvases.get(change.id),kind=change.kind==='element'?'元素':'分区'; + const title=change.kind==='element'?(change.after?.data.title??change.before?.data.title??change.id):change.id; + return
{change.after===null?'删除':change.before===null?'创建':'调整'}{kind} · {String(title)} +
服务端:{current?:不存在}
本地请求:{change.after?:删除此{kind}}
; +} +function PlanStructureReview({plan}:{plan:ConflictPlan}){ + const changes=plan.operationIds.flatMap(id=>plan.operations.get(id)!.changes),structural=changes.filter(change=>change.kind!=='field'||['ownerCanvasId','source','target','path'].includes(change.field)); + if(!structural.length)return null; + const elements=structural.filter(change=>change.kind==='element'&&change.after===null).length,canvases=structural.filter(change=>change.kind==='canvas'&&change.after===null).length; + return
关联结构变更 {structural.length} 项 · 删除 {elements} 个元素、{canvases} 个分区(展开查看) + {structural.map((change,index)=>
{change.kind==='field'?fieldLabel(change.field):change.kind==='element'?'元素':'分区'} · {change.kind==='field'?change.target.id:change.id}
修改前:
修改后:
)} +
; +} + export function HeadlessRecoveryDialog({engine,close}:{engine:ProjectEngine;close():void}){ const owner=useRef(undefined),[session,setSession]=useState(),[records,setRecords]=useState([]); const [choices,setChoices]=useState(new Map()),[plan,setPlan]=useState(),[error,setError]=useState(''),[busy,setBusy]=useState(true),[frozen,setFrozen]=useState(false); @@ -48,12 +67,13 @@ export function HeadlessRecoveryDialog({engine,close}:{engine:ProjectEngine;clos {session&&records.length===0&&

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

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

包含元素或分区结构修改

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

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

} + {plan&&} {error&&

{error}

} {session&&records.length>0&&} diff --git a/src/lib/canvas-bindings/projection.ts b/src/lib/canvas-bindings/projection.ts index 5124ed3..2b3ce11 100644 --- a/src/lib/canvas-bindings/projection.ts +++ b/src/lib/canvas-bindings/projection.ts @@ -4,26 +4,12 @@ import { fault } from '../canvas-core/values.ts'; import type { CanvasNode } from '../canvas-elements.ts'; import type { CanvasEdgeModel, EdgeData } from '../canvas-v2.ts'; -export type Transform = { x: number; y: number; scale: number; rotation: number }; -const origin: Transform = { x: 0, y: 0, scale: 1, rotation: 0 }; -const rotate = (point: Point, angle: number): Point => { const a = angle * Math.PI / 180; return { x: point.x * Math.cos(a) - point.y * Math.sin(a), y: point.x * Math.sin(a) + point.y * Math.cos(a) }; }; -export function toWorld(transform: Transform, point: Point): Point { const value = rotate(point, transform.rotation); return { x: transform.x + value.x * transform.scale, y: transform.y + value.y * transform.scale }; } -export function toLocal(transform: Transform, point: Point): Point { return rotate({ x: (point.x - transform.x) / transform.scale, y: (point.y - transform.y) / transform.scale }, -transform.rotation); } +import { childCanvasTransform, toWorld, transformPath, type Transform } from '../canvas-core/coordinates.ts'; +export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts'; +const origin:Transform={x:0,y:0,scale:1,rotation:0}; export type ProjectionNode = Omit & { type: NonNullable | 'unsupported' }; const nodeTypes: Record> = { 'plain-text': 'plainText', 'plain-image': 'plainImage', frame: 'frame', icon: 'icon' }; const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; -export function transformPath(path: Json, transform: Transform, inverse = false): Json { - const raw = object(path); - if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path; - const point = (input: Json | undefined, vector = false): Json | undefined => { - const p = object(input); if (typeof p.x !== 'number' || typeof p.y !== 'number') return input; - const t = vector ? { ...transform, x: 0, y: 0 } : transform; - return { ...p, ...(inverse ? toLocal(t, { x: p.x, y: p.y }) : toWorld(t, { x: p.x, y: p.y })) }; - }; - return { ...raw, ...(raw.startControl ? { startControl: point(raw.startControl, true)! } : {}), ...(raw.endControl ? { endControl: point(raw.endControl, true)! } : {}), knots: raw.knots.map(knot => { - const k = object(knot); return { ...k, position: point(k.position)!, in: point(k.in, true)!, out: point(k.out, true)! }; - }) }; -} /** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */ export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null) { @@ -46,13 +32,11 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist }; nodes.push(projected); visible.add(element.id); if (child) { - const presentation = object(data.presentation), scale = typeof presentation.contentScale === 'number' && presentation.contentScale > 0 ? presentation.contentScale : 1; + const presentation = object(data.presentation); if (presentation.collapsed === true || !known) { const stack = [child]; while (stack.length) { const hidden = stack.pop()!; hiddenBy.set(hidden, element.id); for (const canvas of document.canvases.values()) if (canvas.parent?.canvasId === hidden) stack.push(canvas.id); } } else { - const offset = rotate({ x: 24 - g.width / 2, y: 36 - g.height / 2 }, g.rotation); - const start = toWorld(transform, { x: g.x + g.width / 2 + offset.x, y: g.y + g.height / 2 + offset.y }); - transforms.set(child, { ...start, scale: transform.scale * scale, rotation: transform.rotation + g.rotation }); visit(child, depth + 1); + transforms.set(child, childCanvasTransform(transform,g,data.presentation)); visit(child, depth + 1); } } } diff --git a/src/lib/canvas-core/coordinates.ts b/src/lib/canvas-core/coordinates.ts new file mode 100644 index 0000000..2b6e37f --- /dev/null +++ b/src/lib/canvas-core/coordinates.ts @@ -0,0 +1,49 @@ +import type { Document, Geometry, Id, Json, Point } from './types.ts'; +import { ancestors } from './document.ts'; +import { equal, fault } from './values.ts'; +import { connectionPathValid } from './features.ts'; +export type Transform = { x: number; y: number; scale: number; rotation: number }; +const origin: Transform = { x: 0, y: 0, scale: 1, rotation: 0 }; +const rotate = (point: Point, angle: number): Point => { const a = angle * Math.PI / 180; return { x: point.x * Math.cos(a) - point.y * Math.sin(a), y: point.x * Math.sin(a) + point.y * Math.cos(a) }; }; +export function toWorld(transform: Transform, point: Point): Point { const value = rotate(point, transform.rotation); return { x: transform.x + value.x * transform.scale, y: transform.y + value.y * transform.scale }; } +export function toLocal(transform: Transform, point: Point): Point { return rotate({ x: (point.x - transform.x) / transform.scale, y: (point.y - transform.y) / transform.scale }, -transform.rotation); } +const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +export function transformPath(path: Json, transform: Transform, inverse = false): Json { + const raw = object(path); + if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path; + const point = (input: Json | undefined, vector = false): Json | undefined => { + const p = object(input); if (typeof p.x !== 'number' || typeof p.y !== 'number') return input; + const t = vector ? { ...transform, x: 0, y: 0 } : transform; + return { ...p, ...(inverse ? toLocal(t, { x: p.x, y: p.y }) : toWorld(t, { x: p.x, y: p.y })) }; + }; + return { ...raw, ...(raw.startControl ? { startControl: point(raw.startControl, true)! } : {}), ...(raw.endControl ? { endControl: point(raw.endControl, true)! } : {}), knots: raw.knots.map(knot => { + const k = object(knot); return { ...k, position: point(k.position)!, in: point(k.in, true)!, out: point(k.out, true)! }; + }) }; +} + +/** Format-1 child content origin: the same inset, scale and rotation used by renderer projection. */ +export function childCanvasTransform(parent:Transform,geometry:Geometry,presentation:Json|undefined):Transform { + const data=object(presentation),scale=typeof data.contentScale==='number'&&data.contentScale>0?data.contentScale:1; + const offset=rotate({x:24-geometry.width/2,y:36-geometry.height/2},geometry.rotation); + const start=toWorld(parent,{x:geometry.x+geometry.width/2+offset.x,y:geometry.y+geometry.height/2+offset.y}); + return{...start,scale:parent.scale*scale,rotation:parent.rotation+geometry.rotation}; +} +export function canvasTransform(document:Document,canvasId:Id):Transform { + let result:Transform={...origin}; + for(const id of ancestors(document,canvasId).reverse()){ + const parent=document.canvases.get(id)!.parent;if(!parent)continue; + const entry=document.elements.get(parent.entryElementId);if(!entry||entry.structure!=='node')throw fault('invalid_canvas_entry',[id]); + result=childCanvasTransform(result,entry.geometry,entry.data.presentation); + } + return result; +} +export function convertGeometry(geometry:Geometry,from:Transform,to:Transform):Geometry { + if(equal(from,to))return geometry; + const center=toLocal(to,toWorld(from,{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2})),ratio=from.scale/to.scale,width=geometry.width*ratio,height=geometry.height*ratio; + return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((geometry.rotation+from.rotation-to.rotation)%360+360)%360}; +} +export function convertPath(path:Json,from:Transform,to:Transform):Json { + if(path===null||equal(from,to))return path; + if(!connectionPathValid(path))throw fault('unsupported_coordinate_path'); + return transformPath(transformPath(path,from),to,true); +} diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index 64ead8c..16f6e9a 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -1,7 +1,10 @@ import type { Applied, Availability, Capability, Change, Command, Commit, CoreSnapshot, Document, ElementRecord, Geometry, HistoryEntry, HistoryState, Id, OperationIntent, Origin, Registry, Result, Snapshot } from './types.ts'; -import { applyChanges, commonCanvas, createDocument, invert, targetElement, validateDocument } from './document.ts'; +import { applyChanges, createDocument, invert, targetElement, validateDocument } from './document.ts'; import { clone, equal, fault, freeze, readMap, reject, validJson } from './values.ts'; +import { canvasTransform, convertGeometry } from './coordinates.ts'; +import { removalChanges, repairConnectionOwners } from './structure.ts'; + export type EditorOptions = { registry: Registry; document: Document; createId?: () => Id; historyLimit?: number; policy?: (command: Command, document: Document) => Availability; @@ -43,6 +46,7 @@ export function createEditor(options: EditorOptions(options: EditorOptionscanvas.parent?.entryElementId===element.id)&&canvasTransform(document,element.ownerCanvasId).scale!==canvasTransform(document,command.canvasId).scale)throw fault('container_scale_conversion_required',[element.id]); + const geometry=command.geometry??(element.ownerCanvasId===command.canvasId?element.geometry:convertGeometry(element.geometry,canvasTransform(document,element.ownerCanvasId),canvasTransform(document,command.canvasId))); + if(!equal(geometry,element.geometry))changes=[...changes,{kind:'field',target:command.target,field:'geometry',before:element.geometry,after:geometry}]; } + for (const canvas of document.canvases.values()) if (canvas.parent?.entryElementId === element.id) changes = [...changes, { kind: 'canvas', id: canvas.id, before: canvas, after: { ...canvas, parent: { ...canvas.parent, canvasId: command.canvasId } } }]; + changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))]; } else { const output: Change[] = []; const run = (capability: Capability, patch: unknown) => { @@ -232,19 +238,26 @@ export function createEditor(options: EditorOptions(); - changes = changes.map(change => { - if (change.kind === 'element' && !change.before && change.after) { - const dead = state.document.tombstones.get(change.id); - if (dead !== undefined && change.after.incarnation <= dead) { generations.set(change.id, dead + 1); return freeze({ ...change, after: { ...change.after, incarnation: dead + 1 } }); } - } + const planned=direction==='undo'?invert(entry.changes):entry.changes; + const generations=new Map>(); + const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;}; + const remap=(change:Change):Change=>{ + if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});} + if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});} return change; - }).map(change => change.kind === 'field' && generations.has(change.target.id) ? freeze({ ...change, target: { ...change.target, incarnation: generations.get(change.target.id)! } }) : change); - const document = applyChanges(state.document, changes); - validateDocument(document, options.registry); - const entries = new Map(state.history.entries); - entries.set(id, freeze({ ...entry, changes: direction === 'undo' ? invert(changes) : changes })); + }; + let document=state.document;const changes:Change[]=[]; + for(const original of planned){ + let change=remap(original); + if(change.kind==='element'&&!change.before&&change.after){const dead=document.tombstones.get(change.id);if(dead!==undefined&&change.after.incarnation<=dead){const mapping=generations.get(change.id)??new Map();mapping.set(change.after.incarnation,dead+1);generations.set(change.id,mapping);change=remap(change);}} + document=applyChanges(document,[change]);changes.push(change); + } + validateDocument(document,options.registry); + const entries=new Map(state.history.entries); + // Later redo entries belong to the lifecycle restored by this explicit history action. + // Frozen operations retain their original identities and incarnations. + if(generations.size)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(remap)})); + entries.set(id,freeze({...entry,changes:direction==='undo'?invert(changes):changes})); const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) }); return publish(document, changes, direction, history, id); } catch (error) { return reject(error); } diff --git a/src/lib/canvas-core/index.ts b/src/lib/canvas-core/index.ts index 1c42352..eba5e08 100644 --- a/src/lib/canvas-core/index.ts +++ b/src/lib/canvas-core/index.ts @@ -5,3 +5,5 @@ export * from './registry.ts'; export * from './features.ts'; export * from './editor.ts'; export * from './gestures.ts'; +export * from './coordinates.ts'; +export * from './structure.ts'; diff --git a/src/lib/canvas-core/structure.ts b/src/lib/canvas-core/structure.ts new file mode 100644 index 0000000..b19ae84 --- /dev/null +++ b/src/lib/canvas-core/structure.ts @@ -0,0 +1,31 @@ +import type { Change, Document, Id, Target } from './types.ts'; +import { commonCanvas, targetElement } from './document.ts'; +import { canvasTransform, convertPath } from './coordinates.ts'; +import { equal, freeze } from './values.ts'; + +/** Own the subtree explicitly; geometry/visibility never selects which descendants are deleted. */ +export function removalChanges(document:Document,target:Target):readonly Change[] { + const element=targetElement(document,target),elements=new Set([element.id]),canvases=new Set(),children=new Map(); + for(const canvas of document.canvases.values())if(canvas.parent){const list=children.get(canvas.parent.canvasId)??[];list.push(canvas.id);children.set(canvas.parent.canvasId,list);} + const stack=[...document.canvases.values()].filter(canvas=>canvas.parent?.entryElementId===element.id).map(canvas=>canvas.id); + while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);for(const child of children.get(id)??[])stack.push(child);} + for(const record of document.elements.values())if(canvases.has(record.ownerCanvasId))elements.add(record.id); + for(const record of document.elements.values())if(record.structure==='connection'&&(elements.has(record.source.elementId)||elements.has(record.target.elementId)))elements.add(record.id); + return freeze([ + ...[...document.elements.values()].filter(record=>elements.has(record.id)).map(record=>({kind:'element' as const,id:record.id,before:record,after:null})), + ...[...document.canvases.values()].filter(canvas=>canvases.has(canvas.id)).map(canvas=>({kind:'canvas' as const,id:canvas.id,before:canvas,after:null})), + ]); +} + +/** Ownership changes and their curve coordinates are one operation, with the same stable edge identity. */ +export function repairConnectionOwners(before:Document,document:Document):readonly Change[] { + const changes:Change[]=[]; + for(const edge of document.elements.values())if(edge.structure==='connection'){ + const source=document.elements.get(edge.source.elementId),target=document.elements.get(edge.target.elementId);if(!source||!target)continue; + const owner=commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId);if(owner===edge.ownerCanvasId)continue; + const path=convertPath(edge.path,canvasTransform(before.canvases.has(edge.ownerCanvasId)?before:document,edge.ownerCanvasId),canvasTransform(document,owner)); + changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner}); + if(!equal(edge.path,path))changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'path',before:edge.path,after:path}); + } + return freeze(changes); +} diff --git a/src/lib/canvas-core/types.ts b/src/lib/canvas-core/types.ts index 5ec85b1..252ec9a 100644 --- a/src/lib/canvas-core/types.ts +++ b/src/lib/canvas-core/types.ts @@ -20,6 +20,7 @@ export type Change = FieldChange | { readonly kind: 'canvas'; readonly id: Id; readonly before: CanvasRecord | null; readonly after: CanvasRecord | null }; export type Command = | { readonly type: 'element.create'; readonly element: ElementRecord } + | { readonly type:'connection.endpoints'; readonly target:Target; readonly source?:Endpoint; readonly destination?:Endpoint } | { readonly type: 'connection.path'; readonly target: Target; readonly path: Json } | { readonly type: 'element.remove'; readonly target: Target } | { readonly type: 'element.data'; readonly target: Target; readonly patch: Data } diff --git a/src/lib/canvas-runtime/change-rebase.ts b/src/lib/canvas-runtime/change-rebase.ts new file mode 100644 index 0000000..a154191 --- /dev/null +++ b/src/lib/canvas-runtime/change-rebase.ts @@ -0,0 +1,114 @@ +import type { Change, Document, ElementRecord, FieldChange, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts'; +import { applyChanges, commonCanvas, validateDocument } from '../canvas-core/document.ts'; +import { canvasTransform, convertGeometry, convertPath, type Transform } from '../canvas-core/coordinates.ts'; +import { removalChanges, repairConnectionOwners } from '../canvas-core/structure.ts'; +import { equal, fault, freeze, readMap } from '../canvas-core/values.ts'; + +export type IntentContext = { readonly before:ReadonlyMap; readonly after:ReadonlyMap; readonly transforms:ReadonlyMap }; +/** Reconstruct only immutable intention metadata. This mutable cursor is never published as a document. */ +export function intentContexts(document:Document,intents:readonly OperationIntent[]):ReadonlyMap { + const elements=new Map(document.elements),canvases=new Map(document.canvases),contexts=new Map(); + const cursor={...document,elements,canvases}; + for(const intent of [...intents].reverse()){ + const ids=new Set(),owners=new Set(); + for(const change of intent.changes){ + if(change.kind==='field'){ids.add(change.target.id);if(change.field==='source'||change.field==='target')for(const endpoint of [change.before,change.after])if(endpoint&&typeof endpoint==='object'&&!Array.isArray(endpoint)&&typeof endpoint.elementId==='string')ids.add(endpoint.elementId);} + else if(change.kind==='element'){ids.add(change.id);for(const record of [change.before,change.after])if(record?.structure==='connection'){ids.add(record.source.elementId);ids.add(record.target.elementId);}} + else for(const record of [change.before,change.after])if(record?.parent)ids.add(record.parent.entryElementId); + } + const after=new Map();for(const id of ids){const record=elements.get(id);if(record){after.set(id,record);owners.add(record.ownerCanvasId);}} + const transforms=new Map();for(const owner of owners)if(canvases.has(owner))transforms.set(owner,canvasTransform(cursor,owner)); + for(const change of [...intent.changes].reverse()){ + if(change.kind==='element'){if(change.before)elements.set(change.id,change.before);else elements.delete(change.id);} + else if(change.kind==='canvas'){if(change.before)canvases.set(change.id,change.before);else canvases.delete(change.id);} + else {const current=elements.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)continue; + if(change.field.startsWith('data.')){const key=change.field.slice(5),data={...current.data};if(change.beforePresent===false)delete data[key];else data[key]=change.before;elements.set(current.id,freeze({...current,data}));} + else elements.set(current.id,freeze({...current,[change.field]:change.before}) as ElementRecord); + } + } + contexts.set(intent.id,Object.freeze({before:readMap(new Map([...ids].flatMap(id=>elements.has(id)?[[id,elements.get(id)!] as const]:[]))),after:readMap(after),transforms:readMap(transforms)})); + } + return readMap(contexts); +} + +/** Re-plan against current data. Whole records are used only for explicit creation/removal. */ +export function rebaseIntent(document:Document,intent:OperationIntent,context:IntentContext,registry:Registry):{document:Document;changes:readonly Change[]} { + const before=document,changes:Change[]=[],moved=new Set(),deferredPaths:FieldChange[]=[]; + for(const change of intent.changes)if(change.kind==='field'&&change.field==='ownerCanvasId'&&context.after.get(change.target.id)?.structure==='node')moved.add(change.target.id); + function apply(change:Change){document=applyChanges(document,[change]);changes.push(freeze(change));} + function target(change:FieldChange){const current=document.elements.get(change.target.id),prior=context.before.get(change.target.id)??context.after.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)throw fault('stale_incarnation',[change.target.id]);if(prior&&(current.componentId!==prior.componentId||current.dataVersion!==prior.dataVersion||current.structure!==prior.structure))throw fault('recovery_target_type_changed',[current.id]);if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]);return current;} + function endpoint(id:Id){const current=document.elements.get(id),expected=context.after.get(id);if(!current||current.structure!=='node'||!expected||current.incarnation!==expected.incarnation)throw fault('stale_endpoint',[id]);} + for(const original of intent.changes){ + if(original.kind==='element'){ + const current=document.elements.get(original.id); + if(original.before===null&&original.after){ + if(current||document.tombstones.has(original.id))throw fault('recovery_identity_collision',[original.id]); + if(registry.resolve(original.after)!=='known')throw fault('unsupported_component',[original.id]); + let after=original.after; + if(after.structure==='node'){const basis=context.transforms.get(after.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[after.id]);after={...after,geometry:convertGeometry(after.geometry,basis,canvasTransform(document,after.ownerCanvasId))};} + if(after.structure==='connection'){ + endpoint(after.source.elementId);endpoint(after.target.elementId);const owner=commonCanvas(document,document.elements.get(after.source.elementId)!.ownerCanvasId,document.elements.get(after.target.elementId)!.ownerCanvasId); + const basis=context.transforms.get(after.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[after.id]); + after={...after,ownerCanvasId:owner,path:convertPath(after.path,basis,canvasTransform(document,owner))}; + } + apply({...original,after}); + }else if(original.before&&original.after===null){ + if(!current)continue; + if(current.incarnation!==original.before.incarnation)throw fault('stale_incarnation',[original.id]); + if(registry.resolve(current)!=='known')throw fault('unsupported_component',[original.id]); + for(const change of removalChanges(document,{id:current.id,incarnation:current.incarnation}))apply(change); + }else throw fault('recovery_record_replacement_not_supported',[original.id]); + continue; + } + if(original.kind==='canvas'){ + if(original.before?.parent&&original.after?.parent&&original.before.parent.entryElementId===original.after.parent.entryElementId&&moved.has(original.after.parent.entryElementId))continue; + const current=document.canvases.get(original.id); + if(!original.before&&original.after){if(current)throw fault('recovery_identity_collision',[original.id]);if(original.after.parent)endpoint(original.after.parent.entryElementId);apply(original);} + else if(original.before&&!original.after){if(current){if(current.parent===null)throw fault('missing_root');for(const record of document.elements.values())if(record.ownerCanvasId===current.id)throw fault('recovery_canvas_not_empty',[current.id]);apply({...original,before:current});}} + else if(original.after&¤t){if(original.after.parent)endpoint(original.after.parent.entryElementId);const after={...current,parent:original.after.parent?{...current.parent,canvasId:original.after.parent.canvasId,entryElementId:original.after.parent.entryElementId}:null};if(!equal(current,after))apply({...original,before:current,after});} + else throw fault('canvas_not_loaded',[original.id]); + continue; + } + let current=target(original);const prior=context.before.get(current.id),afterContext=context.after.get(current.id); + if(original.field==='ownerCanvasId'){ + if(current.structure==='connection')continue; // Recomputed from the final endpoints and tree below. + if(!registry.supports(current,'geometry.move')||typeof original.after!=='string'||!document.canvases.has(original.after))throw fault('unsupported_capability',[current.id]); + const basis=afterContext?context.transforms.get(afterContext.ownerCanvasId):undefined;if(!afterContext||afterContext.structure!=='node'||!basis)throw fault('recovery_coordinate_context_missing',[current.id]); + if([...document.canvases.values()].some(canvas=>canvas.parent?.entryElementId===current.id)&&canvasTransform(document,current.ownerCanvasId).scale!==canvasTransform(document,original.after).scale)throw fault('container_scale_conversion_required',[current.id]); + const geometry=convertGeometry(afterContext.geometry,basis,canvasTransform(document,original.after)); + if(current.ownerCanvasId!==original.after)apply({...original,before:current.ownerCanvasId}); + for(const canvas of document.canvases.values())if(canvas.parent?.entryElementId===current.id&&canvas.parent.canvasId!==original.after)apply({kind:'canvas',id:canvas.id,before:canvas,after:{...canvas,parent:{...canvas.parent,canvasId:original.after}}}); + current=document.elements.get(current.id)!;if(current.structure==='node'&&!equal(current.geometry,geometry))apply({kind:'field',target:original.target,field:'geometry',before:current.geometry,after:geometry}); + continue; + } + if(original.field==='geometry'&&moved.has(current.id))continue; + if(original.field==='source'||original.field==='target'){ + if(current.structure!=='connection'||!registry.supports(current,'connection.edit'))throw fault('unsupported_capability',[current.id]); + const next=original.after as unknown as {elementId:Id};endpoint(next.elementId); + if(!equal(current[original.field],original.after))apply({...original,before:current[original.field]});continue; + } + if(original.field==='path'){ + const related=intent.changes.some(change=>change.kind==='field'&&change.target.id===current.id&&['ownerCanvasId','source','target'].includes(change.field)); + if(related){if(!registry.supports(current,'connection.edit'))throw fault('unsupported_capability',[current.id]);deferredPaths.push(original);continue;} + } + if((original.field==='geometry'||original.field==='path')&&prior&&prior.ownerCanvasId!==current.ownerCanvasId)throw fault('ownership_conflict',[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]); + const key=original.field.startsWith('data.')?original.field.slice(5):undefined,actual=key===undefined?(current as unknown as Record)[original.field]:current.data[key]??null,present=key===undefined?undefined:Object.hasOwn(current.data,key); + let desired=original.after; + if(original.field==='geometry'&¤t.structure==='node'){ + const start=original.before as unknown as typeof current.geometry,end=original.after as unknown as typeof current.geometry; + desired={...current.geometry,...Object.fromEntries((['x','y','width','height','rotation'] as const).filter(key=>start[key]!==end[key]).map(key=>[key,end[key]]))}; + const next=desired 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,desired)||(present??true)!==(original.afterPresent??true))apply({...original,before:actual,beforePresent:present,after:desired}); + } + for(const original of deferredPaths){ + const edge=target(original),intended=context.after.get(edge.id);if(edge.structure!=='connection'||intended?.structure!=='connection')throw fault('invalid_endpoint',[edge.id]); + const owner=commonCanvas(document,document.elements.get(edge.source.elementId)!.ownerCanvasId,document.elements.get(edge.target.elementId)!.ownerCanvasId),basis=context.transforms.get(intended.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[edge.id]); + const path=convertPath(original.after,basis,canvasTransform(document,owner)); + if(edge.ownerCanvasId!==owner)apply({kind:'field',target:original.target,field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner}); + if(!equal(edge.path,path))apply({...original,before:edge.path,after:path}); + } + for(const change of repairConnectionOwners(before,document))apply(change); + validateDocument(document,registry);return{document,changes:freeze(changes)}; +} diff --git a/src/lib/canvas-runtime/conflicts.ts b/src/lib/canvas-runtime/conflicts.ts index be17c67..bb05d8b 100644 --- a/src/lib/canvas-runtime/conflicts.ts +++ b/src/lib/canvas-runtime/conflicts.ts @@ -1,8 +1,8 @@ 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 { createLedger, type Presence } from './ledger.ts'; +import { intentContexts, rebaseIntent } from './change-rebase.ts'; import { operationCanvases, validateCanonicalRead } from './reconcile.ts'; import { referencedResources } from './resources.ts'; @@ -30,38 +30,21 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni const used=new Set([...snapshot.operations.keys(),...snapshot.runtime.ledger.operations.keys(),...snapshot.runtime.ledger.requests.keys(),...snapshot.history.entries.keys(),...(snapshot.runtime.recoveries?.keys()??[])]); function unique(){const id=createId();if(!id||used.has(id))throw fault('duplicate_operation_id');used.add(id);return id;} const id=unique(),ledger=createLedger(snapshot.runtime.ledger),operations=new Map(snapshot.operations),decisions:RecoveryDecision[]=[],replacements=new Map(); - const starts=new Map>(),historyOwner=new Map(); + const starts=new Map>(),historyOwner=new Map(),contexts=intentContexts(snapshot.document,records.map(record=>record.intent)); for(const entry of snapshot.history.entries.values())for(const operationId of entry.operationIds){if(historyOwner.has(operationId))throw fault('recovery_history_ambiguous');historyOwner.set(operationId,entry.id);} 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 before=document; + for(const original of intent.changes)if(original.kind==='field'){ const owner=historyOwner.get(intent.id),key=owner?JSON.stringify([owner,fieldKey(original)]):undefined; - if(key&&!starts.has(key))starts.set(key,value(document,original,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); + if(key&&!starts.has(key))starts.set(key,value(document,original,true)); } - validateDocument(document,registry); + if(choice==='keep-local')for(const change of intent.changes)if(change.kind==='canvas'&&!change.before&&canonical.versions.has(change.id))throw fault('recovery_identity_collision',[change.id]); + const rebased=choice==='keep-local'?rebaseIntent(document,intent,contexts.get(intent.id)!,registry):{document,changes:[]}; + const changes=rebased.changes;document=rebased.document; 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]); @@ -71,7 +54,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni 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)}; + const basic={...intent,id:replacementId,sequence:++sequence,changes:freeze(changes),dependencies:[...dependencySet],canvasIds:operationCanvases({...intent,canvasIds:[],changes},before,document)}; 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); } @@ -82,12 +65,13 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni const history=rewriteHistory(snapshot,records.map(record=>record.intent),replacements,starts); ledger.retire(id,decisions); for(const replacement of replacements.values())if(replacement){ - const presence:Record={};for(const change of replacement.changes)if(change.kind==='field')presence[change.target.id]='exists'; + const presence:Record={};for(const change of replacement.changes)if(change.kind==='field')presence[change.target.id]??='exists';else if(change.kind==='element')presence[change.id]??=change.before?'exists':'absent'; 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); + for(const replacement of replacements.values())if(replacement)for(const resource of referencedResources(replacement.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]:[]))}); @@ -98,6 +82,12 @@ function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent 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]); + if(entry.operationIds.every(id=>affected.has(id))){ + const changes=intents.flatMap(intent=>replacements.get(intent.id)?.changes??[]),operationIds=intents.flatMap(intent=>replacements.get(intent.id)?[replacements.get(intent.id)!.id]:[]); + if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id); + continue; + } + if(intents.some(intent=>intent.changes.some(change=>change.kind!=='field'||['ownerCanvasId','source','target'].includes(change.field))))throw fault('recovery_history_structure_conflict',[entry.id]); const keys=new Map();for(const intent of intents)for(const change of intent.changes as readonly FieldChange[])keys.set(fieldKey(change),change); const fields:FieldChange[]=[]; for(const [key,example]of keys){ diff --git a/src/lib/canvas-runtime/reconcile.ts b/src/lib/canvas-runtime/reconcile.ts index 745d3a9..8a7dbf7 100644 --- a/src/lib/canvas-runtime/reconcile.ts +++ b/src/lib/canvas-runtime/reconcile.ts @@ -24,7 +24,7 @@ export function validateCanonicalRead(previous: Document, previousVersions: Read } /** The footprint follows ownership; geometry does not decide which canvas owns an object. */ -export function operationCanvases(intent: OperationIntent, document: Document): readonly Id[] { +export function operationCanvases(intent: OperationIntent, document: Document, after:Document=document): readonly Id[] { if(intent.operationType==='asset.upload'||intent.operationType==='run.cancel')return[]; if (intent.canvasIds?.length) return intent.canvasIds; const ids = new Set(); @@ -36,9 +36,12 @@ export function operationCanvases(intent: OperationIntent, document: Document): } else if (change.kind === 'element') { if (change.before) ids.add(change.before.ownerCanvasId); if (change.after) ids.add(change.after.ownerCanvasId); + for(const record of [change.before,change.after])if(record?.structure==='connection')for(const endpoint of [record.source,record.target]){const owner=after.elements.get(endpoint.elementId)?.ownerCanvasId??document.elements.get(endpoint.elementId)?.ownerCanvasId;if(owner)ids.add(owner);} } else { const owner = document.elements.get(change.target.id)?.ownerCanvasId; if (owner) ids.add(owner); + const nextOwner=after.elements.get(change.target.id)?.ownerCanvasId;if(nextOwner)ids.add(nextOwner); + if(change.field==='source'||change.field==='target')for(const endpoint of [change.before,change.after])if(endpoint&&typeof endpoint==='object'&&!Array.isArray(endpoint)&&typeof endpoint.elementId==='string'){for(const model of [document,after]){const owner=model.elements.get(endpoint.elementId)?.ownerCanvasId;if(owner)ids.add(owner);}} if (change.field === 'ownerCanvasId') { ids.add(change.before as Id); ids.add(change.after as Id); } } } diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 75a7fa7..8a3f7bd 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -5,6 +5,17 @@ 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 structureRecoveryCase(name,assert){ + const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()); + const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null}],[node('a')]),storage=createBrowserStorage({name:`${name}-structure`}),calls=[];let document=original,versions=new Map([[root,1]]),reject=true,engine; + const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}); + const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject){reject=false;versions.set(root,2);return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};}for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'structural request version mismatch');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};}; + const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; + try{engine=await openProjectEngine(options);const edit=engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('frame'),componentId:'frame'}});tx.dispatch({type:'canvas.create',canvas:{id:child,parent:{canvasId:root,entryElementId:'frame'}}});tx.dispatch({type:'element.create',element:node('member',child)});});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();await engine.close(); + engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').ownerCanvasId===child,'structural replan did not recover its owner tree');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'old structural request was changed');assert(engine.editor.history.undo().status==='applied','structural undo failed');await engine.settle();assert(!document.canvases.has(child),'undo left an orphan canvas');assert(engine.editor.history.redo().status==='applied','structural redo failed');await engine.settle();assert(document.elements.get('member').incarnation===2,'redo reused a deleted lifecycle');await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').incarnation===2,'restored tree/history lifecycle did not survive reopening'); + }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-structure`);} +} + async function appliedReceiptRecoveryCase(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'}}; @@ -283,6 +294,8 @@ export async function run() { 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'); await appliedReceiptRecoveryCase(name,assert); results.push('a persisted successful receipt with failed coordination adopts a fresh base and replan in one cut, releases its writer and never repeats the original write'); + await structureRecoveryCase(name,assert); + results.push('structural replan restores an atomic partition/member tree through IndexedDB, undo/archive and redo/new incarnation without changing the rejected request'); return results; } finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); } } diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index fb93c98..ff03709 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -172,6 +172,10 @@ try { 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'); + rejectNextEdit=true;await click('添加文本卡片');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected creation did not expose recovery');const createdId=rejectedEdit.operations[0].changes.find(change=>change.kind==='element'&&change.before===null).id; + await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('创建元素')`,'creation review omitted the structural change'); + await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('关联结构变更')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'structural plan did not expose related changes');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'structural review did not finish');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.ok(documents.get(root).elements.has(createdId));await click('撤销');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false); + console.log('PASS actual structural recovery dialog reviews a rejected creation and related changes, applies it with a new identity and retains undo'); const selectB=async()=>{await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height-30}})()`);await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI tool did not accept the selected element');}; await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="AI 模型"] option[value="fixture-text"]')`,'AI catalog did not load through the host'); 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}));})()`); diff --git a/tests/headless/structure-recovery.test.mjs b/tests/headless/structure-recovery.test.mjs new file mode 100644 index 0000000..a909db8 --- /dev/null +++ b/tests/headless/structure-recovery.test.mjs @@ -0,0 +1,103 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createDocument,createRegistry,builtinComponents,applyChanges,validateDocument,createEditor,canvasTransform,toWorld,convertGeometry } 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'; +const registry=createRegistry(builtinComponents()),geometry={x:100,y:60,width:100,height:80,rotation:0}; +const node=(id,owner='root',extra={})=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry,...extra}); +const frame=node('f','root',{componentId:'frame',geometry:{x:100,y:50,width:500,height:400,rotation:30},data:{title:'Frame',text:'',presentation:{contentScale:1.5}}}); +const edge=(id,source,target,owner='root',path=null)=>({id,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:owner,data:{label:'preserve'},source:{elementId:source},target:{elementId:target},path}); +const base=()=>createDocument('project',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'f'}}],[frame,node('a'),node('b'),node('c','child')]); +const path={type:'bezier-chain',extension:{keep:true},knots:[{id:'k',mode:'corner',position:{x:200,y:100},in:{x:-10,y:0},out:{x:10,y:0},extra:'keep'}]}; +const target=id=>({id,incarnation:1}); +const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload'); +const choices=engine=>new Map(pending(engine).map(record=>[record.intent.id,'keep-local'])); +function fixture(original=base()){ + let server=original,versions=new Map([...original.canvases.keys()].map(id=>[id,1])),image,owner;const calls=[]; + const f={reject:true,calls,storage:{async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(leader,batch){assert.equal(leader,owner);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}},port:{compile(intent,id,snapshot){return{payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const {intent}=JSON.parse(request.body);for(const[id,version]of Object.entries(request.expectedVersions))assert.equal(version,versions.get(id)??0);const after=applyChanges(server,intent.changes);validateDocument(after,registry);for(const change of intent.changes){const ids=change.kind==='canvas'?[change.id]:change.kind==='element'?[change.before?.ownerCanvasId,change.after?.ownerCanvasId]:[server.elements.get(change.target.id)?.ownerCanvasId,after.elements.get(change.target.id)?.ownerCanvasId];for(const id of ids)if(id)assert.ok(request.canvasIds.includes(id));}server=after;for(const id of request.canvasIds)versions.set(id,(versions.get(id)??0)+1);return{outcome:'applied',canonical:{id:request.id}};},async query(){throw Error('unexpected query');},canonical(){return{document:server,versions};},async read(){return{document:server,versions};}},remote(changes){server=applyChanges(server,changes);validateDocument(server,registry);for(const id of new Set([...versions.keys(),...server.canvases.keys()]))versions.set(id,(versions.get(id)??0)+1);},image:()=>structuredClone(image),server:()=>server,async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([...original.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port});}};return f; +} +const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`); + +test('pending create and later edits recover with absent baselines, new keys and lifecycle-safe undo/redo',async()=>{ + const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});await engine.settle(); + f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine)); + assert.equal(plan.ledger.operations.get(plan.operationIds[0]).basePresence.new,'absent');f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('new').data.title,'edited');assert.equal(engine.getSnapshot().history.undo.length,2); + for(let i=0;i<2;i++){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}assert.equal(f.server().elements.has('new'),false); + for(let i=0;i<2;i++){assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();}assert.equal(f.server().elements.get('new').incarnation,2);assert.equal(f.server().elements.get('new').data.title,'edited'); + assert.equal(decodeRecovery(f.image(),registry).document.elements.get('new').incarnation,2);await engine.close(); +}); + +test('an atomic frame/canvas/member creation can be kept or discarded without orphan records',async()=>{ + for(const keep of [true,false]){ + const f=fixture(),engine=await f.open();engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('new-frame','root',{componentId:'frame'})});tx.dispatch({type:'canvas.create',canvas:{id:'new-canvas',parent:{canvasId:'root',entryElementId:'new-frame'}}});tx.dispatch({type:'element.create',element:node('inside','new-canvas')});});await engine.settle(); + const session=await engine.prepareRecovery(),selected=choices(engine);if(!keep)for(const id of selected.keys())selected.set(id,'discard-local');const plan=session.plan(selected);f.reject=false;await session.resolve(plan);await engine.settle(); + assert.equal(engine.getSnapshot().document.canvases.has('new-canvas'),keep);assert.equal(engine.getSnapshot().document.elements.has('inside'),keep);assert.equal(engine.getSnapshot().history.undo.length,keep?1:0); + if(keep){assert.equal(f.calls.at(-1).expectedVersions['new-canvas'],0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('new-canvas'),false);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('inside').incarnation,2);} + await engine.close(); + } +}); + +test('deletion is re-planned from current content and includes a new remote incident edge in its undo unit',async()=>{ + const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle(); + f.remote([{kind:'field',target:target('a'),field:'data.text',before:'',after:'remote content',beforePresent:true,afterPresent:true},{kind:'element',id:'new-edge',before:null,after:edge('new-edge','a','b')}]); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine)),replacement=plan.operations.get(plan.operationIds[0]);assert.ok(replacement.changes.some(change=>change.kind==='element'&&change.id==='new-edge'&&change.after===null));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.has('new-edge'),false); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.text,'remote content');assert.equal(f.server().elements.get('a').incarnation,2);assert.equal(f.server().elements.get('new-edge').source.elementId,'a');await engine.close(); +}); + +test('deleting a partition uses its current subtree and cross-scope edges; undo restores all lifecycle identities',async()=>{ + const original=base(),f=fixture(original),engine=await f.open();assert.equal(engine.editor.dispatch({type:'element.remove',target:target('f')}).status,'applied');await engine.settle(); + f.remote([{kind:'element',id:'remote-child',before:null,after:node('remote-child','child')},{kind:'element',id:'cross',before:null,after:edge('cross','a','remote-child')}]); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.has('child'),false);assert.equal(f.server().elements.has('cross'),false);assert.ok(f.calls.at(-1).canvasIds.includes('child')); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();validateDocument(f.server(),registry);assert.equal(f.server().elements.get('remote-child').incarnation,2);assert.equal(f.server().canvases.get('child').parent.entryElementId,'f');assert.equal(f.server().elements.get('cross').target.elementId,'remote-child');await engine.close(); +}); + +test('owner moves preserve the intended world position and relocate edge curves with the common canvas',async()=>{ + const original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),edge('e','a','c','root',path)]),f=fixture(original),engine=await f.open(); + const world=toWorld(canvasTransform(original,'root'),{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2});assert.equal(engine.editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'}).status,'applied');await engine.settle(); + f.remote([{kind:'field',target:target('f'),field:'geometry',before:frame.geometry,after:{...frame.geometry,x:400,rotation:70}}]); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();const current=f.server(),a=current.elements.get('a'),actual=toWorld(canvasTransform(current,'child'),{x:a.geometry.x+a.geometry.width/2,y:a.geometry.y+a.geometry.height/2});near(actual.x,world.x);near(actual.y,world.y); + const connection=current.elements.get('e');assert.equal(connection.ownerCanvasId,'child');const knot=toWorld(canvasTransform(current,'child'),connection.path.knots[0].position);near(knot.x,200);near(knot.y,100);assert.deepEqual(connection.path.extension,{keep:true}); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').ownerCanvasId,'root');assert.equal(f.server().elements.get('f').geometry.x,400);assert.equal(f.server().elements.get('e').ownerCanvasId,'root');await engine.close(); +}); + +test('reconnection uses current endpoint scope, preserves unrelated data, and refuses a restored endpoint lifecycle',async()=>{ + const original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),edge('e','a','b','root',path)]); + for(const restored of [false,true]){ + const f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'connection.endpoints',target:target('e'),source:{elementId:'c'}});await engine.settle(); + if(restored)f.remote([{kind:'element',id:'c',before:original.elements.get('c'),after:null},{kind:'element',id:'c',before:null,after:{...original.elements.get('c'),incarnation:2}}]); + else f.remote([{kind:'field',target:target('b'),field:'ownerCanvasId',before:'root',after:'child'},{kind:'field',target:target('e'),field:'data.label',before:'preserve',after:'remote label',beforePresent:true,afterPresent:true}]); + const session=await engine.prepareRecovery();if(restored){assert.throws(()=>session.plan(choices(engine)),/stale_endpoint/);await session.cancel();}else{const plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('e').ownerCanvasId,'child');assert.equal(f.server().elements.get('e').data.label,'remote label');assert.ok(f.calls.at(-1).canvasIds.includes('child'));assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('e').source.elementId,'a');} + await engine.close(); + } +}); + +test('discarded creations cannot be used by kept dependents, and colliding/tombstoned identities never bind foreign content',async()=>{ + const f=fixture(),engine=await f.open();const create=engine.editor.dispatch({type:'element.create',element:node('new')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});await engine.settle(); + let session=await engine.prepareRecovery(),selected=choices(engine);selected.set(create.operationIds[0],'discard-local');assert.throws(()=>session.plan(selected),/stale_incarnation/);await session.cancel(); + f.remote([{kind:'element',id:'new',before:null,after:node('new')}]);session=await engine.prepareRecovery();assert.throws(()=>session.plan(choices(engine)),/recovery_identity_collision/);await session.cancel();await engine.close(); +}); + +test('core world/local geometry conversion and history redo preserve user-owned lifecycles',()=>{ + const document=base(),editor=createEditor({registry,document});editor.dispatch({type:'element.create',element:node('new')});editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}}); + editor.history.undo();editor.history.undo();assert.equal(editor.history.redo().status,'applied');assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('new').data.title,'edited'); + const from=canvasTransform(document,'root'),to=canvasTransform(document,'child'),converted=convertGeometry(geometry,from,to),round=convertGeometry(converted,to,from);for(const key of ['x','y','width','height','rotation'])near(round[key],geometry[key]); +}); + +test('moving an entry follows its current child binding rather than stealing its former canvas',async()=>{ + const g=node('g','root',{componentId:'frame'}),h=node('h','root',{componentId:'frame'}),original=createDocument('project',[...base().canvases.values(),{id:'outer',parent:{canvasId:'root',entryElementId:'g'}}],[...base().elements.values(),g,h]),f=fixture(original),engine=await f.open(); + engine.editor.dispatch({type:'element.move',target:target('f'),canvasId:'outer'});await engine.settle(); + f.remote([{kind:'canvas',id:'child',before:original.canvases.get('child'),after:{id:'child',parent:{canvasId:'root',entryElementId:'h'}}},{kind:'canvas',id:'new-child',before:null,after:{id:'new-child',parent:{canvasId:'root',entryElementId:'f'}}}]); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.get('child').parent.entryElementId,'h');assert.equal(f.server().canvases.get('child').parent.canvasId,'root');assert.equal(f.server().canvases.get('new-child').parent.canvasId,'outer');await engine.close(); +}); + +test('unknown curve formats and container scale changes remain explicit, atomic errors',()=>{ + const g=node('g','root',{componentId:'frame',data:{title:'g',text:'',presentation:{contentScale:2}}}),document=createDocument('project',[...base().canvases.values(),{id:'outer',parent:{canvasId:'root',entryElementId:'g'}}],[...base().elements.values(),g,edge('e','a','c','root',{type:'future-path',keep:true})]),editor=createEditor({registry,document}),before=editor.getSnapshot(); + assert.equal(editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'}).code,'unsupported_coordinate_path');assert.equal(editor.getSnapshot(),before); + assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'outer'}).code,'container_scale_conversion_required');assert.equal(editor.getSnapshot(),before); +}); + +test('reparenting preserves fresh unknown parent metadata instead of restoring an older envelope',async()=>{ + const h={...frame,id:'h'},original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),h]),f=fixture(original),engine=await f.open(); + engine.editor.dispatch({type:'canvas.reparent',canvasId:'child',parent:{canvasId:'root',entryElementId:'h'}});await engine.settle();f.remote([{kind:'canvas',id:'child',before:original.canvases.get('child'),after:{...original.canvases.get('child'),parent:{...original.canvases.get('child').parent,future:{keep:'remote'}}}}]); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.get('child').parent.entryElementId,'h');assert.deepEqual(f.server().canvases.get('child').parent.future,{keep:'remote'});await engine.close(); +});