mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
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 <noreply@anthropic.com>
* Revert "fix(ssh): spend a "Move anyway" consent when the conversion throws"
This reverts commit 4f63d777d9.
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
m4air
Neil
parent
66631d0d26
commit
699709c0ba
@@ -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',
|
||||
|
||||
@@ -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() }
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
|
||||
'runtimeEnvironments:previewOrcadDeltaMove',
|
||||
'runtimeEnvironments:moveOrcadDelta',
|
||||
'runtimeEnvironments:keepOrcadServerVersion',
|
||||
'runtimeEnvironments:allowOrcadMoveLeavingBehind',
|
||||
'runtimeEnvironments:createOrcadSshHost',
|
||||
'runtimeEnvironments:resumeOrcadSshHost',
|
||||
'runtimeEnvironments:listPendingOrcadSshProvisioning'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string>): 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<string>()
|
||||
const next: PersistedMobileClientTabSelection = {
|
||||
activeTabId:
|
||||
selection.activeTabId !== null && tabIds.has(selection.activeTabId)
|
||||
selection.activeTabId !== null && movedMobileTab(selection.activeTabId, tabIds)
|
||||
? selection.activeTabId
|
||||
: null,
|
||||
activeGroupId:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,6 +43,15 @@ export type OrcadMigrationSourceDormantInspection = {
|
||||
blockedCounts: Record<TransferredDormantKind, number>
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<StoreRuntimeState, 'state' | 'terminalScrollbackSnapshotStorage'>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
+60
-23
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
+112
-11
@@ -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<string>
|
||||
): Partial<WorkspaceSessionState> {
|
||||
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 = <T>(
|
||||
rows: Record<string, T> | undefined,
|
||||
keep: (entry: [string, T]) => boolean
|
||||
): Record<string, T> | 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, ReadonlySet<string>>
|
||||
): 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<string, Set<string>> {
|
||||
const hidden = new Map<string, Set<string>>()
|
||||
// Without the unified model, hydration shows every saved file as a tab.
|
||||
if (!session.unifiedTabs || !session.tabGroups) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<string>
|
||||
): { 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 }
|
||||
}
|
||||
@@ -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')] }
|
||||
|
||||
+14
-5
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof load>,
|
||||
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<typeof load>): 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()
|
||||
})
|
||||
})
|
||||
@@ -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']
|
||||
])
|
||||
|
||||
@@ -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<Store, 'getSshRemotePtyLeases' | 'markSshRemotePtyLease'>,
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Store, 'createOrcadMigrationLeaveBehindView'>,
|
||||
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 (
|
||||
|
||||
@@ -46,6 +46,8 @@ export type ManagedOrcadPreloadApi = {
|
||||
previewDeltaMove: (args: { sshTargetId: string }) => Promise<OrcadDeltaMovePreview>
|
||||
moveDelta: (args: { sshTargetId: string }) => Promise<OrcadDeltaMoveResult>
|
||||
keepServerVersion: (args: { sshTargetId: string }) => Promise<void>
|
||||
/** "Move anyway": the next connect leaves what can't move on this desktop. */
|
||||
allowMoveLeavingBehind: (args: { sshTargetId: string }) => Promise<void>
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? <div>{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
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(<SshHostMoveAnywayAction target={target} status={status} onChanged={onChanged} />)
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<string | null>(null)
|
||||
if (!api || status?.kind !== 'relay' || status.reason !== 'refused' || !status.canLeaveBehind) {
|
||||
return null
|
||||
}
|
||||
const moveAnyway = async (): Promise<void> => {
|
||||
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 (
|
||||
<div className="flex flex-wrap justify-end gap-2 px-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
{translate('auto.components.settings.moveAnyway.action', 'Move anyway…')}
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={(next) => !next && !busy && setOpen(false)}>
|
||||
<DialogContent showCloseButton={!busy}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate('auto.components.settings.moveAnyway.title', 'Move this host anyway')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{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.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{status.detail ? (
|
||||
<pre className="scrollbar-sleek max-h-60 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
|
||||
{status.detail}
|
||||
</pre>
|
||||
) : null}
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)} disabled={busy}>
|
||||
{translate('auto.components.settings.moveAnyway.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void moveAnyway()} disabled={busy}>
|
||||
{translate('auto.components.settings.moveAnyway.confirm', 'Move anyway')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<SshHostChangedActions target={target} onChanged={onChanged} />
|
||||
<SshHostMoveAnywayAction
|
||||
target={target}
|
||||
status={state?.managedServer}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -12574,6 +12574,13 @@
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "サーバーのバージョンを保持"
|
||||
},
|
||||
"moveAnyway": {
|
||||
"action": "それでも移行…",
|
||||
"title": "このホストをそれでも移行",
|
||||
"description": "Orca はこのホストを管理サーバーへ移行しますが、以下の保存済み項目は含めません。これらはこのコンピューターに残り、管理サーバーには表示されません。未保存の編集は保持され、削除されることはありません。",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "それでも移行"
|
||||
},
|
||||
"ZcodeCliSetupSection": {
|
||||
"title": "ZCode で GLM を使用する",
|
||||
"description": "コーディング Agent として GLM を使用するにはサインインしてください。上記のクォータ API キーは使用状況の監視のみに使用されます。サインインとモデル選択は ZCode が独自に管理します。",
|
||||
|
||||
@@ -12574,6 +12574,13 @@
|
||||
"cancel": "취소",
|
||||
"confirm": "서버 버전 유지"
|
||||
},
|
||||
"moveAnyway": {
|
||||
"action": "그래도 이동…",
|
||||
"title": "이 호스트를 그래도 이동",
|
||||
"description": "Orca는 아래에 저장된 항목을 제외하고 이 호스트를 관리형 서버로 이동합니다. 이 항목들은 이 컴퓨터에 남아 있으며 관리형 서버에는 표시되지 않습니다. 저장하지 않은 편집은 보존되며 절대 삭제되지 않습니다.",
|
||||
"cancel": "취소",
|
||||
"confirm": "그래도 이동"
|
||||
},
|
||||
"ZcodeCliSetupSection": {
|
||||
"apiKey": "ZCode API 키 설정",
|
||||
"apiKeyInstructions": "Enter를 눌러 ZCode를 시작한 뒤 /login을 입력하고, 이용 중인 요금제 사이트에 맞는 API 키 옵션을 선택하세요. ZCode에서 입력 내용이 가려지는 프롬프트에 키를 입력하세요.",
|
||||
|
||||
@@ -12588,6 +12588,13 @@
|
||||
"cancel": "取消",
|
||||
"confirm": "保留服务器的版本"
|
||||
},
|
||||
"moveAnyway": {
|
||||
"action": "仍要迁移…",
|
||||
"title": "仍要迁移此主机",
|
||||
"description": "Orca 会将此主机迁移到其受管服务器,但不包含下列已保存的项目。它们会保留在这台电脑上,不会出现在受管服务器上。未保存的编辑会被保留,绝不会被删除。",
|
||||
"cancel": "取消",
|
||||
"confirm": "仍要迁移"
|
||||
},
|
||||
"SettingsHostScopePicker": {
|
||||
"label": "主机",
|
||||
"unavailable": "{{value0}}(不可用)",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<SshTargetCreateInput>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user