From 699709c0ba638fa7447a698f05e6e9f532efc452 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 15 Oct 2026 18:26:18 -0700 Subject: [PATCH] fix(ssh): never leave an SSH host unconnectable after a refused move (phone users, #29083, Move anyway) (#29220) * fix(ssh): never leave an SSH host unconnectable after a refused move to its managed server * fix(ssh): keep unsaved drafts out of what Move anyway leaves behind * test: run the stuck-host profile test in the Node runtime project * fix(ssh): spend a "Move anyway" consent when the conversion throws A throw left managedServerLeaveBehind set, so a later connect left saved state behind without asking again. A resumed fence replays its journal, never the export, so clearing is safe. Co-Authored-By: Claude * Revert "fix(ssh): spend a "Move anyway" consent when the conversion throws" This reverts commit 4f63d777d90e4848bb64231c16a52d4a996665b7. * docs(ssh): note why a Move anyway consent survives a conversion throw * fix(ssh): Move anyway needs every terminal proven exited, and skips foreign tabs fully - A bridged terminal proof (relay census live or unverifiable) now refuses a confirmed "Move anyway" before any fence or journal, with a reason that says to close the terminals; the host stays on its relay and connectable. The offer itself is withheld while terminals aren't proven exited. - A foreign tab left behind is also removed from the legacy tabsByWorktree rows, its layout, active-tab pointer, relay session id, scrollback, and pane-keyed resume records, so it can't reappear on the managed server. Co-Authored-By: Claude * fix(ssh): Move anyway drops left-behind files' tabs, and is offered only when it would work - An editor tab whose host-bound file is left behind is removed with the file, so the managed server never opens it as a blank pane. - The offer is previewed against the leave-behind export first: when what refuses is a desktop-only draft the export can't leave, no "Move anyway" is shown and the save-or-discard reason stays. Co-Authored-By: Claude * fix(ssh): drop surface and closed-tab tombstones of foreign tabs Move anyway skips * fix(ssh): match legacy pane keys when Move anyway drops a foreign tab's records --------- Co-authored-by: m4air Co-authored-by: Neil Co-authored-by: Claude --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + src/main/ipc/orcad-leave-behind-handlers.ts | 21 + .../runtime-environment-handler-channels.ts | 1 + .../ipc/runtime-environments-pairing.test.ts | 2 + src/main/ipc/runtime-environments.ts | 2 + .../ipc/ssh-host-server-on-connect-wiring.ts | 8 +- src/main/ipc/ssh-target-crud-handlers.ts | 3 + .../leasing-ssh-ptys/ssh-normalization.ts | 26 +- .../orcad-source-client-state.ts | 8 +- .../orcad-source-dependency-census.ts | 10 +- .../orcad-source-dormant-state.ts | 39 +- .../orcad-source-export.ts | 6 + .../orcad-source-leave-behind-view.ts | 39 ++ ...ad-source-left-behind-session-rows.test.ts | 83 ++- .../orcad-source-left-behind-session-rows.ts | 123 +++- .../orcad-source-scrollback-state.ts | 20 +- .../orcad-source-session-copy-resolution.ts | 104 ++++ .../orcad-source-stale-state-move.test.ts | 19 + ...ad-source-workspace-session-eligibility.ts | 19 +- .../orcad-source-workspace-session.ts | 148 ++++- .../ssh/orcad-migration-refusal-reason.ts | 14 +- src/main/ssh/orcad-migration-source-fence.ts | 12 + ...rcad-migration-stuck-host-profiles.test.ts | 567 ++++++++++++++++++ .../ssh/orcad-migration-terminal-gate.test.ts | 3 +- src/main/ssh/orcad-migration-terminal-gate.ts | 9 +- src/main/ssh/orcad-runtime-conversion.ts | 26 +- .../ssh/ssh-host-server-on-connect.test.ts | 18 + src/main/ssh/ssh-host-server-on-connect.ts | 8 +- src/main/ssh/ssh-host-unserved-status.ts | 1 + src/main/ssh/ssh-target-orcad-preflight.ts | 27 + src/preload/api/managed-orcad-api.ts | 6 +- .../settings/SshHostDeltaMoveDialog.test.tsx | 3 +- .../settings/SshHostMoveAnywayAction.test.tsx | 81 +++ .../settings/SshHostMoveAnywayAction.tsx | 100 +++ .../settings/SshTargetServerStatus.tsx | 6 + src/renderer/src/i18n/locales/en.json | 7 + src/renderer/src/i18n/locales/es.json | 7 + src/renderer/src/i18n/locales/fr.json | 7 + src/renderer/src/i18n/locales/ja.json | 7 + src/renderer/src/i18n/locales/ko.json | 7 + src/renderer/src/i18n/locales/zh.json | 7 + src/shared/orcad-managed-runtime.ts | 2 + .../ssh-managed-server-status-admission.ts | 3 + .../ssh-retained-payload-admission.test.ts | 17 + src/shared/ssh-types.ts | 8 + 45 files changed, 1539 insertions(+), 96 deletions(-) create mode 100644 src/main/ipc/orcad-leave-behind-handlers.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-leave-behind-view.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-session-copy-resolution.ts create mode 100644 src/main/ssh/orcad-migration-stuck-host-profiles.test.ts create mode 100644 src/renderer/src/components/settings/SshHostMoveAnywayAction.test.tsx create mode 100644 src/renderer/src/components/settings/SshHostMoveAnywayAction.tsx diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 018f2c472cd..fb1f69c5f87 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -281,6 +281,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/ssh/orcad-migration-snapshot-resume.test.ts', 'src/main/ssh/orcad-migration-source-fence.test.ts', 'src/main/ssh/orcad-migration-ssh-relay-source.test.ts', + 'src/main/ssh/orcad-migration-stuck-host-profiles.test.ts', 'src/main/ssh/orcad-runtime-conversion.test.ts', 'src/main/ssh/orcad-unreachable-setup-release.test.ts', 'src/main/ssh/ssh-target-orcad-preflight.test.ts', diff --git a/src/main/ipc/orcad-leave-behind-handlers.ts b/src/main/ipc/orcad-leave-behind-handlers.ts new file mode 100644 index 00000000000..67722b0b406 --- /dev/null +++ b/src/main/ipc/orcad-leave-behind-handlers.ts @@ -0,0 +1,21 @@ +/** IPC for "Move anyway": the user confirms that saved state a move can't carry stays on this desktop. */ +import { ipcMain } from 'electron' +import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' +import { requiredString } from './orcad-runtime-lifecycle-handlers' + +export function registerOrcadLeaveBehindHandlers(): void { + ipcMain.handle( + 'runtimeEnvironments:allowOrcadMoveLeavingBehind', + (_event, args: { sshTargetId: string }): void => { + const sshTargetId = requiredString(args?.sshTargetId, 'SSH target') + const registry = getSshTargetRegistryStore() + if (!registry?.getTarget(sshTargetId)) { + throw new Error('The SSH host is gone.') + } + // Read by the export on the next connect; cleared once the host moves. + registry.updateTarget(sshTargetId, { + managedServerLeaveBehind: { confirmedAt: new Date().toISOString() } + }) + } + ) +} diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts index 23afabc3504..18b85d4f91f 100644 --- a/src/main/ipc/runtime-environment-handler-channels.ts +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -28,6 +28,7 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:previewOrcadDeltaMove', 'runtimeEnvironments:moveOrcadDelta', 'runtimeEnvironments:keepOrcadServerVersion', + 'runtimeEnvironments:allowOrcadMoveLeavingBehind', 'runtimeEnvironments:createOrcadSshHost', 'runtimeEnvironments:resumeOrcadSshHost', 'runtimeEnvironments:listPendingOrcadSshProvisioning' diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index 3f5eb01e8d6..288efc7894b 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -168,6 +168,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:previewOrcadDeltaMove', 'runtimeEnvironments:moveOrcadDelta', 'runtimeEnvironments:keepOrcadServerVersion', + 'runtimeEnvironments:allowOrcadMoveLeavingBehind', 'runtimeEnvironments:updateOrcad', 'runtimeEnvironments:rollbackOrcad', 'runtimeEnvironments:recoverOrcad', @@ -215,6 +216,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:previewOrcadDeltaMove', 'runtimeEnvironments:moveOrcadDelta', 'runtimeEnvironments:keepOrcadServerVersion', + 'runtimeEnvironments:allowOrcadMoveLeavingBehind', 'runtimeEnvironments:createOrcadSshHost', 'runtimeEnvironments:resumeOrcadSshHost', 'runtimeEnvironments:listPendingOrcadSshProvisioning', diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index f03fc9ad45e..8746d181d82 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -23,6 +23,7 @@ import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-hand import { registerOrcadRuntimeLifecycleHandlers } from './orcad-runtime-lifecycle-handlers' import { registerOrcadRuntimeConversionHandlers } from './orcad-runtime-conversion-handlers' import { registerOrcadDeltaMoveHandlers } from './orcad-delta-move-handlers' +import { registerOrcadLeaveBehindHandlers } from './orcad-leave-behind-handlers' import { registerOrcadRuntimeMaintenanceHandlers } from './orcad-runtime-maintenance-handlers' import { clearPublishedManagedServer } from './ssh-renderer-broadcast' import { clearManagedServerNotes } from './runtime-environment-managed-tunnel' @@ -150,6 +151,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { registerOrcadRuntimeLifecycleHandlers({ getUserDataPath }) registerOrcadRuntimeConversionHandlers(getUserDataPath) registerOrcadDeltaMoveHandlers(getUserDataPath) + registerOrcadLeaveBehindHandlers() registerOrcadRuntimeMaintenanceHandlers({ getUserDataPath, getActiveEnvironmentId: () => store.getSettings().activeRuntimeEnvironmentId, diff --git a/src/main/ipc/ssh-host-server-on-connect-wiring.ts b/src/main/ipc/ssh-host-server-on-connect-wiring.ts index 2774a0e9307..4149661e0bf 100644 --- a/src/main/ipc/ssh-host-server-on-connect-wiring.ts +++ b/src/main/ipc/ssh-host-server-on-connect-wiring.ts @@ -98,7 +98,7 @@ export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConne if (hostProof?.bridged) { await startLegacyRelayBridge(target.id) } - return convertSshTargetToManagedOrcad(userDataPath, { + const converted = await convertSshTargetToManagedOrcad(userDataPath, { sshTargetId: target.id, name: managedOrcadServerName(target), // The connect's own census already answered; a conversion before any session reuses it. @@ -108,6 +108,12 @@ export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConne destinationFor: orcadMigrationDestinationFor, serverRepositoriesFor: orcadServerRepositoriesFor }) + if (converted.outcome === 'converted' || converted.outcome === 'refused') { + // "Move anyway" covers one attempt; a later move, or a later delta, asks again. + // Kept on a throw: verifyFence re-exports with it, so a held fence could never resume. + registry.updateTarget(target.id, { managedServerLeaveBehind: undefined }) + } + return converted }, abandonDeploy: async (target) => { const environmentId = diff --git a/src/main/ipc/ssh-target-crud-handlers.ts b/src/main/ipc/ssh-target-crud-handlers.ts index 2a842b943d7..935972ad7d8 100644 --- a/src/main/ipc/ssh-target-crud-handlers.ts +++ b/src/main/ipc/ssh-target-crud-handlers.ts @@ -53,6 +53,7 @@ function omitRendererSshTargetGeneration< orcadFence?: unknown managedServerUnavailable?: unknown managedServerMoveOffered?: unknown + managedServerLeaveBehind?: unknown managedServerUpdateFailure?: unknown remoteRuntimeResolution?: unknown } @@ -65,6 +66,7 @@ function omitRendererSshTargetGeneration< | 'orcadFence' | 'managedServerUnavailable' | 'managedServerMoveOffered' + | 'managedServerLeaveBehind' | 'managedServerUpdateFailure' | 'remoteRuntimeResolution' > { @@ -74,6 +76,7 @@ function omitRendererSshTargetGeneration< orcadFence: _orcadFence, managedServerUnavailable: _managedServerUnavailable, managedServerMoveOffered: _managedServerMoveOffered, + managedServerLeaveBehind: _managedServerLeaveBehind, managedServerUpdateFailure: _managedServerUpdateFailure, remoteRuntimeResolution: _remoteRuntimeResolution, ...rest diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts index 5531cb96a4e..b2cbb071545 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts @@ -66,13 +66,15 @@ export function normalizeSshTarget(t: SshTarget): SshTarget { if (remoteRuntimeResolution) { normalized.remoteRuntimeResolution = remoteRuntimeResolution } - return normalizeManagedServerMoveOffered( - normalizeAppVersionNote( + return normalizeManagedServerLeaveBehind( + normalizeManagedServerMoveOffered( normalizeAppVersionNote( - migrateLegacyManagedOrcadOwner(normalized), - 'managedServerUnavailable' - ), - 'managedServerUpdateFailure' + normalizeAppVersionNote( + migrateLegacyManagedOrcadOwner(normalized), + 'managedServerUnavailable' + ), + 'managedServerUpdateFailure' + ) ) ) } @@ -124,6 +126,18 @@ function normalizeManagedServerMoveOffered(target: SshTarget): SshTarget { return rest } +function normalizeManagedServerLeaveBehind(target: SshTarget): SshTarget { + if (target.managedServerLeaveBehind === undefined) { + return target + } + const note = noteFields(target.managedServerLeaveBehind) + if (typeof note?.confirmedAt === 'string') { + return { ...target, managedServerLeaveBehind: { ...note, confirmedAt: note.confirmedAt } } + } + const { managedServerLeaveBehind: _malformed, ...rest } = target + return rest +} + function normalizeOrcadFence(target: SshTarget): SshTarget { if (target.orcadFence === undefined) { return target 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 f35c18de6a4..1a7f3b9fb0b 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 @@ -242,6 +242,12 @@ function collectEligibleSessionIdentity( return { tabIds, groupIds } } +/** A phone names a terminal by its pane, `${tabId}::${leafId}`; the pane moves with its tab. */ +function movedMobileTab(id: string, tabIds: ReadonlySet): boolean { + const separator = id.indexOf('::') + return tabIds.has(id) || (separator !== -1 && tabIds.has(id.slice(0, separator))) +} + /** The selection without references to tabs or groups that did not move; null when none is left. */ function representableMobileSelection( selection: PersistedMobileClientTabSelection, @@ -251,7 +257,7 @@ function representableMobileSelection( const groupIds = eligible?.groupIds ?? new Set() const next: PersistedMobileClientTabSelection = { activeTabId: - selection.activeTabId !== null && tabIds.has(selection.activeTabId) + selection.activeTabId !== null && movedMobileTab(selection.activeTabId, tabIds) ? selection.activeTabId : null, activeGroupId: 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 12d4d00280f..7c7e312784c 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 @@ -9,7 +9,10 @@ import { inspectOrcadMigrationSourceSessions, type OrcadMigrationSourceSessionInspection } from './orcad-source-session-dependencies' -import { collectOrcadMigrationSourceDormantState } from './orcad-source-dormant-state' +import { + collectOrcadMigrationSourceDormantState, + orcadMigrationLeavesBehind +} from './orcad-source-dormant-state' import { createOrcadMigrationSourceScope, orcadMigrationOwnerMatchesScope, @@ -56,7 +59,10 @@ export function collectOrcadMigrationUntransferredDependencyCensus( 'terminal-lease': state.sshRemotePtyLeases.filter( (lease) => lease.targetId === scope.targetId && lease.state !== 'terminated' ).length, - 'terminal-recovery': countTerminalRecoveryState(state, scope, sessions) + // Left behind, recovery records stay with this desktop; the terminal gate still proves exit. + 'terminal-recovery': orcadMigrationLeavesBehind(state, scope.targetId) + ? 0 + : countTerminalRecoveryState(state, scope, sessions) } return { counts, 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 98d4c9f8749..76dd28d53ad 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 @@ -43,6 +43,15 @@ export type OrcadMigrationSourceDormantInspection = { blockedCounts: Record /** What blocks the saved tabs and panes, each with the way to clear it. */ blockedSessionItems: string[] + /** What a confirmed "Move anyway" leaves in this desktop's retained profile. */ + leftBehindItems: string[] +} + +/** The user confirmed "Move anyway" for this host: what can't move stays on this desktop. */ +export function orcadMigrationLeavesBehind(state: PersistedState, targetId: string): boolean { + return state.sshTargets.some( + (target) => target.id === targetId && target.managedServerLeaveBehind !== undefined + ) } export function collectOrcadMigrationSourceDormantState( @@ -156,11 +165,13 @@ export function collectOrcadMigrationSourceDormantState( : [] }) .sort((left, right) => compareKeys(left.repoId, right.repoId)) + const leaveBehind = orcadMigrationLeavesBehind(state, scope.targetId) const workspaceSession = collectOrcadMigrationSourceWorkspaceSession( state, source, catalog, - storage + storage, + leaveBehind ) const blockedSessionItems = [...workspaceSession.blockedItems] const automationState = collectOrcadMigrationSourceAutomationState(state, source, catalog) @@ -174,10 +185,29 @@ export function collectOrcadMigrationSourceDormantState( workspaceSession.payload ) blockedCounts['saved-port-forward'] = clientState.blockedCounts['saved-port-forward'] - blockedSessionItems.push( - ...Array.from({ length: clientState.blockedCount }, () => 'a browser page close saved twice') + const leftBehindItems = [...workspaceSession.leftBehindItems] + const duplicateCloses = Array.from( + { length: clientState.blockedCount }, + () => 'a browser page close saved twice' ) + // "Move anyway": duplicate closes are already out of the payload. What the session still refuses + // is what can't be left behind (an unsaved draft), so it keeps blocking. + ;(leaveBehind ? leftBehindItems : blockedSessionItems).push(...duplicateCloses) blockedCounts['workspace-session'] = blockedSessionItems.length + if (leaveBehind) { + // Every row these counts name is already out of the payload. Saved port forwards never block, + // and the server itself refuses duplicate ports. + for (const kind of ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS) { + if ( + kind !== 'workspace-session' && + kind !== 'saved-port-forward' && + blockedCounts[kind] > 0 + ) { + leftBehindItems.push(`${blockedCounts[kind]} saved ${kind} record(s)`) + blockedCounts[kind] = 0 + } + } + } return { payload: { version: 1, @@ -206,7 +236,8 @@ export function collectOrcadMigrationSourceDormantState( ...(clientState.payload ? { clientState: clientState.payload } : {}) }, blockedCounts, - blockedSessionItems + blockedSessionItems, + leftBehindItems } } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts index 1688955eeee..d3220bd0178 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts @@ -27,6 +27,7 @@ import { type OrcadMigrationDeltaView } from './orcad-source-delta-view' import { syncOrcadMigrationScrollbackRetention } from './orcad-source-scrollback-retention' +import { createOrcadMigrationLeaveBehindView } from './orcad-source-leave-behind-view' type OrcadSourceExportRuntime = Pick< StoreRuntimeState, @@ -62,6 +63,11 @@ export class OrcadSourceExportPersistence { return createOrcadMigrationDeltaView(this[orcadSourceExportContext], moved) } + /** The source as a confirmed "Move anyway" would export it, consent unrecorded. */ + createOrcadMigrationLeaveBehindView(targetId: string): OrcadMigrationDeltaView { + return createOrcadMigrationLeaveBehindView(this[orcadSourceExportContext], targetId) + } + /** What still references the target that this manifest cannot carry. */ inspectOrcadMigrationUntransferredDependencies( manifest: OrcadMigrationManifest diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-leave-behind-view.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-leave-behind-view.ts new file mode 100644 index 00000000000..2e7420a499d --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-leave-behind-view.ts @@ -0,0 +1,39 @@ +/** + * The source as a confirmed "Move anyway" would export it, without recording the consent: lets the + * offer appear only when leaving saved state behind would actually clear what refuses the move. + */ +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' +import { collectOrcadMigrationUntransferredDependencyCensus } from './orcad-source-dependency-census' +import { collectOrcadMigrationSourceDormantState } from './orcad-source-dormant-state' +import type { OrcadMigrationDeltaView } from './orcad-source-delta-view' + +export function createOrcadMigrationLeaveBehindView( + runtime: Pick, + targetId: string +): OrcadMigrationDeltaView { + // Shallow: the collectors only read, and only the target row differs. + const state = { + ...runtime.state, + sshTargets: runtime.state.sshTargets.map((target) => + target.id === targetId + ? { ...target, managedServerLeaveBehind: { confirmedAt: new Date(0).toISOString() } } + : target + ) + } + const storage = runtime.terminalScrollbackSnapshotStorage + return { + getRepos: () => state.repos, + getFolderWorkspaces: () => state.folderWorkspaces, + getProjectGroups: () => state.projectGroups, + collectOrcadMigrationSourceDormantState: (source, catalog, destinationEnvironmentId) => + collectOrcadMigrationSourceDormantState( + state, + source, + catalog, + storage, + destinationEnvironmentId + ).payload, + inspectOrcadMigrationUntransferredDependencies: (manifest) => + collectOrcadMigrationUntransferredDependencyCensus(state, manifest, storage) + } +} 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 index 581248f51b8..01f5f491a19 100644 --- 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 @@ -1,29 +1,66 @@ 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' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { withoutLeftBehindSessionRows } from './orcad-source-left-behind-session-rows' +import type { OrcadMigrationSourceScope } from './orcad-source-scope' -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) - } - }) +const WORKTREE = 'repo-0::/home/dev/src/project-0' +const SCOPE: OrcadMigrationSourceScope = { + targetId: 'ssh-box', + targetGeneration: 1, + hostId: toSshExecutionHostId('ssh-box'), + repoIds: new Set(['repo-0']), + folderWorkspaceKeys: new Set(), + sharedRepoIds: new Set(), + partitionHostId: toSshExecutionHostId('ssh-box') +} - 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) - } +function resume(paneKey: string, tabId: string): SleepingAgentSessionRecord { + return { + paneKey, + tabId, + worktreeId: WORKTREE, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${tabId}` }, + prompt: 'Saved prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1 + } +} + +describe('withoutLeftBehindSessionRows', () => { + it('drops pane-keyed records of a skipped foreign tab, legacy keys included', () => { + const session = getDefaultWorkspaceSession() + session.unifiedTabs = { + [WORKTREE]: ['own', 'foreign'].map((id, sortOrder) => ({ + id, + entityId: id, + groupId: 'group', + worktreeId: WORKTREE, + contentType: 'terminal' as const, + label: id, + customLabel: null, + color: null, + sortOrder, + createdAt: 1, + ...(id === 'foreign' ? { executionHostId: 'runtime:env-other' } : {}) + })) } + session.tabGroups = { + [WORKTREE]: [ + { id: 'group', worktreeId: WORKTREE, activeTabId: 'own', tabOrder: ['own', 'foreign'] } + ] + } + session.sleepingAgentSessionsByPaneKey = { + 'own:0': resume('own:0', 'own'), + 'foreign:0': resume('foreign:0', 'foreign') + } + session.terminalPtyIncarnationsByPaneKey = { 'own:0': 'inc-own', 'foreign:0': 'inc-foreign' } + const exported = withoutLeftBehindSessionRows(session, SCOPE, true) + expect(Object.keys(exported.sleepingAgentSessionsByPaneKey ?? {})).toEqual(['own:0']) + expect(Object.keys(exported.terminalPtyIncarnationsByPaneKey ?? {})).toEqual(['own:0']) + expect(exported.tabGroups?.[WORKTREE]?.[0]?.tabOrder).toEqual(['own']) }) }) 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 index cda753c0c27..c611cb1beb5 100644 --- 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 @@ -3,6 +3,7 @@ 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 { isOrcadSourceTabHost } from './orcad-source-workspace-session-eligibility' import { orcadMigrationOwnerMatchesScope, type OrcadMigrationSourceScope @@ -14,7 +15,11 @@ import { */ export function withoutLeftBehindSessionRows( session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope + scope: OrcadMigrationSourceScope, + /** "Move anyway": also every clean host-bound file, and tabs another host runs. */ + leaveBehind = false, + /** Drafts too: only where a retained copy outlives the move (the host's own partition). */ + options: { leaveDrafts: boolean } = { leaveDrafts: false } ): WorkspaceSessionState { const owns = (ownerKey: string): boolean => orcadMigrationOwnerMatchesScope(ownerKey, scope) const isolated = new Set( @@ -22,18 +27,36 @@ export function withoutLeftBehindSessionRows( owns(ownerKey) ? workspaces.filter(usesSessionProfile).map((workspace) => workspace.id) : [] ) ) - const hiddenFiles = hiddenHostBoundFiles(session, owns) - if (isolated.size === 0 && hiddenFiles.size === 0) { + const hiddenFiles = hiddenHostBoundFiles(session, owns, leaveBehind, options.leaveDrafts) + const foreignTabs = leaveBehind + ? Object.entries(session.unifiedTabs ?? {}).flatMap(([ownerKey, tabs]) => + owns(ownerKey) + ? tabs.filter( + (tab) => + tab.executionHostId !== undefined && + !isOrcadSourceTabHost(tab.executionHostId, scope) + ) + : [] + ) + : [] + const foreignTabIds = new Set(foreignTabs.map((tab) => tab.id)) + // Why both ids: the legacy terminal rows and their layouts are keyed by the terminal's entity id. + const foreignTerminalIds = new Set( + foreignTabs.filter((tab) => tab.contentType === 'terminal').flatMap((t) => [t.id, t.entityId]) + ) + if (isolated.size === 0 && hiddenFiles.size === 0 && foreignTabIds.size === 0) { return session } const keepsFile = (ownerKey: string, filePath: string): boolean => !hiddenFiles.get(ownerKey)?.has(filePath) - const droppedTabIds = new Set( - Object.values(session.unifiedTabs ?? {}) + const droppedTabIds = new Set([ + ...foreignTabIds, + ...Object.values(session.unifiedTabs ?? {}) .flat() .filter((tab) => tab.contentType === 'browser' && isolated.has(tab.entityId)) - .map((tab) => tab.id) - ) + .map((tab) => tab.id), + ...hiddenFileTabIds(session, hiddenFiles) + ]) // Built as a new object: this is an export copy, never a write to the saved layout. return structuredClone({ ...session, @@ -61,7 +84,82 @@ export function withoutLeftBehindSessionRows( Object.entries(session.activeFileIdByWorktree ?? {}).filter( ([ownerKey, fileId]) => !fileId || keepsFile(ownerKey, fileId) ) + ), + ...withoutForeignTerminalRows(session, foreignTerminalIds) + }) +} + +/** The legacy terminal model still hydrates these, so a skipped tab must leave them too. */ +function withoutForeignTerminalRows( + session: WorkspaceSessionState, + tabIds: ReadonlySet +): Partial { + if (tabIds.size === 0) { + return {} + } + const keepTab = ([tabId]: [string, unknown]): boolean => !tabIds.has(tabId) + // Same split as worktreeIdForPaneKey, so legacy `tab:0` keys match as well as UUID leaves. + const keepPane = ([paneKey]: [string, unknown]): boolean => + !tabIds.has(paneKey.slice(0, Math.max(0, paneKey.lastIndexOf(':')))) + const filtered = ( + rows: Record | undefined, + keep: (entry: [string, T]) => boolean + ): Record | undefined => + rows ? Object.fromEntries(Object.entries(rows).filter(keep)) : rows + return { + tabsByWorktree: Object.fromEntries( + Object.entries(session.tabsByWorktree).map(([ownerKey, tabs]) => [ + ownerKey, + tabs.filter((tab) => !tabIds.has(tab.id)) + ]) + ), + terminalLayoutsByTabId: Object.fromEntries( + Object.entries(session.terminalLayoutsByTabId).filter(keepTab) + ), + activeTabIdByWorktree: filtered( + session.activeTabIdByWorktree, + ([, tabId]) => !tabIds.has(tabId ?? '') + ), + remoteSessionIdsByTabId: filtered(session.remoteSessionIdsByTabId, keepTab), + localOnlyScrollbackByTabId: filtered(session.localOnlyScrollbackByTabId, keepTab), + sleepingAgentSessionsByPaneKey: filtered(session.sleepingAgentSessionsByPaneKey, keepPane), + terminalPtyIncarnationsByPaneKey: filtered(session.terminalPtyIncarnationsByPaneKey, keepPane), + terminalSurfaceTombstonesByPaneKey: filtered( + session.terminalSurfaceTombstonesByPaneKey, + ([paneKey, tombstone]) => keepPane([paneKey, tombstone]) && !tabIds.has(tombstone.parentTabId) + ), + closedTerminalTabTombstonesByTabId: filtered( + session.closedTerminalTabTombstonesByTabId, + ([tabId]) => !tabIds.has(tabId) ) + } +} + +/** Tabs that show a dropped file: kept, they would open as a blank pane on the server. */ +function hiddenFileTabIds( + session: WorkspaceSessionState, + hiddenFiles: ReadonlyMap> +): string[] { + return Object.entries(session.unifiedTabs ?? {}).flatMap(([ownerKey, tabs]) => { + const hidden = hiddenFiles.get(ownerKey) + if (!hidden) { + return [] + } + const fileIds = new Set( + (session.openFilesByWorktree?.[ownerKey] ?? []).flatMap((file) => + hidden.has(file.filePath) + ? [file.filePath, ownedEditorFileId(file.filePath, ownerKey, file.runtimeEnvironmentId)] + : [] + ) + ) + return tabs + .filter( + (tab) => + tab.contentType !== 'terminal' && + tab.contentType !== 'browser' && + (fileIds.has(tab.id) || fileIds.has(tab.entityId)) + ) + .map((tab) => tab.id) }) } @@ -113,11 +211,13 @@ function usesSessionProfile(workspace: BrowserWorkspace): boolean { */ function hiddenHostBoundFiles( session: WorkspaceSessionState, - owns: (ownerKey: string) => boolean + owns: (ownerKey: string) => boolean, + leaveBehind: boolean, + leaveDrafts: boolean ): Map> { const hidden = new Map>() // Without the unified model, hydration shows every saved file as a tab. - if (!session.unifiedTabs || !session.tabGroups) { + if (!leaveBehind && (!session.unifiedTabs || !session.tabGroups)) { return hidden } for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { @@ -125,14 +225,15 @@ function hiddenHostBoundFiles( continue } const shown = new Set( - (session.unifiedTabs[ownerKey] ?? []).flatMap((tab) => [tab.id, tab.entityId]) + (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) { + const clean = file.dirtyDraftContent === undefined + if (hostBound && (clean || (leaveBehind && leaveDrafts)) && (leaveBehind || !visible)) { hidden.set(ownerKey, (hidden.get(ownerKey) ?? new Set()).add(file.filePath)) } } 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 cc6e0e02f7f..fbf9726794b 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 @@ -18,6 +18,7 @@ export type ProjectedOrcadMigrationScrollback = { session: WorkspaceSessionState snapshots: OrcadMigrationTerminalScrollbackSnapshot[] blockedItems: string[] + leftBehindItems: string[] } export function hasDuplicateOrcadMigrationScrollbackDescriptors( @@ -38,11 +39,14 @@ export function hasDuplicateOrcadMigrationScrollbackDescriptors( export function projectOrcadMigrationSessionScrollback( session: WorkspaceSessionState, - storage?: TerminalScrollbackSnapshotStorage + storage?: TerminalScrollbackSnapshotStorage, + /** "Move anyway": a pane's history that can't move stays on this desktop; the pane still moves. */ + leaveBehind = false ): ProjectedOrcadMigrationScrollback { const projected = structuredClone(session) const snapshots: OrcadMigrationTerminalScrollbackSnapshot[] = [] const blockedItems: string[] = [] + const leftBehindItems: string[] = [] let totalBytes = 0 for (const [tabId, layout] of Object.entries(projected.terminalLayoutsByTabId ?? {})) { const sourceLayout = session.terminalLayoutsByTabId[tabId] @@ -66,7 +70,17 @@ export function projectOrcadMigrationSessionScrollback( bytes.length === 0 || bytes.length > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT ) { - blockedItems.push(`saved terminal history Orca could not read for tab ${tabId}`) + ;(leaveBehind ? leftBehindItems : blockedItems).push( + `saved terminal history Orca could not read for tab ${tabId}` + ) + continue + } + if ( + leaveBehind && + (snapshots.length + 1 > MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS || + totalBytes + bytes.length > MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES) + ) { + leftBehindItems.push(`saved terminal history over the move's limit for tab ${tabId}`) continue } totalBytes += bytes.length @@ -95,7 +109,7 @@ export function projectOrcadMigrationSessionScrollback( snapshots.sort((left, right) => compareKeys(`${left.tabId}\0${left.leafId}`, `${right.tabId}\0${right.leafId}`) ) - return { session: projected, snapshots, blockedItems } + return { session: projected, snapshots, blockedItems, leftBehindItems } } export function readOrcadMigrationSourceScrollbackChunk(args: { diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-session-copy-resolution.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-session-copy-resolution.ts new file mode 100644 index 00000000000..2342f7cb3e2 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-session-copy-resolution.ts @@ -0,0 +1,104 @@ +import type { + OrcadMigrationCatalogPayload, + OrcadMigrationManifestSource +} from '../../../shared/orcad-migration-manifest' +import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../../shared/orcad-migration-scrollback' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { keepUnsavedDrafts } from '../../../shared/workspace-session-draft-retention' +import { + filterWorkspaceRows, + withoutWorkspaces +} from '../../../shared/workspace-session-stranded-partition-adoption' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + createOrcadMigrationSourceScope, + orcadMigrationOwnerMatchesScope, + orcadMigrationPartitionScope +} from './orcad-source-scope' +import { unsavedDraftItems, worktreeLabel } from './orcad-source-workspace-session-eligibility' + +// What "Move anyway" does with the desktop's own copy of a moving host's saved session. + +export type SessionFragment = { + session: WorkspaceSessionState + snapshots: OrcadMigrationTerminalScrollbackSnapshot[] + local: boolean + sourceHost: boolean +} + +/** Unsaved drafts in this desktop's own session for the source's worktrees. */ +export function desktopDrafts( + state: PersistedState, + source: OrcadMigrationManifestSource, + catalog: OrcadMigrationCatalogPayload +): string[] { + const scope = orcadMigrationPartitionScope( + createOrcadMigrationSourceScope({ source, catalog, repos: state.repos }), + LOCAL_EXECUTION_HOST_ID + ) + return Object.entries(state.workspaceSession.openFilesByWorktree ?? {}).flatMap( + ([ownerKey, files]) => + orcadMigrationOwnerMatchesScope(ownerKey, scope) ? unsavedDraftItems(files, ownerKey) : [] + ) +} + +/** + * The desktop's copy of each conflicting worktree gives way to the host's, keeping its unsaved + * drafts. Two different drafts of one file can't both move, so those still refuse. + */ +export function resolveCopiesForHost( + fragments: readonly SessionFragment[], + conflicting: ReadonlySet +): { fragments: SessionFragment[]; clashingDrafts: string[] } { + const desktop = fragments.filter((fragment) => fragment.local) + const desktopRows = desktop.map((fragment) => + filterWorkspaceRows(fragment.session, (workspaceId) => conflicting.has(workspaceId)) + ) + const clashingDrafts: string[] = [] + if (!fragments.some((fragment) => fragment.sourceHost)) { + // No host copy to carry them, so the desktop's drafts would have nowhere to go. + for (const rows of desktopRows) { + for (const [ownerKey, files] of Object.entries(rows.openFilesByWorktree ?? {})) { + clashingDrafts.push(...unsavedDraftItems(files, ownerKey)) + } + } + } + const resolved = fragments.map((fragment): SessionFragment => { + if (fragment.local) { + const session = withoutWorkspaces(fragment.session, conflicting) + const layouts = new Set(Object.keys(session.terminalLayoutsByTabId)) + return { + ...fragment, + session, + snapshots: fragment.snapshots.filter((snapshot) => layouts.has(snapshot.tabId)) + } + } + if (!fragment.sourceHost) { + return fragment + } + const session = structuredClone(fragment.session) + for (const rows of desktopRows) { + for (const [ownerKey, files] of Object.entries(rows.openFilesByWorktree ?? {})) { + const hostFiles = new Map( + (session.openFilesByWorktree?.[ownerKey] ?? []).map((file) => [file.filePath, file]) + ) + for (const file of files) { + const held = hostFiles.get(file.filePath) + if ( + file.dirtyDraftContent !== undefined && + held?.dirtyDraftContent !== undefined && + held.dirtyDraftContent !== file.dirtyDraftContent + ) { + clashingDrafts.push( + `two different unsaved versions of ${file.filePath} in ${worktreeLabel(ownerKey)}` + ) + } + } + } + keepUnsavedDrafts(session, rows) + } + return { ...fragment, session } + }) + return { fragments: resolved, clashingDrafts } +} 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 index 93cc6b21e37..a688eb372ed 100644 --- 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 @@ -249,6 +249,25 @@ describe('#28946: stale tab selections and isolated browser profiles', () => { expect(state).toEqual(before) }) + // Every host a phone ever showed a terminal on: the phone names the pane, not the tab. + it('carries a phone selection that names a live terminal pane', () => { + const state = sourceState() + host(state).tabsByWorktree = { [WORKTREE_A]: [terminal('tab-1')] } + host(state).unifiedTabs = { [WORKTREE_A]: [unifiedTab('tab-1', 'terminal')] } + host(state).tabGroups = { [WORKTREE_A]: [group(['tab-1'], 'tab-1')] } + const selection = { + activeTabId: 'tab-1::leaf-1', + activeGroupId: 'group-1', + activeTabIdByGroupId: { 'group-1': 'tab-1' } + } + state.mobileClientTabSelectionsByDeviceId = { phone: { [WORKTREE_A]: selection } } + + expect(census(state).totalCount).toBe(0) + expect( + exportState(state).payload.dormantState?.clientState?.mobileClientTabSelectionsByDeviceId + ).toEqual({ phone: { [WORKTREE_A]: selection } }) + }) + it('keeps the part of a selection that still names a moved tab', () => { const state = sourceState() host(state).tabsByWorktree = { [WORKTREE_A]: [terminal('tab-1')] } 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 7506b59c7ba..fb9b2002dfa 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 @@ -36,7 +36,7 @@ export function collectUnsupportedSessionItems( if (sourceHostPartition) { for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { if (!owns(ownerKey)) { - items.push(...unsavedDrafts(files, ownerKey)) + items.push(...unsavedDraftItems(files, ownerKey)) } } } @@ -63,7 +63,7 @@ export function collectUnsupportedSessionItems( (file) => file.externalSshTargetId !== undefined || Boolean(file.runtimeEnvironmentId) ) items.push( - ...unsavedDrafts(hostBound, ownerKey), + ...unsavedDraftItems(hostBound, ownerKey), ...hostBound .filter((file) => file.dirtyDraftContent === undefined) .map((file) => `the editor tab for ${file.filePath}, which another host opened: close it`) @@ -88,15 +88,21 @@ export function collectUnsupportedSessionItems( return items } -function unsavedDrafts(files: readonly PersistedOpenFile[], ownerKey: string): string[] { +const UNSAVED_DRAFT_PREFIX = 'unsaved changes to ' + +export function unsavedDraftItems(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` + `${UNSAVED_DRAFT_PREFIX}${file.filePath} in ${worktreeLabel(ownerKey)}: save or discard them` ) } +export function isUnsavedDraftItem(item: string): boolean { + return item.startsWith(UNSAVED_DRAFT_PREFIX) +} + /** 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) @@ -135,7 +141,10 @@ export function projectDormantSessionFocus( } /** Older builds stamped 'local' on tabs created in an SSH worktree; its owner key proves the host. */ -function isOrcadSourceTabHost(executionHostId: string, scope: OrcadMigrationSourceScope): boolean { +export function isOrcadSourceTabHost( + executionHostId: string, + scope: OrcadMigrationSourceScope +): boolean { return executionHostId === scope.hostId || executionHostId === LOCAL_EXECUTION_HOST_ID } 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 15dab4c51d0..3ebc679673c 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 @@ -20,6 +20,7 @@ import { collectUnsupportedSessionItems, projectDormantSessionFocus, projectSessionToDestination, + isUnsavedDraftItem, transferableSleepingAgentSession, worktreeLabel } from './orcad-source-workspace-session-eligibility' @@ -29,7 +30,14 @@ import { dropOrcadMigrationTerminalBindings } from './orcad-source-workspace-session-layout' import { mergeSessionFragments } from './orcad-source-workspace-session-fragments' +import { + desktopDrafts, + resolveCopiesForHost, + type SessionFragment +} from './orcad-source-session-copy-resolution' import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { normalizeWorkspaceSessionKeyToWorkspaceId } from '../../../shared/workspace-scope' import { orcadMigrationSessionPartitions } from './orcad-source-session-partitions' import { hasDuplicateOrcadMigrationScrollbackDescriptors, @@ -41,6 +49,8 @@ export type OrcadMigrationSourceWorkspaceSessionInspection = { snapshots: OrcadMigrationTerminalScrollbackSnapshot[] /** Each names what blocks and how to clear it. */ blockedItems: string[] + /** What a confirmed "Move anyway" leaves in this desktop's retained profile. */ + leftBehindItems: string[] } const UNREADABLE_SESSION = 'saved tabs Orca could not read' @@ -50,47 +60,91 @@ export function collectOrcadMigrationSourceWorkspaceSession( state: PersistedState, source: OrcadMigrationManifestSource, catalog: OrcadMigrationCatalogPayload, - storage?: TerminalScrollbackSnapshotStorage + storage?: TerminalScrollbackSnapshotStorage, + leaveBehind = false ): OrcadMigrationSourceWorkspaceSessionInspection { + let collected: OrcadMigrationSourceWorkspaceSessionInspection try { - return collectWorkspaceSession(state, source, catalog, storage) + collected = collectWorkspaceSession(state, source, catalog, storage, leaveBehind) } catch (error) { console.warn('[migration] Unreadable workspace session blocks the move:', error) - return { payload: undefined, snapshots: [], blockedItems: [UNREADABLE_SESSION] } + collected = { + payload: undefined, + snapshots: [], + blockedItems: [UNREADABLE_SESSION], + leftBehindItems: [] + } } + if (!leaveBehind || collected.blockedItems.length === 0) { + return collected + } + // "Move anyway": what still can't move leaves the whole saved session on this desktop, unless an + // unsaved draft would go with it. A desktop-side draft has no retained copy once the host moves. + const drafts = desktopDrafts(state, source, catalog) + return drafts.length > 0 || collected.blockedItems.includes(UNREADABLE_SESSION) + ? { ...collected, blockedItems: drafts.length > 0 ? drafts : collected.blockedItems } + : { + payload: undefined, + snapshots: [], + blockedItems: [], + leftBehindItems: [...collected.leftBehindItems, ...collected.blockedItems] + } } function collectWorkspaceSession( state: PersistedState, source: OrcadMigrationManifestSource, catalog: OrcadMigrationCatalogPayload, - storage?: TerminalScrollbackSnapshotStorage + storage: TerminalScrollbackSnapshotStorage | undefined, + leaveBehind: boolean ): OrcadMigrationSourceWorkspaceSessionInspection { const sourceScope = createOrcadMigrationSourceScope({ source, catalog, repos: state.repos }) - const fragments: WorkspaceSessionState[] = [] - const snapshots: OrcadMigrationTerminalScrollbackSnapshot[] = [] + const fragments: SessionFragment[] = [] const blockedItems: string[] = [] + const leftBehindItems: string[] = [] const partitions = orcadMigrationSessionPartitions(state, sourceScope).map( ([partitionId, saved]) => { const scope = orcadMigrationPartitionScope(sourceScope, partitionId) - const session = withoutLeftBehindSessionRows(saved, scope) - return { session, scope, terminalTabIds: collectOwnedTerminalTabIds(session, scope) } + // A desktop-side draft stays a refusal: only the host's own rows are kept once it moves. + const session = withoutLeftBehindSessionRows(saved, scope, leaveBehind, { + leaveDrafts: partitionId !== LOCAL_EXECUTION_HOST_ID + }) + return { + saved, + session, + scope, + terminalTabIds: collectOwnedTerminalTabIds(session, scope) + } } ) const sourceTerminalTabIds = new Set( partitions.flatMap(({ terminalTabIds }) => [...terminalTabIds]) ) - for (const { session, scope, terminalTabIds } of partitions) { + for (const { saved, session, scope, terminalTabIds } of partitions) { const sourceHostPartition = scope.partitionHostId === scope.hostId - blockedItems.push( - ...collectUnsupportedSessionItems( - session, - scope, - sourceHostPartition, - terminalTabIds, - sourceTerminalTabIds - ) + const unsupported = collectUnsupportedSessionItems( + session, + scope, + sourceHostPartition, + terminalTabIds, + sourceTerminalTabIds ) + if (leaveBehind) { + // What the export dropped or never extracts stays behind; only a desktop-side draft refuses. + const kept = new Set(unsupported.filter(isUnsavedDraftItem)) + blockedItems.push(...kept) + leftBehindItems.push( + ...collectUnsupportedSessionItems( + withoutLeftBehindSessionRows(saved, scope), + scope, + sourceHostPartition, + terminalTabIds, + sourceTerminalTabIds + ).filter((item) => !kept.has(item)) + ) + } else { + blockedItems.push(...unsupported) + } const fragment = extractSessionOwnersForTransfer(session, { mapOwnerKey: (ownerKey) => orcadMigrationOwnerMatchesScope(ownerKey, scope) @@ -119,18 +173,21 @@ function collectWorkspaceSession( if (hasTransferredSessionState(fragment)) { const projected = projectOrcadMigrationSessionScrollback( projectSessionToDestination(fragment, scope), - storage + storage, + leaveBehind ) blockedItems.push(...projected.blockedItems) - snapshots.push(...projected.snapshots) - fragments.push(projected.session) + leftBehindItems.push(...projected.leftBehindItems) + fragments.push({ + session: projected.session, + snapshots: projected.snapshots, + local: scope.partitionHostId === LOCAL_EXECUTION_HOST_ID, + sourceHost: sourceHostPartition + }) } } - if (hasDuplicateOrcadMigrationScrollbackDescriptors(snapshots)) { - blockedItems.push('terminal history saved twice for one pane') - } if (blockedItems.length > 0 || fragments.length === 0) { - return { payload: undefined, snapshots: [], blockedItems } + return { payload: undefined, snapshots: [], blockedItems, leftBehindItems } } const closedTabIds = new Set( partitions.flatMap(({ session }) => @@ -139,14 +196,51 @@ function collectWorkspaceSession( ) ) ) - const merged = mergeSessionFragments(fragments, closedTabIds) + let merged = mergeSessionFragments( + fragments.map((fragment) => fragment.session), + closedTabIds + ) + let moving = fragments + if (!merged.session && leaveBehind) { + // "Move anyway": the host's copy moves, as the app reads it, with the desktop copy's drafts. + const conflicting = new Set(merged.conflicts.map(normalizeWorkspaceSessionKeyToWorkspaceId)) + const resolved = resolveCopiesForHost(fragments, conflicting) + if (resolved.clashingDrafts.length > 0) { + return { + payload: undefined, + snapshots: [], + blockedItems: resolved.clashingDrafts, + leftBehindItems + } + } + leftBehindItems.push( + ...merged.conflicts.map( + (ownerKey) => `this desktop's other copy of the tabs in ${worktreeLabel(ownerKey)}` + ) + ) + moving = resolved.fragments + merged = mergeSessionFragments( + moving.map((fragment) => fragment.session), + closedTabIds + ) + } + const snapshots = moving.flatMap((fragment) => fragment.snapshots) + if (merged.session && hasDuplicateOrcadMigrationScrollbackDescriptors(snapshots)) { + return { + payload: undefined, + snapshots: [], + blockedItems: ['terminal history saved twice for one pane'], + leftBehindItems + } + } return merged.session - ? { payload: merged.session, snapshots, blockedItems: [] } + ? { payload: merged.session, snapshots, blockedItems: [], leftBehindItems } : { payload: undefined, snapshots: [], blockedItems: merged.conflicts.map( (ownerKey) => `two different saved copies of the tabs in ${worktreeLabel(ownerKey)}` - ) + ), + leftBehindItems } } diff --git a/src/main/ssh/orcad-migration-refusal-reason.ts b/src/main/ssh/orcad-migration-refusal-reason.ts index 23b62285773..a3e0813da92 100644 --- a/src/main/ssh/orcad-migration-refusal-reason.ts +++ b/src/main/ssh/orcad-migration-refusal-reason.ts @@ -28,13 +28,19 @@ function blockerWords(blocker: OrcadMigrationBlocker): string[] { case 'orcad_migration_owner_unrecorded': return ['its server record is missing'] case 'orcad_migration_direct_ssh_terminal_leases': - return ['terminals still running'] + return [ + `terminals still running (${blocker.terminalLeases.length}): close them, or end them on the host` + ] case 'orcad_migration_dependent_state': - return blocker.dependencies.map(({ kind, names }) => - names?.length ? `${DEPENDENCY_WORDS[kind]} (${names.join('; ')})` : DEPENDENCY_WORDS[kind] + return blocker.dependencies.map(({ kind, count, names }) => + names?.length + ? `${DEPENDENCY_WORDS[kind]} (${names.join('; ')})` + : `${DEPENDENCY_WORDS[kind]} (${count})` ) case 'orcad_migration_dependency_unverifiable': - return ['saved state Orca could not read'] + return [ + `saved state Orca could not read (${blocker.sources.map((kind) => DEPENDENCY_WORDS[kind]).join(', ')})` + ] case 'orcad_migration_direct_ssh_repositories': case 'orcad_migration_direct_ssh_folder_workspaces': case 'orcad_migration_saved_port_forwards': diff --git a/src/main/ssh/orcad-migration-source-fence.ts b/src/main/ssh/orcad-migration-source-fence.ts index fdfa645459f..efceaf0fb9d 100644 --- a/src/main/ssh/orcad-migration-source-fence.ts +++ b/src/main/ssh/orcad-migration-source-fence.ts @@ -32,6 +32,10 @@ import { } from './ssh-target-orcad-preflight' import { orcadMigrationRefusalReason } from './orcad-migration-refusal-reason' +export const ORCAD_MIGRATION_LEAVE_BEHIND_TERMINALS = 'orcad_migration_leave_behind_terminals' +export const ORCAD_MIGRATION_LEAVE_BEHIND_TERMINALS_REASON = + 'Move anyway needs every terminal on this host closed first, and Orca could not confirm they all exited. Close them, or end them on the host, then connect again.' + export type OrcadMigrationFenceState = | { state: 'none' } /** Owner and journal agree: the migration can resume. */ @@ -107,6 +111,14 @@ export async function fenceOrcadMigrationSource(args: { if (proof.verdict !== 'exited' && proof.verdict !== 'bridged') { return refuse(proof.verdict, 'orcad_migration_terminals', proof.reason) } + if (proof.verdict === 'bridged' && target.managedServerLeaveBehind) { + // Why: leaving the saved tabs behind drops the identities the relay bridge reattaches through. + return refuse( + proof.relayVerdict, + ORCAD_MIGRATION_LEAVE_BEHIND_TERMINALS, + ORCAD_MIGRATION_LEAVE_BEHIND_TERMINALS_REASON + ) + } const preflight = preflightOrcadMigrationExport( args.store, target.id, diff --git a/src/main/ssh/orcad-migration-stuck-host-profiles.test.ts b/src/main/ssh/orcad-migration-stuck-host-profiles.test.ts new file mode 100644 index 00000000000..c599730908a --- /dev/null +++ b/src/main/ssh/orcad-migration-stuck-host-profiles.test.ts @@ -0,0 +1,567 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { PersistedState } from '../../shared/persisted-state-types' +import type { SshRemotePtyLease, SshTarget } from '../../shared/ssh-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { Store } from '../persistence/loading-store/store' +import { + fenceOrcadMigrationSource, + ORCAD_MIGRATION_LEAVE_BEHIND_TERMINALS +} from './orcad-migration-source-fence' +import { + assessOrcadMigrationTerminals, + retireProvenExitedLeases, + type HostRelayTerminalProof +} from './orcad-migration-terminal-gate' +import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' +import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import { listOrcadMigrationSourceCutovers } from './orcad-migration-cutover-journal' +import { + leaveBehindClearsOrcadMigrationBlockers, + onlyLeavableOrcadMigrationBlockers +} from './ssh-target-orcad-preflight' +import { ownedEditorFileId } from '../../shared/workspace-session-pane-ownership' +import { allowsDirectSshRelay } from './ssh-connection-store' + +// Relay-era profiles as the reporters' builds left them, loaded by the real Store and taken through +// the conversion's source steps: terminal proof, lease retirement, then the fence and its preflight. + +const TARGET: SshTarget = { + id: 'ssh-wsl', + label: 'wsl', + host: '127.0.0.1', + port: 22, + username: 'dev', + generation: 1 +} +const HOST = `ssh:${TARGET.id}` +const LEAF = '11111111-1111-4111-8111-111111111111' + +const directories: string[] = [] +afterEach(async () => { + await closeTestStores() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function relayEraProfile(projects: number): PersistedState { + const state = getDefaultPersistedState('/home/dev') + state.sshTargets = [TARGET] + state.repos = Array.from({ length: projects }, (_, index) => ({ + id: `repo-${index}`, + path: `/home/dev/src/project-${index}`, + displayName: `project-${index}`, + badgeColor: '#737373', + addedAt: 1, + kind: 'git' as const, + connectionId: TARGET.id + })) + state.workspaceSessionsByHostId = { [HOST]: getDefaultWorkspaceSession() } + return state +} + +/** One terminal tab, as the unified model and the legacy map both hold it. */ +function openTerminal(session: WorkspaceSessionState, worktreeId: string, tabId: string): void { + session.tabsByWorktree[worktreeId] = [ + { + id: tabId, + ptyId: null, + worktreeId, + title: 'bash', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + session.terminalLayoutsByTabId[tabId] = { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null + } + session.unifiedTabs = { + ...session.unifiedTabs, + [worktreeId]: [ + { + id: tabId, + entityId: tabId, + groupId: `group-${tabId}`, + worktreeId, + contentType: 'terminal', + label: 'bash', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + session.tabGroups = { + ...session.tabGroups, + [worktreeId]: [{ id: `group-${tabId}`, worktreeId, activeTabId: tabId, tabOrder: [tabId] }] + } +} + +function load(profile: PersistedState) { + const userDataPath = mkdtempSync(join(tmpdir(), 'orcad-stuck-host-')) + directories.push(userDataPath) + const dataFile = join(userDataPath, 'orca-data.json') + writeFileSync(dataFile, JSON.stringify(profile)) + const store = createSqliteTestStore(Store, { dataFile }) + return { userDataPath, store, claims: new SshTargetOrcadClaims(store) } +} + +/** The connect's own sequence: prove the host's terminals, retire what that proves, then fence. */ +async function connectMove( + loaded: ReturnType, + census: HostRelayTerminalProof = { verdict: 'exited', count: 0 } +) { + const proof = await assessOrcadMigrationTerminals(loaded.store, TARGET.id, async () => census) + if (proof.verdict === 'live' || proof.verdict === 'unverifiable') { + return { outcome: 'refused', reason: proof.reason } + } + retireProvenExitedLeases(loaded.store, TARGET.id, proof) + return fenceOrcadMigrationSource({ + userDataPath: loaded.userDataPath, + store: loaded.store, + claims: loaded.claims, + targetId: TARGET.id, + destinationEnvironmentId: 'env-managed', + destinationName: 'wsl', + terminalProof: proof + }) +} + +describe('a phone user upgrading every SSH host (WeChat report)', () => { + function phoneUserProfile(): PersistedState { + const state = relayEraProfile(2) + const host = state.workspaceSessionsByHostId![HOST]! + const worktreeId = 'repo-0::/home/dev/src/project-0' + openTerminal(host, worktreeId, 'tab-1') + // A browser tab the runtime opened, or one switched back to Default. + host.browserTabsByWorktree = { + [worktreeId]: [ + { + id: 'browser-1', + worktreeId, + url: 'https://example.test/', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1, + sessionProfileId: null, + sessionPartition: 'persist:orca-browser' + } + ] + } + state.mobileClientTabSelectionsByDeviceId = { + // The runtime names a phone's terminal by its pane. + phone: { + [worktreeId]: { + activeTabId: `tab-1::${LEAF}`, + activeGroupId: 'group-tab-1', + activeTabIdByGroupId: { 'group-tab-1': 'tab-1' } + } + }, + tablet: { + [worktreeId]: { activeTabId: 'closed-tab', activeGroupId: null, activeTabIdByGroupId: {} } + } + } + return state + } + + it('moves the host and keeps the phone on its pane', async () => { + const loaded = load(phoneUserProfile()) + const result = await connectMove(loaded) + expect(result).toMatchObject({ outcome: 'fenced' }) + if (!('cutover' in result)) { + return + } + const clientState = result.cutover.manifest.payload.dormantState?.clientState + expect(clientState?.mobileClientTabSelectionsByDeviceId).toEqual({ + phone: { + 'repo-0::/home/dev/src/project-0': { + activeTabId: `tab-1::${LEAF}`, + activeGroupId: 'group-tab-1', + activeTabIdByGroupId: { 'group-tab-1': 'tab-1' } + } + } + }) + }) +}) + +describe('#29083: about 14 projects, folders, a saved session and relay-era leases', () => { + function reportedProfile(): PersistedState { + const state = relayEraProfile(14) + state.projectGroups = [ + { + id: 'group-folders', + name: 'folders', + parentPath: '/home/dev/notes', + connectionId: TARGET.id, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ] + state.folderWorkspaces = ['notes', 'scratch', 'docs'].map((name): FolderWorkspace => ({ + id: `folder-${name}`, + projectGroupId: 'group-folders', + name, + folderPath: `/home/dev/notes/${name}`, + connectionId: TARGET.id, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + })) + const host = state.workspaceSessionsByHostId![HOST]! + for (const index of [0, 3, 7]) { + openTerminal(host, `repo-${index}::/home/dev/src/project-${index}`, `tab-${index}`) + } + openTerminal(host, 'folder:folder-notes', 'tab-notes') + const lease = (ptyId: string, state: SshRemotePtyLease['state']): SshRemotePtyLease => ({ + targetId: TARGET.id, + ptyId, + worktreeId: 'repo-0::/home/dev/src/project-0', + tabId: 'tab-0', + leafId: LEAF, + state, + createdAt: 1, + updatedAt: 1 + }) + // A 1.4.224 that was killed with terminals open never got to detach them. + state.sshRemotePtyLeases = [ + lease('pty-attached-1', 'attached'), + lease('pty-attached-2', 'attached'), + lease('pty-detached', 'detached'), + lease('pty-expired', 'expired') + ] + return state + } + + it('moves once the host proves no relay is left, folders and tabs included', async () => { + const loaded = load(reportedProfile()) + const result = await connectMove(loaded) + expect(result).toMatchObject({ outcome: 'fenced' }) + if (!('cutover' in result)) { + return + } + const payload = result.cutover.manifest.payload + expect(payload.repositories).toHaveLength(14) + expect(payload.folderWorkspaces.map((folder) => folder.id).sort()).toEqual([ + 'folder-docs', + 'folder-notes', + 'folder-scratch' + ]) + expect(Object.keys(payload.dormantState?.workspaceSession?.tabsByWorktree ?? {})).toHaveLength( + 4 + ) + expect( + loaded.store.getSshRemotePtyLeases(TARGET.id).every((entry) => entry.state === 'terminated') + ).toBe(true) + }) + + it('still converts through the bridge when old relays may run, retiring nothing', async () => { + const loaded = load(reportedProfile()) + const result = await connectMove(loaded, { verdict: 'live', count: 2 }) + expect(result).toMatchObject({ outcome: 'fenced' }) + expect( + loaded.store + .getSshRemotePtyLeases(TARGET.id) + .filter((entry) => entry.state !== 'terminated') + .map((entry) => entry.ptyId) + ).toEqual(['pty-attached-1', 'pty-attached-2', 'pty-detached', 'pty-expired']) + }) +}) + +describe('"Move anyway"', () => { + const worktreeId = 'repo-0::/home/dev/src/project-0' + + function savedStateOnly(): PersistedState { + const state = relayEraProfile(1) + const host = state.workspaceSessionsByHostId![HOST]! + openTerminal(host, worktreeId, 'tab-1') + host.openFilesByWorktree = { + [worktreeId]: [ + { + filePath: '/etc/hosts', + relativePath: 'hosts', + worktreeId, + language: 'plaintext', + externalSshTargetId: TARGET.id, + dirtyDraftContent: 'unsaved edit' + } + ] + } + host.unifiedTabs![worktreeId]!.push({ + id: 'runtime-tab', + entityId: 'runtime-tab', + groupId: 'group-tab-1', + worktreeId, + executionHostId: 'runtime:env-other', + contentType: 'terminal', + label: 'elsewhere', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + }) + // The desktop's older copy disagrees with the host's. + state.workspaceSession.tabsByWorktree[worktreeId] = [ + { ...host.tabsByWorktree[worktreeId]![0]!, id: 'older-tab' } + ] + return state + } + + it('is offered when only saved state blocks', async () => { + const loaded = load(savedStateOnly()) + const result = await connectMove(loaded) + expect(result).toMatchObject({ outcome: 'refused', code: 'orcad_migration_preflight_blocked' }) + expect('blockers' in result && onlyLeavableOrcadMigrationBlockers(result.blockers ?? [])).toBe( + true + ) + // A refused move leaves no fence or journal, so a downgraded build still connects as before. + expect(getManagedOrcadFenceEnvironmentId(loaded.store.getSshTarget(TARGET.id))).toBeNull() + expect(listOrcadMigrationSourceCutovers(loaded.userDataPath)).toEqual([]) + }) + + it('moves what it can and leaves the rest in the retained profile', async () => { + const loaded = load(savedStateOnly()) + confirmed(loaded) + const before = structuredClone(loaded.store.getWorkspaceSession(HOST)) + const result = await connectMove(loaded) + expect(result).toMatchObject({ outcome: 'fenced' }) + if (!('cutover' in result)) { + return + } + const session = result.cutover.manifest.payload.dormantState?.workspaceSession + expect(session?.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['tab-1']) + expect(session?.unifiedTabs?.[worktreeId]?.map((tab) => tab.id)).toEqual(['tab-1']) + expect(session?.openFilesByWorktree?.[worktreeId] ?? []).toEqual([]) + // Nothing is deleted: the draft and the older copy are still saved on this desktop. + expect(loaded.store.getWorkspaceSession(HOST)).toEqual(before) + expect( + loaded.store.getWorkspaceSession().tabsByWorktree[worktreeId]?.map((tab) => tab.id) + ).toEqual(['older-tab']) + }) + + function confirmed(loaded: ReturnType): void { + loaded.store.updateSshTarget(TARGET.id, { + managedServerLeaveBehind: { confirmedAt: '2026-10-15T00:00:00.000Z' } + }) + } + + it("still refuses an unsaved draft only this desktop's session holds, and names it", async () => { + const state = savedStateOnly() + const host = state.workspaceSessionsByHostId![HOST]! + state.workspaceSession.openFilesByWorktree = structuredClone(host.openFilesByWorktree) + host.openFilesByWorktree = {} + delete state.workspaceSession.tabsByWorktree[worktreeId] + const loaded = load(state) + confirmed(loaded) + await expect(connectMove(loaded)).resolves.toMatchObject({ + outcome: 'refused', + reason: expect.stringContaining('unsaved changes to /etc/hosts in /home/dev/src/project-0') + }) + }) + + it("carries the desktop copy's drafts when the host's copy of the tabs wins", async () => { + const state = savedStateOnly() + state.workspaceSession.openFilesByWorktree = { + [worktreeId]: [ + { + filePath: '/home/dev/src/project-0/notes.md', + relativePath: 'notes.md', + worktreeId, + language: 'markdown', + dirtyDraftContent: 'desktop draft' + } + ] + } + const loaded = load(state) + confirmed(loaded) + const result = await connectMove(loaded) + expect(result).toMatchObject({ outcome: 'fenced' }) + if (!('cutover' in result)) { + return + } + const session = result.cutover.manifest.payload.dormantState?.workspaceSession + expect(session?.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['tab-1']) + expect(session?.openFilesByWorktree?.[worktreeId]).toEqual([ + expect.objectContaining({ dirtyDraftContent: 'desktop draft' }) + ]) + }) + + it('moves a pane whose saved history is gone, without that history', async () => { + const state = savedStateOnly() + state.workspaceSessionsByHostId![HOST]!.terminalLayoutsByTabId['tab-1'] = { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + scrollbackRefsByLeafId: { [LEAF]: 'missing-snapshot-ref' } + } + delete state.workspaceSession.tabsByWorktree[worktreeId] + const refused = load(structuredClone(state)) + await expect(connectMove(refused)).resolves.toMatchObject({ outcome: 'refused' }) + const loaded = load(state) + confirmed(loaded) + const result = await connectMove(loaded) + expect(result).toMatchObject({ outcome: 'fenced' }) + if ('cutover' in result) { + const session = result.cutover.manifest.payload.dormantState?.workspaceSession + expect(session?.terminalLayoutsByTabId['tab-1']?.scrollbackRefsByLeafId).toBeUndefined() + } + }) + + it('never leaves a running terminal behind', async () => { + const loaded = load(savedStateOnly()) + loaded.store.updateSshTarget(TARGET.id, { + managedServerLeaveBehind: { confirmedAt: '2026-10-15T00:00:00.000Z' } + }) + await expect( + fenceOrcadMigrationSource({ + userDataPath: loaded.userDataPath, + store: loaded.store, + claims: loaded.claims, + targetId: TARGET.id, + destinationEnvironmentId: 'env-managed', + destinationName: 'wsl', + terminalProof: { verdict: 'live', ptyIds: ['pty-1'], reason: 'still running' } + }) + ).resolves.toMatchObject({ outcome: 'refused', code: 'orcad_migration_terminals' }) + }) + + it('refuses while relay terminals may still run, says why, and keeps the host connectable', async () => { + const loaded = load(savedStateOnly()) + confirmed(loaded) + const result = await connectMove(loaded, { verdict: 'live', count: 1 }) + expect(result).toMatchObject({ + outcome: 'refused', + code: ORCAD_MIGRATION_LEAVE_BEHIND_TERMINALS, + reason: expect.stringContaining('every terminal on this host closed first') + }) + const target = loaded.store.getSshTarget(TARGET.id)! + expect(getManagedOrcadFenceEnvironmentId(target)).toBeNull() + expect(listOrcadMigrationSourceCutovers(loaded.userDataPath)).toEqual([]) + expect(allowsDirectSshRelay(target)).toBe(true) + }) + + it('drops a skipped foreign terminal from the legacy rows too', async () => { + const state = savedStateOnly() + const host = state.workspaceSessionsByHostId![HOST]! + host.tabsByWorktree[worktreeId]!.push({ + ...host.tabsByWorktree[worktreeId]![0]!, + id: 'runtime-tab', + sortOrder: 1 + }) + host.terminalLayoutsByTabId['runtime-tab'] = { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null + } + host.remoteSessionIdsByTabId = { 'runtime-tab': 'remote-1' } + host.activeTabIdByWorktree = { [worktreeId]: 'runtime-tab' } + host.sleepingAgentSessionsByPaneKey = { + 'runtime-tab:0': { + paneKey: 'runtime-tab:0', + tabId: 'runtime-tab', + worktreeId, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-foreign' }, + prompt: 'Saved prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + connectionId: TARGET.id, + origin: 'quit' + } + } + host.terminalSurfaceTombstonesByPaneKey = { + [`runtime-tab:${LEAF}`]: { + worktreeId, + parentTabId: 'runtime-tab', + leafId: LEAF, + ptyId: 'pty-old', + incarnationId: 'inc-1', + retiredAt: 1 + } + } + const loaded = load(state) + confirmed(loaded) + const result = await connectMove(loaded) + expect(result).toMatchObject({ outcome: 'fenced' }) + if (!('cutover' in result)) { + return + } + const session = result.cutover.manifest.payload.dormantState?.workspaceSession + expect(session?.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['tab-1']) + expect(Object.keys(session?.terminalLayoutsByTabId ?? {})).not.toContain('runtime-tab') + expect(session?.remoteSessionIdsByTabId?.['runtime-tab']).toBeUndefined() + expect(session?.activeTabIdByWorktree?.[worktreeId]).not.toBe('runtime-tab') + expect(session?.terminalSurfaceTombstonesByPaneKey ?? {}).toEqual({}) + expect(session?.sleepingAgentSessionsByPaneKey?.['runtime-tab:0']).toBeUndefined() + }) + + it('drops the editor tab of a file it leaves behind', async () => { + const state = savedStateOnly() + const host = state.workspaceSessionsByHostId![HOST]! + const editorId = ownedEditorFileId('/etc/hosts', worktreeId, undefined) + host.unifiedTabs![worktreeId]!.push({ + id: editorId, + entityId: editorId, + groupId: 'group-tab-1', + worktreeId, + contentType: 'editor', + label: 'hosts', + customLabel: null, + color: null, + sortOrder: 2, + createdAt: 1 + }) + host.tabGroups![worktreeId]![0]!.tabOrder.push(editorId) + const loaded = load(state) + confirmed(loaded) + const result = await connectMove(loaded) + expect(result).toMatchObject({ outcome: 'fenced' }) + if (!('cutover' in result)) { + return + } + const session = result.cutover.manifest.payload.dormantState?.workspaceSession + expect(session?.unifiedTabs?.[worktreeId]?.map((tab) => tab.id)).toEqual(['tab-1']) + expect(session?.tabGroups?.[worktreeId]?.[0]?.tabOrder).toEqual(['tab-1']) + }) + + it('is offered only when leaving saved state behind would clear the refusal', () => { + expect(leaveBehindClearsOrcadMigrationBlockers(load(savedStateOnly()).store, TARGET.id)).toBe( + true + ) + const state = savedStateOnly() + const host = state.workspaceSessionsByHostId![HOST]! + // A draft only this desktop's session holds can't stay behind, so the offer would dead-end. + state.workspaceSession.openFilesByWorktree = structuredClone(host.openFilesByWorktree) + host.openFilesByWorktree = {} + delete state.workspaceSession.tabsByWorktree[worktreeId] + const loaded = load(state) + expect(leaveBehindClearsOrcadMigrationBlockers(loaded.store, TARGET.id)).toBe(false) + expect(loaded.store.getSshTarget(TARGET.id)?.managedServerLeaveBehind).toBeUndefined() + }) +}) diff --git a/src/main/ssh/orcad-migration-terminal-gate.test.ts b/src/main/ssh/orcad-migration-terminal-gate.test.ts index 51f7ec675b9..7d441b5721e 100644 --- a/src/main/ssh/orcad-migration-terminal-gate.test.ts +++ b/src/main/ssh/orcad-migration-terminal-gate.test.ts @@ -118,7 +118,7 @@ describe('terminal gate characterization: census-only verdicts', () => { }) describe('acting on a terminal proof', () => { - it('retires only the detached and expired leases an exited proof covers', () => { + it('retires every lease an exited proof covers, attached ones included (#29083)', () => { const leases = store(MIXED) const markSshRemotePtyLease = vi.fn() retireProvenExitedLeases({ ...leases, markSshRemotePtyLease }, 'ssh-1', { @@ -126,6 +126,7 @@ describe('acting on a terminal proof', () => { provenPtyIds: ['att', 'det', 'exp', 'ter'] }) expect(markSshRemotePtyLease.mock.calls).toEqual([ + ['ssh-1', 'att', 'terminated'], ['ssh-1', 'det', 'terminated'], ['ssh-1', 'exp', 'terminated'] ]) diff --git a/src/main/ssh/orcad-migration-terminal-gate.ts b/src/main/ssh/orcad-migration-terminal-gate.ts index 5cfe4ba5165..78f5fa56c65 100644 --- a/src/main/ssh/orcad-migration-terminal-gate.ts +++ b/src/main/ssh/orcad-migration-terminal-gate.ts @@ -103,9 +103,10 @@ export function terminalsRunElsewhere(proof: OrcadMigrationTerminalVerdict): boo } /** - * Whoever acts on an exited proof (a conversion or a move) marks the - * detached and expired leases it covers terminated, so later checks stop reading them as running - * or unverifiable and the next connect can convert. + * Whoever acts on an exited proof (a conversion or a move) marks every lease it covers terminated, + * so later checks stop reading them as running or unverifiable and the next connect can convert. + * Attached ones too (#29083): only a host-wide census proves exit, and with no relay left on the + * host an `attached` row is one an older build never got to detach. */ export function retireProvenExitedLeases( store: Pick, @@ -117,7 +118,7 @@ export function retireProvenExitedLeases( } const proven = new Set(proof.provenPtyIds) for (const lease of store.getSshRemotePtyLeases(targetId)) { - if ((lease.state === 'detached' || lease.state === 'expired') && proven.has(lease.ptyId)) { + if (lease.state !== 'terminated' && proven.has(lease.ptyId)) { store.markSshRemotePtyLease(targetId, lease.ptyId, 'terminated') } } diff --git a/src/main/ssh/orcad-runtime-conversion.ts b/src/main/ssh/orcad-runtime-conversion.ts index 359011203ce..008512ea188 100644 --- a/src/main/ssh/orcad-runtime-conversion.ts +++ b/src/main/ssh/orcad-runtime-conversion.ts @@ -33,6 +33,11 @@ import type { SshTarget } from '../../shared/ssh-types' import type { CensusHostRelayTerminals } from './orcad-migration-terminal-gate' import { createManagedOrcadEnvironment } from './orcad-runtime-deployment' import { createSshRelayMigrationSource } from './orcad-migration-ssh-relay-source' +import { + leaveBehindClearsOrcadMigrationBlockers, + onlyLeavableOrcadMigrationBlockers +} from './ssh-target-orcad-preflight' +import { ORCAD_MIGRATION_LEAVE_BEHIND_TERMINALS_REASON } from './orcad-migration-source-fence' import { retainOrcadMigrationSource } from './orcad-migration-source-retention' import { linkedOrcadMigrationCutoverPort, @@ -207,7 +212,26 @@ async function fenceOrResume( ) ) } - return refuse(result.verdict, result.code, result.reason) + // Already moving anyway: what still refuses is what can't be left behind. + const leavable = + result.blockers && + onlyLeavableOrcadMigrationBlockers(result.blockers) && + !target.managedServerLeaveBehind + if (leavable && terminalProof.verdict !== 'exited') { + // Why no offer: leaving tabs behind would strand terminals the relay bridge still serves. + return refuse( + result.verdict, + result.code, + `${result.reason} ${ORCAD_MIGRATION_LEAVE_BEHIND_TERMINALS_REASON}` + ) + } + return { + ...refuse(result.verdict, result.code, result.reason), + // Why previewed: an offer the confirmed export would refuse again is a dead end. + ...(leavable && leaveBehindClearsOrcadMigrationBlockers(store, target.id) + ? { canLeaveBehind: true } + : {}) + } } function requireJournaledCutover( diff --git a/src/main/ssh/ssh-host-server-on-connect.test.ts b/src/main/ssh/ssh-host-server-on-connect.test.ts index 7edfb838a65..4e84ff5a702 100644 --- a/src/main/ssh/ssh-host-server-on-connect.test.ts +++ b/src/main/ssh/ssh-host-server-on-connect.test.ts @@ -148,6 +148,24 @@ describe('which server an SSH host runs on connect', () => { }) }) + it('offers "Move anyway" when only saved state refuses', async () => { + const d = deps({ + convert: async () => ({ + outcome: 'refused', + verdict: 'live', + code: 'orcad_migration_preflight_blocked', + reason: 'This SSH host cannot move yet: saved tabs and panes (2).', + canLeaveBehind: true + }) + }) + await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({ + route: 'unserved', + reason: 'refused', + detail: 'This SSH host cannot move yet: saved tabs and panes (2).', + canLeaveBehind: true + }) + }) + it('falls back to the relay ladder for good when orcad cannot run, and remembers why', async () => { const d = deps({ isEmptyHost: () => true, diff --git a/src/main/ssh/ssh-host-server-on-connect.ts b/src/main/ssh/ssh-host-server-on-connect.ts index 83bb4431a1b..adaa60e333c 100644 --- a/src/main/ssh/ssh-host-server-on-connect.ts +++ b/src/main/ssh/ssh-host-server-on-connect.ts @@ -61,6 +61,7 @@ export type HostServerOnConnectResult = detail?: string terminals?: number terminalsElsewhere?: boolean + canLeaveBehind?: true /** A move that stopped short kept the host locked; the next connect resumes it. */ held?: true } @@ -295,7 +296,12 @@ async function afterConversion( if (result.code === 'orcad_migration_terminals') { return { route: 'unserved', reason: terminalReason(result.verdict) } } - return { route: 'unserved', reason: 'refused', detail: result.reason } + return { + route: 'unserved', + reason: 'refused', + detail: result.reason, + ...(result.canLeaveBehind ? { canLeaveBehind: true } : {}) + } } } diff --git a/src/main/ssh/ssh-host-unserved-status.ts b/src/main/ssh/ssh-host-unserved-status.ts index cc0156becba..c89aad973ed 100644 --- a/src/main/ssh/ssh-host-unserved-status.ts +++ b/src/main/ssh/ssh-host-unserved-status.ts @@ -14,6 +14,7 @@ export function unservedServerStatus( ...(decision.detail ? { detail: decision.detail } : {}), ...(decision.terminals !== undefined ? { terminals: decision.terminals } : {}), ...(decision.terminalsElsewhere ? { terminalsElsewhere: true } : {}), + ...(decision.canLeaveBehind ? { canLeaveBehind: true } : {}), ...(decision.held ? { held: true } : {}) } } diff --git a/src/main/ssh/ssh-target-orcad-preflight.ts b/src/main/ssh/ssh-target-orcad-preflight.ts index abb402cbce0..85fcd5107d4 100644 --- a/src/main/ssh/ssh-target-orcad-preflight.ts +++ b/src/main/ssh/ssh-target-orcad-preflight.ts @@ -72,6 +72,33 @@ export function preflightOrcadMigrationExport( } } +/** Whether a confirmed "Move anyway" would export: what it can't leave (desktop drafts) still refuses. */ +export function leaveBehindClearsOrcadMigrationBlockers( + store: OrcadMigrationPreflightStore & Pick, + targetId: string +): boolean { + const view = store.createOrcadMigrationLeaveBehindView(targetId) + return preflightOrcadMigrationExport( + { + ...view, + getSshTarget: (id) => store.getSshTarget(id), + getSshRemotePtyLeases: (id) => store.getSshRemotePtyLeases(id) + }, + targetId + ).claimable +} + +/** Only saved state blocks: a confirmed "Move anyway" may leave it on this desktop. */ +export function onlyLeavableOrcadMigrationBlockers( + blockers: readonly OrcadMigrationBlocker[] +): boolean { + const blocking = blockers.filter(isBlockingOrcadMigrationBlocker) + return ( + blocking.length > 0 && + blocking.every((blocker) => blocker.code === 'orcad_migration_dependent_state') + ) +} + // Exported catalog rows and saved port forwards (which stay with the source) do not block. export function isBlockingOrcadMigrationBlocker(blocker: OrcadMigrationBlocker): boolean { return ( diff --git a/src/preload/api/managed-orcad-api.ts b/src/preload/api/managed-orcad-api.ts index 3f42b0b3981..3af00bfe3db 100644 --- a/src/preload/api/managed-orcad-api.ts +++ b/src/preload/api/managed-orcad-api.ts @@ -46,6 +46,8 @@ export type ManagedOrcadPreloadApi = { previewDeltaMove: (args: { sshTargetId: string }) => Promise moveDelta: (args: { sshTargetId: string }) => Promise keepServerVersion: (args: { sshTargetId: string }) => Promise + /** "Move anyway": the next connect leaves what can't move on this desktop. */ + allowMoveLeavingBehind: (args: { sshTargetId: string }) => Promise } export const managedOrcadApi: ManagedOrcadPreloadApi = { @@ -65,5 +67,7 @@ export const managedOrcadApi: ManagedOrcadPreloadApi = { previewDeltaMove: (args) => ipcRenderer.invoke('runtimeEnvironments:previewOrcadDeltaMove', args), moveDelta: (args) => ipcRenderer.invoke('runtimeEnvironments:moveOrcadDelta', args), keepServerVersion: (args) => - ipcRenderer.invoke('runtimeEnvironments:keepOrcadServerVersion', args) + ipcRenderer.invoke('runtimeEnvironments:keepOrcadServerVersion', args), + allowMoveLeavingBehind: (args) => + ipcRenderer.invoke('runtimeEnvironments:allowOrcadMoveLeavingBehind', args) } diff --git a/src/renderer/src/components/settings/SshHostDeltaMoveDialog.test.tsx b/src/renderer/src/components/settings/SshHostDeltaMoveDialog.test.tsx index 22bb87ec0bf..9f83517ac4d 100644 --- a/src/renderer/src/components/settings/SshHostDeltaMoveDialog.test.tsx +++ b/src/renderer/src/components/settings/SshHostDeltaMoveDialog.test.tsx @@ -64,7 +64,8 @@ function api(next: OrcadDeltaMovePreview, result: OrcadDeltaMoveResult): Managed listPendingMigrations: unused, previewDeltaMove: vi.fn(async () => next), moveDelta: vi.fn(async () => result), - keepServerVersion: unused + keepServerVersion: unused, + allowMoveLeavingBehind: unused } } diff --git a/src/renderer/src/components/settings/SshHostMoveAnywayAction.test.tsx b/src/renderer/src/components/settings/SshHostMoveAnywayAction.test.tsx new file mode 100644 index 00000000000..e59796d9e71 --- /dev/null +++ b/src/renderer/src/components/settings/SshHostMoveAnywayAction.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshManagedServerStatus, SshTarget } from '../../../../shared/ssh-types' +import { SshHostMoveAnywayAction } from './SshHostMoveAnywayAction' + +vi.mock('../ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
+})) + +const target: SshTarget = { id: 'ssh-1', label: 'Builder', host: 'b', port: 22, username: 'dev' } +const refused: SshManagedServerStatus = { + kind: 'relay', + reason: 'refused', + detail: 'This SSH host cannot move yet: saved tabs and panes (unsaved changes to /etc/hosts).', + canLeaveBehind: true +} + +const allowMoveLeavingBehind = vi.fn(async () => {}) +const connect = vi.fn(async () => null) +const roots: Root[] = [] +beforeEach(() => { + allowMoveLeavingBehind.mockClear() + connect.mockClear() + Object.assign(window, { + api: { + runtimeEnvironments: { managedOrcad: { allowMoveLeavingBehind } }, + ssh: { connect } + } + }) +}) +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()) + } + document.body.innerHTML = '' +}) + +async function render(status: SshManagedServerStatus, onChanged = vi.fn()) { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render() + }) + return container +} + +function button(container: HTMLElement, text: string): HTMLButtonElement { + return [...container.querySelectorAll('button')].find((entry) => entry.textContent === text)! +} + +describe('Move anyway', () => { + it('is offered only for a refusal that saved state alone causes', async () => { + expect((await render({ ...refused, canLeaveBehind: undefined })).textContent).toBe('') + expect( + (await render({ kind: 'relay', reason: 'relay_terminals_live', terminals: 2 })).textContent + ).toBe('') + }) + + it('names what stays behind, then reconnects with consent recorded', async () => { + const onChanged = vi.fn() + const container = await render(refused, onChanged) + await act(async () => button(container, 'Move anyway…').click()) + expect(container.textContent).toContain('unsaved changes to /etc/hosts') + expect(container.textContent).toContain('Unsaved edits are kept, never deleted.') + await act(async () => button(container, 'Move anyway').click()) + expect(allowMoveLeavingBehind).toHaveBeenCalledWith({ sshTargetId: 'ssh-1' }) + expect(connect).toHaveBeenCalledWith({ targetId: 'ssh-1' }) + expect(onChanged).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/settings/SshHostMoveAnywayAction.tsx b/src/renderer/src/components/settings/SshHostMoveAnywayAction.tsx new file mode 100644 index 00000000000..4a78037158b --- /dev/null +++ b/src/renderer/src/components/settings/SshHostMoveAnywayAction.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import type { SshManagedServerStatus, SshTarget } from '../../../../shared/ssh-types' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' + +/** + * The way out of a move refused only by saved state: move the host now and keep what can't move on + * this computer. Unsaved drafts are never deleted, and live terminals still have to be closed. + */ +export function SshHostMoveAnywayAction({ + target, + status, + onChanged +}: { + target: SshTarget + status: SshManagedServerStatus | undefined + onChanged: () => void +}): React.JSX.Element | null { + const api = window.api.runtimeEnvironments.managedOrcad + const mountedRef = useMountedRef() + const [open, setOpen] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + if (!api || status?.kind !== 'relay' || status.reason !== 'refused' || !status.canLeaveBehind) { + return null + } + const moveAnyway = async (): Promise => { + setBusy(true) + setError(null) + try { + await api.allowMoveLeavingBehind({ sshTargetId: target.id }) + await window.api.ssh.connect({ targetId: target.id }) + if (mountedRef.current) { + setOpen(false) + } + onChanged() + } catch (cause) { + if (mountedRef.current) { + setError(cause instanceof Error ? cause.message : String(cause)) + } + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + } + return ( +
+ + !next && !busy && setOpen(false)}> + + + + {translate('auto.components.settings.moveAnyway.title', 'Move this host anyway')} + + + {translate( + 'auto.components.settings.moveAnyway.description', + 'Orca moves this host to its managed server without the saved items below. They stay on this computer and don’t appear on the managed server. Unsaved edits are kept, never deleted.' + )} + + + {status.detail ? ( +
+              {status.detail}
+            
+ ) : null} + {error ?

{error}

: null} + + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/SshTargetServerStatus.tsx b/src/renderer/src/components/settings/SshTargetServerStatus.tsx index 6b970e48949..533c00b8d48 100644 --- a/src/renderer/src/components/settings/SshTargetServerStatus.tsx +++ b/src/renderer/src/components/settings/SshTargetServerStatus.tsx @@ -7,6 +7,7 @@ import { useAppStore } from '@/store' import { Button } from '../ui/button' import { sshHostServerStatusLine } from '@/ssh/ssh-host-server-status-copy' import { SshHostChangedActions } from './SshHostChangedActions' +import { SshHostMoveAnywayAction } from './SshHostMoveAnywayAction' /** Whether this SSH host's managed Orca server serves it, and why not when it doesn't. */ export function SshTargetServerStatus({ @@ -49,6 +50,11 @@ export function SshTargetServerStatus({ ) : null} + ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 85c79f32996..2146fc30325 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12813,6 +12813,13 @@ "cancel": "Cancel", "confirm": "Keep the server’s version" }, + "moveAnyway": { + "action": "Move anyway…", + "title": "Move this host anyway", + "description": "Orca moves this host to its managed server without the saved items below. They stay on this computer and don’t appear on the managed server. Unsaved edits are kept, never deleted.", + "cancel": "Cancel", + "confirm": "Move anyway" + }, "NotificationHostToggles": { "offCount": "{{count}} off", "machines": "Machines", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9ddc59e3051..d7de1f29fff 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12574,6 +12574,13 @@ "cancel": "Cancelar", "confirm": "Conservar la versión del servidor" }, + "moveAnyway": { + "action": "Mover de todos modos…", + "title": "Mover este host de todos modos", + "description": "Orca mueve este host a su servidor gestionado sin los elementos guardados que aparecen abajo. Se quedan en este equipo y no aparecen en el servidor gestionado. Las ediciones sin guardar se conservan; nunca se eliminan.", + "cancel": "Cancelar", + "confirm": "Mover de todos modos" + }, "ZcodeCliSetupSection": { "title": "Usar GLM con ZCode", "description": "Inicia sesión para usar GLM como agente de programación. La clave de API de cuota de arriba solo supervisa el uso; ZCode gestiona su propio inicio de sesión y su selección de modelo.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 6118157efcb..1d24d98e454 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -12682,6 +12682,13 @@ "cancel": "Annuler", "confirm": "Conserver la version du serveur" }, + "moveAnyway": { + "action": "Déplacer quand même…", + "title": "Déplacer cet hôte quand même", + "description": "Orca déplace cet hôte vers son serveur géré sans les éléments enregistrés ci-dessous. Ils restent sur cet ordinateur et n’apparaissent pas sur le serveur géré. Les modifications non enregistrées sont conservées, jamais supprimées.", + "cancel": "Annuler", + "confirm": "Déplacer quand même" + }, "ZcodeCliSetupSection": { "title": "Utiliser GLM avec ZCode", "description": "Connectez-vous pour utiliser GLM comme agent de codage. La clé API de quota ci-dessus ne fait que surveiller l'utilisation ; ZCode gère sa propre connexion et le choix du modèle.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index eddff290f8f..13b6f849e30 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12574,6 +12574,13 @@ "cancel": "キャンセル", "confirm": "サーバーのバージョンを保持" }, + "moveAnyway": { + "action": "それでも移行…", + "title": "このホストをそれでも移行", + "description": "Orca はこのホストを管理サーバーへ移行しますが、以下の保存済み項目は含めません。これらはこのコンピューターに残り、管理サーバーには表示されません。未保存の編集は保持され、削除されることはありません。", + "cancel": "キャンセル", + "confirm": "それでも移行" + }, "ZcodeCliSetupSection": { "title": "ZCode で GLM を使用する", "description": "コーディング Agent として GLM を使用するにはサインインしてください。上記のクォータ API キーは使用状況の監視のみに使用されます。サインインとモデル選択は ZCode が独自に管理します。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3316009982d..ef15ebcda9a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12574,6 +12574,13 @@ "cancel": "취소", "confirm": "서버 버전 유지" }, + "moveAnyway": { + "action": "그래도 이동…", + "title": "이 호스트를 그래도 이동", + "description": "Orca는 아래에 저장된 항목을 제외하고 이 호스트를 관리형 서버로 이동합니다. 이 항목들은 이 컴퓨터에 남아 있으며 관리형 서버에는 표시되지 않습니다. 저장하지 않은 편집은 보존되며 절대 삭제되지 않습니다.", + "cancel": "취소", + "confirm": "그래도 이동" + }, "ZcodeCliSetupSection": { "apiKey": "ZCode API 키 설정", "apiKeyInstructions": "Enter를 눌러 ZCode를 시작한 뒤 /login을 입력하고, 이용 중인 요금제 사이트에 맞는 API 키 옵션을 선택하세요. ZCode에서 입력 내용이 가려지는 프롬프트에 키를 입력하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 32cf4f30187..512ee51c3a2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12588,6 +12588,13 @@ "cancel": "取消", "confirm": "保留服务器的版本" }, + "moveAnyway": { + "action": "仍要迁移…", + "title": "仍要迁移此主机", + "description": "Orca 会将此主机迁移到其受管服务器,但不包含下列已保存的项目。它们会保留在这台电脑上,不会出现在受管服务器上。未保存的编辑会被保留,绝不会被删除。", + "cancel": "取消", + "confirm": "仍要迁移" + }, "SettingsHostScopePicker": { "label": "主机", "unavailable": "{{value0}}(不可用)", diff --git a/src/shared/orcad-managed-runtime.ts b/src/shared/orcad-managed-runtime.ts index 70a85b12b85..bb1f4b619a5 100644 --- a/src/shared/orcad-managed-runtime.ts +++ b/src/shared/orcad-managed-runtime.ts @@ -13,6 +13,8 @@ export type OrcadManagedRefusal = { verdict: 'live' | 'unverifiable' code: string reason: string + /** Only saved state blocks, which a confirmed "Move anyway" leaves on this desktop. */ + canLeaveBehind?: true } export type OrcadManagedDeferral = { diff --git a/src/shared/ssh-managed-server-status-admission.ts b/src/shared/ssh-managed-server-status-admission.ts index c6d612b75ca..afc2fb5bdff 100644 --- a/src/shared/ssh-managed-server-status-admission.ts +++ b/src/shared/ssh-managed-server-status-admission.ts @@ -79,6 +79,9 @@ export function admitSshManagedServerStatus(value: unknown): { ...('terminalsElsewhere' in value && value.terminalsElsewhere === true ? { terminalsElsewhere: true } : {}), + ...('canLeaveBehind' in value && value.canLeaveBehind === true + ? { canLeaveBehind: true } + : {}), ...('held' in value && value.held === true ? { held: true } : {}) } } diff --git a/src/shared/ssh-retained-payload-admission.test.ts b/src/shared/ssh-retained-payload-admission.test.ts index 0e2de58ea81..172a365bac9 100644 --- a/src/shared/ssh-retained-payload-admission.test.ts +++ b/src/shared/ssh-retained-payload-admission.test.ts @@ -147,6 +147,23 @@ describe('SSH retained payload admission', () => { ).toEqual(relay) }) + it('admits only a literal "Move anyway" offer', () => { + const state = { targetId: 'ssh-a', status: 'connected', error: null, reconnectAttempt: 0 } + const refused = { kind: 'relay', reason: 'refused', detail: 'saved tabs and panes' } + expect( + admitSshConnectionState( + { ...state, managedServer: { ...refused, canLeaveBehind: true } }, + 'ssh-a' + )?.managedServer + ).toEqual({ ...refused, canLeaveBehind: true }) + expect( + admitSshConnectionState( + { ...state, managedServer: { ...refused, canLeaveBehind: 1 } }, + 'ssh-a' + )?.managedServer + ).toEqual(refused) + }) + it('rejects partial and malformed provider authority', () => { const state = { targetId: 'ssh-a', diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index b4774b4bf58..3264dd52254 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -101,6 +101,11 @@ export type SshTarget = { managedServerUnavailable?: { reason: string; appVersion: string } /** Main-owned: the one-time "move now" offer was shown under this app version. */ managedServerMoveOffered?: { appVersion: string } + /** + * Main-owned: the user confirmed "Move anyway", so saved state the move can't carry stays in this + * desktop's retained profile instead of refusing the move. Cleared once the host moves. + */ + managedServerLeaveBehind?: { confirmedAt: string } /** Main-owned: updating the managed server to this app version failed; retried once it changes. */ managedServerUpdateFailure?: { reason: string; appVersion: string } orcadFence?: { @@ -119,6 +124,7 @@ export type SshTargetCreateInput = Omit< | 'orcadFence' | 'managedServerUnavailable' | 'managedServerMoveOffered' + | 'managedServerLeaveBehind' | 'managedServerUpdateFailure' > export type SshTargetUpdateInput = Partial @@ -322,6 +328,8 @@ export type SshManagedServerStatus = terminals?: number /** Those terminals belong to another Orca desktop or session, so this one offers no move. */ terminalsElsewhere?: boolean + /** The refusal is only saved state the user may leave on this desktop ("Move anyway"). */ + canLeaveBehind?: true /** A move to the managed server stopped short and keeps the host locked until it resumes. */ held?: true }