From f1c3b63a32581b5dbab1d6f165d73f1f93d27443 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 11 Nov 2026 16:50:41 +0800 Subject: [PATCH] feat(canvas): retain explicit migration eligibility at the entry boundary --- src/lib/canvas-entry.ts | 6 +++++- tests/headless/entry.test.mjs | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/lib/canvas-entry.ts b/src/lib/canvas-entry.ts index 5bedf59..51522ee 100644 --- a/src/lib/canvas-entry.ts +++ b/src/lib/canvas-entry.ts @@ -14,15 +14,19 @@ export async function loadCanvasEntry(options: { request: CanvasRequest; workspa 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'); + let migration:Extract|undefined; 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,...(options.onPreview?{onPreview:(preview:LiveModelPreview)=>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. + // This read describes eligibility, not a frozen write request: the migration + // action must drain legacy writers and refresh source versions before sending. + migration=model; } catch (error) { options.signal?.throwIfAborted(); if ((error as { code?: string }).code !== 'not_found') throw error; } const snapshot = decodeSnapshot(await options.request(path + '/canvas', { signal: options.signal, headers: { Accept: CANVAS_ACCEPT } })); - return { mode: 'legacy' as const, board, profile, snapshot }; + return { mode: 'legacy' as const, board, profile, snapshot,...(migration?{migration}:{}) }; } diff --git a/tests/headless/entry.test.mjs b/tests/headless/entry.test.mjs index f37e5c4..0b22544 100644 --- a/tests/headless/entry.test.mjs +++ b/tests/headless/entry.test.mjs @@ -23,3 +23,14 @@ test('explicit migration requirement or missing deployment keeps legacy reads; p const f = fixture({ status: 403, body: { error: 'forbidden' } }); await assert.rejects(() => loadCanvasEntry(f.options), /forbidden/); assert.equal(f.calls.some(path => path.endsWith('/canvas')), false); }); + +test('legacy entry retains verified migration root and the full source vector without offering migration for an unavailable API',async()=>{ + const child=id(4),f=fixture({status:409,body:{error:'canonical_migration_required',root_id:board.id,document_format_version:1,source_versions:{[board.id]:3,[child]:7}}}); + const result=await loadCanvasEntry(f.options); + assert.equal(result.mode,'legacy');assert.equal(result.migration.rootId,board.id); + assert.deepEqual([...result.migration.sourceVersions],[[board.id,3],[child,7]]);assert.equal(result.migration.sourceVersions.set,undefined); + assert.equal(result.profile.id,profile.id);assert.ok(result.snapshot); + assert.equal(f.calls.some(path=>path.endsWith('/migrate')),false); + const unavailable=await loadCanvasEntry(fixture({status:404,body:{error:'not_found'}}).options); + assert.equal(unavailable.mode,'legacy');assert.equal(unavailable.migration,undefined); +});