From ef563bccf4bb32fbf0e5a900677069a7d148e732 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 15 Oct 2026 01:23:39 -0700 Subject: [PATCH] fix(ssh): stop stale saved state from blocking an SSH host's move to a managed server (#29003) * fix(ssh): stop stale saved state from blocking an SSH host's move to a managed server Leftover records nobody can see or close (empty host tab rows beside the desktop's older copy, phone tab selections naming missing tabs, agent "seen" markers, client-hosted pages, isolated-profile browser tabs) now move or stay behind in the retained profile instead of refusing the move. What still refuses is live or unsaved work, and the refusal now names each item and how to clear it. Fixes #27281 Fixes #28946 Co-authored-by: akushonkamen * fix(ssh): build the export copy without editing saved layout fields in place --------- Co-authored-by: m4air Co-authored-by: akushonkamen --- .../orcad-source-client-browser-intents.ts | 2 +- .../orcad-source-client-state.test.ts | 22 +- .../orcad-source-client-state.ts | 101 ++-- .../orcad-source-dependency-census.ts | 8 +- .../orcad-source-dormant-state.ts | 14 +- ...ad-source-left-behind-session-rows.test.ts | 29 ++ .../orcad-source-left-behind-session-rows.ts | 141 ++++++ .../orcad-source-release-upgrade.test.ts | 15 +- .../orcad-source-scrollback-state.ts | 10 +- .../orcad-source-stale-state-move.test.ts | 434 ++++++++++++++++++ ...ad-source-workspace-session-eligibility.ts | 132 +++--- ...rcad-source-workspace-session-fragments.ts | 85 +++- .../orcad-source-workspace-session.ts | 64 ++- .../ssh/orcad-migration-refusal-reason.ts | 4 +- src/main/ssh/ssh-target-orcad-dependents.ts | 14 +- 15 files changed, 888 insertions(+), 187 deletions(-) create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-left-behind-session-rows.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-left-behind-session-rows.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-stale-state-move.test.ts diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts index b139d31d17c..46c1d2247e5 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts @@ -48,11 +48,11 @@ export function collectCloseIntents( } const worktreeId = unqualifyOrcadMigrationOwnerKey(intent.worktreeId) const key = `${sourceEnvironmentId}\0${intent.browserPageId}\0${worktreeId}` + // A close for a page the move does not carry stays with this client, which still owes it. if ( !eligiblePages.has(`${worktreeId}\0${intent.browserPageId}`) || !destinationEnvironmentId ) { - onBlocked() continue } if (seen.has(key)) { diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts index cf3f668e7b6..1e856879789 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts @@ -128,11 +128,7 @@ describe('source client-state migration', () => { 'environment-1', scoped ) - expect(result.blockedCounts).toEqual({ - 'mobile-tab-selection': 0, - 'ui-routing': 0, - 'saved-port-forward': 0 - }) + expect(result.blockedCounts).toEqual({ 'saved-port-forward': 0 }) expect(result.payload?.mobileClientTabSelectionsByDeviceId).toMatchObject({ phone: { 'repo-1::/srv/worktree': { activeTabId: 'tab-1' } } }) @@ -144,7 +140,7 @@ describe('source client-state migration', () => { }) }) - it('blocks a mobile selection that points at a group outside the migrated owner', () => { + it('leaves behind a mobile selection that points at a group outside the migrated owner', () => { const current = state() const sourceOwner = 'ssh:target-1|repo-1::/srv/worktree' const unrelatedOwner = 'ssh:target-1|repo-2::/srv/other' @@ -180,10 +176,10 @@ describe('source client-state migration', () => { ) expect(result.payload?.mobileClientTabSelectionsByDeviceId).toBeUndefined() - expect(result.blockedCounts['mobile-tab-selection']).toBe(1) + expect(result.blockedCount).toBe(0) }) - it('blocks a per-group tab selection whose group is outside the migrated owner', () => { + it('leaves behind a per-group tab selection whose group is outside the migrated owner', () => { const current = state() const sourceOwner = 'ssh:target-1|repo-1::/srv/worktree' const unrelatedOwner = 'ssh:target-1|repo-2::/srv/other' @@ -221,7 +217,7 @@ describe('source client-state migration', () => { ) expect(result.payload?.mobileClientTabSelectionsByDeviceId).toBeUndefined() - expect(result.blockedCounts['mobile-tab-selection']).toBe(1) + expect(result.blockedCount).toBe(0) }) it('captures saved forwards and reports duplicate local ports', () => { @@ -269,7 +265,8 @@ describe('source client-state migration', () => { current.workspaceSession ) - expect(result.blockedCount).toBe(1) + // The unrelated close stays with this client; it does not block. + expect(result.blockedCount).toBe(0) expect(result.payload?.clientHostedBrowserCloseIntents).toEqual([ { sourceEnvironmentId: 'old-environment', @@ -280,7 +277,7 @@ describe('source client-state migration', () => { ]) }) - it("ignores another host's agent acknowledgements and blocks only the source's own", () => { + it('leaves behind agent acknowledgements for panes that did not move', () => { const current = state() current.workspaceSession = session({ tabsByWorktree: { '/local/repo::/local/repo': [terminalTab('local-tab', '/local/repo')] } @@ -303,6 +300,7 @@ describe('source client-state migration', () => { 'environment-1', undefined ) - expect(result.blockedCounts['ui-routing']).toBe(1) + expect(result.payload?.uiRouting?.acknowledgedAgentsByPaneKey).toBeUndefined() + expect(result.blockedCounts).toEqual({ 'saved-port-forward': 0 }) }) }) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts index 2c3acd8011d..f35c18de6a4 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts @@ -25,13 +25,10 @@ import { paneBelongsToTabs } from '../../../shared/workspace-session-pane-owners export type OrcadMigrationSourceClientStateInspection = { payload: OrcadMigrationClientStatePayload | undefined - /** Invalid or unmatched closes are folded into the existing workspace-session blocker. */ + /** Duplicate closes are folded into the existing workspace-session blocker. */ blockedCount: number - blockedCounts: { - 'mobile-tab-selection': number - 'ui-routing': number - 'saved-port-forward': number - } + /** Mobile selections and sidebar routing are presentation: what can't move stays behind. */ + blockedCounts: { 'saved-port-forward': number } } export function collectOrcadMigrationSourceClientState( @@ -42,20 +39,10 @@ export function collectOrcadMigrationSourceClientState( eligibleSession: WorkspaceSessionState | undefined ): OrcadMigrationSourceClientStateInspection { const scope = createOrcadMigrationSourceScope({ source, catalog, repos: state.repos }) - const blockedCounts = { - 'mobile-tab-selection': 0, - 'ui-routing': 0, - 'saved-port-forward': 0 - } + const blockedCounts = { 'saved-port-forward': 0 } let blockedCount = 0 - const mobile = collectMobileSelections(state, scope, eligibleSession, blockedCounts) - const uiRouting = collectUiRouting( - state, - scope, - destinationEnvironmentId, - eligibleSession, - blockedCounts - ) + const mobile = collectMobileSelections(state, scope, eligibleSession) + const uiRouting = collectUiRouting(state, scope, destinationEnvironmentId, eligibleSession) const target = state.sshTargets.find((entry) => entry.id === scope.targetId) const savedPortForwards = target?.portForwards ? structuredClone(target.portForwards) : undefined if (savedPortForwards) { @@ -96,8 +83,7 @@ export function collectOrcadMigrationSourceClientState( function collectMobileSelections( state: PersistedState, scope: ReturnType, - session: WorkspaceSessionState | undefined, - blockedCounts: OrcadMigrationSourceClientStateInspection['blockedCounts'] + session: WorkspaceSessionState | undefined ): | NonNullable | undefined { @@ -115,16 +101,13 @@ function collectMobileSelections( continue } const destinationKey = unqualifyOrcadMigrationOwnerKey(ownerKey) - if (destinationKeys.has(`${deviceId}\0${destinationKey}`)) { - blockedCounts['mobile-tab-selection'] += 1 - continue - } - if (!mobileSelectionIsRepresentable(selection, eligible)) { - blockedCounts['mobile-tab-selection'] += 1 + const representable = representableMobileSelection(selection, eligible) + // A selection naming a tab that did not move, or a second copy for one worktree, stays behind. + if (!representable || destinationKeys.has(`${deviceId}\0${destinationKey}`)) { continue } destinationKeys.add(`${deviceId}\0${destinationKey}`) - projected[destinationKey] = structuredClone(selection) + projected[destinationKey] = representable } if (Object.keys(projected).length > 0) { result[deviceId] = projected @@ -137,8 +120,7 @@ function collectUiRouting( state: PersistedState, scope: ReturnType, destinationEnvironmentId: string | undefined, - session: WorkspaceSessionState | undefined, - blockedCounts: OrcadMigrationSourceClientStateInspection['blockedCounts'] + session: WorkspaceSessionState | undefined ): OrcadMigrationUiRoutingState | undefined { const ui = state.ui const result: OrcadMigrationUiRoutingState = {} @@ -202,15 +184,12 @@ function collectUiRouting( const sourceTabIds = collectSourceOwnedTabIds(state, scope) const acknowledgements = Object.entries(ui.acknowledgedAgentsByPaneKey ?? {}).filter( ([paneKey]) => { - // Another host's acknowledgement is not this source's state to move or block on. - if (!paneBelongsToTabs(paneKey, sourceTabIds)) { - return false - } - const allowed = eligible ? paneBelongsToTabs(paneKey, eligible.tabIds) : false - if (!allowed) { - blockedCounts['ui-routing'] += 1 - } - return allowed + // An acknowledgement for a pane that did not move stays behind with that pane. + return ( + paneBelongsToTabs(paneKey, sourceTabIds) && + eligible !== null && + paneBelongsToTabs(paneKey, eligible.tabIds) + ) } ) if (acknowledgements.length > 0) { @@ -249,6 +228,11 @@ function collectEligibleSessionIdentity( } tabs.forEach((tab) => tabIds.add(tab.id)) } + for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { + if (orcadMigrationOwnerMatchesScope(ownerKey, scope)) { + tabs.forEach((tab) => tabIds.add(tab.id)) + } + } for (const [ownerKey, groups] of Object.entries(session.tabGroups ?? {})) { if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { continue @@ -258,24 +242,31 @@ function collectEligibleSessionIdentity( return { tabIds, groupIds } } -function mobileSelectionIsRepresentable( +/** The selection without references to tabs or groups that did not move; null when none is left. */ +function representableMobileSelection( selection: PersistedMobileClientTabSelection, eligible: EligibleSessionIdentity | null -): boolean { - if (!eligible) { - return ( - selection.activeTabId === null && - selection.activeGroupId === null && - Object.keys(selection.activeTabIdByGroupId).length === 0 +): PersistedMobileClientTabSelection | null { + const tabIds = eligible?.tabIds ?? new Set() + const groupIds = eligible?.groupIds ?? new Set() + const next: PersistedMobileClientTabSelection = { + activeTabId: + selection.activeTabId !== null && tabIds.has(selection.activeTabId) + ? selection.activeTabId + : null, + activeGroupId: + selection.activeGroupId !== null && groupIds.has(selection.activeGroupId) + ? selection.activeGroupId + : null, + activeTabIdByGroupId: Object.fromEntries( + Object.entries(selection.activeTabIdByGroupId).filter( + ([groupId, tabId]) => groupIds.has(groupId) && tabIds.has(tabId) + ) ) } - if (selection.activeTabId !== null && !eligible.tabIds.has(selection.activeTabId)) { - return false - } - if (selection.activeGroupId !== null && !eligible.groupIds.has(selection.activeGroupId)) { - return false - } - return Object.entries(selection.activeTabIdByGroupId).every( - ([groupId, tabId]) => eligible.groupIds.has(groupId) && eligible.tabIds.has(tabId) - ) + return next.activeTabId === null && + next.activeGroupId === null && + Object.keys(next.activeTabIdByGroupId).length === 0 + ? null + : next } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts index ec5293b3c60..12d4d00280f 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts @@ -21,6 +21,8 @@ import { paneBelongsToTabs } from '../../../shared/workspace-session-pane-owners export type OrcadMigrationSourceDependencyCensus = { totalCount: number counts: Record + /** For the kinds it can name: what blocks and how to clear it. */ + names?: Partial> } /** What still references the target that this manifest cannot carry. */ @@ -58,7 +60,11 @@ export function collectOrcadMigrationUntransferredDependencyCensus( } return { counts, - totalCount: ORCAD_MIGRATION_DEPENDENCY_KINDS.reduce((total, kind) => total + counts[kind], 0) + totalCount: ORCAD_MIGRATION_DEPENDENCY_KINDS.reduce((total, kind) => total + counts[kind], 0), + names: + dormant.blockedSessionItems.length > 0 + ? { 'workspace-session': dormant.blockedSessionItems } + : {} } } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts index 5118a47103f..98d4c9f8749 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts @@ -41,6 +41,8 @@ type TransferredDormantKind = (typeof ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS) export type OrcadMigrationSourceDormantInspection = { payload: OrcadMigrationDormantStatePayload blockedCounts: Record + /** What blocks the saved tabs and panes, each with the way to clear it. */ + blockedSessionItems: string[] } export function collectOrcadMigrationSourceDormantState( @@ -160,7 +162,7 @@ export function collectOrcadMigrationSourceDormantState( catalog, storage ) - blockedCounts['workspace-session'] = workspaceSession.blockedCount + const blockedSessionItems = [...workspaceSession.blockedItems] const automationState = collectOrcadMigrationSourceAutomationState(state, source, catalog) blockedCounts.automation = automationState.blockedAutomationCount blockedCounts['automation-run'] = automationState.blockedRunCount @@ -171,10 +173,11 @@ export function collectOrcadMigrationSourceDormantState( destinationEnvironmentId, workspaceSession.payload ) - blockedCounts['mobile-tab-selection'] = clientState.blockedCounts['mobile-tab-selection'] - blockedCounts['ui-routing'] = clientState.blockedCounts['ui-routing'] blockedCounts['saved-port-forward'] = clientState.blockedCounts['saved-port-forward'] - blockedCounts['workspace-session'] += clientState.blockedCount + blockedSessionItems.push( + ...Array.from({ length: clientState.blockedCount }, () => 'a browser page close saved twice') + ) + blockedCounts['workspace-session'] = blockedSessionItems.length return { payload: { version: 1, @@ -202,7 +205,8 @@ export function collectOrcadMigrationSourceDormantState( : {}), ...(clientState.payload ? { clientState: clientState.payload } : {}) }, - blockedCounts + blockedCounts, + blockedSessionItems } } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-left-behind-session-rows.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-left-behind-session-rows.test.ts new file mode 100644 index 00000000000..581248f51b8 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-left-behind-session-rows.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_LOCAL_ORCA_PROFILE_ID, + getOrcaProfileBrowserDefaultPartition, + getOrcaProfileBrowserSessionPartition +} from '../../../shared/orca-profiles' +import { isOrcaProfileBrowserDefaultPartition } from './orcad-source-left-behind-session-rows' + +describe('isOrcaProfileBrowserDefaultPartition', () => { + it('recognizes every Orca profile default partition', () => { + for (const profileId of [DEFAULT_LOCAL_ORCA_PROFILE_ID, 'work-profile']) { + expect( + isOrcaProfileBrowserDefaultPartition(getOrcaProfileBrowserDefaultPartition(profileId)) + ).toBe(true) + } + }) + + it('rejects session-profile partitions, even one named like a default', () => { + for (const profileId of [DEFAULT_LOCAL_ORCA_PROFILE_ID, 'work-profile']) { + for (const sessionId of ['isolated-1', 'x-browser-default']) { + expect( + isOrcaProfileBrowserDefaultPartition( + getOrcaProfileBrowserSessionPartition(profileId, sessionId) + ) + ).toBe(false) + } + } + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-left-behind-session-rows.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-left-behind-session-rows.ts new file mode 100644 index 00000000000..cda753c0c27 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-left-behind-session-rows.ts @@ -0,0 +1,141 @@ +import type { BrowserWorkspace } from '../../../shared/browser-workspace-types' +import type { TabGroup } from '../../../shared/tab-types' +import { ORCA_BROWSER_PARTITION } from '../../../shared/constants' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { ownedEditorFileId } from '../../../shared/workspace-session-pane-ownership' +import { + orcadMigrationOwnerMatchesScope, + type OrcadMigrationSourceScope +} from './orcad-source-scope' + +/** + * Saved rows the managed server cannot hold and that are no one's live work, removed from a copy + * of one partition before export. The profile itself keeps them, so a downgrade still has them. + */ +export function withoutLeftBehindSessionRows( + session: WorkspaceSessionState, + scope: OrcadMigrationSourceScope +): WorkspaceSessionState { + const owns = (ownerKey: string): boolean => orcadMigrationOwnerMatchesScope(ownerKey, scope) + const isolated = new Set( + Object.entries(session.browserTabsByWorktree ?? {}).flatMap(([ownerKey, workspaces]) => + owns(ownerKey) ? workspaces.filter(usesSessionProfile).map((workspace) => workspace.id) : [] + ) + ) + const hiddenFiles = hiddenHostBoundFiles(session, owns) + if (isolated.size === 0 && hiddenFiles.size === 0) { + return session + } + const keepsFile = (ownerKey: string, filePath: string): boolean => + !hiddenFiles.get(ownerKey)?.has(filePath) + const droppedTabIds = new Set( + Object.values(session.unifiedTabs ?? {}) + .flat() + .filter((tab) => tab.contentType === 'browser' && isolated.has(tab.entityId)) + .map((tab) => tab.id) + ) + // Built as a new object: this is an export copy, never a write to the saved layout. + return structuredClone({ + ...session, + browserTabsByWorktree: mapRows(session.browserTabsByWorktree, (_key, workspaces) => + workspaces.filter((workspace) => !isolated.has(workspace.id)) + ), + browserPagesByWorkspace: Object.fromEntries( + Object.entries(session.browserPagesByWorkspace ?? {}).filter(([id]) => !isolated.has(id)) + ), + activeBrowserTabIdByWorktree: Object.fromEntries( + Object.entries(session.activeBrowserTabIdByWorktree ?? {}).filter( + ([, id]) => !isolated.has(id ?? '') + ) + ), + unifiedTabs: mapRows(session.unifiedTabs, (_key, tabs) => + tabs.filter((tab) => !droppedTabIds.has(tab.id)) + ), + tabGroups: mapRows(session.tabGroups, (_key, groups) => + groups.map((group) => withoutTabs(group, droppedTabIds)) + ), + openFilesByWorktree: mapRows(session.openFilesByWorktree, (ownerKey, files) => + files.filter((file) => keepsFile(ownerKey, file.filePath)) + ), + activeFileIdByWorktree: Object.fromEntries( + Object.entries(session.activeFileIdByWorktree ?? {}).filter( + ([ownerKey, fileId]) => !fileId || keepsFile(ownerKey, fileId) + ) + ) + }) +} + +function mapRows( + rows: Record | undefined, + map: (ownerKey: string, row: T[]) => T[] +): Record | undefined { + return rows + ? Object.fromEntries(Object.entries(rows).map(([key, row]) => [key, map(key, row)])) + : rows +} + +function withoutTabs(group: TabGroup, tabIds: ReadonlySet): TabGroup { + const tabOrder = group.tabOrder.filter((id) => !tabIds.has(id)) + const recentTabIds = group.recentTabIds?.filter((id) => !tabIds.has(id)) + const activeTabId = + group.activeTabId && tabIds.has(group.activeTabId) + ? (recentTabIds?.at(-1) ?? tabOrder[0] ?? null) + : group.activeTabId + return { ...group, tabOrder, ...(recentTabIds ? { recentTabIds } : {}), activeTabId } +} + +/** Whether a partition is some Orca profile's shared browser default rather than a session profile. */ +export function isOrcaProfileBrowserDefaultPartition(partition: string): boolean { + return ( + partition === ORCA_BROWSER_PARTITION || + (partition.startsWith('persist:orca-profile-') && + partition.endsWith('-browser-default') && + !partition.includes('-browser-session-')) + ) +} + +/** + * Isolated or imported login profiles are this desktop's alone and the server stores none, so the + * tab stays behind rather than reopen on the shared profile. A tab switched back to Default keeps + * the default partition and moves as usual. + */ +function usesSessionProfile(workspace: BrowserWorkspace): boolean { + const { sessionProfileId, sessionPartition } = workspace + return ( + (sessionProfileId ? sessionProfileId !== 'default' : false) || + (sessionPartition ? !isOrcaProfileBrowserDefaultPartition(sessionPartition) : false) + ) +} + +/** + * Clean editor files bound to another host that no tab shows. The user cannot see or close them, + * and the server cannot hold them; one with an unsaved draft is never in this set. + */ +function hiddenHostBoundFiles( + session: WorkspaceSessionState, + owns: (ownerKey: string) => boolean +): Map> { + const hidden = new Map>() + // Without the unified model, hydration shows every saved file as a tab. + if (!session.unifiedTabs || !session.tabGroups) { + return hidden + } + for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { + if (!owns(ownerKey)) { + continue + } + const shown = new Set( + (session.unifiedTabs[ownerKey] ?? []).flatMap((tab) => [tab.id, tab.entityId]) + ) + for (const file of files) { + const hostBound = file.externalSshTargetId !== undefined || Boolean(file.runtimeEnvironmentId) + const visible = + shown.has(file.filePath) || + shown.has(ownedEditorFileId(file.filePath, ownerKey, file.runtimeEnvironmentId)) + if (hostBound && file.dirtyDraftContent === undefined && !visible) { + hidden.set(ownerKey, (hidden.get(ownerKey) ?? new Set()).add(file.filePath)) + } + } + } + return hidden +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts index 503c3673fc3..1b1d7a39b87 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts @@ -259,10 +259,23 @@ describe('migrating saved state from released SSH clients', () => { expect(census(state).counts['workspace-session']).toBe(1) }) - it('refuses when the host holds an empty tab list and local holds tabs', () => { + // #27281: the app shows the local tabs here, because an empty row is not a close. + it('exports the local tabs when the host holds an empty tab list', () => { const { state, local, host } = split() host.tabsByWorktree = { [WORKTREE]: [] } local.tabsByWorktree = { [WORKTREE]: [terminal('local')] } + expect(census(state).totalCount).toBe(0) + const session = exportState(state).payload.dormantState?.workspaceSession + expect(session?.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['local']) + }) + + it('refuses an empty host row beside local tabs the host recorded as closed', () => { + const { state, local, host } = split() + host.tabsByWorktree = { [WORKTREE]: [] } + host.closedTerminalTabTombstonesByTabId = { + local: { worktreeId: WORKTREE, reason: 'user', closedAt: Date.now() } + } + local.tabsByWorktree = { [WORKTREE]: [terminal('local')] } expect(census(state).counts['workspace-session']).toBe(1) }) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts index b5480507c2b..cc6e0e02f7f 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts @@ -17,7 +17,7 @@ import { compareKeys } from './orcad-source-key-order' export type ProjectedOrcadMigrationScrollback = { session: WorkspaceSessionState snapshots: OrcadMigrationTerminalScrollbackSnapshot[] - blockedCount: number + blockedItems: string[] } export function hasDuplicateOrcadMigrationScrollbackDescriptors( @@ -42,7 +42,7 @@ export function projectOrcadMigrationSessionScrollback( ): ProjectedOrcadMigrationScrollback { const projected = structuredClone(session) const snapshots: OrcadMigrationTerminalScrollbackSnapshot[] = [] - let blockedCount = 0 + const blockedItems: string[] = [] let totalBytes = 0 for (const [tabId, layout] of Object.entries(projected.terminalLayoutsByTabId ?? {})) { const sourceLayout = session.terminalLayoutsByTabId[tabId] @@ -66,7 +66,7 @@ export function projectOrcadMigrationSessionScrollback( bytes.length === 0 || bytes.length > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT ) { - blockedCount += 1 + blockedItems.push(`saved terminal history Orca could not read for tab ${tabId}`) continue } totalBytes += bytes.length @@ -90,12 +90,12 @@ export function projectOrcadMigrationSessionScrollback( snapshots.length > MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS || totalBytes > MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES ) { - blockedCount += 1 + blockedItems.push('more saved terminal history than one move carries: close some terminal tabs') } snapshots.sort((left, right) => compareKeys(`${left.tabId}\0${left.leafId}`, `${right.tabId}\0${right.leafId}`) ) - return { session: projected, snapshots, blockedCount } + return { session: projected, snapshots, blockedItems } } export function readOrcadMigrationSourceScrollbackChunk(args: { diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-stale-state-move.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-stale-state-move.test.ts new file mode 100644 index 00000000000..93cc6b21e37 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-stale-state-move.test.ts @@ -0,0 +1,434 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' +import { + CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, + type PersistedClientHostedBrowserPage +} from '../../../shared/client-hosted-browser-page-record' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Tab, TabGroup } from '../../../shared/tab-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { PersistedOpenFile } from '../../../shared/workspace-session-state-types' +import { createOrcadMigrationManifest } from '../../ssh/orcad-migration-manifest-export' +import { orcadMigrationRefusalReason } from '../../ssh/orcad-migration-refusal-reason' +import { collectUntransferredDependentBlockers } from '../../ssh/ssh-target-orcad-dependents' +import { collectOrcadMigrationUntransferredDependencyCensus } from './orcad-source-dependency-census' +import { collectOrcadMigrationSourceDormantState } from './orcad-source-dormant-state' + +const TARGET = { id: 'ssh-prod', label: 'Prod', host: 'prod', port: 22, username: 'u' } +const HOST = 'ssh:ssh-prod' +const WORKTREE_A = 'remote-repo::/srv/repo' +const WORKTREE_B = 'remote-repo::/srv/repo-b' + +function sourceState(): PersistedState { + const state = getDefaultPersistedState('/home/user') + state.sshTargets = [TARGET] + state.repos = [ + { + id: 'remote-repo', + path: '/srv/repo', + displayName: 'Remote', + badgeColor: '#737373', + addedAt: 1, + connectionId: TARGET.id + } + ] + state.workspaceSessionsByHostId = { [HOST]: getDefaultWorkspaceSession() } + return state +} + +function host(state: PersistedState) { + return state.workspaceSessionsByHostId![HOST]! +} + +function terminal(id: string, worktreeId = WORKTREE_A): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function openFile(overrides: Partial = {}): PersistedOpenFile { + return { + filePath: '/srv/repo/notes.md', + relativePath: 'notes.md', + worktreeId: WORKTREE_A, + language: 'markdown', + ...overrides + } +} + +function browserWorkspace( + id: string, + worktreeId: string, + overrides: Partial = {} +): BrowserWorkspace { + return { + id, + worktreeId, + activePageId: `${id}-page`, + pageIds: [`${id}-page`], + url: 'https://example.test/', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1, + ...overrides + } +} + +function browserPage(workspaceId: string, worktreeId: string): BrowserPage { + return { + id: `${workspaceId}-page`, + workspaceId, + worktreeId, + url: 'https://example.test/', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } +} + +function unifiedTab( + id: string, + contentType: Tab['contentType'], + entityId = id, + overrides: Partial = {} +): Tab { + return { + id, + entityId, + groupId: 'group-1', + worktreeId: WORKTREE_A, + contentType, + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function group(tabOrder: string[], activeTabId: string): TabGroup { + return { id: 'group-1', worktreeId: WORKTREE_A, activeTabId, tabOrder, recentTabIds: tabOrder } +} + +function exportState(state: PersistedState) { + return createOrcadMigrationManifest( + { + getRepos: () => state.repos, + getFolderWorkspaces: () => state.folderWorkspaces, + getProjectGroups: () => state.projectGroups, + collectOrcadMigrationSourceDormantState: (source, catalog, destination) => + collectOrcadMigrationSourceDormantState(state, source, catalog, undefined, destination) + .payload + }, + TARGET, + { destinationEnvironmentId: 'managed-prod' } + ) +} + +function census(state: PersistedState) { + return collectOrcadMigrationUntransferredDependencyCensus(state, exportState(state)) +} + +/** The Details line Settings shows for a refused move. */ +function refusal(state: PersistedState): string { + const manifest = exportState(state) + return orcadMigrationRefusalReason( + collectUntransferredDependentBlockers( + { + inspectOrcadMigrationUntransferredDependencies: (candidate) => + collectOrcadMigrationUntransferredDependencyCensus(state, candidate) + }, + manifest + ) + ) +} + +describe('#27281: saved tabs with no visible tab to close', () => { + function reported(): PersistedState { + const state = sourceState() + const partition = host(state) + // Every tab closed: the host copy keeps empty rows for each worktree. + partition.tabsByWorktree = { [WORKTREE_A]: [], [WORKTREE_B]: [] } + partition.unifiedTabs = { [WORKTREE_A]: [], [WORKTREE_B]: [] } + partition.tabGroups = { [WORKTREE_A]: [], [WORKTREE_B]: [] } + partition.openFilesByWorktree = { [WORKTREE_A]: [openFile()] } + partition.browserTabsByWorktree = { [WORKTREE_B]: [browserWorkspace('browser-1', WORKTREE_B)] } + partition.browserPagesByWorkspace = { 'browser-1': [browserPage('browser-1', WORKTREE_B)] } + // Weeks-old terminals from before sessions were kept per host, in the desktop's own copy. + state.workspaceSession.tabsByWorktree = { + [WORKTREE_A]: [terminal('legacy-1'), terminal('legacy-2')], + [WORKTREE_B]: [terminal('legacy-3', WORKTREE_B)] + } + return state + } + + it('moves, carrying every leftover record instead of refusing', () => { + const state = reported() + const before = structuredClone(state) + + expect(census(state).totalCount).toBe(0) + expect(refusal(state)).toBe('This SSH host cannot move yet.') + const session = exportState(state).payload.dormantState?.workspaceSession + expect(session?.tabsByWorktree[WORKTREE_A]?.map((tab) => tab.id)).toEqual([ + 'legacy-1', + 'legacy-2' + ]) + expect(session?.tabsByWorktree[WORKTREE_B]?.map((tab) => tab.id)).toEqual(['legacy-3']) + expect(session?.openFilesByWorktree?.[WORKTREE_A]).toEqual([openFile()]) + expect(session?.browserTabsByWorktree?.[WORKTREE_B]?.map((tab) => tab.id)).toEqual([ + 'browser-1' + ]) + expect(state).toEqual(before) + }) + + // From #27526 (akushonkamen): the move never carries client-hosted pages. + it('does not refuse on a client-hosted page whose browser tab is gone', () => { + const state = sourceState() + const page: PersistedClientHostedBrowserPage = { + v: CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, + browserPageId: 'page-1', + workspaceId: 'gone-browser-workspace', + browserProfileId: 'profile-1', + url: 'https://example.test/', + title: 'Example', + pairedDeviceId: 'device-1', + savedAt: 1 + } + host(state).tabsByWorktree = { [WORKTREE_A]: [terminal('tab-1')] } + host(state).clientHostedBrowserPagesByWorktree = { [WORKTREE_A]: [page] } + const before = structuredClone(state) + + expect(census(state).totalCount).toBe(0) + const session = exportState(state).payload.dormantState?.workspaceSession + expect(session?.clientHostedBrowserPagesByWorktree ?? {}).toEqual({}) + expect(state).toEqual(before) + }) +}) + +describe('#28946: stale tab selections and isolated browser profiles', () => { + it('leaves behind mobile selections that point at missing tabs', () => { + const state = sourceState() + host(state).tabsByWorktree = { [WORKTREE_A]: [terminal('tab-1')] } + state.mobileClientTabSelectionsByDeviceId = { + 'runtime-device': { + [`${HOST}|${WORKTREE_A}`]: { + activeTabId: 'missing-1', + activeGroupId: null, + activeTabIdByGroupId: {} + }, + [`${HOST}|${WORKTREE_B}`]: { + activeTabId: 'missing-2', + activeGroupId: 'missing-group', + activeTabIdByGroupId: { 'missing-group': 'missing-3' } + } + } + } + const before = structuredClone(state) + + expect(census(state).totalCount).toBe(0) + const clientState = exportState(state).payload.dormantState?.clientState + expect(clientState?.mobileClientTabSelectionsByDeviceId).toBeUndefined() + // The retained profile keeps them, device registration included. + expect(state).toEqual(before) + }) + + it('keeps the part of a selection that still names a moved tab', () => { + const state = sourceState() + host(state).tabsByWorktree = { [WORKTREE_A]: [terminal('tab-1')] } + state.mobileClientTabSelectionsByDeviceId = { + phone: { + [`${HOST}|${WORKTREE_A}`]: { + activeTabId: 'tab-1', + activeGroupId: 'missing-group', + activeTabIdByGroupId: {} + } + } + } + expect( + exportState(state).payload.dormantState?.clientState?.mobileClientTabSelectionsByDeviceId + ).toEqual({ + phone: { + [WORKTREE_A]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} } + } + }) + }) + + function isolated(): PersistedState { + const state = sourceState() + const partition = host(state) + partition.tabsByWorktree = { [WORKTREE_A]: [terminal('tab-1')] } + partition.terminalLayoutsByTabId = { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf' }, + activeLeafId: 'leaf', + expandedLeafId: null + } + } + partition.browserTabsByWorktree = { + [WORKTREE_A]: [ + browserWorkspace('browser-work', WORKTREE_A, { + sessionProfileId: 'work', + sessionPartition: 'persist:orca-browser-session-work' + }), + browserWorkspace('browser-shared', WORKTREE_A) + ] + } + partition.browserPagesByWorkspace = { + 'browser-work': [browserPage('browser-work', WORKTREE_A)], + 'browser-shared': [browserPage('browser-shared', WORKTREE_A)] + } + partition.activeBrowserTabIdByWorktree = { [WORKTREE_A]: 'browser-work' } + partition.unifiedTabs = { + [WORKTREE_A]: [ + unifiedTab('tab-1', 'terminal'), + unifiedTab('browser-tab-work', 'browser', 'browser-work'), + unifiedTab('browser-tab-shared', 'browser', 'browser-shared') + ] + } + partition.tabGroups = { + [WORKTREE_A]: [group(['tab-1', 'browser-tab-work', 'browser-tab-shared'], 'browser-tab-work')] + } + state.ui.acknowledgedAgentsByPaneKey = { 'tab-1:leaf': 1, 'tab-1:leaf-2': 2, 'tab-1:leaf-3': 3 } + return state + } + + it('moves without the isolated-profile browser tab, and without a sidebar refusal', () => { + const state = isolated() + const before = structuredClone(state) + + expect(census(state).totalCount).toBe(0) + const dormant = exportState(state).payload.dormantState + const session = dormant?.workspaceSession + expect(session?.browserTabsByWorktree?.[WORKTREE_A]?.map((tab) => tab.id)).toEqual([ + 'browser-shared' + ]) + expect(Object.keys(session?.browserPagesByWorkspace ?? {})).toEqual(['browser-shared']) + expect(session?.activeBrowserTabIdByWorktree?.[WORKTREE_A]).toBeUndefined() + expect(session?.unifiedTabs?.[WORKTREE_A]?.map((tab) => tab.id)).toEqual([ + 'tab-1', + 'browser-tab-shared' + ]) + expect(session?.tabGroups?.[WORKTREE_A]).toEqual([ + { + id: 'group-1', + worktreeId: WORKTREE_A, + activeTabId: 'browser-tab-shared', + tabOrder: ['tab-1', 'browser-tab-shared'], + recentTabIds: ['tab-1', 'browser-tab-shared'] + } + ]) + // The acknowledgements were only ever refused as a knock-on of the browser tab. + expect(dormant?.clientState?.uiRouting?.acknowledgedAgentsByPaneKey).toEqual( + state.ui.acknowledgedAgentsByPaneKey + ) + // The retained profile keeps the isolated tab and its profile. + expect(state).toEqual(before) + }) + + it('carries a tab switched back to the shared default profile', () => { + const state = isolated() + host(state).browserTabsByWorktree![WORKTREE_A][0] = browserWorkspace( + 'browser-work', + WORKTREE_A, + { sessionProfileId: null, sessionPartition: 'persist:orca-browser' } + ) + + expect(census(state).totalCount).toBe(0) + const session = exportState(state).payload.dormantState?.workspaceSession + expect(session?.browserTabsByWorktree?.[WORKTREE_A]?.map((tab) => tab.id)).toEqual([ + 'browser-work', + 'browser-shared' + ]) + }) +}) + +describe('live work still refuses, naming what blocks', () => { + it('refuses an unsaved draft the server cannot hold and says how to clear it', () => { + const state = sourceState() + host(state).openFilesByWorktree = { + [WORKTREE_A]: [ + openFile({ + filePath: '/etc/hosts', + externalSshTargetId: TARGET.id, + dirtyDraftContent: 'unsaved' + }) + ] + } + + expect(census(state).counts['workspace-session']).toBe(1) + expect(refusal(state)).toBe( + 'This SSH host cannot move yet: saved tabs and panes (unsaved changes to /etc/hosts in /srv/repo: save or discard them).' + ) + }) + + it('refuses a tab another host runs and names it', () => { + const state = sourceState() + host(state).unifiedTabs = { + [WORKTREE_A]: [unifiedTab('tab-1', 'terminal', 'tab-1', { executionHostId: 'runtime:env-2' })] + } + host(state).tabGroups = { [WORKTREE_A]: [group(['tab-1'], 'tab-1')] } + + expect(refusal(state)).toBe( + 'This SSH host cannot move yet: saved tabs and panes (the tab "tab-1" in /srv/repo, which another host runs: close it).' + ) + }) + + it('refuses a resume record behind a worker fence', () => { + const state = sourceState() + // Older profiles still carry the fence, which the record type no longer models. + const fenced = { automaticResumeBlockedBy: 'worker-owner' } + host(state).sleepingAgentSessionsByPaneKey = { + 'closed:leaf': { + ...fenced, + paneKey: 'closed:leaf', + tabId: 'closed', + worktreeId: WORKTREE_A, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'Saved prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'quit' + } + } + + expect(refusal(state)).toContain('a codex session saved in /srv/repo') + }) + + it('leaves behind a clean host-bound editor file no tab shows, but refuses a visible one', () => { + const state = sourceState() + const external = openFile({ filePath: '/etc/hosts', externalSshTargetId: TARGET.id }) + host(state).openFilesByWorktree = { [WORKTREE_A]: [external] } + host(state).unifiedTabs = { [WORKTREE_A]: [] } + host(state).tabGroups = { [WORKTREE_A]: [] } + + expect(census(state).totalCount).toBe(0) + expect( + exportState(state).payload.dormantState?.workspaceSession?.openFilesByWorktree?.[WORKTREE_A] + ).toEqual([]) + + host(state).unifiedTabs = { [WORKTREE_A]: [unifiedTab('/etc/hosts', 'editor')] } + host(state).tabGroups = { [WORKTREE_A]: [group(['/etc/hosts'], '/etc/hosts')] } + expect(refusal(state)).toBe( + 'This SSH host cannot move yet: saved tabs and panes (the editor tab for /etc/hosts, which another host opened: close it).' + ) + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts index 6e5f98e6df5..7506b59c7ba 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts @@ -1,8 +1,10 @@ import { isWorkspaceKey } from '../../../shared/workspace-scope' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { buildMarkdownFrontmatterIdMap } from '../../orca-profiles/profile-session-owner-transfer' +import type { + PersistedOpenFile, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' import { orcadMigrationOwnerMatchesScope, orcadMigrationOwnsRepoId, @@ -13,99 +15,93 @@ import { paneBelongsToTabs, paneBelongsToTerminalLayout } from '../../../shared/workspace-session-pane-ownership' -import { collectSessionOwnerKeys } from './orcad-source-workspace-session-fragments' -export function countUnrepresentableMarkdownState( - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope, - sourceHostPartition: boolean -): number { - const projection = { - mapOwnerKey: (ownerKey: string) => - sourceHostPartition || orcadMigrationOwnerMatchesScope(ownerKey, scope) - ? unqualifyOrcadMigrationOwnerKey(ownerKey) - : null, - mapWorktreeId: unqualifyOrcadMigrationOwnerKey - } - const mappings = buildMarkdownFrontmatterIdMap(session.openFilesByWorktree, projection) - return Object.keys(session.markdownFrontmatterVisible ?? {}).filter( - (fileId) => mappings.get(fileId) === null - ).length -} - -export function countUnsupportedSessionState( +/** + * What on the source the move must not drop or cannot carry, each named for the refusal with the + * way out. Stale or unowned rows are not here: they stay in the retained profile. + */ +export function collectUnsupportedSessionItems( session: WorkspaceSessionState, scope: OrcadMigrationSourceScope, sourceHostPartition: boolean, terminalTabIds: ReadonlySet, /** Every partition's source terminal tabs: a record is orphaned only when none lists its tab. */ sourceTerminalTabIds: ReadonlySet -): number { +): string[] { const owns = (ownerKey: string): boolean => orcadMigrationOwnerMatchesScope(ownerKey, scope) const matches = (ownerKey: string): boolean => Boolean(ownerKey) && (sourceHostPartition || owns(ownerKey)) - let count = sourceHostPartition - ? [...collectSessionOwnerKeys(session)].filter((ownerKey) => !owns(ownerKey)).length - : 0 + const items: string[] = [] + // Another project's rows in this host's copy stay behind; only an unsaved draft there is work. + if (sourceHostPartition) { + for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { + if (!owns(ownerKey)) { + items.push(...unsavedDrafts(files, ownerKey)) + } + } + } // PTY bindings, remote session ids and shutdown markers are not counted: whether their terminals // still run is the terminal gate's verdict, taken before every move; the move drops them. - count += Object.values(session.sleepingAgentSessionsByPaneKey ?? {}).filter((record) => { + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { const touchesSource = matches(record.worktreeId) || record.connectionId === scope.targetId - return ( + if ( touchesSource && !transferableSleepingAgentSession(record, session, scope, terminalTabIds) && !retainedOrphanedSleepingAgentSession(record, scope, sourceTerminalTabIds) - ) - }).length - count += Object.entries(session.clientHostedBrowserPagesByWorktree ?? {}) - .filter(([ownerKey]) => matches(ownerKey)) - .filter( - ([ownerKey, pages]) => !clientHostedPagesAreTransferable(session, ownerKey, pages) - ).length - count += - sourceHostPartition && - session.activeRepoId && - owns(session.activeRepoId) && - !scope.repoIds.has(session.activeRepoId) - ? 1 - : 0 - // Focus outside the source partition is client focus, not host state, so it never moves. - // activeConnectionIdsAtShutdown is not counted: it is the renderer's live "connected now" hint, and - // the remote work it can stand for (tab PTYs, remote session ids, leases) is the terminal gate's. - for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { - if (owns(ownerKey)) { - count += files.filter( - (file) => file.externalSshTargetId !== undefined || Boolean(file.runtimeEnvironmentId) - ).length + ) { + items.push( + `a ${record.agent} session saved in ${worktreeLabel(record.worktreeId)} that cannot resume on the server: resume it here and let it finish, or close its tab` + ) } } - for (const [ownerKey, workspaces] of Object.entries(session.browserTabsByWorktree ?? {})) { + // Client-hosted pages, focus on a project outside the move and ambiguous markdown visibility are + // not counted: the move never carries the first, and the rest are presentation the server + // rebuilds. Activity at shutdown is the terminal gate's, like the PTY bindings above. + for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { if (owns(ownerKey)) { - count += workspaces.filter((workspace) => - Boolean(workspace.sessionProfileId || workspace.sessionPartition) - ).length + const hostBound = files.filter( + (file) => file.externalSshTargetId !== undefined || Boolean(file.runtimeEnvironmentId) + ) + items.push( + ...unsavedDrafts(hostBound, ownerKey), + ...hostBound + .filter((file) => file.dirtyDraftContent === undefined) + .map((file) => `the editor tab for ${file.filePath}, which another host opened: close it`) + ) } } for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { if (owns(ownerKey)) { - count += tabs.filter( - (tab) => - tab.executionHostId !== undefined && !isOrcadSourceTabHost(tab.executionHostId, scope) - ).length + items.push( + ...tabs + .filter( + (tab) => + tab.executionHostId !== undefined && !isOrcadSourceTabHost(tab.executionHostId, scope) + ) + .map( + (tab) => + `the tab "${tab.customLabel ?? tab.label}" in ${worktreeLabel(ownerKey)}, which another host runs: close it` + ) + ) } } - return count + return items } -function clientHostedPagesAreTransferable( - session: WorkspaceSessionState, - ownerKey: string, - pages: NonNullable[string] -): boolean { - const browserWorkspaceIds = new Set( - (session.browserTabsByWorktree?.[ownerKey] ?? []).map((workspace) => workspace.id) - ) - return pages.every((page) => browserWorkspaceIds.has(page.workspaceId)) +function unsavedDrafts(files: readonly PersistedOpenFile[], ownerKey: string): string[] { + return files + .filter((file) => file.dirtyDraftContent !== undefined) + .map( + (file) => + `unsaved changes to ${file.filePath} in ${worktreeLabel(ownerKey)}: save or discard them` + ) +} + +/** The path a worktree id carries, which is what the user sees in the sidebar. */ +export function worktreeLabel(ownerKey: string): string { + const raw = unqualifyOrcadMigrationOwnerKey(ownerKey) + const separator = raw.indexOf('::') + return separator === -1 ? raw : raw.slice(separator + 2) } export function projectDormantSessionFocus( diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts index 9b8951895fb..54ff9060306 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts @@ -21,24 +21,39 @@ export function sessionPartitions( ] } +export type MergedSessionFragments = { + session: WorkspaceSessionState | null + /** Worktrees two partitions saved differently, when `session` is null. */ + conflicts: string[] +} + /** * Null when two partitions disagree about the same worktree. The same marker in both (a renderer - * snapshot can copy one into the local and the host partition) is not a disagreement. + * snapshot can copy one into the local and the host partition) is not a disagreement, and neither + * is an empty row beside a populated one: like the app's own adoption, empty is not evidence. */ export function mergeSessionFragments( - fragments: WorkspaceSessionState[] -): WorkspaceSessionState | null { + fragments: WorkspaceSessionState[], + /** Tabs some partition recorded as closed: a row listing one never wins over an empty row. */ + closedTabIds: ReadonlySet +): MergedSessionFragments { const ownerKeys = new Set() const entityKeys = new Set() const keyedValues = new Map() let merged: WorkspaceSessionState | undefined - for (const fragment of fragments) { + const populated = populatedOwnerRows(fragments, closedTabIds) + for (const fragment of fragments.map((entry) => + withoutEmptyRowsFilledElsewhere(entry, populated) + )) { const owners = collectSessionOwnerKeys(fragment) - if ( - [...owners].some((key) => ownerKeys.has(key) && !agreesOnOwner(fragment, key, keyedValues)) || - hasDuplicates(entityKeys, collectSessionEntityKeys(fragment)) - ) { - return null + const conflicts = [...owners].filter( + (key) => ownerKeys.has(key) && !agreesOnOwner(fragment, key, keyedValues) + ) + if (conflicts.length > 0) { + return { session: null, conflicts } + } + if (hasDuplicates(entityKeys, collectSessionEntityKeys(fragment))) { + return { session: null, conflicts: [...owners] } } owners.forEach((key) => ownerKeys.add(key)) for (const field of OWNER_KEYED_FIELDS) { @@ -48,7 +63,57 @@ export function mergeSessionFragments( } merged = mergeWorkspaceSessions(merged, fragment) } - return merged ?? null + return merged ? { session: merged, conflicts: [] } : { session: null, conflicts: [] } +} + +/** Field and owner key of every populated row, except a tab row naming a closed tab. */ +function populatedOwnerRows( + fragments: readonly WorkspaceSessionState[], + closedTabIds: ReadonlySet +): Set { + const populated = new Set() + for (const fragment of fragments) { + for (const field of OWNER_KEYED_FIELDS) { + for (const [key, value] of Object.entries(fragment[field] ?? {})) { + const reopensClosedTab = + field === 'tabsByWorktree' && + (fragment.tabsByWorktree[key] ?? []).some((tab) => closedTabIds.has(tab.id)) + if (!isEmptyRow(value) && !reopensClosedTab) { + populated.add(`${field}\0${key}`) + } + } + } + } + return populated +} + +function withoutEmptyRowsFilledElsewhere( + fragment: WorkspaceSessionState, + populated: ReadonlySet +): WorkspaceSessionState { + let next: WorkspaceSessionState | undefined + for (const field of OWNER_KEYED_FIELDS) { + for (const [key, value] of Object.entries(fragment[field] ?? {})) { + if (isEmptyRow(value) && populated.has(`${field}\0${key}`)) { + next ??= structuredClone(fragment) + const rows: unknown = next[field] + if (rows && typeof rows === 'object') { + Reflect.deleteProperty(rows, key) + } + } + } + } + return next ?? fragment +} + +function isEmptyRow(value: unknown): boolean { + if (value === null || value === undefined) { + return true + } + if (Array.isArray(value)) { + return value.length === 0 + } + return typeof value === 'object' && Object.keys(value).length === 0 } const OWNER_KEYED_FIELDS = [ diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts index f56f61d3207..15dab4c51d0 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts @@ -17,17 +17,19 @@ import { unqualifyOrcadMigrationOwnerKey } from './orcad-source-scope' import { - countUnrepresentableMarkdownState, - countUnsupportedSessionState, + collectUnsupportedSessionItems, projectDormantSessionFocus, projectSessionToDestination, - transferableSleepingAgentSession + transferableSleepingAgentSession, + worktreeLabel } from './orcad-source-workspace-session-eligibility' +import { withoutLeftBehindSessionRows } from './orcad-source-left-behind-session-rows' import { collectOwnedTerminalTabIds, dropOrcadMigrationTerminalBindings } from './orcad-source-workspace-session-layout' import { mergeSessionFragments } from './orcad-source-workspace-session-fragments' +import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones' import { orcadMigrationSessionPartitions } from './orcad-source-session-partitions' import { hasDuplicateOrcadMigrationScrollbackDescriptors, @@ -37,9 +39,12 @@ import { export type OrcadMigrationSourceWorkspaceSessionInspection = { payload: WorkspaceSessionState | undefined snapshots: OrcadMigrationTerminalScrollbackSnapshot[] - blockedCount: number + /** Each names what blocks and how to clear it. */ + blockedItems: string[] } +const UNREADABLE_SESSION = 'saved tabs Orca could not read' + /** Fails closed: a session shape no collector expects blocks the move instead of failing connect. */ export function collectOrcadMigrationSourceWorkspaceSession( state: PersistedState, @@ -51,7 +56,7 @@ export function collectOrcadMigrationSourceWorkspaceSession( return collectWorkspaceSession(state, source, catalog, storage) } catch (error) { console.warn('[migration] Unreadable workspace session blocks the move:', error) - return { payload: undefined, snapshots: [], blockedCount: 1 } + return { payload: undefined, snapshots: [], blockedItems: [UNREADABLE_SESSION] } } } @@ -64,10 +69,11 @@ function collectWorkspaceSession( const sourceScope = createOrcadMigrationSourceScope({ source, catalog, repos: state.repos }) const fragments: WorkspaceSessionState[] = [] const snapshots: OrcadMigrationTerminalScrollbackSnapshot[] = [] - let blockedCount = 0 + const blockedItems: string[] = [] const partitions = orcadMigrationSessionPartitions(state, sourceScope).map( - ([partitionId, session]) => { + ([partitionId, saved]) => { const scope = orcadMigrationPartitionScope(sourceScope, partitionId) + const session = withoutLeftBehindSessionRows(saved, scope) return { session, scope, terminalTabIds: collectOwnedTerminalTabIds(session, scope) } } ) @@ -76,14 +82,15 @@ function collectWorkspaceSession( ) for (const { session, scope, terminalTabIds } of partitions) { const sourceHostPartition = scope.partitionHostId === scope.hostId - blockedCount += countUnsupportedSessionState( - session, - scope, - sourceHostPartition, - terminalTabIds, - sourceTerminalTabIds + blockedItems.push( + ...collectUnsupportedSessionItems( + session, + scope, + sourceHostPartition, + terminalTabIds, + sourceTerminalTabIds + ) ) - blockedCount += countUnrepresentableMarkdownState(session, scope, sourceHostPartition) const fragment = extractSessionOwnersForTransfer(session, { mapOwnerKey: (ownerKey) => orcadMigrationOwnerMatchesScope(ownerKey, scope) @@ -114,19 +121,32 @@ function collectWorkspaceSession( projectSessionToDestination(fragment, scope), storage ) - blockedCount += projected.blockedCount + blockedItems.push(...projected.blockedItems) snapshots.push(...projected.snapshots) fragments.push(projected.session) } } if (hasDuplicateOrcadMigrationScrollbackDescriptors(snapshots)) { - blockedCount += 1 + blockedItems.push('terminal history saved twice for one pane') } - if (blockedCount > 0 || fragments.length === 0) { - return { payload: undefined, snapshots: [], blockedCount } + if (blockedItems.length > 0 || fragments.length === 0) { + return { payload: undefined, snapshots: [], blockedItems } } - const merged = mergeSessionFragments(fragments) - return merged - ? { payload: merged, snapshots, blockedCount: 0 } - : { payload: undefined, snapshots: [], blockedCount: 1 } + const closedTabIds = new Set( + partitions.flatMap(({ session }) => + Object.keys(session.closedTerminalTabTombstonesByTabId ?? {}).filter((tabId) => + hasClosedTerminalTabRecord(session.closedTerminalTabTombstonesByTabId, tabId) + ) + ) + ) + const merged = mergeSessionFragments(fragments, closedTabIds) + return merged.session + ? { payload: merged.session, snapshots, blockedItems: [] } + : { + payload: undefined, + snapshots: [], + blockedItems: merged.conflicts.map( + (ownerKey) => `two different saved copies of the tabs in ${worktreeLabel(ownerKey)}` + ) + } } diff --git a/src/main/ssh/orcad-migration-refusal-reason.ts b/src/main/ssh/orcad-migration-refusal-reason.ts index 4f4681c6961..23b62285773 100644 --- a/src/main/ssh/orcad-migration-refusal-reason.ts +++ b/src/main/ssh/orcad-migration-refusal-reason.ts @@ -30,7 +30,9 @@ function blockerWords(blocker: OrcadMigrationBlocker): string[] { case 'orcad_migration_direct_ssh_terminal_leases': return ['terminals still running'] case 'orcad_migration_dependent_state': - return blocker.dependencies.map(({ kind }) => DEPENDENCY_WORDS[kind]) + return blocker.dependencies.map(({ kind, names }) => + names?.length ? `${DEPENDENCY_WORDS[kind]} (${names.join('; ')})` : DEPENDENCY_WORDS[kind] + ) case 'orcad_migration_dependency_unverifiable': return ['saved state Orca could not read'] case 'orcad_migration_direct_ssh_repositories': diff --git a/src/main/ssh/ssh-target-orcad-dependents.ts b/src/main/ssh/ssh-target-orcad-dependents.ts index 72b7184028f..ef76aea5caf 100644 --- a/src/main/ssh/ssh-target-orcad-dependents.ts +++ b/src/main/ssh/ssh-target-orcad-dependents.ts @@ -92,9 +92,9 @@ export function collectUntransferredDependentBlockers( store: Pick, manifest: OrcadMigrationManifest ): OrcadMigrationBlocker[] { - let counts: Record + let census: ReturnType try { - counts = store.inspectOrcadMigrationUntransferredDependencies(manifest).counts + census = store.inspectOrcadMigrationUntransferredDependencies(manifest) } catch { return [ { @@ -104,10 +104,12 @@ export function collectUntransferredDependentBlockers( } ] } - const dependencies = CENSUS_DEPENDENCY_KINDS.filter((kind) => counts[kind] > 0).map((kind) => ({ - kind, - count: counts[kind] - })) + const dependencies = CENSUS_DEPENDENCY_KINDS.filter((kind) => census.counts[kind] > 0).map( + (kind) => { + const names = census.names?.[kind]?.slice(0, MAX_NAMES) + return { kind, count: census.counts[kind], ...(names?.length ? { names } : {}) } + } + ) return dependencies.length > 0 ? [{ code: 'orcad_migration_dependent_state', category: 'client-owned-state', dependencies }] : []