From a66933775ee8d936500255d0960c4c640f83a99c Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 9 Nov 2026 13:39:39 +0800 Subject: [PATCH] Allow durable imports and deep copies in certified loading scopes --- headless/README.md | 34 +++++++++++- .../canvas/headless-graph-canvas.tsx | 7 +-- src/components/canvas/headless-imports.tsx | 2 +- src/lib/api.ts | 5 ++ src/lib/canvas-runtime/engine.ts | 35 ++++++++----- src/lib/canvas-runtime/host.ts | 2 +- src/lib/canvas-runtime/imports.ts | 4 +- src/lib/canvas-runtime/index.ts | 2 +- src/lib/canvas-runtime/loading-import.ts | 52 +++++++++++++++++++ src/lib/canvas-runtime/loading-structure.ts | 6 +-- src/lib/canvas-runtime/loading.ts | 40 +++++++------- src/lib/canvas-runtime/model-preview.ts | 8 +-- src/lib/canvas-runtime/recovery-encoding.ts | 4 +- src/lib/canvas-runtime/recovery.ts | 6 +-- src/lib/canvas-runtime/scope-counts.ts | 12 +++++ src/lib/canvas-runtime/storage-port.ts | 2 +- src/lib/canvas-runtime/storage.ts | 2 +- tests/headless/browser-storage-cases.mjs | 12 ++++- tests/headless/loading-engine.test.mjs | 45 +++++++++++++++- tests/headless/model-preview.test.mjs | 2 +- tests/headless/package.integration.mjs | 2 + tests/headless/recovery-encoding.test.mjs | 2 +- tests/headless/renderer.integration.mjs | 10 +++- 23 files changed, 234 insertions(+), 62 deletions(-) create mode 100644 src/lib/canvas-runtime/loading-import.ts diff --git a/headless/README.md b/headless/README.md index bb7df32..069de93 100644 --- a/headless/README.md +++ b/headless/README.md @@ -57,7 +57,7 @@ textarea, history and viewport when the full model arrives. App navigation cance view's stream and asks the retained project owner to complete through a fresh read. Reload recovers the durable prefix when that project is opened again. This is not a persistent HTTP cursor or a workspace-wide startup scan. Version-3 scoped ACK/chunk -coordination is described below; broader structural permissions remain unfinished. +coordination and later scope-based structure/import permissions are described below. Scope-aware queries are obtained from `engine.editor.getQueries()` and share the same publication boundary as the root. `lookupElement(id)` returns `present`, @@ -108,7 +108,7 @@ capture when the affected owners are ready. Existing-element removal/reparenting requires the entire captured source closure; a missing incoming edge is not proof that no edge exists. Declared references are checked against the transaction's final model. Shared loaded asset references are permitted; new uploads, generation, -progressive import/copy admission and complete export remain gated during loading. +complete export remain gated in version 4. Version 5 adds import/copy admission as described below. ACK validation reconstructs the exact protocol-2 effects of applied logical intents on the original source; late source blocks cannot erase births, restorations or tree @@ -122,3 +122,33 @@ remains unloaded until received. An old tombstone alone cannot classify a captur restored ID as absent. Absence does not make an old ID available for fresh creation. The identity manifest costs O(N) header bytes and memory and still obeys the existing 8 MiB line / 64 MiB response limits; this does not make database acquisition lazy. + +Loading version 5 (recovery format 11) also retains the complete captured +`sourceCanvases` topology from the existing stream header, even for partitions +whose entry payloads are not published yet. With counts and `elementOwners`, the +page now selects this version. Existing v1–v4 recovery images retain their original +contracts; future format 12 is rejected. + +`engine.imports.canStart(canvasId)` and `canDuplicate(selection, {viewCanvasId})` +return availability for UI controls, including policy and lifecycle checks. The +actual methods recheck admission. Native NDJSON import requires a complete target +owner. Deep copy requires complete selected subtrees and captured ancestor owners, +so upper-canvas incoming edges are copied from the same original endpoint into +the new partition. A declared but unpublished child is not an empty partition. +Unrelated incomplete partitions do not prevent these jobs from starting. + +The existing ImportJob/source Blob/runner, history and scheduler are reused. +`source.loadingAdmission` records the source token, latest captured logical +operation sequence, and sorted required canvas IDs. On partial recovery it must +match the retained read and proved scopes. Before copy planning/resume, the source +topology is checked against that historical logical cut, independently of newer +source chunks and local edits. Source SHA, stable mappings and generation checks +continue to protect payloads and late callbacks. This is provenance metadata, +not another queue or a lock held during parsing. + +Pause/reopen/resume retains source bytes and identities. Cancellation keeps already +published content and does not restart; undo remains independent of the source +parser. All content sends through the normal durability and frozen-request barriers. +Full-source completion removes the loading marker and read baseline without +replacing job/history state. New image uploads and generation remain gated while +loading; loaded shared resource references obey the existing structural rules. diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index c38603c..fcb78aa 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -41,12 +41,13 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi if (liveView) lastView.current = liveView; const view = liveView ?? lastView.current!, flow = useReactFlow(); - const canCreate=!loading||loading.version===4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId); + const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId); useEffect(()=>{if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.elements.size)return;needsLoadingFit.current=false;void flow.fitView({padding:.2,maxZoom:1});},[flow,root.document]); const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId); const [editorFocused, setEditorFocused] = useState(false), focusEpoch = useRef(0); useEffect(() => { focusEpoch.current++; setEditorFocused(false); return () => { focusEpoch.current++; }; }, [engine,viewId]); const [copying, setCopying] = useState(false); + const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]); const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false); const [error, setError] = useState(""); const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0}); @@ -246,7 +247,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
report(engine.editor.history.undo())} /> report(engine.editor.history.redo())} /> - { + { const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation})); setCopying(true); setError(""); void engine.imports.duplicate(selection, {viewCanvasId: captured.view.canvasId}).catch(error => { if (current.current.engine === engine && current.current.view.id === viewId) setError(errorText(error)); }).finally(() => { if (current.current.engine === engine && current.current.view.id === viewId) setCopying(false); }); @@ -261,7 +262,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi add('plain-text')} /> add('text')} /> imageInput.current?.click()} /> add('frame')} />
{ const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file, defaultSize: true })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} /> - {!loading&&} + {(!loading||loading.version===5)&&} {showAI && aiElement && setShowAI(false)} />} {showRecovery && setShowRecovery(false)} />} {error &&
{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&}
} diff --git a/src/components/canvas/headless-imports.tsx b/src/components/canvas/headless-imports.tsx index 7fd524c..7ff901a 100644 --- a/src/components/canvas/headless-imports.tsx +++ b/src/components/canvas/headless-imports.tsx @@ -17,7 +17,7 @@ export function HeadlessImports({ engine, canvasId }: { engine: ProjectEngine; c const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse(); const control = (work: Promise) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); }; return