diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx
index 838f52b..1444865 100644
--- a/src/components/canvas/headless-graph-canvas.tsx
+++ b/src/components/canvas/headless-graph-canvas.tsx
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
-import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection } from "@xyflow/react";
+import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection,type Viewport } from "@xyflow/react";
import { Copy, Download, Frame, Hand, ImagePlus, Maximize, Minus, Ratio, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
import type { Board } from "@/lib/workspace";
import type { ReactRenderers } from "@/lib/canvas-bindings/react-renderers";
@@ -261,8 +261,9 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
{error &&
{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&{setError('');resourceView.current?.retry();}}>重试图片 } setError('')}>关闭
}
;
}
-export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers }) {
+export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
+ const [handoffViewport]=useState(initialViewport);
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
- useEffect(() => { const detach = engine.sessions.attach(viewId, board.id); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id]);
+ useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]);
return attached ? : 正在准备画布…
;
}
diff --git a/src/components/canvas/loading-graph-canvas.tsx b/src/components/canvas/loading-graph-canvas.tsx
new file mode 100644
index 0000000..525256d
--- /dev/null
+++ b/src/components/canvas/loading-graph-canvas.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import {useEffect,useMemo,useRef} from 'react';
+import {Background,BackgroundVariant,ReactFlow,useReactFlow,type Viewport,type NodeTypes} from '@xyflow/react';
+import type {Registry} from '@/lib/canvas-core/types';
+import type {ModelPreview} from '@/lib/canvas-runtime/model-preview';
+import type {ReactRenderers} from '@/lib/canvas-bindings/react-renderers';
+import {projectCanvas} from '@/lib/canvas-bindings/projection';
+import {CanvasRendererCommandsContext,type CanvasRendererCommands} from '@/lib/canvas-bindings/renderer-context';
+import {UnsupportedNode,UnsupportedEdge} from './unsupported-elements';
+import styles from './canvas.module.css';
+
+const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]});
+const commands:CanvasRendererCommands=Object.freeze({dispatch:reject,transaction:reject});
+function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:Viewport):void;nodeCount:number}){
+ const flow=useReactFlow(),fitted=useRef(false),notify=useRef(onViewport);notify.current=onViewport;
+ useEffect(()=>{if(!flow.viewportInitialized||fitted.current||!nodeCount)return;fitted.current=true;let active=true;void flow.fitView({padding:.2,maxZoom:1}).then(()=>{if(active)notify.current(flow.getViewport());});return()=>{active=false;};},[flow,nodeCount]);
+ return null;
+}
+/** Render the validated read prefix directly. No temporary editor, ACK, ledger or
+ * writable engine is constructed, and no partial model enters durable storage. */
+export default function LoadingGraphCanvas({preview,canvasId,registry,renderers,workspaceId,onViewport}: {preview:ModelPreview;canvasId:string;workspaceId:string;registry:Registry;renderers:ReactRenderers;onViewport(viewport:Viewport):void}){
+ // Without a declared reference schema, use the existing opaque placeholder
+ // until the complete document is available to the installed renderer.
+ const catalog=useMemo(()=>({...renderers.catalog,resolve(element:Parameters[0]){return registry.components.get(element.componentId)?.copy?renderers.catalog.resolve(element):undefined;}}),[registry,renderers]);
+ const projection=useMemo(()=>{
+ const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog);
+ return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&®istry.components.get(preview.document.elements.get(node.id)!.componentId)?.resourceFields?.includes('assetId')?{src:`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(node.data.assetId)}?variant=preview`}:{})}})),edges:value.edges.map(edge=>({...edge,selectable:false,focusable:false}))};
+ },[preview.document,canvasId,workspaceId,registry,catalog]);
+ const nodeTypes=useMemo(()=>{const result:NodeTypes={};for(const [id,Renderer]of Object.entries({...renderers.nodeTypes,unsupported:UnsupportedNode}))result[id]=props=>
;return result;},[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
+ return
+
+ onViewport(viewport)}>
+
+
+
+
+
正在加载… {preview.receivedElements}/{preview.totalElements},加载期间可浏览
+
;
+}
diff --git a/src/components/workspace/graph-page.tsx b/src/components/workspace/graph-page.tsx
index 9250621..ab6f68b 100644
--- a/src/components/workspace/graph-page.tsx
+++ b/src/components/workspace/graph-page.tsx
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useRef, useState } from "react";
+import type {Viewport} from '@xyflow/react';
+import type {ModelPreview} from '@/lib/canvas-runtime/model-preview';
import { useRouter, useSearchParams } from "next/navigation";
import { api, errorText } from "@/lib/api";
import { readDemoBoards } from "@/lib/demo-workspace";
@@ -15,6 +17,7 @@ import dynamic from "next/dynamic";
const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () => 正在准备画布…
});
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 };
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
const renderers=useCanvasRenderers();
@@ -24,11 +27,13 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
const route = workspaceId + ":" + boardId;
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const [loaded, setLoaded] = useState(null);
+ const [preview,setPreview]=useState<{board:Board;value:ModelPreview;route:string}|null>(null);
+ const previewViewport=useRef(undefined);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
const navigationGeneration=useRef(0);
useEffect(() => {
- setLoaded(null); setError("");
+ setLoaded(null);setPreview(null);previewViewport.current=undefined; setError("");
if (demo) {
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
if (item) setLoaded({ board: item, route });
@@ -39,25 +44,27 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
const controller = new AbortController();
const startingIdentity=runtimeHost.identity();let currentIdentity:(()=>boolean)|undefined;
loadCanvasEntry({ request: api, workspaceRef: workspaceId, boardRef: boardId, registry: runtimeHost.registry, signal: controller.signal,navigationGeneration:++navigationGeneration.current,
- 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:(value,board)=>{if(active&&(!currentIdentity||currentIdentity())&&value.document.canvases.has(board.id))setPreview({board,value,route});},
+ 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 }); return; }
+ if (result.mode === 'legacy') { setLoaded({ board: result.board, snapshot: result.snapshot, route });setPreview(null); return; }
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions);
- if (active&&(!currentIdentity||currentIdentity())) setLoaded({ board: result.board, engine, route });
+ if (active&&(!currentIdentity||currentIdentity())){setLoaded({ board: result.board, engine, route });setPreview(null);}
})
.catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } });
return () => { active = false; controller.abort(); };
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
const current = loaded?.route === route ? loaded : null;
- const board = current?.board;
+ const currentPreview=preview?.route===route?preview:null;
+ const board = current?.board??currentPreview?.board;
useEffect(() => {
if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title });
return () => setActiveBoard(null);
}, [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 ? :
}
+ return
+ {displayBoard && current?.engine ? : displayBoard&¤tPreview ? {previewViewport.current=viewport;}} /> : displayBoard ? :
}
{error ? {error} setRetry((value) => value + 1)}>重新加载
: !board && 正在打开图谱…
}
;
}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 77f17a6..76b64e8 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -59,6 +59,12 @@ export async function startOAuth(provider: "google" | "slack") {
export const errorText = (error: unknown) => {
const code = error instanceof Error ? error.message : "";
const messages: Record = {
+ invalid_model_stream: "画布返回的数据无法解析,请重新加载。",
+ incomplete_model_stream: "画布数据未加载完整,请重新加载。",
+ unsupported_model_stream: "当前画布加载格式暂不受支持,请更新后重试。",
+ model_stream_budget: "画布数据量超过当前加载上限,请联系支持。",
+ load_snapshot_changed: "加载期间画布快照发生变化,请重新加载。",
+ model_loading: "画布尚在加载,请等待加载完成后编辑。",
identity_changed: "账户已变化,当前画布已停止同步,请重新打开。",
engine_closing: "画布正在关闭,本地修改会保留用于恢复。",
storage_scope_in_use: "这张图谱已在另一个标签页中打开,请先关闭那里的编辑器。",
diff --git a/src/lib/canvas-core/document.ts b/src/lib/canvas-core/document.ts
index 5a83ae0..6a3397d 100644
--- a/src/lib/canvas-core/document.ts
+++ b/src/lib/canvas-core/document.ts
@@ -34,6 +34,17 @@ export function commonCanvas(document: Document, left: Id, right: Id): Id {
if (!result) throw fault('no_common_canvas', [left, right]);
return result;
}
+/** Validate one record before its graph dependencies have arrived. This does not
+ * prove owner membership, endpoint existence or connection common ownership. */
+export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap = new Map(), componentData = true): void {
+ if (!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data)) throw fault('invalid_element', [element?.id]);
+ const dead=tombstones.get(element.id);if(dead!==undefined&&element.incarnation<=dead)throw fault('stale_incarnation',[element.id]);
+ const definition=registry.components.get(element.componentId);
+ if(componentData&®istry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!definition.validate(element.data)))throw fault('invalid_component_data',[element.id]);
+ if(element.structure==='node'){if(!geometryValid(element.geometry))throw fault('invalid_geometry',[element.id]);}
+ else if(!element.source||!element.target||typeof element.source.elementId!=='string'||!element.source.elementId||typeof element.target.elementId!=='string'||!element.target.elementId||[element.source,element.target].some(endpoint=>endpoint.portId!==undefined&&(typeof endpoint.portId!=='string'||!endpoint.portId))||!validJson(element.path))throw fault('invalid_endpoint',[element.id]);
+}
+
/** Cooperative validation over an immutable document; synchronous callers drain the same checks. */
function* validateDocumentChecks(document: Document, registry: Registry, componentData:boolean): Generator {
if (!document.canvases.size) throw fault('missing_root');
@@ -52,15 +63,9 @@ function* validateDocumentChecks(document: Document, registry: Registry, compone
if (roots !== 1) throw fault('invalid_roots');
for (const element of document.elements.values()) {
yield;
- if (typeof element.id !== 'string' || !element.id || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !document.canvases.has(element.ownerCanvasId) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data)) throw fault('invalid_element', [element.id]);
- const dead = document.tombstones.get(element.id);
- if (dead !== undefined && element.incarnation <= dead) throw fault('stale_incarnation', [element.id]);
- const definition = registry.components.get(element.componentId);
- if (componentData && registry.resolve(element) === 'known' && (!definition || definition.structure !== element.structure || !definition.validate(element.data))) throw fault('invalid_component_data', [element.id]);
- if (element.structure === 'node') {
- if (!geometryValid(element.geometry)) throw fault('invalid_geometry', [element.id]);
- } else {
- if (!element.source || !element.target || typeof element.source.elementId !== 'string' || typeof element.target.elementId !== 'string' || [element.source,element.target].some(endpoint => endpoint.portId !== undefined && (typeof endpoint.portId !== 'string' || !endpoint.portId))) throw fault('invalid_endpoint', [element.id]);
+ validateElementRecord(element, registry, document.tombstones, componentData);
+ if (!document.canvases.has(element.ownerCanvasId)) throw fault('invalid_element', [element.id]);
+ if (element.structure === 'connection') {
const source = document.elements.get(element.source.elementId), target = document.elements.get(element.target.elementId);
if (!source || !target || source.structure !== 'node' || target.structure !== 'node' || !validJson(element.path)) throw fault('invalid_endpoint', [element.id]);
if (commonCanvas(document, source.ownerCanvasId, target.ownerCanvasId) !== element.ownerCanvasId) throw fault('invalid_connection_owner', [element.id]);
diff --git a/src/lib/canvas-entry.ts b/src/lib/canvas-entry.ts
index b91b93a..bbe983f 100644
--- a/src/lib/canvas-entry.ts
+++ b/src/lib/canvas-entry.ts
@@ -1,3 +1,4 @@
+import type {ModelPreview} from './canvas-runtime/model-preview.ts';
import type { Profile } from './api.ts';
import type { CanvasRequest } from './canvas-sync.ts';
import { decodeSnapshot } from './canvas-sync.ts';
@@ -7,14 +8,14 @@ import type { Registry } from './canvas-core/types.ts';
import { loadFoundryModel, type ModelLoad, type ServiceTransport } from './canvas-service/foundry.ts';
/** Read canonical first: a migrated project must never be opened through snapshot writers. */
-export async function loadCanvasEntry(options: { request: CanvasRequest; workspaceRef: string; boardRef: string; registry: Registry; transport?(accountId: string): ServiceTransport; readModel?(input:{accountId:string;workspaceId:string;canvasId:string;signal?:AbortSignal;navigationGeneration:number}):Promise;signal?: AbortSignal;navigationGeneration?:number }) {
+export async function loadCanvasEntry(options: { request: CanvasRequest; workspaceRef: string; boardRef: string; registry: Registry; transport?(accountId: string): ServiceTransport; readModel?(input:{accountId:string;workspaceId:string;canvasId:string;signal?:AbortSignal;navigationGeneration:number;onPreview?:(preview:ModelPreview)=>void}):Promise;onPreview?:(preview:ModelPreview,board:Board,profile:Profile)=>void;signal?: AbortSignal;navigationGeneration?:number }) {
const path = `workspaces/${encodeURIComponent(options.workspaceRef)}/materials/${encodeURIComponent(options.boardRef)}`;
const [board, profile] = await Promise.all([options.request(path, { signal: options.signal }), options.request('profile', { signal: options.signal })]);
options.signal?.throwIfAborted();
if (board.kind !== 'board' || !matchesRef(board, options.boardRef) || !board.workspace_id || !profile.id) throw new Error('invalid_reference');
if(!options.readModel&&!options.transport)throw new Error('model_loader_unavailable');
try {
- const model = options.readModel?await options.readModel({accountId:profile.id,workspaceId:board.workspace_id,canvasId:board.id,signal:options.signal,navigationGeneration:options.navigationGeneration??0}):await loadFoundryModel(options.transport!(profile.id), board.workspace_id, board.id, options.registry, options.signal);
+ const model = options.readModel?await options.readModel({accountId:profile.id,workspaceId:board.workspace_id,canvasId:board.id,signal:options.signal,navigationGeneration:options.navigationGeneration??0,...(options.onPreview?{onPreview:(preview:ModelPreview)=>options.onPreview!(preview,board,profile)}:{})}):await loadFoundryModel(options.transport!(profile.id), board.workspace_id, board.id, options.registry, options.signal);
options.signal?.throwIfAborted();
if (model.status === 'ready') return { mode: 'headless' as const, board, profile, model };
// Existing projects keep their current editor until the user explicitly migrates them.
diff --git a/src/lib/canvas-runtime/host.ts b/src/lib/canvas-runtime/host.ts
index 8d689cd..1296e6d 100644
--- a/src/lib/canvas-runtime/host.ts
+++ b/src/lib/canvas-runtime/host.ts
@@ -1,3 +1,4 @@
+import {createModelPreview,type ModelPreview} from './model-preview.ts';
import type { Id, Registry } from '../canvas-core/types.ts';
import { fault, readMap } from '../canvas-core/values.ts';
import type { WorkspaceRuntime } from './workspace.ts';
@@ -5,7 +6,7 @@ import { createScopedLoader, type LoadPort, type ScopedLoader } from './loader.t
import type { CanonicalReadUpdate } from './contracts.ts';
/** Host lifetime matches the signed-in application, independently of navigation. */
-export function createRuntimeHost(options: { registry: Registry; workspace(accountId: Id, workspaceId: Id, authEpoch: number, current: () => boolean): WorkspaceRuntime; modelLoader?(accountId:Id,workspaceId:Id,authEpoch:number,current:()=>boolean):LoadPort; onError?: (error: unknown) => void }) {
+export function createRuntimeHost(options: { registry: Registry; workspace(accountId: Id, workspaceId: Id, authEpoch: number, current: () => boolean): WorkspaceRuntime; modelLoader?(accountId:Id,workspaceId:Id,authEpoch:number,current:()=>boolean):LoadPort; onError?: (error: unknown) => void; onObserverError?: (error: unknown) => void }) {
let accountId: Id | null = null, authEpoch = 0, disposed = false;
const workspaces = new Map(), retired = new Set();
const readers = new Map>();
@@ -27,14 +28,20 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
bindAccount(account: Id) { activate(account); const epoch = authEpoch; return () => !disposed && accountId === account && authEpoch === epoch; },
identity: () => Object.freeze({ accountId, authEpoch }),
getWorkspaces: () => readMap(workspaces),
- async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number}={}) {
+ async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:ModelPreview)=>void}={}) {
activate(account);
const readEpoch=authEpoch;
if(!options.modelLoader)throw fault('model_loader_unavailable');
let reader=readers.get(workspace);
if(!reader){const epoch=authEpoch,current=()=>!disposed&&accountId===account&&authEpoch===epoch;reader=createScopedLoader({load:options.modelLoader(account,workspace,epoch,current),current:()=>current(),maxCacheEntries:0});readers.set(workspace,reader);}
- const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal});
- try{const snapshot=await handle.ready;if(disposed||accountId!==account||authEpoch!==readEpoch)throw fault('identity_changed');const last=snapshot.chunks.at(-1);if(!last||!last.complete||last.value.status==='receiving')throw fault('incomplete_load');return last.value;}finally{handle.release();}
+ const preview=settings.onPreview?createModelPreview(options.registry):undefined;let seen=0,previewFailed=false;
+ const onUpdate=(state:import('./loader.ts').LoadSnapshot)=>{
+ if(!preview||previewFailed||disposed||accountId!==account||authEpoch!==readEpoch||settings.signal?.aborted)return;
+ try{for(;seen=new Map(),published:Document|undefined,snapshot:ModelPreview|undefined;
+ const canvases=new Map(),records=new Map();
+ const key=(kind:'canvas'|'element',id:Id)=>JSON.stringify([kind,id]);
+ const known=new Set();
+ function update(value:CanonicalReadUpdate):ModelPreview|undefined{
+ if(finished)throw fault('model_preview_closed');
+ if(value.status!=='receiving'){
+ if(header&&(value.status!=='ready'||value.document.projectId!==projectId||received!==expected||value.document.canvases.size!==canvases.size||[...canvases].some(([id,canvas])=>!equal(value.document.canvases.get(id),canvas))||!equal(Object.fromEntries(value.document.tombstones),Object.fromEntries(tombstones))||value.document.elements.size!==expected||[...(published?.elements??[]),...records].some(([id,record])=>!equal(value.document.elements.get(id),record))))throw fault('model_preview_changed');
+ finished=true;return snapshot;
+ }
+ if(!header){
+ if(value.receivedElements!==0||value.elements.length||!value.canvases.length||!Number.isSafeInteger(value.totalElements)||value.totalElements<0)throw fault('invalid_model_preview');
+ projectId=value.projectId;expected=value.totalElements;tombstones=readMap(new Map(value.tombstones));
+ for(const canvas of value.canvases){if(canvases.has(canvas.id))throw fault('invalid_model_preview');canvases.set(canvas.id,freeze(clone(canvas)));}
+ const topology=createDocument(projectId,[...canvases.values()]);let roots=0;const entries=new Set();
+ for(const canvas of canvases.values()){
+ if(!canvas.id||!Object.hasOwn(canvas,'parent'))throw fault('invalid_canvas');
+ ancestors(topology,canvas.id);
+ if(canvas.parent===null){roots++;if(canvas.id!==projectId)throw fault('project_mismatch');}
+ else if(!canvas.parent||typeof canvas.parent.entryElementId!=='string'||!canvas.parent.entryElementId||entries.has(canvas.parent.entryElementId))throw fault('invalid_canvas_entry');
+ else entries.add(canvas.parent.entryElementId);
+ }
+ if(roots!==1)throw fault('invalid_roots');header=true;
+ }else if(value.projectId!==projectId||value.totalElements!==expected||value.canvases.length||value.tombstones.size)throw fault('model_preview_changed');
+ if(value.receivedElements!==received+value.elements.length||value.receivedElements>expected)throw fault('invalid_model_preview');
+ // Validate the entire incoming batch before publishing any prefix from it.
+ const incoming=new Set();
+ for(const record of value.elements){if(records.has(record.id)||known.has(key('element',record.id))||incoming.has(record.id)||!canvases.has(record.ownerCanvasId))throw fault('invalid_model_preview');validateElementRecord(record,registry,tombstones);incoming.add(record.id);}
+ for(const record of value.elements)records.set(record.id,freeze(clone(record)));received=value.receivedElements;
+ // Greatest closed subset: removing an unavailable dependency also removes its
+ // dependants. Fully received cycles survive and publish in one legal prefix.
+ const candidates=new Map}>();
+ for(const canvas of canvases.values())if(!known.has(key('canvas',canvas.id)))candidates.set(key('canvas',canvas.id),{canvas,deps:new Set(canvas.parent?[key('canvas',canvas.parent.canvasId),key('element',canvas.parent.entryElementId)]:[])});
+ for(const element of records.values())if(!known.has(key('element',element.id))){
+ const deps=new Set([key('canvas',element.ownerCanvasId)]);
+ if(element.structure==='connection'){deps.add(key('element',element.source.elementId));deps.add(key('element',element.target.elementId));}
+ const definition=registry.resolve(element)==='known'?registry.components.get(element.componentId):undefined;
+ for(const ref of definition?.copy?.references??[])for(const id of copyReferenceIds(element.data,ref))deps.add(key(ref.target,id));
+ candidates.set(key('element',element.id),{element,deps});
+ }
+ const dependants=new Map>(),blocked=new Set(),queue:string[]=[];
+ for(const [id,item]of candidates)for(const dependency of item.deps){let users=dependants.get(dependency);if(!users){users=new Set();dependants.set(dependency,users);}users.add(id);if(!known.has(dependency)&&!candidates.has(dependency)&&!blocked.has(id)){blocked.add(id);queue.push(id);}}
+ for(let index=0;indexsnapshot,close(){finished=true;records.clear();canvases.clear();known.clear();published=undefined;snapshot=undefined;}});
+}
diff --git a/src/lib/canvas-service/model-stream.ts b/src/lib/canvas-service/model-stream.ts
index 4533264..cc6b9fe 100644
--- a/src/lib/canvas-service/model-stream.ts
+++ b/src/lib/canvas-service/model-stream.ts
@@ -9,18 +9,21 @@ const uuid=/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
const object=(value:unknown):Record=>{if(!value||typeof value!=='object'||Array.isArray(value)||!validJson(value))throw fault('invalid_model_stream');return value as Record;};
/** Bounded NDJSON framing. EOF is required even after a terminal record. */
async function* lines(source:AsyncIterable,signal:AbortSignal){
- const decoder=new TextDecoder('utf-8',{fatal:true});let pending='',total=0,lineBytes=0,count=0;
+ const decoder=new TextDecoder('utf-8',{fatal:true});
+ const decode=(bytes?:Uint8Array,stream=false)=>{try{return decoder.decode(bytes,{stream});}catch{throw fault('invalid_model_stream');}};
+ let pending='',total=0,lineBytes=0,count=0;
for await(const bytes of source){
signal.throwIfAborted();if(!(bytes instanceof Uint8Array))throw fault('invalid_model_stream');total+=bytes.byteLength;if(total>maxBytes)throw fault('model_stream_budget');
let start=0;
for(let index=0;indexmaxLine)throw fault('model_stream_budget');
- pending+=decoder.decode(bytes.subarray(start,index),{stream:true});pending+=decoder.decode();if(++count>maxLines||!pending)throw fault('invalid_model_stream');
- yield{raw:JSON.parse(pending),byteLength:lineBytes+1};pending='';lineBytes=0;start=index+1;
+ pending+=decode(bytes.subarray(start,index),true);pending+=decode();if(++count>maxLines||!pending)throw fault('invalid_model_stream');
+ let raw:unknown;try{raw=JSON.parse(pending);}catch{throw fault('invalid_model_stream');}
+ yield{raw,byteLength:lineBytes+1};pending='';lineBytes=0;start=index+1;
}
- lineBytes+=bytes.length-start;if(lineBytes>maxLine)throw fault('model_stream_budget');pending+=decoder.decode(bytes.subarray(start),{stream:true});
+ lineBytes+=bytes.length-start;if(lineBytes>maxLine)throw fault('model_stream_budget');pending+=decode(bytes.subarray(start),true);
}
- signal.throwIfAborted();pending+=decoder.decode();if(pending||lineBytes)throw fault('incomplete_model_stream');
+ signal.throwIfAborted();pending+=decode();if(pending||lineBytes)throw fault('incomplete_model_stream');
}
export async function* decodeModelStream(source:AsyncIterable,canvasId:Id,registry:Registry,signal:AbortSignal){
let sequence=0,token='',projectId='',expected=0,ended=false;
diff --git a/tests/headless/model-loader.test.mjs b/tests/headless/model-loader.test.mjs
index 833f898..7345e9b 100644
--- a/tests/headless/model-loader.test.mjs
+++ b/tests/headless/model-loader.test.mjs
@@ -56,3 +56,30 @@ test('entry injection uses resolved IDs and navigation context while preserving
const result = await loadCanvasEntry(options); assert.equal(result.mode, 'headless'); assert.deepEqual(inputs[0], { accountId: account, workspaceId: workspace, canvasId: root, signal: undefined, navigationGeneration: 9 }); assert.equal(reads.some(path => path.endsWith('/canvas')), false);
await assert.rejects(() => loadCanvasEntry({ ...options, readModel: async () => { throw Object.assign(Error('forbidden'), { code: 'forbidden' }); } }), { code: 'forbidden' });
});
+
+test('Host previews share one source but cancelled observers never receive a late prefix or open storage',async()=>{
+ const gate=deferred(),aStates=[],bStates=[];let reads=0;
+ const first=reply().body.model.elements[0],second={...first,id:'second'},canvases=[{id:root,parent:null}],serverVersions={[root]:1};
+ const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened storage');},modelLoader(){return async function*(scope){
+ reads++;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'one-read',serverVersions,permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value});
+ yield part(0,{status:'receiving',projectId:root,canvases,elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:2});
+ yield part(1,{status:'receiving',projectId:root,canvases:[],elements:[first],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:1,totalElements:2});
+ await gate.promise;
+ yield part(2,{status:'receiving',projectId:root,canvases:[],elements:[second],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:2,totalElements:2});
+ yield part(3,{status:'ready',document:(await import('../../src/lib/canvas-core/document.ts')).createDocument(root,canvases,[first,second]),versions:new Map([[root,1]])},true);
+ };}});
+ const abort=new AbortController();const a=host.loadModel(account,workspace,root,{signal:abort.signal,onPreview:value=>aStates.push(value)}),b=host.loadModel(account,workspace,root,{onPreview:value=>bStates.push(value)});
+ try{while(!aStates.some(value=>value.receivedElements===1))await tick();assert.equal(reads,1);assert.equal(aStates.at(-1).document.elements.size,1);const rejected=assert.rejects(a,{code:'load_aborted'});abort.abort();await rejected;const count=aStates.length;gate.resolve();const complete=await b;assert.equal(complete.document.elements.size,2);assert.equal(aStates.length,count);assert.equal(bStates.at(-1).document.elements.size,2);assert.equal(host.getWorkspaces().size,0);}finally{gate.resolve();await host.close();}
+});
+test('preview callback failures do not stop canonical loading and identity retirement fences future previews',async()=>{
+ const gate=deferred(),secondGate=deferred(),errors=[],states=[];let reading=0;
+ const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened a runtime');},onObserverError:error=>errors.push(error),modelLoader(){return async function*(scope){
+ const pass=++reading;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'read',serverVersions:{[root]:1},permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value});
+ yield part(0,{status:'receiving',projectId:root,canvases:[{id:root,parent:null}],elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:0});await(pass===1?gate.promise:secondGate.promise);
+ yield part(1,{status:'ready',document:(await import('../../src/lib/canvas-core/document.ts')).createDocument(root,[{id:root,parent:null}]),versions:new Map([[root,1]])},true);
+ };}});
+ try{
+ const read=host.loadModel(account,workspace,root,{onPreview(){throw Error('observer');}});await tick();gate.resolve();assert.equal((await read).status,'ready');assert.equal(errors.length,1);
+ const next=host.loadModel(account,workspace,root,{onPreview:value=>states.push(value)});const rejected=assert.rejects(next,{code:'loader_closed'});while(!states.length)await tick();await host.retireIdentity();secondGate.resolve();await rejected;const count=states.length;await tick();assert.equal(states.length,count);
+ }finally{gate.resolve();secondGate.resolve();await host.close();}
+});
diff --git a/tests/headless/model-preview.test.mjs b/tests/headless/model-preview.test.mjs
new file mode 100644
index 0000000..9977300
--- /dev/null
+++ b/tests/headless/model-preview.test.mjs
@@ -0,0 +1,44 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {createRegistry,builtinComponents,createDocument,validateDocument} from '../../src/lib/canvas-core/index.ts';
+import {createModelPreview} from '../../src/lib/canvas-runtime/model-preview.ts';
+const registry=createRegistry(builtinComponents()),root='root',child='child';
+const node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:80,rotation:0}});
+const frame={...node('F'),componentId:'frame',data:{title:'F',text:'',ports:[{id:'p',nodeId:'A',direction:'output',handle:'right'}]}};
+const a=node('A',child),b=node('B'),edge={id:'E',incarnation:1,ownerCanvasId:root,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'A'},target:{elementId:'B'},path:null};
+const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],versions=new Map([[root,1],[child,1]]);
+const head=(total=4)=>({status:'receiving',projectId:root,canvases,elements:[],tombstones:new Map(),versions,receivedElements:0,totalElements:total});
+const delta=(elements,received,total=4)=>({...head(total),canvases:[],elements,receivedElements:received});
+
+test('partial model waits for entry/owner/port cycles and edge endpoints; publishes one closed legal prefix',()=>{
+ const preview=createModelPreview(registry);const first=preview.update(head());validateDocument(first.document,registry);assert.equal(first.document.canvases.size,1);
+ const second=preview.update(delta([a,edge],2));assert.equal(second.document.elements.size,0);assert.equal(second.waitingElements,2);assert.strictEqual(second.document,first.document);
+ const third=preview.update(delta([b],3));assert.deepEqual([...third.document.elements.keys()],['B']);assert.equal(third.waitingElements,2);
+ const fourth=preview.update(delta([frame],4));validateDocument(fourth.document,registry);assert.equal(fourth.document.elements.size,4);assert.equal(fourth.document.canvases.size,2);assert.equal(fourth.waitingElements,0);assert.equal(fourth.document.elements.get('E').ownerCanvasId,root);
+ assert.equal(first.document.elements.size,0);assert.equal(third.document.elements.size,1);assert.equal(fourth.complete,false);preview.close();assert.throws(()=>preview.update(delta([],4)),{code:'model_preview_closed'});
+});
+test('declared external reference waits; unsupported component data remains an opaque read-only candidate',()=>{
+ const preview=createModelPreview(registry);preview.update(head(2));const future={...node('future'),componentId:'plugin.future',dataVersion:99,data:{opaque:{ref:'absent',assetId:'secret'}}};
+ const shown=preview.update(delta([future],1,2));assert.deepEqual(shown.document.elements.get('future'),future);assert.equal(registry.resolve(shown.document.elements.get('future')),'unknown-component');
+ const delayed={...node('delayed'),componentId:'frame',data:{title:'delayed',text:'',ports:[{nodeId:'absent'}]}};
+ const next=preview.update(delta([delayed],2,2));assert.equal(next.document.elements.size,1);assert.equal(next.waitingElements,1);
+});
+test('invalid batches do not replace the last legal prefix or publish a partial batch',()=>{
+ for(const bad of [{...node('bad'),geometry:{...node('bad').geometry,width:-1}},{...node('bad'),incarnation:0},{...node('bad'),ownerCanvasId:'foreign'}]){
+ const preview=createModelPreview(registry),first=preview.update(head(2));assert.throws(()=>preview.update(delta([node('good'),bad],2,2)));assert.strictEqual(preview.getSnapshot(),first);assert.equal(first.document.elements.size,0);
+ }
+ const preview=createModelPreview(registry),first=preview.update(head(2));assert.throws(()=>preview.update(delta([node('same'),node('same')],2,2)));assert.strictEqual(preview.getSnapshot(),first);
+});
+test('bad topology and version/source switches cannot silently alter a preview',()=>{
+ for(const topology of [[{id:root,parent:{canvasId:child,entryElementId:'F'}},{id:child,parent:{canvasId:root,entryElementId:'G'}}],[{id:root,parent:null},{id:child,parent:null}]])assert.throws(()=>createModelPreview(registry).update({...head(),canvases:topology}));
+ const preview=createModelPreview(registry);preview.update(head());assert.throws(()=>preview.update({...delta([b],1),projectId:'other'}));
+});
+
+test('owned preview records do not freeze or retain mutable caller payloads; final completion must match',()=>{
+ const preview=createModelPreview(registry),header=structuredClone(head(1)),element=node('one');preview.update(header);const state=preview.update(delta([element],1,1));
+ element.data.text='changed by caller';header.canvases[0].id='changed';assert.equal(state.document.elements.get('one').data.text,'');assert.equal(state.document.canvases.has(root),true);
+ const final=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],[element]);assert.throws(()=>preview.update({status:'ready',document:final,versions}),{code:'model_preview_changed'});
+});
+test('published identities cannot be repeated in later chunks and invalid input closes the projection',()=>{
+ const preview=createModelPreview(registry);preview.update({...head(2),canvases:[{id:root,parent:null}]});preview.update(delta([node('one')],1,2));assert.throws(()=>preview.update(delta([node('one')],2,2)));assert.throws(()=>preview.update(delta([node('two')],2,2)),{code:'model_preview_closed'});
+});
diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs
index 7a09b05..2a2a423 100644
--- a/tests/headless/renderer.integration.mjs
+++ b/tests/headless/renderer.integration.mjs
@@ -22,6 +22,8 @@ if(performanceMode){
}
const boards = [root, other].map((id, i) => ({ id, workspace_id: workspace, kind: 'board', version: 1, title: i ? 'Other board' : 'Headless board' }));
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=', 'base64');
+let brokenStreamRoot=null;
+let releaseFirstModel;const firstModelGate=new Promise(resolve=>{releaseFirstModel=resolve;});let gatedFirstModel=!performanceMode;
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; });
@@ -44,7 +46,10 @@ const upstream = createServer(async (request, response) => {
response.writeHead(200,{'Content-Type':'application/vnd.foundry.canvas-model+ndjson;version=1'});
const line=record=>response.write(JSON.stringify({streamVersion:1,snapshotToken:token,sequence:sequence++,...record})+'\n');
line({kind:'header',documentFormatVersion:1,projectId:value.projectId,canvases:value.canvases,versions:value.versions,tombstones:value.tombstones,removedCanvasIds:value.removedCanvasIds,elementCount:value.elements.length});
- for(let offset=0;offsetcall.method==='POST'&&call.path.endsWith('/model/operations')).length,0);
+ assert.equal(await evaluate('document.querySelector("[data-canvas-loading-preview] textarea").readOnly'),true);
+ await until('document.querySelector("[data-canvas-loading-preview] .react-flow__node[data-id=a]").getBoundingClientRect().x>300','initial preview node remained under the sidebar');
+ await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1});
+ await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:1070,y:630,button:'left',buttons:1});
+ await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1070,y:630,button:'left',buttons:0});await pause(100);
+ const previewTransform=await evaluate('document.querySelector("[data-canvas-loading-preview] .react-flow__viewport").style.transform');
+ await writeFile('/private/tmp/headless-loading-preview.png',Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
+ releaseFirstModel();
+ console.log('PASS actual GraphPage displays the first legal model prefix before the gated tail, without opening a writable engine or issuing an edit');
await until('!!document.querySelector("[data-headless-canvas]") && document.querySelectorAll(".react-flow__node").length === 2', 'actual headless canvas did not open');
+ assert.equal(await evaluate('document.querySelector("[data-headless-canvas] .react-flow__viewport").style.transform'),previewTransform,'preview viewport was lost during editor handoff');
assert.equal(calls.some(call => call.path.endsWith('/canvas')), false, 'canonical project read the legacy snapshot');
assert.equal(await evaluate('!!document.querySelector("[aria-label=画布快捷操作]") && !document.querySelector("[aria-label=展开侧边栏]")'), true);
const click = label => evaluate(`document.querySelector('[aria-label=${JSON.stringify(label)}]').click()`);
@@ -184,14 +203,12 @@ try {
await until(`!!document.querySelector('[aria-label="曲线点 1"]')`,'pane near-curve hit did not select the edge');
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeHit,'read-only hit query created a document operation');
console.log('PASS screen-tolerance pane hit selects the displayed curve beyond native SVG stroke without a model operation');
- await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: 400, y: 250, button: 'left', buttons: 1, clickCount: 1 });
- await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 400, y: 250, button: 'left', buttons: 0, clickCount: 1 });
- await until(`!!document.querySelector('[aria-label="曲线点 1"]')`, 'curve did not become selected');
- assert.equal(await evaluate(`document.elementFromPoint(400,250)?.getAttribute('aria-label')`),'曲线点 1','curve drag handle is not hit-testable');
+ const curveHandle=await evaluate(`(()=>{const r=document.querySelector('[aria-label="曲线点 1"]').getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2};})()`);
+ assert.equal(await evaluate(`document.elementFromPoint(${curveHandle.x},${curveHandle.y})?.getAttribute('aria-label')`),'曲线点 1','curve drag handle is not hit-testable');
const beforeCurve = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
- await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: 400, y: 250, button: 'left', buttons: 1, clickCount: 1 });
- await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 420, y: 260, button: 'left', buttons: 1 });
- await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 440, y: 265, button: 'left', buttons: 0 });
+ await call('Input.dispatchMouseEvent', { type: 'mousePressed', ...curveHandle, button: 'left', buttons: 1, clickCount: 1 });
+ await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: curveHandle.x+20, y: curveHandle.y+10, button: 'left', buttons: 1 });
+ await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: curveHandle.x+40, y: curveHandle.y+15, button: 'left', buttons: 0 });
for(let i=0;i<100&&documents.get(root).elements.get('edge').path.knots[0].position.x!==440;i++)await pause(20);
assert.deepEqual(documents.get(root).elements.get('edge').path.knots[0].position,{x:440,y:265},JSON.stringify({inputs:await evaluate('window.inputEvents'),text:await evaluate('document.body.innerText'),writes:calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).map(call=>JSON.parse(call.bytes.toString()).operations)}));
assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length,beforeCurve+1);
@@ -330,6 +347,15 @@ try {
await click('撤销');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==3;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,3);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data.raw,{retained:true});await click('重做');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,4);await call('Page.reload');await until(`document.querySelector('[data-id="meter-one"] [data-renderer-fixture="meter"]')?.textContent.includes('Metric 4')&&!!document.querySelector('[data-renderer-fixture="metric-edge"]')`,'registered renderer edits did not recover');
console.log('PASS Host-installed custom model/node/edge definitions render without core type switches; command-backed edits preserve raw fields and recover through undo/redo/reload');
+ brokenStreamRoot=id(830);const brokenRoot=brokenStreamRoot;
+ documents.set(brokenRoot,createDocument(brokenRoot,[{id:brokenRoot,parent:null}],[{...node('prefix-visible',brokenRoot,80),data:{title:'Preserved prefix',text:'prefix-kept'}},node('tail-node',brokenRoot,450)]));versions.set(brokenRoot,1);boards.push({id:brokenRoot,workspace_id:workspace,kind:'board',version:1,title:'Interrupted board'});
+ await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${brokenRoot}`});
+ await until(`document.querySelector('[data-canvas-loading-preview] textarea')?.value==='prefix-kept'&&!!document.querySelector('[role="alert"]')`,'malformed model tail hid the legal prefix or failed to report');assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas]')`),false);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.includes('/'+brokenRoot+'/canvas/model/operations')).length,0);
+ assert.ok((await evaluate(`document.querySelector('[role="alert"]').textContent`)).includes('画布返回的数据无法解析'));
+ brokenStreamRoot=null;await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='重新加载').click()`);
+ await until(`!!document.querySelector('[data-headless-canvas]')&&document.querySelectorAll('.react-flow__node').length===2&&!document.querySelector('[role="alert"]')`,'retry did not replace the failed prefix with a complete editor');
+ console.log('PASS malformed model tail retains its legal read-only prefix, reports a readable error and retries into the complete editor without model writes');
+
await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`);
await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view');
assert.ok(calls.some(call=>call.path.endsWith('/canvas/model')&&call.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'));
@@ -337,7 +363,7 @@ try {
console.log('PASS logout retires the runtime before dropping its view without a render error');
}
} finally {
- releaseImport();releaseApply();releaseUpload(); socket?.close();
+ releaseFirstModel();releaseImport();releaseApply();releaseUpload(); socket?.close();
if (chrome) { const exit = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); }); chrome.kill(); await exit; }
if (next?.pid) { const exit = new Promise(resolve => { if (next.exitCode !== null) resolve(); else next.once('exit', resolve); }); try { process.kill(-next.pid, 'SIGTERM'); } catch {} await Promise.race([exit, pause(3000)]); if (next.exitCode === null) try { process.kill(-next.pid, 'SIGKILL'); } catch {} }
upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve));