From 24c45920602378d52a42cda7e8ec2840864c8dbd Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 11 Nov 2026 18:05:22 +0800 Subject: [PATCH] feat(canvas): gate legacy editing and hand off explicit migration safely --- headless/SERVICE_ACCEPTANCE.md | 28 +++- src/components/canvas/canvas.module.css | 4 + src/components/canvas/graph-canvas.tsx | 29 ++-- .../canvas/legacy-canvas-migration.tsx | 105 +++++++++++++++ src/components/canvas/use-canvas-tree.ts | 71 ++++++++-- src/components/canvas/use-image-uploads.ts | 2 +- src/components/workspace/graph-page.tsx | 16 ++- src/lib/browser-canvas-migration.ts | 45 +++++++ tests/headless/browser-migration.test.mjs | 39 ++++++ tests/headless/renderer-migration.mjs | 124 ++++++++++++++++++ tests/headless/renderer.integration.mjs | 10 +- 11 files changed, 440 insertions(+), 33 deletions(-) create mode 100644 src/components/canvas/legacy-canvas-migration.tsx create mode 100644 src/lib/browser-canvas-migration.ts create mode 100644 tests/headless/browser-migration.test.mjs create mode 100644 tests/headless/renderer-migration.mjs diff --git a/headless/SERVICE_ACCEPTANCE.md b/headless/SERVICE_ACCEPTANCE.md index 7ba18e5..aaafefb 100644 --- a/headless/SERVICE_ACCEPTANCE.md +++ b/headless/SERVICE_ACCEPTANCE.md @@ -15,7 +15,7 @@ the complete Draft acceptance matrix, main integration or production deployment. - Existing `ces-control` runner executes Go checks/build, schema migration and isolated PostgreSQL integration, then the pinned Node 24 consumer and HTTP smoke checks. No local backend build or Docker execution was used. -- Local frontend: 797 Node tests and full TypeScript checking passed. Tests use +- Local frontend: 800 Node tests and full TypeScript checking passed. Tests use the source modules; packaged SDK and application delivery remain separate gates. ## What the checks actually prove @@ -31,6 +31,7 @@ the complete Draft acceptance matrix, main integration or production deployment. | AI execution identity and controls | `TestCanonicalAIAdmissionAndLifecycle` covers admission/query/retry, zero-effect rejection, old incarnation callbacks, cancellation, managed image output, owner relocation, archived admission routes and execution after permission revocation. | | Actual frontend RunPort | `tests/headless/foundry-ai.integration.mjs` exercises `createFoundryRuns`, the HTTP transport and ProjectEngine: successful admission response loss/reopen/query, queued cancellation response loss, generation-time content conflict, explicit apply response loss plus undo/reopen/redo, generated image metadata/original bytes/preview, and copy without a new run or upload. | | Host identity migration coordination | `canvas-migration.ts` now drives the real consumer's initial migration: the server commits, the response is lost, a new coordinator reads the stored body/key and queries the original receipt without a second POST. A later intentional raw POST still checks backend idempotent retry. The coordinator's drain and lock ports are fixtures here; this does not prove the old editor's browser handoff gate is connected. | +| Browser legacy handoff | `renderer.integration.mjs --migration-only` exercises the actual GraphPage, legacy editor, upgrade panel and Next proxy with native Chrome input, localStorage and Web Locks. Dirty old PATCH ACK precedes migration; another editable view excludes migration; committed response loss survives reload without a second migration POST; uncommitted response loss reuses the original body/key, with terminal rejection followed by explicit fresh preparation; a different cached draft must be explicitly restored before migration. A draft modal cannot block receipt recovery; it becomes actionable after terminal rejection. The upstream is a protocol fixture, not the real Foundry service. | The CI #1174 consumer report contains `status: "ok"`, 72 HTTP transport calls, all 14 prior document/resource flags true, plus `runtimeAI`, `aiLostAdmission`, @@ -72,10 +73,27 @@ The recovery store is an in-memory durable-batch fixture, and response loss is injected after successful HTTP responses. Real IndexedDB/Web Locks/browser acceptance is recorded separately in `STEP4_ACCEPTANCE.md`; this service run does not combine every browser crash window with every server operation. -The migration coordinator uses an injected string-storage port before a canonical -engine exists. The browser still needs its legacy editing freeze/drain, actual -storage and workspace lock wiring, reload handling and upgrade UI. Stage 6 is not -complete merely because the coordinator's ports pass unit and service tests. +The migration coordinator uses a string-storage port before a canonical engine +exists. `browser-canvas-migration.ts` supplies localStorage, identity fences and +workspace-scoped native Web Locks. Every editable legacy view holds a shared +lease; upgrade first freezes and drains its writer, releases that lease, then +takes the exclusive lease and prepares again before creating a request. Pending +records block affected legacy views across reload. Unknown results retain the +original request; only a verified terminal rejection permits fresh preparation. +Upload/import/member-task and draft-recovery gates preserve the old editor's +content rather than converting in-memory work silently. A differing cached draft +is surfaced for explicit recovery, and the current draft is backed up before +waiting for its PATCH ACK. The account fence also covers old snapshot requests. + +The browser fixture above proves the connected UI path, including actual native +locking, and the separate real-service consumer proves server coordination. +They do not combine a real browser and real PostgreSQL in one scenario. Full +webpack and Turbopack application regressions each passed 51 checks; the migration +entry adds five scenarios and three lock-lifetime unit tests. Unknown +receipt/error states remain read-only with an export action. Unsupported Web +Locks prevents upgrading, while existing legacy editing remains available when +there is no pending migration. Stage 6 and the final requirement matrix still +require their delivery audit; passing these checks alone does not complete them. `canvas_headless_worker_integration_test.go` supplies deterministic generation output over the actual worker HTTP protocol. Admission, claim, events, canonical diff --git a/src/components/canvas/canvas.module.css b/src/components/canvas/canvas.module.css index 50cd115..53a411f 100644 --- a/src/components/canvas/canvas.module.css +++ b/src/components/canvas/canvas.module.css @@ -21,6 +21,10 @@ .helpPopup p { margin-top: 9px; } .error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; } .helpToolbar { position: absolute; right: 12px; bottom: 12px; z-index: 10; } +.migrationPanel { position: absolute; right: 12px; top: 64px; z-index: 220; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 360px; padding: 8px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #78523f; font-size: 12px; } +.migrationPanel button { border: 1px solid #ded7ce; border-radius: 5px; padding: 6px 9px; background: white; cursor: pointer; } +.migrationPanel button:disabled { cursor: default; opacity: .5; } +.migrationSurface { position: absolute; inset: 0; } @media (max-width: 700px) { .topRight { top: 62px; right: 12px; } .bottomToolbar { left: 12px; bottom: 12px; transform: none; max-width: calc(100vw - 92px); overflow-x: auto; overscroll-behavior-x: contain; } diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx index 06a7064..716a902 100644 --- a/src/components/canvas/graph-canvas.tsx +++ b/src/components/canvas/graph-canvas.tsx @@ -1,5 +1,7 @@ "use client"; import { useImageUploads } from "./use-image-uploads"; +import {LegacyCanvasMigration} from "./legacy-canvas-migration"; +import type {LegacyMigrationContext} from "@/lib/browser-canvas-migration"; import { notify, dismissNotification } from "@/lib/notifications"; import { FeedbackContext } from "@/components/ui/app-feedback"; import { ImagePreviews } from "./canvas-image"; @@ -43,7 +45,7 @@ const edgeTypes = { default: CanvasEdge }; const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () =>
正在加载图标面板…
}); const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode }; -function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) { +function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) { const flow = useReactFlow(); const { zoom } = useViewport(); const reduceMotion = useReducedMotion(); @@ -80,7 +82,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap const imageInput = useRef(null); const projectInput = useRef(null); const [recovery, setRecovery] = useState(initial.recovery); - const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id); + const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration); const childRecovery = [...tree.recoveries][0]; const downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; const sync = tree; @@ -89,6 +91,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap const treeRef = useRef(tree); treeRef.current = tree; const uploads = useImageUploads(board.workspace_id, (job, asset) => treeRef.current.resolveImageUpload(job.node.id, asset,{width:job.node.width!,height:job.node.height!})); const imageUploading = demoImageUploading; + const ioActive=useRef(false); useEffect(() => { const current = treeRef.current; for (const job of uploads.jobs) { @@ -322,6 +325,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap const file = event.target.files?.[0]; event.target.value = ""; if (!file) { pendingImage.current = null; return; } + if(treeRef.current.isMigrationHeld()){pendingImage.current=null;setError("画布交接期间暂时不能添加图片。");return;} if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > (snapshot ? 20 * 1024 * 1024 : 2_000_000)) { pendingImage.current = null; setError(snapshot ? "请选择小于 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片。" : "演示模式请选择小于 2 MB 的图片。"); return; } @@ -370,6 +374,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; + if(treeRef.current.isMigrationHeld()){setError("画布交接期间暂时不能导入。");return;} + ioActive.current=true; try { if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。"); if (tree.rootDocument.nodes.some((node) => node.data.subgraph)) throw new Error("当前画布包含真实子图,请先导出备份并通过子图事务恢复,避免覆盖子图关系。"); @@ -379,6 +385,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap void flow.setViewport(document.viewport); setError(""); } catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); } + finally{ioActive.current=false;} } async function exportProject() { let documents: Map; @@ -416,6 +423,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap ai.activeEdges.has(ref.boardId + "/" + ref.id)).map(([id]) => id)) }}> +
nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} onBeforeDelete={async ({ nodes: removing }) => { if (tree.busy || tree.transactionBusy) return false; @@ -483,6 +491,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap return owned && tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />; })} +
@@ -500,7 +509,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap -
+
setTool("select")} /> setTool("hand")} />