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 <akushonkamen@163.com>

* fix(ssh): build the export copy without editing saved layout fields in place

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: akushonkamen <akushonkamen@163.com>
This commit is contained in:
OrcaWin
2026-10-15 01:23:39 -07:00
committed by GitHub
co-authored by akushonkamen m4air
parent efd7e50e0a
commit ef563bccf4
15 changed files with 888 additions and 187 deletions
@@ -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)) {
@@ -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 })
})
})
@@ -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<typeof createOrcadMigrationSourceScope>,
session: WorkspaceSessionState | undefined,
blockedCounts: OrcadMigrationSourceClientStateInspection['blockedCounts']
session: WorkspaceSessionState | undefined
):
| NonNullable<OrcadMigrationClientStatePayload['mobileClientTabSelectionsByDeviceId']>
| 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<typeof createOrcadMigrationSourceScope>,
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<string>()
const groupIds = eligible?.groupIds ?? new Set<string>()
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
}
@@ -21,6 +21,8 @@ import { paneBelongsToTabs } from '../../../shared/workspace-session-pane-owners
export type OrcadMigrationSourceDependencyCensus = {
totalCount: number
counts: Record<OrcadMigrationDependencyKind, number>
/** For the kinds it can name: what blocks and how to clear it. */
names?: Partial<Record<OrcadMigrationDependencyKind, string[]>>
}
/** 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 }
: {}
}
}
@@ -41,6 +41,8 @@ type TransferredDormantKind = (typeof ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS)
export type OrcadMigrationSourceDormantInspection = {
payload: OrcadMigrationDormantStatePayload
blockedCounts: Record<TransferredDormantKind, number>
/** 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
}
}
@@ -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)
}
}
})
})
@@ -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<T>(
rows: Record<string, T[]> | undefined,
map: (ownerKey: string, row: T[]) => T[]
): Record<string, T[]> | undefined {
return rows
? Object.fromEntries(Object.entries(rows).map(([key, row]) => [key, map(key, row)]))
: rows
}
function withoutTabs(group: TabGroup, tabIds: ReadonlySet<string>): 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<string, Set<string>> {
const hidden = new Map<string, Set<string>>()
// 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
}
@@ -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)
})
@@ -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: {
@@ -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> = {}): 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> = {}
): 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> = {}
): 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).'
)
})
})
@@ -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<string>,
/** Every partition's source terminal tabs: a record is orphaned only when none lists its tab. */
sourceTerminalTabIds: ReadonlySet<string>
): 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<WorkspaceSessionState['clientHostedBrowserPagesByWorktree']>[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(
@@ -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<string>
): MergedSessionFragments {
const ownerKeys = new Set<string>()
const entityKeys = new Set<string>()
const keyedValues = new Map<string, string>()
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<string>
): Set<string> {
const populated = new Set<string>()
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<string>
): 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 = [
@@ -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)}`
)
}
}
@@ -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':
+8 -6
View File
@@ -92,9 +92,9 @@ export function collectUntransferredDependentBlockers(
store: Pick<Store, 'inspectOrcadMigrationUntransferredDependencies'>,
manifest: OrcadMigrationManifest
): OrcadMigrationBlocker[] {
let counts: Record<OrcadMigrationDependencyKind, number>
let census: ReturnType<Store['inspectOrcadMigrationUntransferredDependencies']>
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 }]
: []