From 0ac328c4e5e5bfdb19f355d14433792ca894fe87 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:36:33 -0700 Subject: [PATCH] fix(ssh): let a second desktop move onto a host another desktop already set up (#35879) Moving a desktop's SSH state onto the host's Orca server refused with orcad_migration_dormant_focus_conflict whenever the host already had a focused project from another desktop. Focus is each desktop's own selection, not saved state, so two desktops always differ and the second could never move, on every retry. The host now keeps its own focus as a unit and takes a desktop's focus only when it has none, which is what the merge already did for the fields it borrowed. Older servers still refuse until they update. Fixes #35585 --- .../orcad-destination-workspace-session.ts | 33 +++++------ ...ersistence-orcad-migration-catalog.test.ts | 59 +++++++++++++++++++ .../wsl-move-session-rewrite.test.ts | 3 - .../wsl-move-session-rewrite.ts | 5 +- 4 files changed, 74 insertions(+), 26 deletions(-) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts index c70330c6650..e11784bc2a8 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts @@ -42,31 +42,26 @@ export function prepareOrcadMigrationWorkspaceSession( return { incoming: undefined, merged: undefined } } assertNoSessionRecordConflicts(state.workspaceSession, incoming) - assertNoSessionFocusConflicts(state.workspaceSession, incoming) assertNoSessionEntityConflicts(state.workspaceSession, incoming) + const merged = mergeWorkspaceSessions(state.workspaceSession, incoming) + // Focus is one desktop's selection, not saved state: a host already focused keeps all of its own. return { incoming, - merged: mergeWorkspaceSessions(state.workspaceSession, incoming) + merged: hasSessionFocus(state.workspaceSession) + ? { ...merged, ...sessionFocus(state.workspaceSession) } + : merged } } -function assertNoSessionFocusConflicts( - existing: WorkspaceSessionState, - incoming: WorkspaceSessionState -): void { - for (const field of SESSION_FOCUS_FIELDS) { - const current = existing[field] - const next = incoming[field] - if ( - next !== undefined && - next !== null && - current !== undefined && - current !== null && - serializeOrcadMigrationValue(current) !== serializeOrcadMigrationValue(next) - ) { - throw new Error(`orcad_migration_dormant_focus_conflict:${field}`) - } - } +/** Every focus field, so none the host left unset is borrowed from the desktop. */ +function sessionFocus(session: WorkspaceSessionState): Partial { + return Object.fromEntries(SESSION_FOCUS_FIELDS.map((field) => [field, session[field] ?? null])) +} + +function hasSessionFocus(session: WorkspaceSessionState): boolean { + return SESSION_FOCUS_FIELDS.some( + (field) => session[field] !== undefined && session[field] !== null + ) } export function applyPreparedOrcadMigrationWorkspaceSession( diff --git a/src/main/persistence/migrating-orcad-catalog/persistence-orcad-migration-catalog.test.ts b/src/main/persistence/migrating-orcad-catalog/persistence-orcad-migration-catalog.test.ts index 7d9ee86314b..b465ff16692 100644 --- a/src/main/persistence/migrating-orcad-catalog/persistence-orcad-migration-catalog.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/persistence-orcad-migration-catalog.test.ts @@ -13,6 +13,7 @@ import { DORMANT_NAMESPACE, DORMANT_WORKTREE_ID, FOLDER_WORKSPACE, + PROJECT_GROUP, REPOSITORY, dormantState, manifest @@ -124,6 +125,64 @@ describe('orcad migration catalog persistence', () => { }) }) + // Two desktops on one host each carry their own focus; the second must not be refused for it. + it('takes a second desktop onto a host another desktop already moved, keeping the host focus whole', () => { + const store = createStore() + const first = manifest() + commitCatalog(store, manifest({ payload: { ...first.payload, dormantState: dormantState() } })) + // The host names only a project; the optional focus fields stay unset. + const { + activeWorkspaceKey: _key, + activeWorkspaceExecutionHostId: _host, + ...hostSession + } = store.getWorkspaceSession() + store.setWorkspaceSession({ + ...hostSession, + activeRepoId: REPOSITORY.id, + activeWorktreeId: null, + activeTabId: null + }) + + const secondRepo = { ...REPOSITORY, id: 'repo-2', path: '/srv/repo-2' } + const secondWorktree = `${secondRepo.id}::/srv/repo-2` + const second = manifest({ + migrationId: 'migration-second-desktop', + source: { sshTargetId: 'ssh-prod-b', sshTargetGeneration: 1, targetLabel: 'Production' }, + payload: { + repositories: [secondRepo], + projectGroups: [PROJECT_GROUP], + folderWorkspaces: [], + dormantState: { + version: 1, + worktreeMeta: [], + worktreeLineage: [], + workspaceLineage: [], + sparsePresets: [], + retiredWorktreeNames: [], + retiredWorktreeNamespaces: [], + workspaceSession: { + ...getDefaultWorkspaceSession(), + activeRepoId: secondRepo.id, + activeWorktreeId: secondWorktree, + activeWorkspaceKey: worktreeWorkspaceKey(secondWorktree), + activeWorkspaceExecutionHostId: 'local' + } + } + } + }) + + expect(commitCatalog(store, second)).toMatchObject({ state: 'committed' }) + expect(store.getRepos().map((repo) => repo.id)).toEqual([REPOSITORY.id, secondRepo.id]) + const focus = store.getWorkspaceSession() + expect(focus).toMatchObject({ + activeRepoId: REPOSITORY.id, + activeWorktreeId: null, + activeTabId: null + }) + expect(focus.activeWorkspaceKey ?? null).toBeNull() + expect(focus.activeWorkspaceExecutionHostId ?? null).toBeNull() + }) + it('stages durably without publishing rows, then commits catalog and receipt together', async () => { const store = createStore() const input = manifest() diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.test.ts index 87426f0a780..c5a04153170 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.test.ts @@ -354,8 +354,5 @@ describe('WSL move carried session focus', () => { activeWorktreeId: 'repo-on-host::/home/dev/other', activeTabId: 'host-tab' }) - expect(() => prepareOrcadMigrationWorkspaceSession(desktop, host)).toThrow( - 'orcad_migration_dormant_focus_conflict' - ) }) }) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.ts b/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.ts index 143d2f7b974..8aa701fc189 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-session-rewrite.ts @@ -143,10 +143,7 @@ function isSessionField(field: string): field is keyof WorkspaceSessionState { return Object.hasOwn(WORKSPACE_SESSION_FIELD_OWNERSHIP, field) } -/** - * This desktop's focus, which names nothing on the host: the host keeps its own, and a host that - * already has one refuses a manifest naming another (`orcad_migration_dormant_focus_conflict`). - */ +/** This desktop's focus, which names nothing on the host: the host keeps its own. */ export function withoutDesktopFocus(session: WorkspaceSessionState): WorkspaceSessionState { const { activeWorkspaceKey: _key, activeWorkspaceExecutionHostId: _host, ...rest } = session return { ...rest, activeRepoId: null, activeWorktreeId: null, activeTabId: null }