feat(canvas): gate legacy editing and hand off explicit migration safely

This commit is contained in:
2026-11-11 18:05:22 +08:00
parent 1f98d8d61f
commit 24c4592060
11 changed files with 440 additions and 33 deletions
+23 -5
View File
@@ -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
+4
View File
@@ -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; }
+21 -8
View File
@@ -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: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> });
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<CanvasNode>();
const { zoom } = useViewport();
const reduceMotion = useReducedMotion();
@@ -80,7 +82,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const imageInput = useRef<HTMLInputElement>(null);
const projectInput = useRef<HTMLInputElement>(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<string, import("@/lib/canvas").CanvasDocument>;
@@ -416,6 +423,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
<NodeEditing.Provider value={editing}>
<FrameFeedback.Provider value={frameFeedback}>
<EdgeEditing.Provider value={{ begin: beginEdit, patch: tree.patchEdge, active: new Set([...tree.views.edgeRefs].filter(([id, ref]) => ai.activeEdges.has(ref.boardId + "/" + ref.id)).map(([id]) => id)) }}>
<div className={styles.migrationSurface} inert={tree.migrationHeld}>
<ReactFlow<CanvasNode> 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 && <AIPanel key={node.id} id={node.id} node={owned} workspace={board.workspace_id} boardId={ref.boardId} ai={ai} save={(config) => tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
})}
</ReactFlow>
</div>
</EdgeEditing.Provider>
</FrameFeedback.Provider>
</NodeEditing.Provider>
@@ -500,7 +509,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
<IconButton icon={Download} label="导出图谱" onClick={exportProject} />
</div>
</div>
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具" inert={tree.migrationHeld}>
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={tool === "select"} onClick={() => setTool("select")} />
<IconButton icon={Hand} label="抓手工具(H)" aria-pressed={tool === "hand"} onClick={() => setTool("hand")} />
<span className={toolbar.divider} aria-hidden="true" />
@@ -542,8 +551,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
setTool("select");
}} />}
{!feedback && <div className={styles.membershipNotice} role="status" aria-live="polite">{membershipNotice}</div>}
{recovery && <DraftRecovery canRestore={canRestoreCanvasDraft(recovery, tree.rootDocument)} exportDraft={() => downloadDraft(recovery)} restore={() => { dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => setRecovery(null)} />}
{!recovery && childRecovery && <DraftRecovery canRestore={canRestoreCanvasDraft(childRecovery[1], tree.documents.get(childRecovery[0])!)} exportDraft={() => downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />}
{!tree.migrationHeld && recovery && <DraftRecovery canRestore={canRestoreCanvasDraft(recovery, tree.rootDocument)} exportDraft={() => 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 && <DraftRecovery canRestore={canRestoreCanvasDraft(childRecovery[1], tree.documents.get(childRecovery[0])!)} exportDraft={() => downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />}
{snapshot && sync.error && <div className={styles.syncError} role="alert"><span>{sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))}</span>
{sync.error !== "version_conflict" && <button onClick={() => void sync.retry()}>重试保存</button>}
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
@@ -556,7 +565,11 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
</div>
</NodeToolbar>;
}))}
{tree.busy && <div className={styles.canvasBusy} role="status">{tree.pendingCommand ? <div>同步结果尚未确认,为保护内容暂时停止编辑。<button onClick={() => void tree.retryPending().catch((error) => setError(error.message))}>重试同步</button><button onClick={() => window.location.reload()}>重新读取画布</button></div> : "正在同步画布…"}</div>}
{migration&&<LegacyCanvasMigration context={migration} workspaceId={board.workspace_id} canvasId={board.id} registry={migration.registry} held={tree.migrationHeld} hold={tree.setMigrationHeld} prepare={tree.prepareMigration}
check={()=>{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 && <div className={styles.canvasBusy} role="status">{tree.pendingCommand ? <div>同步结果尚未确认,为保护内容暂时停止编辑。<button onClick={() => void tree.retryPending().catch((error) => setError(error.message))}>重试同步</button><button onClick={() => window.location.reload()}>重新读取画布</button></div> : tree.migrationHeld?"正在检查或交接画布,编辑暂时暂停。":"正在同步画布…"}</div>}
{error && !feedback && <button className={styles.error} role="alert" onClick={() => setError("")} title="点击关闭提示">{error}</button>}
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={readImage} hidden />
<input ref={projectInput} type="file" accept="application/json,.json" onChange={importProject} hidden />
@@ -564,6 +577,6 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
);
}
export default function GraphCanvas({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} /></ReactFlowProvider>;
export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} migration={migration} /></ReactFlowProvider>;
}
@@ -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<string,string>)[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<void>;check():void;canLeave():boolean;exportDraft():void};
export function LegacyCanvasMigration(props:Props){
const port=useRef(props);port.current=props;
const [job,setJob]=useState<CanvasMigrationJob>(),[error,setError]=useState(''),[checking,setChecking]=useState(true),[running,setRunning]=useState(false),[changedElsewhere,setChangedElsewhere]=useState(false);
const lease=useRef<{release():Promise<void>}|undefined>(undefined),life=useRef<AbortController|undefined>(undefined),attempt=useRef(false),finished=useRef(false),previous=useRef<CanvasMigrationJob|undefined>(undefined);
const scope={accountId:props.context.accountId,workspaceId:props.workspaceId},key=canvasMigrationStorageKey(scope);
const refresh=useRef<()=>Promise<void>>(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 <div className={styles.migrationPanel} aria-label="画布升级">
{checking?<span role="status">正在检查画布编辑权限…</span>:<>
{running?<span role="status">正在保存并升级画布…</span>:changedElsewhere?<button onClick={()=>{try{port.current.check();if(!port.current.canLeave())throw new Error('本页有未同步内容,请先导出备份并手动重新打开画布。');port.current.context.onApplied();}catch(error){setError(message(error));}}}>重新打开画布</button>:props.context.rootId||pending?<button disabled={other} title="保存内容并使用新版编辑器;旧撤销记录不会带入" onClick={()=>void upgrade()}>{pending?'继续查询升级':job?.phase==='rejected'?'重新准备升级':'升级画布'}</button>:props.held?<button onClick={()=>void refresh.current()}>重新检查</button>:null}
{pending&&!running&&<span role="status">结果待确认,旧编辑已暂停。</span>}
{error&&<span role="alert">{error}</span>}
{props.held&&!running&&<button onClick={props.exportDraft}>导出本页备份</button>}
</>}
</div>;
}
+57 -14
View File
@@ -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<void>; 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 <T,>(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<T>(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<CanvasNode>[]) => {
if (locked.current || retryOperation.current) return;
if (migrationBlocked() || locked.current || retryOperation.current) return;
const groups = new Map<string, NodeChange<CanvasNode>[]>();
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<T>(operation: () => Promise<T>) {
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<void>((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<string>(); 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 };
}
+1 -1
View File
@@ -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); } };
}
+13 -3
View File
@@ -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: () => <p role="status">正在准备画布…</p> });
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&&currentIdentity?.())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 <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!current && !error}>
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} renderers={renderers} initialViewport={previewViewport.current} /> : displayBoard&&currentPreview ? <LoadingGraphCanvas key={displayBoard.workspace_id+":"+displayBoard.id} preview={currentPreview.value} canvasId={displayBoard.id} workspaceId={displayBoard.workspace_id} registry={runtimeHost.registry} renderers={renderers} onViewport={viewport=>{previewViewport.current=viewport;}} /> : displayBoard ? <GraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} renderers={renderers} initialViewport={previewViewport.current} /> : displayBoard&&currentPreview ? <LoadingGraphCanvas key={displayBoard.workspace_id+":"+displayBoard.id} preview={currentPreview.value} canvasId={displayBoard.id} workspaceId={displayBoard.workspace_id} registry={runtimeHost.registry} renderers={renderers} onViewport={viewport=>{previewViewport.current=viewport;}} /> : displayBoard ? <GraphCanvas key={(current?.migration?.accountId??"demo")+":"+displayBoard.workspace_id+":"+displayBoard.id} board={displayBoard} snapshot={current?.snapshot} migration={current?.migration} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{error ? <div className={styles.graphStatus} role="alert">{error} <button onClick={() => setRetry((value) => value + 1)}>重新加载</button></div> : !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
</main>;
}
+45
View File
@@ -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>})=>void,reject!:(error:unknown)=>void;
const ready=new Promise<{release():Promise<void>}>((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<void>(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();
}),
});
}
+39
View File
@@ -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);
});
+124
View File
@@ -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');
}
+8 -2
View File
@@ -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');