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 }] : []