From fa8029a79ef70a60f18aa9bbc0fe3b58943a42bf Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 21 Sep 2026 10:35:15 +0800 Subject: [PATCH 1/4] fix(desktop): bind imported sessions to selected workspace Generated-by: Codex --- ...me-host-external-sessions-ipc-main.test.ts | 38 ++++++++++++++++++- apps/desktop/src/main/runtime-host-boot.ts | 2 + apps/desktop/src/main/runtime-host-client.ts | 2 + .../main/runtime-host-desktop-candidate.ts | 10 +++++ ...runtime-host-external-sessions-ipc-main.ts | 12 +++++- .../external-session-coordinator.test.ts | 26 +++++++++++++ .../external-session-protocol.test.ts | 12 +++++- .../src/protocol/external-session.ts | 20 +++++++--- .../server/external-session-coordinator.ts | 18 ++++++++- 9 files changed, 129 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts index 54bbb7891e..8a8689a405 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts @@ -63,6 +63,7 @@ test('forwards bounded external Session requests and publishes imported Sessions }, }), emitSessionsChanged: (reason, sessionId) => events.push({ reason, sessionId }), + resolveImportWorkspace: async () => ({ kind: 'project', projectId: 'selected-project' }), }, ipc, ); @@ -99,7 +100,11 @@ test('forwards bounded external Session requests and publishes imported Sessions ); assert.deepEqual(requests, [ { adapterId: 'codex', includeArchived: true, cursor: '16' }, - { adapterId: 'codex', sourceSessionId: 'source-1' }, + { + adapterId: 'codex', + sourceSessionId: 'source-1', + workspace: { kind: 'project', projectId: 'selected-project' }, + }, ]); assert.deepEqual(events, [{ reason: 'created', sessionId: 'imported-1' }]); }); @@ -138,6 +143,37 @@ test('an uncertain commit still asks the shell to re-read the catalog', async () assert.deepEqual(events, [{ reason: 'created', sessionId: undefined }]); }); +test('does not turn a missing import destination into an uncertain commit', async () => { + let imports = 0; + const events: string[] = []; + const ipc = ipcHarness(); + registerRuntimeHostExternalSessionsIpc( + { + client: clientFixture({ + importExternalSession: async () => { + imports += 1; + return { kind: 'imported', session: session('unexpected') }; + }, + }), + emitSessionsChanged: (reason) => events.push(reason), + resolveImportWorkspace: async () => { + throw new Error('Select a project from the Runtime Host first'); + }, + }, + ipc, + ); + + await assert.rejects( + () => ipc.invoke('external-sessions:import', { + adapterId: 'codex', + sourceSessionId: 'source-1', + }), + /Select a project from the Runtime Host first/, + ); + assert.equal(imports, 0); + assert.deepEqual(events, []); +}); + test('keeps catalog eligibility owned by the Host after an uncertain import', async () => { const ipc = ipcHarness(); registerRuntimeHostExternalSessionsIpc( diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 459e8f9610..b503487de3 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1126,6 +1126,8 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( { allowHostPath: !runtimeHostProfileUsesHostWorkspace(target.kind) }, ); }, + resolveExternalSessionImportWorkspace: (target) => + currentDesktopWorkspaceTarget(target), emitSessionsChanged, cacheTranscript: (scope, snapshot) => sessionLocal.cacheTranscript(scope, snapshot), ...(e2eFixture?.scenario === "chat-partial-history" diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index fcd970fd39..ffbe09c44b 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -77,6 +77,7 @@ import { type ExternalSessionCatalogQueryResult, type ExternalSessionImportResult, type ExternalSessionSourceQueryResult, + type WorkspaceTarget, type ClientCapabilityReplaceResult, type ClientCapabilityUnregisterResult, type InteractionAnswerInput, @@ -1047,6 +1048,7 @@ export class DesktopRuntimeHostClient { async importExternalSession(input: { readonly adapterId: string; readonly sourceSessionId: string; + readonly workspace?: WorkspaceTarget; }): Promise> { const result = await this.request("external-session.import", input); return result.kind === 'imported' diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 5f5a56a286..6d77dfa517 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -149,6 +149,10 @@ export interface DesktopRuntimeHostCandidateDeps { input: Pick, target: DesktopRuntimeHostTargetPolicy, ) => Promise; + /** Resolves the selected import destination on the target Host. */ + readonly resolveExternalSessionImportWorkspace?: ( + target: DesktopRuntimeHostTargetPolicy, + ) => Promise; readonly emitSessionsChanged: ( scope: DesktopTargetScope, reason: SessionChangedReason, @@ -897,6 +901,12 @@ export async function createDesktopRuntimeHostCandidate( { client, emitSessionsChanged, + ...(deps.resolveExternalSessionImportWorkspace + ? { + resolveImportWorkspace: () => + deps.resolveExternalSessionImportWorkspace!(target), + } + : {}), }, ipc, ); diff --git a/apps/desktop/src/main/runtime-host-external-sessions-ipc-main.ts b/apps/desktop/src/main/runtime-host-external-sessions-ipc-main.ts index ae7aa2d628..0a8016d6a7 100644 --- a/apps/desktop/src/main/runtime-host-external-sessions-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-external-sessions-ipc-main.ts @@ -28,6 +28,7 @@ import type { ExternalSessionImportResult, ExternalSessionSourceQueryResult, SessionCatalogProjection, + WorkspaceTarget, } from '@maka/runtime-host/protocol'; import { decodeExternalSessionCatalogQueryInput, @@ -52,12 +53,15 @@ type ExternalSessionClient = { importExternalSession(input: { readonly adapterId: string; readonly sourceSessionId: string; + readonly workspace?: WorkspaceTarget; }): Promise>; }; export interface RuntimeHostExternalSessionsIpcDeps { readonly client: ExternalSessionClient; readonly emitSessionsChanged: (reason: SessionChangedReason, sessionId?: string) => void; + /** Resolves the Desktop-selected workspace on the target Host. */ + readonly resolveImportWorkspace?: () => Promise; } export function registerRuntimeHostExternalSessionsIpc( @@ -80,8 +84,14 @@ export function registerRuntimeHostExternalSessionsIpc( }); ipcMain.handle('external-sessions:import', async (_event, input: unknown) => { const request = decodeExternalSessionImportInput(input); + const workspace = deps.resolveImportWorkspace + ? await deps.resolveImportWorkspace() + : undefined; try { - const result = await deps.client.importExternalSession(request); + const result = await deps.client.importExternalSession({ + ...request, + ...(workspace === undefined ? {} : { workspace }), + }); if (result.kind === 'source_limit_exceeded') { return { ok: false, diff --git a/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts b/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts index bc534ff899..a150550ffe 100644 --- a/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts @@ -917,6 +917,32 @@ test('does not classify untyped source errors or errors after persistence as sou assert.equal(committed.drainRequests(), 1); }); +test('uses the Host-resolved workspace as the imported Session cwd', async () => { + const fixture = coordinatorFixture([adapterFixture()]); + + const outcome = await fixture.coordinator.handlers['external-session.import']( + { + adapterId: 'codex', + sourceSessionId: 'source-0', + workspace: { kind: 'project', projectId: 'project-1' }, + }, + context, + ); + + assert.equal(outcome.ok, true); + assert.deepEqual(fixture.creates[0]?.input, { + backend: 'ai-sdk', + cwd: '/resolved-project', + projectId: 'project-1', + llmConnectionSlug: 'default', + model: 'gpt-5', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + name: 'Source 0', + }); +}); + test('reports a model-target failure before any commit is attempted', async () => { let createAttempts = 0; const fixture = coordinatorFixture([adapterFixture()], { diff --git a/packages/runtime-host/src/__tests__/external-session-protocol.test.ts b/packages/runtime-host/src/__tests__/external-session-protocol.test.ts index c2ba30a877..b6a9d702dd 100644 --- a/packages/runtime-host/src/__tests__/external-session-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/external-session-protocol.test.ts @@ -126,12 +126,20 @@ describe('external Session protocol', () => { decodeClientFrame({ requestId: 'request-import', operation: 'external-session.import', - input: { adapterId: 'codex', sourceSessionId: 'source-session-1' }, + input: { + adapterId: 'codex', + sourceSessionId: 'source-session-1', + workspace: { kind: 'project', projectId: 'project-1' }, + }, }), { requestId: 'request-import', operation: 'external-session.import', - input: { adapterId: 'codex', sourceSessionId: 'source-session-1' }, + input: { + adapterId: 'codex', + sourceSessionId: 'source-session-1', + workspace: { kind: 'project', projectId: 'project-1' }, + }, }, ); }); diff --git a/packages/runtime-host/src/protocol/external-session.ts b/packages/runtime-host/src/protocol/external-session.ts index ecba69aee9..1d98c443c8 100644 --- a/packages/runtime-host/src/protocol/external-session.ts +++ b/packages/runtime-host/src/protocol/external-session.ts @@ -107,6 +107,8 @@ export interface ExternalSessionCatalogItem { export interface ExternalSessionImportInput { readonly adapterId: string; readonly sourceSessionId: string; + /** Optional target chosen by a Desktop client; older clients retain source cwd fallback. */ + readonly workspace?: WorkspaceTarget; } /** A completed import command may refuse the source before any Session is written. */ @@ -224,10 +226,12 @@ export function decodeExternalSessionCatalogQueryResult( } export function decodeExternalSessionImportInput(value: unknown): ExternalSessionImportInput { - const input = requireExactRecord(value, 'external Session import input', [ - 'adapterId', - 'sourceSessionId', - ]); + const input = requireShapedRecord( + value, + 'external Session import input', + ['adapterId', 'sourceSessionId'], + ['workspace'], + ); const sourceSessionId = requireUtf8String( input.sourceSessionId, 'external source Session id', @@ -236,7 +240,13 @@ export function decodeExternalSessionImportInput(value: unknown): ExternalSessio if (/[\u0000-\u001f\u007f]/.test(sourceSessionId)) { throw invalidProtocolFrame('Invalid external source Session id'); } - return { adapterId: adapterId(input.adapterId), sourceSessionId }; + return { + adapterId: adapterId(input.adapterId), + sourceSessionId, + ...(Object.hasOwn(input, 'workspace') + ? { workspace: decodeWorkspaceTarget(input.workspace) } + : {}), + }; } export function decodeExternalSessionImportResult(value: unknown): ExternalSessionImportResult { diff --git a/packages/runtime-host/src/server/external-session-coordinator.ts b/packages/runtime-host/src/server/external-session-coordinator.ts index 0903779f06..775fe77f35 100644 --- a/packages/runtime-host/src/server/external-session-coordinator.ts +++ b/packages/runtime-host/src/server/external-session-coordinator.ts @@ -32,7 +32,10 @@ import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import type { SessionExternalOrigin, SessionHeader, StoredMessage } from '@maka/core/session'; import type { ExternalSessionImportLookupResult } from '@maka/storage/execution-stores'; import type { SessionCatalogRecord } from '@maka/storage/execution-stores'; -import { ExternalSessionImporter } from '@maka/storage/external-sessions'; +import { + ExternalSessionImporter, + type ExternalSessionImportTarget, +} from '@maka/storage/external-sessions'; import { EXTERNAL_SESSION_CWD_MAX_BYTES, EXTERNAL_SESSION_IMPORTED_SESSION_IDS_MAX_ITEMS, @@ -282,9 +285,17 @@ export class HostExternalSessionCoordinator { return importFailure('operation_unavailable', 'External Session source is unavailable'); } - let target: Omit; + let target: ExternalSessionImportTarget; try { target = await this.#resolveTarget(); + if (input.workspace !== undefined) { + const workspace = await this.#workspaceResolver.resolve(input.workspace); + target = { + ...target, + cwd: workspace.cwd, + projectId: workspace.projectId, + }; + } } catch (error) { if (error instanceof NoUsableImportModelError) { return importFailure('model_unavailable', error.message); @@ -292,6 +303,9 @@ export class HostExternalSessionCoordinator { if (error instanceof SessionOperationFailure) { return importFailure(error.code, error.message); } + if (error instanceof WorkspaceResolutionError) { + return importFailure(error.code, error.message); + } return importFailure('persistence_failed', 'Session defaults are unavailable'); } From 24aa4d4f48f98434483ba8b2687ee60108a6d050 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 21 Sep 2026 10:40:27 +0800 Subject: [PATCH 2/4] chore(runtime-host): advance protocol compatibility epoch Generated-by: Codex --- packages/runtime-host/src/protocol/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 532fde7e44..5578468372 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 176 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 177 as const; +// 177: External Session import input may carry an optional Host-resolved +// workspace target. Epoch-176 peers reject the unknown `workspace` key. // 176: Session-scoped capability publication and MCP admission require compatible // Client and Host builds; older peers do not enforce their isolation contract. // 175: WorkHub coordinator model configuration again accepts only native From 37f7ef54607679aae5907cd519b175d3f4835eae Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 21 Sep 2026 12:25:34 +0800 Subject: [PATCH 3/4] docs(protocol): explain compatibility epoch 170 Generated-by: Codex --- apps/desktop/src/main/runtime-host-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index ffbe09c44b..9f10d1772e 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -155,7 +155,6 @@ import { type TurnMessageSubmitInput, type TurnMessageSubmitResult, type WorkspaceProjection, - type WorkspaceTarget, } from "@maka/runtime-host/protocol"; const decodeStoredMessage = (value: unknown): StoredMessage => From ce14467f85b546e8e6c3a62535e135cc4325a278 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 22 Sep 2026 17:17:33 +0800 Subject: [PATCH 4/4] fix(runtime-host): reject conflicting import destinations Generated-by: Codex --- .../__tests__/runtime-host-client-uds.test.ts | 1 + .../runtime-host-desktop-candidate.test.ts | 1 + ...me-host-external-sessions-ipc-main.test.ts | 10 +++ .../main/runtime-host-desktop-candidate.ts | 9 +-- ...runtime-host-external-sessions-ipc-main.ts | 8 +-- .../external-session-coordinator.test.ts | 64 +++++++++++++++++++ .../server/external-session-coordinator.ts | 33 ++++++++-- 7 files changed, 108 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index 8af0e2e3ae..34600216a5 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -280,6 +280,7 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn workspace: { kind: 'host_path', path: base }, }), resolveSessionCreateProject: async () => ({ kind: 'host_path', path: base }), + resolveExternalSessionImportWorkspace: async () => ({ kind: 'host_path', path: base }), emitSessionsChanged: (_hostId, reason, sessionId) => changes.push({ reason, sessionId }), completeDesktopInteractionTurn() {}, createSessionCopyCleanup: () => ({ diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index a3fae5a713..73ad57e8c3 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -1262,6 +1262,7 @@ function deps( workspace: { kind: 'host_path', path: '/workspace' }, }), resolveSessionCreateProject: async () => ({ kind: 'host_path', path: '/workspace' }), + resolveExternalSessionImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }), emitSessionsChanged() {}, completeDesktopInteractionTurn() {}, createSessionCopyCleanup: () => ({ diff --git a/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts index 8a8689a405..d01ed832b5 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts @@ -124,6 +124,7 @@ test('an uncertain commit still asks the shell to re-read the catalog', async () }, }), emitSessionsChanged: (reason, sessionId) => events.push({ reason, sessionId }), + resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }), }, ipc, ); @@ -197,6 +198,7 @@ test('keeps catalog eligibility owned by the Host after an uncertain import', as }, }), emitSessionsChanged() {}, + resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }), }, ipc, ); @@ -236,6 +238,7 @@ test('a dispatched interrupted import has the same uncertain outcome as the Host }, }), emitSessionsChanged: (reason, sessionId) => events.push({ reason, sessionId }), + resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }), }, ipc, ); @@ -261,6 +264,7 @@ test('fails closed when a dispatched import response cannot be decoded', async ( }, }), emitSessionsChanged: (reason, sessionId) => events.push({ reason, sessionId }), + resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }), }, ipc, ); @@ -290,6 +294,7 @@ test('does not relabel an explicitly undispatched import as uncertain', async () }, }), emitSessionsChanged() {}, + resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }), }, ipc, ); @@ -320,6 +325,7 @@ test('maps a no-usable-model failure to a distinct, non-recovering reason', asyn }, }), emitSessionsChanged: (reason, sessionId) => events.push({ reason, sessionId }), + resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }), }, ipc, ); @@ -349,6 +355,7 @@ test('maps a pre-commit conversion failure to source_unreadable', async () => { }, }), emitSessionsChanged() {}, + resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }), }, ipc, ); @@ -374,6 +381,7 @@ test('maps a decoded source limit to IPC data without publishing a created Sessi registerRuntimeHostExternalSessionsIpc({ client: clientFixture({ importExternalSession: async () => wireResult }), emitSessionsChanged: (reason) => events.push(reason), + resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }), }, ipc); assert.deepEqual(await ipc.invoke('external-sessions:import', { @@ -401,6 +409,7 @@ test('rethrows import failures that have no distinct renderer reason', async () }, }), emitSessionsChanged() {}, + resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }), }, ipc, ); @@ -431,6 +440,7 @@ test('rejects malformed renderer requests before they reach the Host client', as }, }), emitSessionsChanged() {}, + resolveImportWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }), }, ipc, ); diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 6d77dfa517..b34415459b 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -150,7 +150,7 @@ export interface DesktopRuntimeHostCandidateDeps { target: DesktopRuntimeHostTargetPolicy, ) => Promise; /** Resolves the selected import destination on the target Host. */ - readonly resolveExternalSessionImportWorkspace?: ( + readonly resolveExternalSessionImportWorkspace: ( target: DesktopRuntimeHostTargetPolicy, ) => Promise; readonly emitSessionsChanged: ( @@ -901,12 +901,7 @@ export async function createDesktopRuntimeHostCandidate( { client, emitSessionsChanged, - ...(deps.resolveExternalSessionImportWorkspace - ? { - resolveImportWorkspace: () => - deps.resolveExternalSessionImportWorkspace!(target), - } - : {}), + resolveImportWorkspace: () => deps.resolveExternalSessionImportWorkspace(target), }, ipc, ); diff --git a/apps/desktop/src/main/runtime-host-external-sessions-ipc-main.ts b/apps/desktop/src/main/runtime-host-external-sessions-ipc-main.ts index 0a8016d6a7..c6efbef8b8 100644 --- a/apps/desktop/src/main/runtime-host-external-sessions-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-external-sessions-ipc-main.ts @@ -61,7 +61,7 @@ export interface RuntimeHostExternalSessionsIpcDeps { readonly client: ExternalSessionClient; readonly emitSessionsChanged: (reason: SessionChangedReason, sessionId?: string) => void; /** Resolves the Desktop-selected workspace on the target Host. */ - readonly resolveImportWorkspace?: () => Promise; + readonly resolveImportWorkspace: () => Promise; } export function registerRuntimeHostExternalSessionsIpc( @@ -84,13 +84,11 @@ export function registerRuntimeHostExternalSessionsIpc( }); ipcMain.handle('external-sessions:import', async (_event, input: unknown) => { const request = decodeExternalSessionImportInput(input); - const workspace = deps.resolveImportWorkspace - ? await deps.resolveImportWorkspace() - : undefined; + const workspace = await deps.resolveImportWorkspace(); try { const result = await deps.client.importExternalSession({ ...request, - ...(workspace === undefined ? {} : { workspace }), + workspace, }); if (result.kind === 'source_limit_exceeded') { return { diff --git a/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts b/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts index a150550ffe..5ec8d5d373 100644 --- a/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts @@ -38,6 +38,7 @@ import { type ExternalSessionCatalogPageQuery, type ExternalSessionSummary, } from '@maka/core/external-session'; +import type { WorkspaceTarget } from '../protocol/index.js'; import { type SessionHeader } from '@maka/core/session'; import { headerToSummary } from '@maka/runtime/session-manager'; import type { SessionCatalogRecord } from '@maka/storage/execution-stores'; @@ -673,6 +674,69 @@ test('coalesces a repeat import issued while the first is still running', async assert.equal(fixture.creates.length, 2); }); +for (const workspace of [ + { kind: 'host_path', path: '/workspace/A' }, + { kind: 'project', projectId: 'project-A' }, +] satisfies WorkspaceTarget[]) { + test(`coalesces concurrent imports into the same ${workspace.kind} destination`, async () => { + const fixture = coordinatorFixture([adapterFixture()]); + const request = { adapterId: 'codex', sourceSessionId: 'source-0', workspace }; + const [first, second] = await Promise.all([ + fixture.coordinator.importSession(request), + fixture.coordinator.importSession({ ...request, workspace: { ...workspace } }), + ]); + assert.equal(first.ok, true); + assert.deepEqual(second, first); + assert.equal(fixture.creates.length, 1); + }); +} + +for (const [firstWorkspace, secondWorkspace] of [ + [ + { kind: 'host_path', path: '/workspace/A' }, + { kind: 'host_path', path: '/workspace/B' }, + ], + [ + { kind: 'project', projectId: 'project-A' }, + { kind: 'project', projectId: 'project-B' }, + ], + [undefined, { kind: 'host_path', path: '/workspace/B' }], +] satisfies Array<[WorkspaceTarget | undefined, WorkspaceTarget]>) { + test(`rejects a conflicting import destination (${firstWorkspace?.kind ?? 'source cwd'})`, async () => { + const fixture = coordinatorFixture([adapterFixture()]); + const source = { adapterId: 'codex', sourceSessionId: 'source-0' }; + const [first, second] = await Promise.all([ + fixture.coordinator.importSession({ ...source, workspace: firstWorkspace }), + fixture.coordinator.importSession({ ...source, workspace: secondWorkspace }), + ]); + assert.equal(first.ok, true); + assert.deepEqual(second, { + ok: false, + error: { + code: 'operation_conflict', + message: 'This source is already being imported into a different workspace', + }, + }); + assert.equal(fixture.creates.length, 1); + assert.equal(fixture.drainRequests(), 0); + + // A conflict is scoped to the running import, not a permanent ban on + // making a second copy in the independently chosen destination. + const later = await fixture.coordinator.importSession({ + ...source, + workspace: secondWorkspace, + }); + assert.equal(later.ok, true); + assert.equal(fixture.creates.length, 2); + assert.equal( + secondWorkspace.kind === 'project' + ? fixture.creates[1]?.input.projectId + : fixture.creates[1]?.input.cwd, + secondWorkspace.kind === 'project' ? secondWorkspace.projectId : secondWorkspace.path, + ); + }); +} + test('reports conversion errors before persistence and store uncertainty after entry', async () => { let createAttempts = 0; const conversionFailure = coordinatorFixture( diff --git a/packages/runtime-host/src/server/external-session-coordinator.ts b/packages/runtime-host/src/server/external-session-coordinator.ts index 775fe77f35..a97a78631d 100644 --- a/packages/runtime-host/src/server/external-session-coordinator.ts +++ b/packages/runtime-host/src/server/external-session-coordinator.ts @@ -117,13 +117,17 @@ export class HostExternalSessionCoordinator { * be unmounted mid-import by design, and its in-flight state goes with it — * as would a second window's, or the CLI's. * - * Coalesced, not rejected: the second caller gets the first one's outcome, - * success or failure, because it is the same operation. Entries are keyed on - * a JSON pair so no separator can be forged out of the ids themselves. + * Requests for the same destination share the first outcome. A different + * destination is a conflicting intent: it must not receive a successful + * Session in a workspace it did not request. The source key also owns the + * catalog's isImporting projection, regardless of the chosen destination. */ readonly #importsInFlight = new Map< string, - Promise> + { + readonly workspace: ExternalSessionImportInput['workspace']; + readonly outcome: Promise>; + } >(); constructor(options: HostExternalSessionCoordinatorOptions) { @@ -266,9 +270,16 @@ export class HostExternalSessionCoordinator { ): Promise> { const key = importKey(input.adapterId, input.sourceSessionId); const running = this.#importsInFlight.get(key); - if (running) return running; + if (running) { + return sameImportWorkspace(running.workspace, input.workspace) + ? running.outcome + : importFailure( + 'operation_conflict', + 'This source is already being imported into a different workspace', + ); + } const attempt = this.#importSession(input); - this.#importsInFlight.set(key, attempt); + this.#importsInFlight.set(key, { workspace: input.workspace, outcome: attempt }); try { return await attempt; } finally { @@ -495,6 +506,16 @@ function importKey(adapterId: string, sourceSessionId: string): string { return JSON.stringify([adapterId, sourceSessionId]); } +function sameImportWorkspace( + left: ExternalSessionImportInput['workspace'], + right: ExternalSessionImportInput['workspace'], +): boolean { + if (left === undefined || right === undefined) return left === right; + return left.kind === 'project' + ? right.kind === 'project' && left.projectId === right.projectId + : right.kind === 'host_path' && left.path === right.path; +} + function queryFailure( code: OperationError<'external-session.catalog.query'>['code'], message: string,