diff --git a/headless/SERVICE_ACCEPTANCE.md b/headless/SERVICE_ACCEPTANCE.md
index 7ba18e5..aaafefb 100644
--- a/headless/SERVICE_ACCEPTANCE.md
+++ b/headless/SERVICE_ACCEPTANCE.md
@@ -15,7 +15,7 @@ the complete Draft acceptance matrix, main integration or production deployment.
- Existing `ces-control` runner executes Go checks/build, schema migration and
isolated PostgreSQL integration, then the pinned Node 24 consumer and HTTP smoke
checks. No local backend build or Docker execution was used.
-- Local frontend: 797 Node tests and full TypeScript checking passed. Tests use
+- Local frontend: 800 Node tests and full TypeScript checking passed. Tests use
the source modules; packaged SDK and application delivery remain separate gates.
## What the checks actually prove
@@ -31,6 +31,7 @@ the complete Draft acceptance matrix, main integration or production deployment.
| AI execution identity and controls | `TestCanonicalAIAdmissionAndLifecycle` covers admission/query/retry, zero-effect rejection, old incarnation callbacks, cancellation, managed image output, owner relocation, archived admission routes and execution after permission revocation. |
| Actual frontend RunPort | `tests/headless/foundry-ai.integration.mjs` exercises `createFoundryRuns`, the HTTP transport and ProjectEngine: successful admission response loss/reopen/query, queued cancellation response loss, generation-time content conflict, explicit apply response loss plus undo/reopen/redo, generated image metadata/original bytes/preview, and copy without a new run or upload. |
| Host identity migration coordination | `canvas-migration.ts` now drives the real consumer's initial migration: the server commits, the response is lost, a new coordinator reads the stored body/key and queries the original receipt without a second POST. A later intentional raw POST still checks backend idempotent retry. The coordinator's drain and lock ports are fixtures here; this does not prove the old editor's browser handoff gate is connected. |
+| Browser legacy handoff | `renderer.integration.mjs --migration-only` exercises the actual GraphPage, legacy editor, upgrade panel and Next proxy with native Chrome input, localStorage and Web Locks. Dirty old PATCH ACK precedes migration; another editable view excludes migration; committed response loss survives reload without a second migration POST; uncommitted response loss reuses the original body/key, with terminal rejection followed by explicit fresh preparation; a different cached draft must be explicitly restored before migration. A draft modal cannot block receipt recovery; it becomes actionable after terminal rejection. The upstream is a protocol fixture, not the real Foundry service. |
The CI #1174 consumer report contains `status: "ok"`, 72 HTTP transport calls,
all 14 prior document/resource flags true, plus `runtimeAI`, `aiLostAdmission`,
@@ -72,10 +73,27 @@ The recovery store is an in-memory durable-batch fixture, and response loss is
injected after successful HTTP responses. Real IndexedDB/Web Locks/browser
acceptance is recorded separately in `STEP4_ACCEPTANCE.md`; this service run does
not combine every browser crash window with every server operation.
-The migration coordinator uses an injected string-storage port before a canonical
-engine exists. The browser still needs its legacy editing freeze/drain, actual
-storage and workspace lock wiring, reload handling and upgrade UI. Stage 6 is not
-complete merely because the coordinator's ports pass unit and service tests.
+The migration coordinator uses a string-storage port before a canonical engine
+exists. `browser-canvas-migration.ts` supplies localStorage, identity fences and
+workspace-scoped native Web Locks. Every editable legacy view holds a shared
+lease; upgrade first freezes and drains its writer, releases that lease, then
+takes the exclusive lease and prepares again before creating a request. Pending
+records block affected legacy views across reload. Unknown results retain the
+original request; only a verified terminal rejection permits fresh preparation.
+Upload/import/member-task and draft-recovery gates preserve the old editor's
+content rather than converting in-memory work silently. A differing cached draft
+is surfaced for explicit recovery, and the current draft is backed up before
+waiting for its PATCH ACK. The account fence also covers old snapshot requests.
+
+The browser fixture above proves the connected UI path, including actual native
+locking, and the separate real-service consumer proves server coordination.
+They do not combine a real browser and real PostgreSQL in one scenario. Full
+webpack and Turbopack application regressions each passed 51 checks; the migration
+entry adds five scenarios and three lock-lifetime unit tests. Unknown
+receipt/error states remain read-only with an export action. Unsupported Web
+Locks prevents upgrading, while existing legacy editing remains available when
+there is no pending migration. Stage 6 and the final requirement matrix still
+require their delivery audit; passing these checks alone does not complete them.
`canvas_headless_worker_integration_test.go` supplies deterministic generation
output over the actual worker HTTP protocol. Admission, claim, events, canonical
diff --git a/src/components/canvas/canvas.module.css b/src/components/canvas/canvas.module.css
index 50cd115..53a411f 100644
--- a/src/components/canvas/canvas.module.css
+++ b/src/components/canvas/canvas.module.css
@@ -21,6 +21,10 @@
.helpPopup p { margin-top: 9px; }
.error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; }
.helpToolbar { position: absolute; right: 12px; bottom: 12px; z-index: 10; }
+.migrationPanel { position: absolute; right: 12px; top: 64px; z-index: 220; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 360px; padding: 8px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #78523f; font-size: 12px; }
+.migrationPanel button { border: 1px solid #ded7ce; border-radius: 5px; padding: 6px 9px; background: white; cursor: pointer; }
+.migrationPanel button:disabled { cursor: default; opacity: .5; }
+.migrationSurface { position: absolute; inset: 0; }
@media (max-width: 700px) {
.topRight { top: 62px; right: 12px; }
.bottomToolbar { left: 12px; bottom: 12px; transform: none; max-width: calc(100vw - 92px); overflow-x: auto; overscroll-behavior-x: contain; }
diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx
index 06a7064..716a902 100644
--- a/src/components/canvas/graph-canvas.tsx
+++ b/src/components/canvas/graph-canvas.tsx
@@ -1,5 +1,7 @@
"use client";
import { useImageUploads } from "./use-image-uploads";
+import {LegacyCanvasMigration} from "./legacy-canvas-migration";
+import type {LegacyMigrationContext} from "@/lib/browser-canvas-migration";
import { notify, dismissNotification } from "@/lib/notifications";
import { FeedbackContext } from "@/components/ui/app-feedback";
import { ImagePreviews } from "./canvas-image";
@@ -43,7 +45,7 @@ const edgeTypes = { default: CanvasEdge };
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () =>
正在加载图标面板…
});
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
-function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
+function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
const flow = useReactFlow();
const { zoom } = useViewport();
const reduceMotion = useReducedMotion();
@@ -80,7 +82,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const imageInput = useRef(null);
const projectInput = useRef(null);
const [recovery, setRecovery] = useState(initial.recovery);
- const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id);
+ const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration);
const childRecovery = [...tree.recoveries][0];
const downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); };
const sync = tree;
@@ -89,6 +91,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const treeRef = useRef(tree); treeRef.current = tree;
const uploads = useImageUploads(board.workspace_id, (job, asset) => treeRef.current.resolveImageUpload(job.node.id, asset,{width:job.node.width!,height:job.node.height!}));
const imageUploading = demoImageUploading;
+ const ioActive=useRef(false);
useEffect(() => {
const current = treeRef.current;
for (const job of uploads.jobs) {
@@ -322,6 +325,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const file = event.target.files?.[0];
event.target.value = "";
if (!file) { pendingImage.current = null; return; }
+ if(treeRef.current.isMigrationHeld()){pendingImage.current=null;setError("画布交接期间暂时不能添加图片。");return;}
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > (snapshot ? 20 * 1024 * 1024 : 2_000_000)) {
pendingImage.current = null; setError(snapshot ? "请选择小于 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片。" : "演示模式请选择小于 2 MB 的图片。"); return;
}
@@ -370,6 +374,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
+ if(treeRef.current.isMigrationHeld()){setError("画布交接期间暂时不能导入。");return;}
+ ioActive.current=true;
try {
if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。");
if (tree.rootDocument.nodes.some((node) => node.data.subgraph)) throw new Error("当前画布包含真实子图,请先导出备份并通过子图事务恢复,避免覆盖子图关系。");
@@ -379,6 +385,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
void flow.setViewport(document.viewport);
setError("");
} catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
+ finally{ioActive.current=false;}
}
async function exportProject() {
let documents: Map;
@@ -416,6 +423,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
ai.activeEdges.has(ref.boardId + "/" + ref.id)).map(([id]) => id)) }}>
+
nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
onBeforeDelete={async ({ nodes: removing }) => {
if (tree.busy || tree.transactionBusy) return false;
@@ -483,6 +491,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
return owned && tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
})}
+
@@ -500,7 +509,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
-
+
setTool("select")} />
setTool("hand")} />
@@ -542,8 +551,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
setTool("select");
}} />}
{!feedback && {membershipNotice}
}
- {recovery && downloadDraft(recovery)} restore={() => { dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => setRecovery(null)} />}
- {!recovery && childRecovery && downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />}
+ {!tree.migrationHeld && recovery && downloadDraft(recovery)} restore={() => { if(treeRef.current.isMigrationHeld())return;dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => {if(!treeRef.current.isMigrationHeld())setRecovery(null);}} />}
+ {!tree.migrationHeld && !recovery && childRecovery && downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />}
{snapshot && sync.error && {sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))}
{sync.error !== "version_conflict" && void sync.retry()}>重试保存 }
导出当前内容 window.location.reload()}>重新读取
@@ -556,7 +565,11 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
;
}))}
- {tree.busy && {tree.pendingCommand ?
同步结果尚未确认,为保护内容暂时停止编辑。 void tree.retryPending().catch((error) => setError(error.message))}>重试同步 window.location.reload()}>重新读取画布
: "正在同步画布…"}
}
+ {migration&&{if(recovery||treeRef.current.recoveries.size)throw new Error('draft_recovery_required');if(ioActive.current||uploads.hasPending()||demoImageUploading)throw new Error('migration_pending_uploads');}}
+ canLeave={()=>![...treeRef.current.current.current].some(([id,doc])=>treeRef.current.writers.current.get(id)?.needsSave(doc))}
+ exportDraft={()=>downloadDraft(recovery??treeRef.current.rootDocument)} />}
+ {tree.busy && {tree.pendingCommand ?
同步结果尚未确认,为保护内容暂时停止编辑。 void tree.retryPending().catch((error) => setError(error.message))}>重试同步 window.location.reload()}>重新读取画布
: tree.migrationHeld?"正在检查或交接画布,编辑暂时暂停。":"正在同步画布…"}
}
{error && !feedback && setError("")} title="点击关闭提示">{error} }
@@ -564,6 +577,6 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
);
}
-export default function GraphCanvas({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
- return ;
+export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
+ return ;
}
diff --git a/src/components/canvas/legacy-canvas-migration.tsx b/src/components/canvas/legacy-canvas-migration.tsx
new file mode 100644
index 0000000..a4188f4
--- /dev/null
+++ b/src/components/canvas/legacy-canvas-migration.tsx
@@ -0,0 +1,105 @@
+"use client";
+import {useEffect,useRef,useState} from 'react';
+import type {Registry} from '@/lib/canvas-core/types';
+import {claimLegacyCanvasEditing,createBrowserCanvasMigration,type LegacyMigrationContext} from '@/lib/browser-canvas-migration';
+import {canvasMigrationStorageKey,migrationBlocksCanvas,readCanvasMigration,type CanvasMigrationJob} from '@/lib/canvas-migration';
+import {errorText} from '@/lib/api';
+import styles from './canvas.module.css';
+
+function message(error:unknown){
+ const code=error instanceof Error?error.message:String(error);
+ return ({migration_busy:'其他旧画布页面仍在编辑或升级,请先完成那里的保存和上传,再关闭页面重试。',migration_lock_unavailable:'此浏览器不支持安全升级,请使用支持 Web Locks 的浏览器。',migration_response_unknown:'升级结果尚未确认,编辑已暂停。可继续查询原请求。',invalid_migration_receipt:'升级回执无法确认,已保留原请求并暂停编辑。请继续查询或联系支持。',migration_pending_uploads:'请先完成或取消图片上传,再升级。',draft_recovery_required:'请先处理本地草稿恢复,再升级。',canvas_busy:'请先完成当前操作与同步,再升级。',migration_waiting_for_runs:'请等待生成任务结束,再重新准备升级。',migration_version_conflict:'画布已发生变化,请重新准备升级。',workspace_migration_pending:'本工作区还有另一画布的升级待确认,请先处理原画布。',invalid_migration_record:'无法读取本地升级进度,已保留记录并暂停编辑。',migration_interpreter_unavailable:'本地升级进度需要其他版本解释,已保留记录并暂停编辑。',migration_not_durable:'无法可靠保存升级进度,请保留页面并重试。'} as Record)[code]??errorText(error instanceof Error?error:new Error(code));
+}
+type Props={context:LegacyMigrationContext;workspaceId:string;canvasId:string;registry:Registry;held:boolean;hold(value:boolean):void;prepare(rootId:string):Promise;check():void;canLeave():boolean;exportDraft():void};
+export function LegacyCanvasMigration(props:Props){
+ const port=useRef(props);port.current=props;
+ const [job,setJob]=useState(),[error,setError]=useState(''),[checking,setChecking]=useState(true),[running,setRunning]=useState(false),[changedElsewhere,setChangedElsewhere]=useState(false);
+ const lease=useRef<{release():Promise}|undefined>(undefined),life=useRef(undefined),attempt=useRef(false),finished=useRef(false),previous=useRef(undefined);
+ const scope={accountId:props.context.accountId,workspaceId:props.workspaceId},key=canvasMigrationStorageKey(scope);
+ const refresh=useRef<()=>Promise>(async()=>{});
+ useEffect(()=>{
+ const controller=new AbortController();life.current=controller;
+ const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
+ const read=()=>readCanvasMigration(localStorage,scope);
+ async function inspect(){
+ if(attempt.current||!current())return;
+ setChecking(true);
+ try{
+ const next=read();previous.current=next;setJob(next);
+ if(migrationBlocksCanvas(next,props.canvasId)||finished.current){
+ port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();setError('');return;
+ }
+ if(lease.current){port.current.hold(false);setError('');return;}
+ port.current.hold(true);
+ const acquired=await claimLegacyCanvasEditing({scope,canvasId:props.canvasId,signal:controller.signal,isCurrentIdentity:current});
+ if(!current()){await acquired.release();return;}
+ lease.current=acquired;port.current.hold(false);setError('');
+ }catch(error){
+ if(!current())return;
+ port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();
+ // Without Web Locks this browser cannot upgrade, but existing legacy
+ // editing remains supported unless there is an unresolved identity job.
+ if(error instanceof Error&&error.message==='migration_lock_unavailable'){
+ try{if(!migrationBlocksCanvas(read(),props.canvasId))port.current.hold(false);}catch{}
+ }
+ setError(message(error));
+ }finally{if(current())setChecking(false);}
+ }
+ refresh.current=inspect;void inspect();
+ const storage=(event:StorageEvent)=>{
+ if(event.key!==key&&event.key!==null||!current()||attempt.current)return;
+ // Never discard another view's in-memory content because a record vanished.
+ if(previous.current?.phase==='pending'&&event.newValue===null){
+ finished.current=true;port.current.hold(true);setChangedElsewhere(true);setError('升级进度已在其他页面改变。本页内容保留,可导出后重新打开。');
+ const held=lease.current;lease.current=undefined;void held?.release();return;
+ }
+ void inspect();
+ };
+ window.addEventListener('storage',storage);
+ return()=>{controller.abort();window.removeEventListener('storage',storage);const held=lease.current;lease.current=undefined;void held?.release();};
+ // The port ref follows renders without re-acquiring a shared editing lease.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[key,props.canvasId]);
+ async function upgrade(){
+ const controller=life.current;if(!controller||controller.signal.aborted||attempt.current)return;
+ attempt.current=true;setRunning(true);setError('');
+ const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
+ let failure='';
+ try{
+ const pending=readCanvasMigration(localStorage,scope);
+ if(pending?.phase==='pending'&&!migrationBlocksCanvas(pending,props.canvasId))throw new Error('workspace_migration_pending');
+ if(pending?.phase!=='pending')port.current.check();
+ port.current.hold(true);
+ // Drain while still holding our shared lease, so losing the subsequent
+ // exclusive-lock race never abandons a dirty old writer between locks.
+ if(pending?.phase!=='pending'&&props.context.rootId){await port.current.prepare(props.context.rootId);if(!current())return;}
+ const held=lease.current;lease.current=undefined;await held?.release();if(!current())return;
+ const migration=createBrowserCanvasMigration({scope,registry:props.registry,signal:controller.signal,isCurrentIdentity:current});
+ const result=pending?.phase==='pending'?await migration.resume():props.context.rootId?await migration.start(props.context.rootId,async()=>{port.current.check();await port.current.prepare(props.context.rootId!);port.current.check();}):undefined;
+ if(!current())return;
+ if(result?.status==='applied'||result?.status==='already-migrated'){
+ finished.current=true;port.current.context.onApplied();return;
+ }
+ if(result&&'error'in result){failure=message(result.error);setError(failure);}
+ const next=migration.read();previous.current=next;setJob(next);
+ }catch(error){if(current()){failure=message(error);setError(failure);}}
+ finally{
+ attempt.current=false;
+ if(current()){
+ setRunning(false);
+ try{const next=readCanvasMigration(localStorage,scope);previous.current=next;setJob(next);if(!finished.current&&!migrationBlocksCanvas(next,props.canvasId))await refresh.current();}catch(error){port.current.hold(true);failure=message(error);}
+ if(failure)setError(failure);
+ }
+ }
+ }
+ const pending=migrationBlocksCanvas(job,props.canvasId),other=job?.phase==='pending'&&!pending;
+ if(!props.context.rootId&&!pending&&!error&&!props.held)return null;
+ return
+ {checking?正在检查画布编辑权限… :<>
+ {running?正在保存并升级画布… :changedElsewhere?{try{port.current.check();if(!port.current.canLeave())throw new Error('本页有未同步内容,请先导出备份并手动重新打开画布。');port.current.context.onApplied();}catch(error){setError(message(error));}}}>重新打开画布 :props.context.rootId||pending?void upgrade()}>{pending?'继续查询升级':job?.phase==='rejected'?'重新准备升级':'升级画布'} :props.held?void refresh.current()}>重新检查 :null}
+ {pending&&!running&&结果待确认,旧编辑已暂停。 }
+ {error&&{error} }
+ {props.held&&!running&&导出本页备份 }
+ >}
+
;
+}
diff --git a/src/components/canvas/use-canvas-tree.ts b/src/components/canvas/use-canvas-tree.ts
index f11494a..30db0a3 100644
--- a/src/components/canvas/use-canvas-tree.ts
+++ b/src/components/canvas/use-canvas-tree.ts
@@ -1,9 +1,9 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { EdgeChange, NodeChange } from "@xyflow/react";
-import { api, ApiError } from "@/lib/api";
+import { api as requestAPI, ApiError } from "@/lib/api";
import { canvasReducer, decodeCanvas, type CanvasAction, type CanvasDocument, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
-import { CanvasSync, canvasFingerprint, canvasPayload, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
+import { CanvasSync, canvasFingerprint, canvasPayload, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot, type CanvasRequest } from "@/lib/canvas-sync";
import { CANVAS_ACCEPT, type EdgeData } from "@/lib/canvas-v2";
import { subgraphMovePath, subgraphMoveParticipants } from "@/lib/canvas-subgraphs";
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData, referenceAfterMoves } from "@/lib/canvas-tree";
@@ -16,7 +16,19 @@ type Entry = LocalHistory | TransactionHistory;
type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise; resolve: () => void };
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
-export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string) {
+export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string, migration?: {accountId:string;isCurrentIdentity():boolean}) {
+ const migrationGate=useRef(!!migration);
+ const migrationFlush=useRef(false);
+ const [migrationHeld,setMigrationState]=useState(!!migration);
+ const migrationBlocked=()=>migrationGate.current||!!migration&&!migration.isCurrentIdentity();
+ function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
+ const api:CanvasRequest=async (path:string,options?:RequestInit)=>{
+ if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
+ if(migrationBlocked()&&options?.method&&options.method!=='GET'&&!(migrationFlush.current&&options.method==='PATCH'&&path.endsWith('/canvas')))throw new Error('canvas_busy');
+ const headers=new Headers(options?.headers);if(migration)headers.set('X-Expected-Account',migration.accountId);
+ const value=await requestAPI(path,{...options,headers:Object.fromEntries(headers)});
+ if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');return value;
+ };
const [documents, setDocuments] = useState(new Map([[root, initial]]));
const current = useRef(documents); current.current = documents;
const [status, setStatus] = useState("saved");
@@ -45,6 +57,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
}
function resolveRecovery(boardId: string, restore: boolean) {
+ if(migrationBlocked())return;
const draft = pendingRecovery.current.get(boardId);
if (restore && draft) update(boardId, { type: "replace", document: draft });
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
@@ -68,7 +81,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
if (snapshot && !writers.current.has(root)) install(root, snapshot);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
const update = (boardId: string, action: CanvasAction) => {
- if (locked.current || retryOperation.current) return;
+ if (migrationBlocked() || locked.current || retryOperation.current) return;
const before = current.current.get(boardId); if (!before) return;
const result = canvasReducer(history(before), action);
const protectedIDs = protectedNodes(boardId);
@@ -84,7 +97,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action);
const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); };
const nodesChanged = (changes: NodeChange[]) => {
- if (locked.current || retryOperation.current) return;
+ if (migrationBlocked() || locked.current || retryOperation.current) return;
const groups = new Map[]>();
const moves = new Map(changes.flatMap((change) => change.type === "position" && change.position ? [[change.id, change.position] as const] : []));
for (const change of changes) {
@@ -180,8 +193,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documents, retryLoad]);
useEffect(() => {
- if (paused) return;
+ if (paused || migrationBlocked()) return;
const backup = setTimeout(() => {
+ if(migrationBlocked())return;
for (const [boardId, document] of current.current) try {
if (pendingRecovery.current.has(boardId)) continue;
const writer = writers.current.get(boardId);
@@ -189,14 +203,14 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
} catch { setError("local_backup_failed"); }
}, 250);
const save = setTimeout(() => {
- if (locked.current || retryOperation.current || pause.current || members.current.length || dragging.current) return;
+ if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.length || dragging.current) return;
for (const [boardId, document] of current.current) if (!pendingRecovery.current.has(boardId)) void writers.current.get(boardId)?.enqueue(document);
}, 800);
return () => { clearTimeout(backup); clearTimeout(save); };
- }, [documents, paused, workspace, memberJobs]);
+ }, [documents, paused, workspace, memberJobs, migrationHeld]);
useEffect(() => {
const backup = () => {
- if (pause.current) return;
+ if (pause.current || migrationBlocked()) return;
for (const [boardId, document] of current.current) {
if (pendingRecovery.current.has(boardId)) continue;
try { const writer = writers.current.get(boardId); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
@@ -214,8 +228,35 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error);
}
}
+ async function prepareMigration(projectRoot:string){
+ if(!migrationGate.current)throw new Error('migration_gate_required');
+ if(locked.current||retryOperation.current||members.current.length||dragging.current||loading.current.size)throw new Error('canvas_busy');
+ if(pause.current||pendingRecovery.current.size)throw new Error('draft_recovery_required');
+ await Promise.all([...writers.current.values()].map(writer=>writer.settled()));
+ await ensure(projectRoot,true);
+ if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
+ if(pendingRecovery.current.size)throw new Error('draft_recovery_required');
+ if([...current.current.values()].some(document=>document.nodes.some(node=>node.pendingUpload)))throw new Error('migration_pending_uploads');
+ for(const[boardId,document]of current.current){
+ const writer=writers.current.get(boardId);
+ const local=readLocalRecovery(localStorage,`endoleap_canvas_v1:${workspace}:${boardId}`,writer?{version:writer.version,document:writer.document}:undefined);
+ if(local&&canvasFingerprint(local,false)!==canvasFingerprint(document,false)){
+ const next=new Map(pendingRecovery.current).set(boardId,local);pendingRecovery.current=next;setRecoveries(next);throw new Error('draft_recovery_required');
+ }
+ // Preserve the current view even if it closes while PATCH is in flight.
+ localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:writer?writer.needsSave(document):false}));
+ }
+ migrationFlush.current=true;
+ try{await flush();}finally{migrationFlush.current=false;}
+ if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
+ for(const[boardId,document]of current.current){
+ const writer=writers.current.get(boardId);
+ if(writer?.error||writer?.needsSave(document))throw new Error(writer.error||'canvas_busy');
+ localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:false}));
+ }
+ }
async function exclusive(operation: () => Promise) {
- if (locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
+ if (migrationBlocked() || locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
locked.current = true; setBusy(true);
try { return await operation(); } catch (error) { setError(error instanceof Error ? error.message : "request_failed"); throw error; }
finally { locked.current = false; if (mounted.current) setBusy(false); }
@@ -280,7 +321,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Ownership changes pause autosaves, not unrelated local editing. Server
// acknowledgements merge against the persisted base, preserving intervening edits.
async function runMembers() {
- if (memberRunning.current || !members.current.length) return;
+ if (migrationBlocked() || memberRunning.current || !members.current.length) return;
const job = members.current[0]; memberRunning.current = true; job.error = undefined; showMembers();
try {
if (!job.run) {
@@ -345,12 +386,13 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
}
function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) {
- if (locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
+ if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
return new Promise((resolve) => {
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
});
}
function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) {
+ if(migrationBlocked())return;
const resolve = (document: CanvasDocument): CanvasDocument => ({ ...document, nodes: document.nodes.map((node) => {
if (node.pendingUpload !== id) return node;
const fitted=node.data.kind==="plain-image"&&node.width===320&&node.height===240?size:undefined;
@@ -362,6 +404,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
publish(new Map([...current.current].map(([board,doc])=>[board,resolve(doc)])));
}
function movePendingImage(source: string, target: string, id: string) {
+ if(migrationBlocked())throw new Error("canvas_busy");
const from = current.current.get(source)!, to = current.current.get(target)!;
const node = from.nodes.find((node)=>node.id===id); if (!node?.pendingUpload || to.nodes.length>=500 || to.nodes.some((item)=>item.id===id)) throw new Error("invalid_reference");
const projected=projectCanvasTree(root,current.current), a=projected.boards.get(source)!,b=projected.boards.get(target)!;
@@ -395,7 +438,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
async function undoAction(isRedo = false) {
const stack = isRedo ? redo.current : undo.current, destination = isRedo ? undo.current : redo.current;
- const original = stack.at(-1); if (!original || locked.current || retryOperation.current || members.current.length) return;
+ const original = stack.at(-1); if (!original || migrationBlocked() || locked.current || retryOperation.current || members.current.length) return;
const alias = (id: string) => { const seen = new Set(); while (boardAliases.current.has(id) && !seen.has(id)) { seen.add(id); id = boardAliases.current.get(id)!; } return id; };
const remapDocument = (document: CanvasDocument) => ({ ...document, nodes: document.nodes.map((node) => node.data.subgraph ? { ...node, data: { ...node.data, subgraph: { boardId: alias(node.data.subgraph.boardId) } } } : node) });
const entry: Entry = isRedo ? "id" in original ? { ...original, boardId: alias(original.boardId), participants: original.participants.map(alias), payload: { ...original.payload, ...(typeof original.payload.source_board_id === "string" ? { source_board_id: alias(original.payload.source_board_id) } : {}), ...(typeof original.payload.target_board_id === "string" ? { target_board_id: alias(original.payload.target_board_id) } : {}), ...Object.fromEntries(["source", "target"].flatMap((key) => { const endpoint = original.payload[key] as { board_id?: string } | undefined; return endpoint?.board_id ? [[key, { ...endpoint, board_id: alias(endpoint.board_id) }]] : []; })) } } : { ...original, boardId: alias(original.boardId), before: remapDocument(original.before), ...(original.related?{related:new Map([...original.related].map(([id,doc])=>[alias(id),remapDocument(doc)]))}:{}) } : original;
@@ -430,5 +473,5 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
tick((v) => v + 1);
}
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
- return { documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
+ return { migrationHeld,setMigrationHeld,prepareMigration,isMigrationHeld:migrationBlocked, documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand || migrationHeld, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
}
diff --git a/src/components/canvas/use-image-uploads.ts b/src/components/canvas/use-image-uploads.ts
index cbb893d..bc793f4 100644
--- a/src/components/canvas/use-image-uploads.ts
+++ b/src/components/canvas/use-image-uploads.ts
@@ -43,5 +43,5 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
void run(job);
return job;
}
- return { jobs, active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } };
+ return { jobs, hasPending:()=>[...pending.current.values()].some(job=>job.status!=="uploaded"), active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } };
}
diff --git a/src/components/workspace/graph-page.tsx b/src/components/workspace/graph-page.tsx
index 6fbe02f..2c97742 100644
--- a/src/components/workspace/graph-page.tsx
+++ b/src/components/workspace/graph-page.tsx
@@ -8,6 +8,8 @@ import { api, errorText } from "@/lib/api";
import { readDemoBoards } from "@/lib/demo-workspace";
import { matchesRef, type Board } from "@/lib/workspace";
import { loadCanvasEntry } from "@/lib/canvas-entry";
+import {createBrowserCanvasMigration,type LegacyMigrationContext} from "@/lib/browser-canvas-migration";
+import {readCanvasMigration} from "@/lib/canvas-migration";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
import { useCanvasRuntimeHost, useCanvasRenderers } from "./canvas-runtime-host";
import type { CanvasSnapshot } from "@/lib/canvas-sync";
@@ -18,7 +20,7 @@ const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), {
const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () => 正在准备画布…
});
const LoadingGraphCanvas=dynamic(()=>import("@/components/canvas/loading-graph-canvas"),{ssr:false});
-type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine };
+type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine; migration?:LegacyMigrationContext };
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
const renderers=useCanvasRenderers();
const demo = useSearchParams().get("demo") === "1";
@@ -57,7 +59,15 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
readModel:input=>{const identity=runtimeHost.identity();if(identity.authEpoch!==startingIdentity.authEpoch&&identity.accountId!==input.accountId)throw new Error('identity_changed');currentIdentity=runtimeHost.bindAccount(input.accountId);return runtimeHost.loadModel(input.accountId,input.workspaceId,input.canvasId,{signal:input.signal,navigationGeneration:input.navigationGeneration,onPreview:input.onPreview});},
}).then(async result => {
if (!active||currentIdentity&&!currentIdentity()) return;
- if (result.mode === 'legacy') { setLoaded({ board: result.board, snapshot: result.snapshot, route });setPreview(null); return; }
+ if (result.mode === 'legacy') {
+ const migration:LegacyMigrationContext={accountId:result.profile.id,rootId:result.migration?.rootId,registry:runtimeHost.registry,isCurrentIdentity:()=>!!currentIdentity?.(),onApplied:()=>{if(active&¤tIdentity?.())setRetry(value=>value+1);}};
+ setLoaded({board:result.board,snapshot:result.snapshot,route,migration});setPreview(null);return;
+ }
+ const pending=readCanvasMigration(localStorage,{accountId:result.profile.id,workspaceId:result.board.workspace_id});
+ if(pending?.phase==='pending'&&pending.rootId===result.model.document.projectId){
+ try{await createBrowserCanvasMigration({scope:{accountId:result.profile.id,workspaceId:result.board.workspace_id},registry:runtimeHost.registry,isCurrentIdentity:()=>active&&!!currentIdentity?.(),signal:controller.signal}).resume();}
+ catch(error){reportBackground(error);}
+ }
while(pumping)await pumping;
if(!active||!currentIdentity?.())return;
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions,{sourceToken:result.model.sourceToken});
@@ -75,7 +85,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
}, [board, workspaceId, setActiveBoard]);
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board;
return
- {displayBoard && current?.engine ? : displayBoard&¤tPreview ? {previewViewport.current=viewport;}} /> : displayBoard ? :
}
+ {displayBoard && current?.engine ? : displayBoard&¤tPreview ? {previewViewport.current=viewport;}} /> : displayBoard ? :
}
{error ? {error} setRetry((value) => value + 1)}>重新加载
: !board && 正在打开图谱…
}
;
}
diff --git a/src/lib/browser-canvas-migration.ts b/src/lib/browser-canvas-migration.ts
new file mode 100644
index 0000000..6ccdf3b
--- /dev/null
+++ b/src/lib/browser-canvas-migration.ts
@@ -0,0 +1,45 @@
+import type {Registry} from './canvas-core/types.ts';
+import {fault} from './canvas-core/values.ts';
+import {createFoundryTransport} from './canvas-service/foundry.ts';
+import {canvasMigrationStorageKey,createCanvasMigration,migrationBlocksCanvas,readCanvasMigration,type CanvasMigrationScope} from './canvas-migration.ts';
+
+export type LegacyMigrationContext={accountId:string;rootId?:string;registry:Registry;isCurrentIdentity():boolean;onApplied():void};
+type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage};
+const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing';
+function ports(options:BrowserScope){
+ const locks=options.locks??globalThis.navigator?.locks;
+ if(!locks)throw fault('migration_lock_unavailable');
+ return{locks,storage:options.storage??globalThis.localStorage};
+}
+const check=(options:BrowserScope)=>{if(!options.isCurrentIdentity())throw fault('identity_changed');options.signal.throwIfAborted();};
+
+/** Held for the entire lifetime of an editable legacy view, not just each PATCH. */
+export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:string}){
+ check(options);const{locks,storage}=ports(options);
+ let resolve!:(value:{release():Promise})=>void,reject!:(error:unknown)=>void;
+ const ready=new Promise<{release():Promise}>((yes,no)=>{resolve=yes;reject=no;});
+ const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{
+ try{
+ check(options);if(!lock)throw fault('migration_busy');
+ if(migrationBlocksCanvas(readCanvasMigration(storage,options.scope),options.canvasId))throw fault('migration_response_unknown');
+ check(options);
+ let unlock!:()=>void;const released=new Promise(yes=>{unlock=yes;});
+ const release=async()=>{unlock();await request;};
+ const abort=()=>{void release().catch(()=>{});};options.signal.addEventListener('abort',abort,{once:true});
+ resolve({release});
+ try{await released;}finally{options.signal.removeEventListener('abort',abort);}
+ }catch(error){reject(error);}
+ });
+ void request.catch(reject);return ready;
+}
+
+export function createBrowserCanvasMigration(options:BrowserScope&{registry:Registry}){
+ check(options);const{locks,storage}=ports(options);
+ return createCanvasMigration({scope:options.scope,registry:options.registry,storage,
+ transport:createFoundryTransport({accountId:options.scope.accountId,isCurrentIdentity:options.isCurrentIdentity}),
+ isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(),
+ lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{
+ check(options);if(!lock)throw fault('migration_busy');return work();
+ }),
+ });
+}
diff --git a/tests/headless/browser-migration.test.mjs b/tests/headless/browser-migration.test.mjs
new file mode 100644
index 0000000..52516b0
--- /dev/null
+++ b/tests/headless/browser-migration.test.mjs
@@ -0,0 +1,39 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {claimLegacyCanvasEditing,createBrowserCanvasMigration} from '../../src/lib/browser-canvas-migration.ts';
+import {canvasMigrationStorageKey} from '../../src/lib/canvas-migration.ts';
+import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
+const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,scope={accountId:id(1),workspaceId:id(2)},root=id(3),registry=createRegistry(builtinComponents());
+function fixture(){
+ const records=new Map(),held=new Map(),calls=[];
+ const storage={getItem:key=>records.get(key)??null,setItem:(key,value)=>records.set(key,value),removeItem:key=>records.delete(key)};
+ const locks={request(name,options,work){calls.push({name,options});return Promise.resolve().then(async()=>{
+ const state=held.get(name)??{shared:0,exclusive:false},granted=options.mode==='shared'?!state.exclusive:!state.exclusive&&!state.shared;
+ if(!granted)return work(null);
+ if(options.mode==='shared')state.shared++;else state.exclusive=true;held.set(name,state);
+ try{return await work({name,mode:options.mode});}finally{if(options.mode==='shared')state.shared--;else state.exclusive=false;if(!state.shared&&!state.exclusive)held.delete(name);}
+ });}};
+ const controller=new AbortController(),options={scope,registry,locks,storage,signal:controller.signal,isCurrentIdentity:()=>true};
+ return{records,held,calls,controller,options};
+}
+test('editable legacy views share a lease and exclude migration until every editor releases',async()=>{
+ const f=fixture(),one=await claimLegacyCanvasEditing({...f.options,canvasId:root}),two=await claimLegacyCanvasEditing({...f.options,canvasId:root});
+ const migration=createBrowserCanvasMigration(f.options);
+ await assert.rejects(()=>migration.resume(),/migration_busy/);await one.release();await assert.rejects(()=>migration.resume(),/migration_busy/);
+ await two.release();assert.equal((await migration.resume()).status,'idle');assert.equal(f.held.size,0);
+ assert.ok(f.calls.every(call=>call.name===canvasMigrationStorageKey(scope)+':editing'&&call.options.ifAvailable===true));
+ assert.deepEqual(f.calls.slice(0,2).map(call=>call.options.mode),['shared','shared']);assert.ok(f.calls.slice(2).every(call=>call.options.mode==='exclusive'));
+});
+test('pending migration blocks affected old views and corrupt records cannot enable editing',async()=>{
+ const f=fixture(),key=canvasMigrationStorageKey(scope),job={version:1,...scope,rootId:root,id:id(4),phase:'pending',body:JSON.stringify({protocol_version:2,request_id:id(4),expected_versions:{[root]:1}})};
+ f.storage=f.options.storage;f.storage.setItem(key,JSON.stringify(job));
+ await assert.rejects(()=>claimLegacyCanvasEditing({...f.options,canvasId:root}),/migration_response_unknown/);assert.equal(f.held.size,0);
+ const unrelated=await claimLegacyCanvasEditing({...f.options,canvasId:id(99)});await unrelated.release();
+ const saved=JSON.stringify({version:2,data:'retain'});f.storage.setItem(key,saved);
+ await assert.rejects(()=>claimLegacyCanvasEditing({...f.options,canvasId:root}),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.held.size,0);
+});
+test('aborted or stale view lifetimes release leases and cannot acquire a late editing grant',async()=>{
+ const f=fixture(),lease=await claimLegacyCanvasEditing({...f.options,canvasId:root});f.controller.abort();await lease.release();assert.equal(f.held.size,0);await lease.release();
+ const g=fixture(),waiting=claimLegacyCanvasEditing({...g.options,canvasId:root});g.controller.abort();await assert.rejects(waiting,{name:'AbortError'});assert.equal(g.held.size,0);
+ const h=fixture();let current=true;const stale=claimLegacyCanvasEditing({...h.options,canvasId:root,isCurrentIdentity:()=>current});current=false;await assert.rejects(stale,/identity_changed/);assert.equal(h.held.size,0);
+});
diff --git a/tests/headless/renderer-migration.mjs b/tests/headless/renderer-migration.mjs
new file mode 100644
index 0000000..d819454
--- /dev/null
+++ b/tests/headless/renderer-migration.mjs
@@ -0,0 +1,124 @@
+import assert from 'node:assert/strict';
+import {writeFile} from 'node:fs/promises';
+import {createDocument} from '../../src/lib/canvas-core/index.ts';
+export function createRendererMigrationFixture({id,account,workspace,boards,documents,versions,model}){
+ const states=Array.from({length:5},(_,index)=>{
+ const root=id(7000+index*10),element=id(7001+index*10);
+ boards.push({id:root,workspace_id:workspace,kind:'board',version:1,title:'Legacy upgrade '+index});
+ return{root,element,version:1,ready:false,loss:undefined,holdPatch:false,patchWait:undefined,releasePatch:undefined,receipts:new Map(),keys:new Map(),patches:0,posts:[],document:{schema_version:2,nodes:[{id:'old-card',type:'plainText',position:{x:500,y:200},width:240,height:100,data:{kind:'plain-text',title:'Old card',text:'Legacy content'}}],edges:[],viewport:{x:0,y:0,zoom:1}}};
+ });
+ return{states,async handle(request,path,reply,bytes){
+ const state=states.find(value=>path.includes('/materials/'+value.root+'/canvas'));if(!state)return false;
+ const base=`/materials/${state.root}/canvas`,raw=bytes.length?JSON.parse(bytes.toString()):undefined;
+ if(path.endsWith(base+'/model')&&!state.ready){reply(409,{error:'canonical_migration_required',root_id:state.root,document_format_version:1,source_versions:{[state.root]:state.version}});return true;}
+ if(path.endsWith(base)){
+ if(request.method==='PATCH'){
+ assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['if-match'],`"${state.version}"`);assert.equal(state.ready,false,'retired snapshot writer sent after migration');
+ state.document=raw.document;state.version++;state.patches++;
+ if(state.holdPatch){state.patchWait=new Promise(resolve=>state.releasePatch=resolve);await state.patchWait;}
+ reply(200,{version:state.version});
+ }else reply(200,{version:state.version,document:state.document});return true;
+ }
+ if(path.endsWith(base+'/capabilities')){reply(200,{subgraphs:{member_positions:true}});return true;}
+ if(path.endsWith(base+'/ai/runs')&&request.method==='GET'){reply(200,{runs:[]});return true;}
+ if(path.includes(base+'/model/operations/')&&request.method==='GET'){
+ const key=path.split('/').at(-1),receipt=state.receipts.get(key);reply(receipt?200:404,receipt??{status:'unknown',request_id:key});return true;
+ }
+ if(path.endsWith(base+'/model/migrate')){
+ assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['idempotency-key'],raw.request_id);
+ state.posts.push({id:raw.request_id,body:bytes.toString()});
+ if(state.loss==='before'){state.loss=undefined;reply(503,{error:'upstream_unavailable'});return true;}
+ let receipt=state.receipts.get(raw.request_id);
+ if(receipt){assert.equal(state.keys.get(raw.request_id),bytes.toString());}
+ else if(raw.expected_versions[state.root]!==state.version){
+ receipt={protocol_version:2,request_id:raw.request_id,status:'not-applied',terminal:true,error:'migration_version_conflict'};
+ }else{
+ const old=state.document.nodes[0];state.version++;state.ready=true;versions.set(state.root,state.version);
+ documents.set(state.root,createDocument(state.root,[{id:state.root,parent:null}],[{id:state.element,incarnation:1,ownerCanvasId:state.root,componentId:'plain-text',dataVersion:1,structure:'node',data:{title:old.data.title,text:old.data.text},geometry:{...old.position,width:old.width,height:old.height,rotation:0}}]));
+ receipt={protocol_version:2,request_id:raw.request_id,status:'applied',source_versions:raw.expected_versions,identity_map:[{sourceCanvasId:state.root,sourceId:old.id,structure:'node',elementId:state.element}],model:model(documents.get(state.root))};
+ }
+ state.receipts.set(raw.request_id,receipt);state.keys.set(raw.request_id,bytes.toString());
+ if(state.loss==='after'){state.loss=undefined;reply(503,{error:'upstream_unavailable'});return true;}
+ reply(receipt.status==='applied'?200:412,receipt);return true;
+ }
+ return false;
+ }};
+}
+
+export async function verifyRendererMigration({fixture,call,evaluate,until,port,workspace,account,pause}){
+ const route=root=>`http://127.0.0.1:${port}/dashboard/${workspace}/${root}`;
+ const button=label=>`[...document.querySelectorAll('[aria-label="画布升级"] button')].find(button=>button.textContent.trim()===${JSON.stringify(label)})`;
+ async function click(label){const point=await evaluate(`(()=>{const button=${button(label)};if(!button||button.disabled)throw Error('missing enabled upgrade button');const r=button.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`);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});}
+ const editable=()=>`!!${button('升级画布')}&&!document.querySelector('.react-flow').parentElement.inert`;
+ const storageKey=`endoleap_canvas_migration_v1:${account}:${workspace}`;
+ const[a,b,c,d,e]=fixture.states;
+ await until(editable(),'legacy editor did not claim an editing lease');
+ const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="old-card"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+r.width*.4,y:r.y+Math.min(25,r.height*.3)}})()`);
+ assert.equal(await evaluate(`document.elementFromPoint(${point.x},${point.y})?.closest('.react-flow__node')?.getAttribute('data-id')`),'old-card','legacy edit point is covered by another surface');
+ await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:2});
+ await until(`document.activeElement?.tagName==='TEXTAREA'&&!document.activeElement.readOnly`,'legacy text did not enter editing');
+ await call('Input.insertText',{text:' migration edit'});const edited=await evaluate('document.activeElement.value');
+ a.holdPatch=true;a.loss='after';await click('升级画布');
+ for(let i=0;i<100&&!a.releasePatch;i++)await pause(20);assert.ok(a.releasePatch,'upgrade did not drain its dirty legacy writer');assert.equal(a.posts.length,0,'migration crossed an unacknowledged legacy PATCH');assert.equal(a.document.nodes[0].data.text,edited);
+ a.holdPatch=false;a.releasePatch();
+ await until(`!!${button('继续查询升级')}`,'lost migration acknowledgement did not leave a resumable UI');
+ assert.equal(a.posts.length,1);assert.equal(await evaluate(`document.querySelector('.react-flow').parentElement.inert`),true);
+ const frozen=await evaluate(`JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)}))`);assert.equal(frozen.body,a.posts[0].body);
+ await writeFile('/private/tmp/headless-migration-unknown.png',Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
+ await call('Page.reload');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${a.element}"]')&&!localStorage.getItem(${JSON.stringify(storageKey)})`,'canonical reload did not reconcile its saved migration');
+ assert.equal(a.posts.length,1);assert.equal(a.patches,1);assert.equal(a.version,3);assert.equal(JSON.parse(a.posts[0].body).expected_versions[a.root],2);
+ assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${a.element}"] textarea').value`),edited);
+ console.log('PASS legacy dirty writer drains before identity migration; lost ACK reload queries once and retires snapshot writes');
+
+ await call('Page.navigate',{url:route(b.root)});await until(editable(),'second legacy editor did not open');
+ await evaluate(`(()=>{const frame=document.createElement('iframe');frame.id='migration-peer';frame.src=location.href;frame.style.display='none';document.body.append(frame);})()`);
+ await until(`(()=>{const doc=document.getElementById('migration-peer')?.contentDocument;return !!doc?.querySelector('[aria-label="画布升级"] button')&&!doc.querySelector('.react-flow')?.parentElement.inert;})()`,'another legacy view did not acquire its shared lease');
+ await click('升级画布');await until(`document.querySelector('[aria-label="画布升级"]').textContent.includes('其他旧画布页面')&&!document.querySelector('.react-flow').parentElement.inert`,'another editing view did not prevent the identity handoff');assert.equal(b.posts.length,0);
+ await evaluate(`document.getElementById('migration-peer').remove()`);
+ await until(`navigator.locks.query().then(value=>value.held.filter(lock=>lock.name===${JSON.stringify(storageKey+':editing')}).length===1)`,'peer editing lease was not released');
+ await click('升级画布');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${b.element}"]')`,'upgrade did not open the canonical editor after the peer closed');assert.equal(b.posts.length,1);
+ console.log('PASS actual Web Locks exclude identity migration while another legacy view is editable');
+
+ await call('Page.navigate',{url:route(c.root)});await until(editable(),'third legacy editor did not open');c.loss='before';await click('升级画布');await until(`!!${button('继续查询升级')}`,'uncommitted request did not remain resumable');
+ const original=c.posts[0];c.version++;c.document.nodes[0].data.text='Remote edit before recovery';
+ await call('Page.reload');await until(`!!${button('继续查询升级')}&&document.querySelector('.react-flow').parentElement.inert`,'reload enabled old writes before resolving the frozen migration');await pause(1000);assert.equal(c.patches,0);
+ await click('继续查询升级');await until(`!!${button('重新准备升级')}&&!document.querySelector('.react-flow').parentElement.inert`,'known zero-effect rejection did not release editing');
+ assert.equal(c.posts.length,2);assert.deepEqual(c.posts[1],original,'recovery rebuilt a request with newer source versions');
+ await click('重新准备升级');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${c.element}"]')`,'explicit fresh preparation did not upgrade after a version conflict');
+ assert.equal(c.posts.length,3);assert.notEqual(c.posts[2].id,original.id);assert.equal(JSON.parse(c.posts[2].body).expected_versions[c.root],2);
+ assert.equal(c.document.nodes[0].data.text,'Remote edit before recovery');
+ assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${c.element}"] textarea').value`),'Remote edit before recovery');
+ console.log('PASS legacy reload remains frozen until exact retry resolves; known rejection requires explicit fresh preparation');
+
+ await call('Page.navigate',{url:route(d.root)});await until(editable(),'draft conflict fixture did not open');
+ const draft=structuredClone(d.document);draft.nodes[0].data.text='Draft from another closed editor';
+ await evaluate(`localStorage.setItem(${JSON.stringify(`endoleap_canvas_v1:${workspace}:${d.root}:recovery`)},${JSON.stringify(JSON.stringify({document:draft,baseVersion:1,dirty:true}))})`);
+ await click('升级画布');await until(`!!document.querySelector('dialog[open]')&&!document.querySelector('.react-flow').parentElement.inert`,'different cached draft was not surfaced before migration');
+ assert.equal(d.posts.length,0);assert.equal(d.patches,0);
+ const restorePoint=await evaluate(`(()=>{const button=[...document.querySelectorAll('dialog button')].find(button=>button.textContent==='恢复本地草稿');const r=button.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`);
+ await call('Input.dispatchMouseEvent',{type:'mousePressed',...restorePoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...restorePoint,button:'left',buttons:0,clickCount:1});
+ await until(`!document.querySelector('dialog[open]')`,'explicit draft choice did not close recovery');
+ await click('升级画布');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${d.element}"]')`,'recovered draft did not survive migration');
+ assert.equal(d.document.nodes[0].data.text,draft.nodes[0].data.text);assert.equal(d.posts.length,1);
+ assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${d.element}"] textarea').value`),draft.nodes[0].data.text);
+ console.log('PASS a different cached legacy draft blocks migration until explicit recovery and survives the handoff');
+
+ await call('Page.navigate',{url:route(e.root)});await until(editable(),'pending draft fixture did not open');
+ e.loss='before';await click('升级画布');await until(`!!${button('继续查询升级')}`,'pending draft fixture did not retain the original upgrade');
+ const retained=structuredClone(e.document);retained.nodes[0].data.text='Retained while migration is unknown';
+ const recoveryKey=`endoleap_canvas_v1:${workspace}:${e.root}:recovery`;
+ await evaluate(`localStorage.setItem(${JSON.stringify(recoveryKey)},${JSON.stringify(JSON.stringify({document:retained,baseVersion:e.version,dirty:true}))})`);
+ e.version++;e.document.nodes[0].data.text='Remote version invalidates the pending upgrade';
+ await call('Page.reload');await until(`!!${button('继续查询升级')}&&document.querySelector('.react-flow').parentElement.inert`,'pending upgrade did not keep its editing gate');
+ assert.equal(await evaluate(`!!document.querySelector('dialog[open]')`),false,'draft dialog trapped the original receipt query behind a modal');
+ assert.equal(await evaluate(`JSON.parse(localStorage.getItem(${JSON.stringify(recoveryKey)})).document.nodes[0].data.text`),retained.nodes[0].data.text);assert.equal(e.patches,0);
+ await click('继续查询升级');await until(`!!document.querySelector('dialog[open]')&&!document.querySelector('.react-flow').parentElement.inert`,'terminal rejection did not make the retained draft recoverable');
+ assert.equal(e.posts.length,2);assert.deepEqual(e.posts[1],e.posts[0]);
+ const retainedPoint=await evaluate(`(()=>{const button=[...document.querySelectorAll('dialog button')].find(button=>button.textContent==='恢复本地草稿');const r=button.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`);
+ await call('Input.dispatchMouseEvent',{type:'mousePressed',...retainedPoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...retainedPoint,button:'left',buttons:0,clickCount:1});
+ await until(`!document.querySelector('dialog[open]')`,'retained draft choice did not close recovery');
+ await click('重新准备升级');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${e.element}"]')`,'explicitly recovered pending draft did not reach the new editor');
+ assert.equal(e.posts.length,3);assert.notEqual(e.posts[2].id,e.posts[0].id);
+ assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${e.element}"] textarea').value`),retained.nodes[0].data.text);
+ console.log('PASS a retained draft cannot trap receipt recovery behind a modal and is restored only after terminal rejection');
+}
diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs
index 983330f..734f69c 100644
--- a/tests/headless/renderer.integration.mjs
+++ b/tests/headless/renderer.integration.mjs
@@ -1,6 +1,7 @@
import {execFileSync} from 'node:child_process';
import {createCdpCalls} from './cdp-client.mjs';
import {measureRenderer} from './renderer-performance.mjs';
+import {createRendererMigrationFixture,verifyRendererMigration} from './renderer-migration.mjs';
/** Actual dashboard, GraphPage, headless renderer, browser storage and Next proxy. */
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
@@ -12,7 +13,9 @@ import { setTimeout as pause } from 'node:timers/promises';
import { createDocument, applyChanges } from '../../src/lib/canvas-core/index.ts';
const bundler=process.argv.includes('--turbopack')?'turbopack':'webpack';
const productionMode=process.argv.includes('--production');if(productionMode&&!process.argv.includes('--performance'))throw Error('--production requires --performance');
+const migrationOnly=process.argv.includes('--migration-only');
const hooksOnly=process.argv.includes('--hooks-only'),performanceMode=process.argv.includes('--performance'),performanceCount=Number(process.env.HEADLESS_PERFORMANCE_COUNT??1000);
+if(migrationOnly&&(hooksOnly||performanceMode))throw Error('migration-only requires its own functional entry');
if(performanceMode&&(!Number.isSafeInteger(performanceCount)||performanceCount<1||performanceCount>10000))throw Error('invalid performance count');
if(process.env.HEADLESS_PERFORMANCE_EDIT_DURING_COPY==='1'&&(!performanceMode||performanceCount<1000))throw Error('concurrent input requires --performance and at least 1000 nodes');
const baseline=process.env.HEADLESS_PERFORMANCE_BASELINE;if(baseline&&!/^[a-f0-9]{7,40}$/.test(baseline))throw Error('invalid diagnostic baseline');
@@ -35,12 +38,13 @@ const boards = [root, other].map((id, i) => ({ id, workspace_id: workspace, kind
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=', 'base64');
let scopeStreamRoot=null,releaseScopeStream;const scopeStreamGate=new Promise(resolve=>releaseScopeStream=resolve);
let brokenStreamRoot=null,abandonedRoot=null,releaseAbandoned;const abandonedGate=new Promise(resolve=>releaseAbandoned=resolve);
-let releaseFirstModel;const firstModelGate=new Promise(resolve=>{releaseFirstModel=resolve;});let gatedFirstModel=!performanceMode;
+let releaseFirstModel;const firstModelGate=new Promise(resolve=>{releaseFirstModel=resolve;});let gatedFirstModel=!performanceMode&&!migrationOnly;
let releaseImport;const importGate=new Promise(resolve=>{releaseImport=resolve;});let importWriteSeen=false;const editReceipts=new Map();
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,denyAssetMetadata=false,metadataWait,releaseMetadata;
+const migrationFixture=migrationOnly?createRendererMigrationFixture({id,account,workspace,boards,documents,versions,model}):undefined;
const upstream = createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://fixture'), path = url.pathname, chunks = [];
@@ -52,6 +56,7 @@ const upstream = createServer(async (request, response) => {
if (path.endsWith('/workspaces')) return reply(200, [{ id: workspace, name: 'Fixture workspace', role: 'admin' }]);
if (path.endsWith('/materials')) return reply(200, { items: boards, next_offset: null });
const board = boards.find(board => path.endsWith('/materials/' + board.id)); if (board) return reply(200, board);
+ if(await migrationFixture?.handle(request,path,reply,bytes))return;
const read = path.match(/\/materials\/([^/]+)\/canvas\/model$/);
if(read&&request.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'){
const value=model(documents.get(read[1])),token='fixture-snapshot-'+calls.length;let sequence=0;
@@ -179,10 +184,11 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
const evaluate = async expression => { const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text); return r.result?.value; };
async function until(expression, label) { for (let i = 0; i < 200; i++) { if (exceptions.length) throw new Error(exceptions.join('\n')); if (await evaluate(expression)) return; await pause(100); } throw new Error(label + '\n' + JSON.stringify(await evaluate(`[...window.__fixtureActualEngine?.imports.getJobs().values()??[]].map(job=>({id:job.id,status:job.status,error:job.error,sourceError:job.source?.integrityError}))`)) + '\n' + await evaluate('document.body.innerText') + '\n' + logs); }
await call('Runtime.enable');await call('Page.enable');await call('Target.setDiscoverTargets',{discover:true}); await call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
- const route = `http://127.0.0.1:${port}/dashboard/${workspace}/${root}`;
+ const route = `http://127.0.0.1:${port}/dashboard/${workspace}/${migrationFixture?.states[0].root??root}`;
await call('Page.navigate', { url: route });
if(performanceMode){
await measureRenderer({call,evaluate,documents,calls,root,partition,count:performanceCount,exceptions,browserLogs:()=>browserLogs,baseline,buildMode:productionMode?'production':'development',bundler,buildMs,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')});
+ }else if(migrationOnly){await verifyRendererMigration({fixture:migrationFixture,call,evaluate,until,port,workspace,account,pause});
}else if(hooksOnly){await until('typeof window.__runCanvasHookChecks==="function"','hook fixture did not mount');const reports=await evaluate('window.__runCanvasHookChecks()');for(const report of reports)console.log('PASS React hooks: '+report);
}else{
await until('!!document.querySelector("[data-headless-canvas][data-model-loading] .react-flow__node[data-id=a]")','first editable model prefix did not open before the gated tail');