From 0ead9f05bdb9823860c545f00d7153f620bb6984 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 3 Oct 2026 19:17:06 -0700 Subject: [PATCH] feat(ssh): offer to move a host whose open terminals keep it on the relay (#25097) A host with any open SSH terminal never converted: the connect gate keeps the relay while relay terminals are live, and an open tab respawns them on every connect. The first such connect per host per app version now marks the relay status with offerMove (recorded as managedServerMoveOffered), which toasts "Move ... Its N open terminals will restart." The SSH Hosts status line keeps a "Move to managed server" action while terminals are live. Confirming runs ssh:moveToManagedServer: stop the host's relay terminals through the extracted ssh:terminateSessions path, re-run the connect gate's terminal census, refuse on anything but exited, then reconnect so the connect-time decision runs the journaled conversion. Co-authored-by: m4air --- src/main/ipc/ssh-connect-flow.ts | 14 +-- src/main/ipc/ssh-connection-handlers.ts | 110 ++--------------- src/main/ipc/ssh-host-server-connect.ts | 17 +++ .../ssh-host-server-relay-decision.test.ts | 51 ++++++++ src/main/ipc/ssh-ipc-module-mocks.ts | 3 +- src/main/ipc/ssh-managed-server-move.test.ts | 105 +++++++++++++++++ src/main/ipc/ssh-managed-server-move.ts | 79 +++++++++++++ src/main/ipc/ssh-target-crud-handlers.ts | 3 + src/main/ipc/ssh-target-registry.test.ts | 1 + src/main/ipc/ssh-terminate-sessions.ts | 100 ++++++++++++++++ src/main/ipc/ssh.ts | 1 + .../ssh-normalization-orcad-fence.test.ts | 13 ++ .../leasing-ssh-ptys/ssh-normalization.ts | 16 ++- .../ssh/ssh-host-server-move-offer.test.ts | 54 +++++++++ src/main/ssh/ssh-host-server-move-offer.ts | 35 ++++++ src/preload/api/ssh-api.ts | 3 + src/preload/api/ssh-bridge.ts | 4 + .../SshManagedServerMoveDialog.test.tsx | 111 ++++++++++++++++++ .../settings/SshManagedServerMoveDialog.tsx | 105 +++++++++++++++++ .../settings/SshTargetServerStatus.tsx | 47 ++++++-- .../ssh-host-server-status-copy.test.ts | 16 +++ .../settings/ssh-host-server-status-copy.ts | 10 +- .../ipc-events/direct-ssh-state-ipc-bridge.ts | 2 +- .../ssh-managed-server-state-effects.test.ts | 105 +++++++++++++++++ .../ssh-managed-server-state-effects.ts | 42 ++++++- src/renderer/src/i18n/locales/en.json | 13 ++ .../src/ssh/ssh-managed-server-move.ts | 84 +++++++++++++ src/shared/ssh-managed-server-move.ts | 7 ++ .../ssh-retained-payload-admission.test.ts | 14 +++ src/shared/ssh-retained-payload-admission.ts | 3 +- src/shared/ssh-types.ts | 11 +- 31 files changed, 1054 insertions(+), 125 deletions(-) create mode 100644 src/main/ipc/ssh-host-server-relay-decision.test.ts create mode 100644 src/main/ipc/ssh-managed-server-move.test.ts create mode 100644 src/main/ipc/ssh-managed-server-move.ts create mode 100644 src/main/ipc/ssh-terminate-sessions.ts create mode 100644 src/main/ssh/ssh-host-server-move-offer.test.ts create mode 100644 src/main/ssh/ssh-host-server-move-offer.ts create mode 100644 src/renderer/src/components/settings/SshManagedServerMoveDialog.test.tsx create mode 100644 src/renderer/src/components/settings/SshManagedServerMoveDialog.tsx create mode 100644 src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.test.ts create mode 100644 src/renderer/src/ssh/ssh-managed-server-move.ts create mode 100644 src/shared/ssh-managed-server-move.ts diff --git a/src/main/ipc/ssh-connect-flow.ts b/src/main/ipc/ssh-connect-flow.ts index 8ffb3936a5a..317909786b7 100644 --- a/src/main/ipc/ssh-connect-flow.ts +++ b/src/main/ipc/ssh-connect-flow.ts @@ -11,8 +11,11 @@ import { } from '../ssh/ssh-provider-authority' import { allowsDirectSshRelay } from '../ssh/ssh-connection-store' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { setSshHostServerStatus } from '../ssh/ssh-host-server-status' -import { decideHostServer, publishManagedServerConnect } from './ssh-host-server-connect' +import { + decideHostServer, + publishManagedServerConnect, + recordRelayDecision +} from './ssh-host-server-connect' import { activeSessions } from './ssh-active-relay-sessions' import { assertSshConnectsNotFenced, @@ -179,12 +182,7 @@ async function doConnect( return publishManagedServerConnect(targetId, server.environmentId) } if (server) { - setSshHostServerStatus(targetId, { - kind: 'relay', - reason: server.reason, - ...(server.detail ? { detail: server.detail } : {}), - ...(server.terminals !== undefined ? { terminals: server.terminals } : {}) - }) + recordRelayDecision(target, server) } // Re-read: a conversion attempt may have fenced the host since the lookup above. const relayTarget = getSshTargetRegistryStore()!.getTarget(targetId) ?? target diff --git a/src/main/ipc/ssh-connection-handlers.ts b/src/main/ipc/ssh-connection-handlers.ts index 946dfed7bf9..7d38fb1859f 100644 --- a/src/main/ipc/ssh-connection-handlers.ts +++ b/src/main/ipc/ssh-connection-handlers.ts @@ -1,38 +1,24 @@ import { ipcMain } from 'electron' -import { - sshRemotePtyLeaseAllowsReattach, - type SshTarget, - type SshTerminateSessionsResult -} from '../../shared/ssh-types' -import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' -import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors' -import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' +import type { SshTarget } from '../../shared/ssh-types' +import { toAppSshPtyId } from '../providers/ssh-pty-id' import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' import { setSshTargetRegistryHandlers, getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { - clearProviderPtyState, - deletePtyOwnership, - getPtyIdsForConnection, - getSshPtyProvider -} from './pty' +import { clearProviderPtyState, deletePtyOwnership, getPtyIdsForConnection } from './pty' import { activeSessions } from './ssh-active-relay-sessions' import { assertSshConnectsNotFenced, connectInFlight, credentialRequestedForTarget, - invalidateConnectAttempt, resetRelayInFlight, runSshTestConnectionProbe } from './ssh-connect-attempt-registry' import { connectTarget } from './ssh-connect-flow' import { connectionManager, persistedStore } from './ssh-ipc-context' import { getPublicSshState } from './ssh-renderer-broadcast' -import { - disconnectRegisteredSshTarget, - teardownActiveSshSession, - teardownSshTargetTransport -} from './ssh-session-teardown' +import { disconnectRegisteredSshTarget, teardownActiveSshSession } from './ssh-session-teardown' +import { terminateSshTargetSessions } from './ssh-terminate-sessions' +import { moveSshHostToManagedServer } from './ssh-managed-server-move' import { runTargetLifecycle } from './ssh-target-lifecycle-queue' async function doResetRelay(targetId: string, target: SshTarget): Promise { @@ -115,85 +101,13 @@ export function registerSshConnectionHandlers(): void { await disconnectRegisteredSshTarget(args.targetId) }) - ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => { - invalidateConnectAttempt(args.targetId) - // Why (#12661): an offline sweep tears down local transport only. The caller must be able to tell - // "the host stopped these" from "nobody asked the host", so carry the verdict out of the lifecycle queue. - let outcome: SshTerminateSessionsResult = { terminated: 0, unverifiable: 0 } - await runTargetLifecycle(args.targetId, async () => { - const provider = getSshPtyProvider(args.targetId) - const leases = persistedStore!.getSshRemotePtyLeases(args.targetId) - const ptyIdsByRelayId = new Map() - // Why: only leases the app still believes it owns may force a reconnect; a lease whose route - // died for good is swept opportunistically instead, so a target that can no longer answer - // never blocks its own removal (issue #2626, and the renderer tolerates the refusal there). - const ownedRelayIds = new Set() - const trackPtyId = (ptyId: string, owned: boolean): void => { - const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) - if (!ptyIdsByRelayId.has(relayPtyId)) { - ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(args.targetId, ptyId)) - } - if (owned) { - ownedRelayIds.add(relayPtyId) - } - } - for (const ptyId of getPtyIdsForConnection(args.targetId)) { - trackPtyId(ptyId, true) - } - for (const lease of leases) { - if (lease.state === 'terminated') { - continue - } - // Why the predicate and not `state !== 'expired'`: an `expired` lease carrying no - // retirement mark records only that reattach gave up, never that the remote shell died, so - // it is exactly the orphan the user's terminate must reach — and reaching it needs the - // relay, which is what the fence below demands. Only `supersededBy` / `relayIdRecycled` - // prove the route is dead for good, and those stay unowned. - trackPtyId(lease.ptyId, sshRemotePtyLeaseAllowsReattach(lease)) - } - const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({ - relayPtyId, - appPtyId - })) + ipcMain.handle('ssh:terminateSessions', (_event, args: { targetId: string }) => + terminateSshTargetSessions(args.targetId) + ) - if (ownedRelayIds.size > 0 && !provider) { - throw new Error( - `${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.` - ) - } - const shutdownResults = provider - ? await Promise.allSettled( - ptyIds.map(({ appPtyId }) => - provider.shutdown(appPtyId, { immediate: true, keepHistory: false }) - ) - ) - : [] - if (!provider) { - // Nothing observed these remote shells, so their state is unknown — not "nothing to do". - outcome = { terminated: 0, unverifiable: ptyIds.length } - } - const shutdownFailures: string[] = [] - for (const [index, result] of shutdownResults.entries()) { - const { appPtyId, relayPtyId } = ptyIds[index] - if (result.status !== 'fulfilled' && !isSshPtyNotFoundError(result.reason)) { - shutdownFailures.push( - `${relayPtyId}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}` - ) - continue - } - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') - outcome = { ...outcome, terminated: outcome.terminated + 1 } - } - if (shutdownFailures.length > 0) { - // Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry. - throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`) - } - await teardownSshTargetTransport(args.targetId, (session) => session.disposeAndPersist()) - }) - return outcome - }) + ipcMain.handle('ssh:moveToManagedServer', (_event, args: { targetId: string }) => + moveSshHostToManagedServer(args.targetId) + ) ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => { const existingReset = resetRelayInFlight.get(args.targetId) diff --git a/src/main/ipc/ssh-host-server-connect.ts b/src/main/ipc/ssh-host-server-connect.ts index deb7977a396..2122c47197d 100644 --- a/src/main/ipc/ssh-host-server-connect.ts +++ b/src/main/ipc/ssh-host-server-connect.ts @@ -2,7 +2,9 @@ import { getAppEnvironment } from '../../shared/app-environment' import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' import type { HostServerOnConnectResult } from '../ssh/ssh-host-server-on-connect' +import { relayServerStatus, shouldToastManagedServerMove } from '../ssh/ssh-host-server-move-offer' import { setSshHostServerStatus } from '../ssh/ssh-host-server-status' +import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' import { broadcastSshState, getPublicSshState } from './ssh-renderer-broadcast' @@ -43,3 +45,18 @@ export function publishManagedServerConnect( broadcastSshState(getCurrentMainWindow, targetId, state) return getPublicSshState(targetId) ?? state } + +/** Records why the host keeps the relay; the first live-terminals stop this version offers a move. */ +export function recordRelayDecision( + target: SshTarget, + decision: Extract +): void { + const appVersion = getAppEnvironment().getVersion() + const offerMove = shouldToastManagedServerMove(target, decision, appVersion) + if (offerMove) { + getSshTargetRegistryStore()!.updateTarget(target.id, { + managedServerMoveOffered: { appVersion } + }) + } + setSshHostServerStatus(target.id, relayServerStatus(decision, offerMove)) +} diff --git a/src/main/ipc/ssh-host-server-relay-decision.test.ts b/src/main/ipc/ssh-host-server-relay-decision.test.ts new file mode 100644 index 00000000000..b1677d6d51e --- /dev/null +++ b/src/main/ipc/ssh-host-server-relay-decision.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshTarget } from '../../shared/ssh-types' + +const mocks = vi.hoisted(() => ({ + updateTarget: vi.fn(), + setStatus: vi.fn() +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getVersion: () => '1.5.0' }) +})) +vi.mock('../ssh/ssh-target-registry', () => ({ + getSshTargetRegistryStore: () => ({ updateTarget: mocks.updateTarget }) +})) +vi.mock('../ssh/ssh-host-server-status', () => ({ setSshHostServerStatus: mocks.setStatus })) +vi.mock('./ssh-ipc-context', () => ({ connectionManager: null, getCurrentMainWindow: () => null })) +vi.mock('./ssh-renderer-broadcast', () => ({ + broadcastSshState: vi.fn(), + getPublicSshState: vi.fn() +})) + +const { recordRelayDecision } = await import('./ssh-host-server-connect') + +const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' } +const live = { route: 'relay' as const, reason: 'relay_terminals_live' as const, terminals: 2 } + +beforeEach(() => vi.clearAllMocks()) + +describe('recording why a connect kept the relay', () => { + it('records the first offer this version and marks the status for the toast', () => { + recordRelayDecision(target, live) + expect(mocks.updateTarget).toHaveBeenCalledWith('ssh-1', { + managedServerMoveOffered: { appVersion: '1.5.0' } + }) + expect(mocks.setStatus).toHaveBeenCalledWith('ssh-1', { + kind: 'relay', + reason: 'relay_terminals_live', + terminals: 2, + offerMove: true + }) + }) + + it('keeps the status line offer but skips the toast once this version offered it', () => { + recordRelayDecision({ ...target, managedServerMoveOffered: { appVersion: '1.5.0' } }, live) + expect(mocks.updateTarget).not.toHaveBeenCalled() + expect(mocks.setStatus).toHaveBeenCalledWith('ssh-1', { + kind: 'relay', + reason: 'relay_terminals_live', + terminals: 2 + }) + }) +}) diff --git a/src/main/ipc/ssh-ipc-module-mocks.ts b/src/main/ipc/ssh-ipc-module-mocks.ts index 78a00499457..7eadb1c96f6 100644 --- a/src/main/ipc/ssh-ipc-module-mocks.ts +++ b/src/main/ipc/ssh-ipc-module-mocks.ts @@ -146,7 +146,8 @@ export function createSshIpcMocks(): SshIpcMocks { // Null keeps today's relay path; the real decision is covered by its own tests. hostServerConnect: { decideHostServer: vi.fn(async () => null), - publishManagedServerConnect: vi.fn() + publishManagedServerConnect: vi.fn(), + recordRelayDecision: vi.fn() }, sshConnectionStore: { isRuntimeOwnedSshTarget: (target: { owner?: unknown }) => target.owner !== undefined, diff --git a/src/main/ipc/ssh-managed-server-move.test.ts b/src/main/ipc/ssh-managed-server-move.test.ts new file mode 100644 index 00000000000..3986ffb116e --- /dev/null +++ b/src/main/ipc/ssh-managed-server-move.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' +import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' +import type { SshManagedServerStatus, SshTarget } from '../../shared/ssh-types' +import type { HostServerTerminalVerdict } from '../ssh/ssh-host-server-on-connect' + +vi.mock('./ssh-connect-flow', () => ({ connectTarget: vi.fn() })) +vi.mock('./ssh-terminate-sessions', () => ({ terminateSshTargetSessions: vi.fn() })) + +const { moveSshHostToManagedServer } = await import('./ssh-managed-server-move') + +const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' } + +function deps( + options: { + census?: HostServerTerminalVerdict + unverifiable?: number + afterConnect?: SshManagedServerStatus + } = {} +) { + const calls: string[] = [] + let status: SshManagedServerStatus | undefined = { + kind: 'relay', + reason: 'relay_terminals_live', + terminals: 2 + } + return { + calls, + getTarget: vi.fn(() => target), + terminate: vi.fn(async () => { + calls.push('terminate') + return { terminated: 2, unverifiable: options.unverifiable ?? 0 } + }), + relayTerminals: vi.fn(async () => { + calls.push('census') + return options.census ?? { verdict: 'exited' as const, count: 0 } + }), + connect: vi.fn(async () => { + calls.push('connect') + status = options.afterConnect ?? { kind: 'managed', environmentId: 'env-1' } + }), + serverStatus: vi.fn(() => status) + } +} + +describe('moving an SSH host to its managed server on request', () => { + it('stops the relay terminals, proves them exited, then reconnects through the conversion', async () => { + const move = deps() + await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ + outcome: 'moved', + environmentId: 'env-1' + }) + expect(move.calls).toEqual(['terminate', 'census', 'connect']) + expect(move.relayTerminals).toHaveBeenCalledWith(target) + }) + + it('refuses without converting when the stop could not reach every terminal', async () => { + const move = deps({ unverifiable: 1 }) + await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ + outcome: 'refused', + verdict: 'unverifiable', + terminals: 1 + }) + expect(move.calls).toEqual(['terminate']) + }) + + it('refuses without converting when the census is unverifiable or still live', async () => { + const unverifiable = deps({ census: { verdict: 'unverifiable', count: 3 } }) + await expect(moveSshHostToManagedServer('ssh-1', unverifiable)).resolves.toEqual({ + outcome: 'refused', + verdict: 'unverifiable', + terminals: 3 + }) + expect(unverifiable.connect).not.toHaveBeenCalled() + + const live = deps({ census: { verdict: 'live', count: 1 } }) + await expect(moveSshHostToManagedServer('ssh-1', live)).resolves.toMatchObject({ + outcome: 'refused', + verdict: 'live' + }) + expect(live.connect).not.toHaveBeenCalled() + }) + + it('reports a connect that kept the relay for another reason', async () => { + const move = deps({ afterConnect: { kind: 'relay', reason: 'refused', detail: 'blocked' } }) + await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ outcome: 'stayed' }) + }) + + it('reattaches the relay first when preserved terminals need one to be stopped', async () => { + const move = deps() + move.terminate.mockRejectedValueOnce( + new Error(`${SSH_TERMINATE_RECONNECT_REQUIRED}: reconnect`) + ) + await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toMatchObject({ + outcome: 'moved' + }) + expect(move.calls).toEqual(['connect', 'terminate', 'census', 'connect']) + }) + + it('surfaces any other stop failure without converting', async () => { + const move = deps() + move.terminate.mockRejectedValueOnce(new Error('Failed to terminate SSH host sessions')) + await expect(moveSshHostToManagedServer('ssh-1', move)).rejects.toThrow('Failed to terminate') + expect(move.connect).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/ssh-managed-server-move.ts b/src/main/ipc/ssh-managed-server-move.ts new file mode 100644 index 00000000000..953e4145b7a --- /dev/null +++ b/src/main/ipc/ssh-managed-server-move.ts @@ -0,0 +1,79 @@ +/** + * The user's "Move to managed server": stop the host's relay terminals, prove them exited with the + * connect gate's own census, then reconnect so the connect-time decision runs the conversion. + */ +import { getAppEnvironment } from '../../shared/app-environment' +import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' +import type { SshManagedServerMoveResult } from '../../shared/ssh-managed-server-move' +import type { + SshManagedServerStatus, + SshTarget, + SshTerminateSessionsResult +} from '../../shared/ssh-types' +import type { HostServerTerminalVerdict } from '../ssh/ssh-host-server-on-connect' +import { getSshHostServerStatus } from '../ssh/ssh-host-server-status' +import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' +import { connectTarget } from './ssh-connect-flow' +import { terminateSshTargetSessions } from './ssh-terminate-sessions' + +export type SshManagedServerMoveDeps = { + getTarget: (targetId: string) => SshTarget | undefined + terminate: (targetId: string) => Promise + relayTerminals: (target: SshTarget) => Promise + connect: (targetId: string) => Promise + serverStatus: (targetId: string) => SshManagedServerStatus | undefined +} + +export async function moveSshHostToManagedServer( + targetId: string, + deps: SshManagedServerMoveDeps = defaultMoveDeps() +): Promise { + const target = deps.getTarget(targetId) + if (!target) { + throw new Error(`SSH target "${targetId}" not found`) + } + const stopped = await stopRelayTerminals(targetId, deps) + if (stopped.unverifiable > 0) { + // Why: an unreached shell is never evidence that it exited (ssh-execution-boundary.md). + return { outcome: 'refused', verdict: 'unverifiable', terminals: stopped.unverifiable } + } + const census = await deps.relayTerminals(deps.getTarget(targetId) ?? target) + if (census.verdict !== 'exited') { + return { outcome: 'refused', verdict: census.verdict, terminals: census.count } + } + await deps.connect(targetId) + const status = deps.serverStatus(targetId) + return status?.kind === 'managed' + ? { outcome: 'moved', environmentId: status.environmentId } + : { outcome: 'stayed' } +} + +/** Mirrors the renderer's terminate: preserved shells need a fresh relay before they can be stopped. */ +async function stopRelayTerminals( + targetId: string, + deps: SshManagedServerMoveDeps +): Promise { + try { + return await deps.terminate(targetId) + } catch (error) { + if (!(error instanceof Error) || !error.message.includes(SSH_TERMINATE_RECONNECT_REQUIRED)) { + throw error + } + await deps.connect(targetId) + return deps.terminate(targetId) + } +} + +function defaultMoveDeps(): SshManagedServerMoveDeps { + return { + getTarget: (targetId) => getSshTargetRegistryStore()!.getTarget(targetId), + terminate: terminateSshTargetSessions, + relayTerminals: async (target) => { + // Why lazy, like the connect: the managed-server graph loads only when it is needed. + const { hostServerOnConnectDeps } = await import('./ssh-host-server-on-connect-wiring') + return hostServerOnConnectDeps(getAppEnvironment().getPath('userData')).relayTerminals(target) + }, + connect: connectTarget, + serverStatus: getSshHostServerStatus + } +} diff --git a/src/main/ipc/ssh-target-crud-handlers.ts b/src/main/ipc/ssh-target-crud-handlers.ts index 67ca165116b..e5b2a787407 100644 --- a/src/main/ipc/ssh-target-crud-handlers.ts +++ b/src/main/ipc/ssh-target-crud-handlers.ts @@ -43,6 +43,7 @@ function omitRendererSshTargetGeneration< orcadProvisioning?: unknown orcadFence?: unknown managedServerUnavailable?: unknown + managedServerMoveOffered?: unknown remoteRuntimeResolution?: unknown } >( @@ -53,6 +54,7 @@ function omitRendererSshTargetGeneration< | 'orcadProvisioning' | 'orcadFence' | 'managedServerUnavailable' + | 'managedServerMoveOffered' | 'remoteRuntimeResolution' > { const { @@ -60,6 +62,7 @@ function omitRendererSshTargetGeneration< orcadProvisioning: _orcadProvisioning, orcadFence: _orcadFence, managedServerUnavailable: _managedServerUnavailable, + managedServerMoveOffered: _managedServerMoveOffered, remoteRuntimeResolution: _remoteRuntimeResolution, ...rest } = value diff --git a/src/main/ipc/ssh-target-registry.test.ts b/src/main/ipc/ssh-target-registry.test.ts index 2d357b11d1f..9aa205d7a04 100644 --- a/src/main/ipc/ssh-target-registry.test.ts +++ b/src/main/ipc/ssh-target-registry.test.ts @@ -53,6 +53,7 @@ describe('SSH IPC handlers', () => { expect(channels).toContain('ssh:connect') expect(channels).toContain('ssh:disconnect') expect(channels).toContain('ssh:terminateSessions') + expect(channels).toContain('ssh:moveToManagedServer') expect(channels).toContain('ssh:resetRelay') expect(channels).toContain('ssh:getState') expect(channels).toContain('ssh:testConnection') diff --git a/src/main/ipc/ssh-terminate-sessions.ts b/src/main/ipc/ssh-terminate-sessions.ts new file mode 100644 index 00000000000..f3d97797694 --- /dev/null +++ b/src/main/ipc/ssh-terminate-sessions.ts @@ -0,0 +1,100 @@ +import { + sshRemotePtyLeaseAllowsReattach, + type SshTerminateSessionsResult +} from '../../shared/ssh-types' +import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' +import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors' +import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' +import { + clearProviderPtyState, + deletePtyOwnership, + getPtyIdsForConnection, + getSshPtyProvider +} from './pty' +import { invalidateConnectAttempt } from './ssh-connect-attempt-registry' +import { persistedStore } from './ssh-ipc-context' +import { teardownSshTargetTransport } from './ssh-session-teardown' +import { runTargetLifecycle } from './ssh-target-lifecycle-queue' + +/** Stops every relay terminal on the target and closes its transport (`ssh:terminateSessions`). */ +export async function terminateSshTargetSessions( + targetId: string +): Promise { + invalidateConnectAttempt(targetId) + // Why (#12661): an offline sweep tears down local transport only. The caller must be able to tell + // "the host stopped these" from "nobody asked the host", so carry the verdict out of the lifecycle queue. + let outcome: SshTerminateSessionsResult = { terminated: 0, unverifiable: 0 } + await runTargetLifecycle(targetId, async () => { + const provider = getSshPtyProvider(targetId) + const leases = persistedStore!.getSshRemotePtyLeases(targetId) + const ptyIdsByRelayId = new Map() + // Why: only leases the app still believes it owns may force a reconnect; a lease whose route + // died for good is swept opportunistically instead, so a target that can no longer answer + // never blocks its own removal (issue #2626, and the renderer tolerates the refusal there). + const ownedRelayIds = new Set() + const trackPtyId = (ptyId: string, owned: boolean): void => { + const relayPtyId = toRelaySshPtyId(targetId, ptyId) + if (!ptyIdsByRelayId.has(relayPtyId)) { + ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(targetId, ptyId)) + } + if (owned) { + ownedRelayIds.add(relayPtyId) + } + } + for (const ptyId of getPtyIdsForConnection(targetId)) { + trackPtyId(ptyId, true) + } + for (const lease of leases) { + if (lease.state === 'terminated') { + continue + } + // Why the predicate and not `state !== 'expired'`: an `expired` lease carrying no + // retirement mark records only that reattach gave up, never that the remote shell died, so + // it is exactly the orphan the user's terminate must reach — and reaching it needs the + // relay, which is what the fence below demands. Only `supersededBy` / `relayIdRecycled` + // prove the route is dead for good, and those stay unowned. + trackPtyId(lease.ptyId, sshRemotePtyLeaseAllowsReattach(lease)) + } + const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({ + relayPtyId, + appPtyId + })) + + if (ownedRelayIds.size > 0 && !provider) { + throw new Error( + `${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.` + ) + } + const shutdownResults = provider + ? await Promise.allSettled( + ptyIds.map(({ appPtyId }) => + provider.shutdown(appPtyId, { immediate: true, keepHistory: false }) + ) + ) + : [] + if (!provider) { + // Nothing observed these remote shells, so their state is unknown — not "nothing to do". + outcome = { terminated: 0, unverifiable: ptyIds.length } + } + const shutdownFailures: string[] = [] + for (const [index, result] of shutdownResults.entries()) { + const { appPtyId, relayPtyId } = ptyIds[index] + if (result.status !== 'fulfilled' && !isSshPtyNotFoundError(result.reason)) { + shutdownFailures.push( + `${relayPtyId}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}` + ) + continue + } + clearProviderPtyState(appPtyId) + deletePtyOwnership(appPtyId) + persistedStore!.markSshRemotePtyLease(targetId, relayPtyId, 'terminated') + outcome = { ...outcome, terminated: outcome.terminated + 1 } + } + if (shutdownFailures.length > 0) { + // Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry. + throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`) + } + await teardownSshTargetTransport(targetId, (session) => session.disposeAndPersist()) + }) + return outcome +} diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 15f86b44390..9c34afb4fd9 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -93,6 +93,7 @@ const SSH_IPC_CHANNELS = [ 'ssh:connect', 'ssh:disconnect', 'ssh:terminateSessions', + 'ssh:moveToManagedServer', 'ssh:resetRelay', 'ssh:getState', 'ssh:needsPassphrasePrompt', diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-normalization-orcad-fence.test.ts b/src/main/persistence/leasing-ssh-ptys/ssh-normalization-orcad-fence.test.ts index e7246f23bbc..ddf77db4c77 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-normalization-orcad-fence.test.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-normalization-orcad-fence.test.ts @@ -30,4 +30,17 @@ describe('loading a managed Orca server fence', () => { const malformed = { ...base, orcadFence: { environmentId: 7 } } as unknown as SshTarget expect(normalizeSshTarget(malformed).orcadFence).toBeUndefined() }) + + it('keeps the recorded move offer and drops a malformed one', () => { + expect( + normalizeSshTarget({ ...base, managedServerMoveOffered: { appVersion: '1.5.0' } }) + .managedServerMoveOffered + ).toEqual({ appVersion: '1.5.0' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: simulates a hand-edited or corrupt stored record. + const malformed = { + ...base, + managedServerMoveOffered: { appVersion: 7 } + } as unknown as SshTarget + expect(normalizeSshTarget(malformed).managedServerMoveOffered).toBeUndefined() + }) }) diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts index ef461e2deba..7f0bde5a446 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts @@ -66,7 +66,9 @@ export function normalizeSshTarget(t: SshTarget): SshTarget { if (remoteRuntimeResolution) { normalized.remoteRuntimeResolution = remoteRuntimeResolution } - return normalizeManagedServerUnavailable(migrateLegacyManagedOrcadOwner(normalized)) + return normalizeManagedServerMoveOffered( + normalizeManagedServerUnavailable(migrateLegacyManagedOrcadOwner(normalized)) + ) } /** @@ -94,6 +96,18 @@ function normalizeManagedServerUnavailable(target: SshTarget): SshTarget { return rest } +function normalizeManagedServerMoveOffered(target: SshTarget): SshTarget { + if (target.managedServerMoveOffered === undefined) { + return target + } + const { appVersion } = target.managedServerMoveOffered ?? {} + if (typeof appVersion === 'string') { + return { ...target, managedServerMoveOffered: { appVersion } } + } + const { managedServerMoveOffered: _malformed, ...rest } = target + return rest +} + function normalizeOrcadFence(target: SshTarget): SshTarget { if (target.orcadFence === undefined) { return target diff --git a/src/main/ssh/ssh-host-server-move-offer.test.ts b/src/main/ssh/ssh-host-server-move-offer.test.ts new file mode 100644 index 00000000000..4595d0ef9c0 --- /dev/null +++ b/src/main/ssh/ssh-host-server-move-offer.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + canOfferManagedServerMove, + relayServerStatus, + shouldToastManagedServerMove +} from './ssh-host-server-move-offer' + +const live = { route: 'relay' as const, reason: 'relay_terminals_live' as const, terminals: 3 } + +describe('offering to move a host whose relay terminals keep it on the relay', () => { + it('offers only when live terminals are the reason', () => { + expect(canOfferManagedServerMove(live)).toBe(true) + expect(canOfferManagedServerMove({ reason: 'relay_terminals_unverifiable' })).toBe(false) + expect(canOfferManagedServerMove({ reason: 'refused' })).toBe(false) + expect(canOfferManagedServerMove({ reason: 'orcad_unavailable' })).toBe(false) + }) + + it('toasts once per host per app version', () => { + expect(shouldToastManagedServerMove({}, live, '1.5.0')).toBe(true) + expect( + shouldToastManagedServerMove( + { managedServerMoveOffered: { appVersion: '1.5.0' } }, + live, + '1.5.0' + ) + ).toBe(false) + expect( + shouldToastManagedServerMove( + { managedServerMoveOffered: { appVersion: '1.4.0' } }, + live, + '1.5.0' + ) + ).toBe(true) + expect( + shouldToastManagedServerMove({}, { reason: 'relay_terminals_unverifiable' }, '1.5.0') + ).toBe(false) + }) + + it('carries the offer and terminal count on the published relay status', () => { + expect(relayServerStatus(live, true)).toEqual({ + kind: 'relay', + reason: 'relay_terminals_live', + terminals: 3, + offerMove: true + }) + expect(relayServerStatus(live, false)).not.toHaveProperty('offerMove') + expect( + relayServerStatus( + { route: 'relay', reason: 'refused', detail: 'An automation runs here.' }, + false + ) + ).toEqual({ kind: 'relay', reason: 'refused', detail: 'An automation runs here.' }) + }) +}) diff --git a/src/main/ssh/ssh-host-server-move-offer.ts b/src/main/ssh/ssh-host-server-move-offer.ts new file mode 100644 index 00000000000..b30cb498f6c --- /dev/null +++ b/src/main/ssh/ssh-host-server-move-offer.ts @@ -0,0 +1,35 @@ +/** When a connect that kept the relay offers to move the host now, restarting its terminals. */ +import type { SshManagedServerStatus, SshTarget } from '../../shared/ssh-types' +import type { HostServerOnConnectResult } from './ssh-host-server-on-connect' + +type RelayDecision = Extract + +/** Only live terminals: an unverifiable census can't be fixed by stopping them from here. */ +export function canOfferManagedServerMove(decision: Pick): boolean { + return decision.reason === 'relay_terminals_live' +} + +/** The toast shows once per host per app version; the SSH Hosts action stays regardless. */ +export function shouldToastManagedServerMove( + target: Pick, + decision: Pick, + appVersion: string +): boolean { + return ( + canOfferManagedServerMove(decision) && + target.managedServerMoveOffered?.appVersion !== appVersion + ) +} + +export function relayServerStatus( + decision: RelayDecision, + offerMove: boolean +): Extract { + return { + kind: 'relay', + reason: decision.reason, + ...(decision.detail ? { detail: decision.detail } : {}), + ...(decision.terminals !== undefined ? { terminals: decision.terminals } : {}), + ...(offerMove ? { offerMove: true } : {}) + } +} diff --git a/src/preload/api/ssh-api.ts b/src/preload/api/ssh-api.ts index 5fc64c030e5..0dd563a198f 100644 --- a/src/preload/api/ssh-api.ts +++ b/src/preload/api/ssh-api.ts @@ -12,6 +12,7 @@ import type { SshTargetUpdateInput, SshTerminateSessionsResult } from '../../shared/ssh-types' +import type { SshManagedServerMoveResult } from '../../shared/ssh-managed-server-move' import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' export type SshApi = { @@ -27,6 +28,8 @@ export type SshApi = { connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise terminateSessions: (args: { targetId: string }) => Promise + /** Desktop-only: stops the host's relay terminals and moves it to a managed Orca server. */ + moveToManagedServer?: (args: { targetId: string }) => Promise resetRelay: (args: { targetId: string }) => Promise getState: (args: { targetId: string }) => Promise needsPassphrasePrompt: (args: { targetId: string }) => Promise diff --git a/src/preload/api/ssh-bridge.ts b/src/preload/api/ssh-bridge.ts index 240da3c9508..68452fe27af 100644 --- a/src/preload/api/ssh-bridge.ts +++ b/src/preload/api/ssh-bridge.ts @@ -18,6 +18,7 @@ import { admitSshDetectedPorts } from '../../shared/ssh-retained-payload-admission' import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' +import type { SshManagedServerMoveResult } from '../../shared/ssh-managed-server-move' import type { PreloadApi } from '../api-types' export const sshApi = { @@ -55,6 +56,9 @@ export const sshApi = { terminateSessions: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:terminateSessions', args), + moveToManagedServer: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:moveToManagedServer', args), + resetRelay: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:resetRelay', args), diff --git a/src/renderer/src/components/settings/SshManagedServerMoveDialog.test.tsx b/src/renderer/src/components/settings/SshManagedServerMoveDialog.test.tsx new file mode 100644 index 00000000000..48ad7fa6821 --- /dev/null +++ b/src/renderer/src/components/settings/SshManagedServerMoveDialog.test.tsx @@ -0,0 +1,111 @@ +// @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 { SshManagedServerMoveDialog } from './SshManagedServerMoveDialog' + +vi.mock('../ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
+})) + +const moveToManagedServer = vi.fn() +const roots: Root[] = [] + +beforeEach(() => { + moveToManagedServer.mockReset() + Object.assign(window, { api: { ssh: { moveToManagedServer } } }) +}) +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()) + } + document.body.innerHTML = '' + Reflect.deleteProperty(window, 'api') +}) + +function render(onClose = vi.fn()): HTMLElement { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + act(() => + root.render( + + ) + ) + return container +} + +function button(container: HTMLElement, label: string): HTMLButtonElement { + const found = Array.from(container.querySelectorAll('button')).find( + (entry) => entry.textContent === label + ) + if (!found) { + throw new Error(`No "${label}" button`) + } + return found +} + +describe('SshManagedServerMoveDialog', () => { + it('explains the reliability gain and that the open terminals restart', () => { + const container = render() + expect(container.textContent).toContain('Move to managed server') + expect(container.textContent).toContain( + 'Move Box to a managed Orca server for more reliable connections. Its 3 open terminals will restart.' + ) + expect(button(container, 'Not now')).toBeTruthy() + }) + + it('"Not now" closes without moving', () => { + const onClose = vi.fn() + const container = render(onClose) + act(() => button(container, 'Not now').click()) + expect(onClose).toHaveBeenCalled() + expect(moveToManagedServer).not.toHaveBeenCalled() + }) + + it('closes after the host moved', async () => { + moveToManagedServer.mockResolvedValue({ outcome: 'moved', environmentId: 'env-1' }) + const onClose = vi.fn() + const container = render(onClose) + await act(async () => button(container, 'Move').click()) + expect(moveToManagedServer).toHaveBeenCalledWith({ targetId: 'ssh-1' }) + expect(onClose).toHaveBeenCalled() + }) + + it('shows why a move was refused and keeps the dialog open', async () => { + moveToManagedServer.mockResolvedValue({ + outcome: 'refused', + verdict: 'unverifiable', + terminals: 2 + }) + const onClose = vi.fn() + const container = render(onClose) + await act(async () => button(container, 'Move').click()) + expect(container.textContent).toContain( + 'Not moved: Orca couldn’t confirm that 2 terminals on Box stopped.' + ) + expect(onClose).not.toHaveBeenCalled() + expect(button(container, 'Close')).toBeTruthy() + }) + + it('shows a move that failed outright', async () => { + moveToManagedServer.mockRejectedValue(new Error('relay offline')) + const container = render() + await act(async () => button(container, 'Move').click()) + expect(container.textContent).toContain('Could not move Box: relay offline') + }) +}) diff --git a/src/renderer/src/components/settings/SshManagedServerMoveDialog.tsx b/src/renderer/src/components/settings/SshManagedServerMoveDialog.tsx new file mode 100644 index 00000000000..d9d0eddfaec --- /dev/null +++ b/src/renderer/src/components/settings/SshManagedServerMoveDialog.tsx @@ -0,0 +1,105 @@ +import { Loader2 } from 'lucide-react' +import { useState } from 'react' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { + describeManagedServerMove, + managedServerMoveErrorText, + managedServerMoveOfferText +} from '@/ssh/ssh-managed-server-move' +import { Button } from '../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' + +type SshManagedServerMoveDialogProps = { + open: boolean + targetId: string + host: string + terminals: number + onClose: () => void +} + +/** Confirms moving a host whose live relay terminals keep it off its managed server. */ +export function SshManagedServerMoveDialog({ + open, + targetId, + host, + terminals, + onClose +}: SshManagedServerMoveDialogProps): React.JSX.Element { + const mountedRef = useMountedRef() + const [running, setRunning] = useState(false) + const [refusal, setRefusal] = useState(null) + + const close = (): void => { + setRefusal(null) + onClose() + } + + const move = async (): Promise => { + const moveToManagedServer = window.api.ssh.moveToManagedServer + if (!moveToManagedServer) { + return + } + setRunning(true) + setRefusal(null) + let message: string | null + try { + const report = describeManagedServerMove(host, await moveToManagedServer({ targetId })) + message = report.level === 'success' ? null : report.message + } catch (error) { + message = managedServerMoveErrorText(host, error) + } + if (!mountedRef.current) { + return + } + setRunning(false) + if (message) { + setRefusal(message) + } else { + close() + } + } + + return ( + !next && !running && close()}> + + + + {translate('auto.ssh.managedServerMove.title', 'Move to managed server')} + + {managedServerMoveOfferText(host, terminals)} + + {running ? ( +
+ + {translate( + 'auto.ssh.managedServerMove.running', + 'Moving {{host}} to a managed Orca server…', + { host } + )} +
+ ) : null} + {refusal ?

{refusal}

: null} + + + {refusal ? null : ( + + )} + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/SshTargetServerStatus.tsx b/src/renderer/src/components/settings/SshTargetServerStatus.tsx index fcc1d481d09..861c62de4f2 100644 --- a/src/renderer/src/components/settings/SshTargetServerStatus.tsx +++ b/src/renderer/src/components/settings/SshTargetServerStatus.tsx @@ -1,6 +1,12 @@ +import { ArrowRightLeft } from 'lucide-react' +import { useState } from 'react' import type { SshTarget } from '../../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' +import { canMoveSshHostToManagedServer } from '@/ssh/ssh-managed-server-move' import { useAppStore } from '@/store' +import { Button } from '../ui/button' +import { SshManagedServerMoveDialog } from './SshManagedServerMoveDialog' import { sshHostServerStatusLine } from './ssh-host-server-status-copy' import { SshHostChangedActions } from './SshHostChangedActions' @@ -13,23 +19,44 @@ export function SshTargetServerStatus({ onChanged: () => void }): React.JSX.Element | null { const state = useAppStore((s) => s.sshConnectionStates.get(target.id)) + const [moveOpen, setMoveOpen] = useState(false) const line = sshHostServerStatusLine(target, state) if (!line) { return null } + const status = state?.managedServer + const terminals = status?.kind === 'relay' ? (status.terminals ?? 0) : 0 + const canMove = line.action === 'move' && canMoveSshHostToManagedServer() return (
-

- {line.text} -

+
+

+ {line.text} +

+ {canMove ? ( + + ) : null} +
+ {canMove ? ( + setMoveOpen(false)} + /> + ) : null}
) } diff --git a/src/renderer/src/components/settings/ssh-host-server-status-copy.test.ts b/src/renderer/src/components/settings/ssh-host-server-status-copy.test.ts index 6336c68c9aa..06e279b65e1 100644 --- a/src/renderer/src/components/settings/ssh-host-server-status-copy.test.ts +++ b/src/renderer/src/components/settings/ssh-host-server-status-copy.test.ts @@ -31,6 +31,22 @@ describe('SSH host server status line', () => { }) }) + it('offers the move only while live relay terminals keep the host on the relay', () => { + expect( + sshHostServerStatusLine(plain, { + managedServer: { kind: 'relay', reason: 'relay_terminals_live', terminals: 3 } + }) + ).toMatchObject({ action: 'move', text: expect.stringContaining('3 open terminals') }) + expect( + sshHostServerStatusLine(plain, { + managedServer: { kind: 'relay', reason: 'relay_terminals_unverifiable' } + }) + ).not.toHaveProperty('action') + expect( + sshHostServerStatusLine(plain, { managedServer: { kind: 'managed', environmentId: 'e' } }) + ).not.toHaveProperty('action') + }) + it('keeps durable reasons visible without a live state', () => { expect(sshHostServerStatusLine({ orcadFence: { environmentId: 'e' } }, undefined)?.text).toBe( 'Runs a managed Orca server' diff --git a/src/renderer/src/components/settings/ssh-host-server-status-copy.ts b/src/renderer/src/components/settings/ssh-host-server-status-copy.ts index 1f090837a16..bb77020a905 100644 --- a/src/renderer/src/components/settings/ssh-host-server-status-copy.ts +++ b/src/renderer/src/components/settings/ssh-host-server-status-copy.ts @@ -2,7 +2,12 @@ import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' import { translate } from '@/i18n/i18n' -export type SshHostServerStatusLine = { text: string; tone: 'muted' | 'warning' | 'destructive' } +export type SshHostServerStatusLine = { + text: string + tone: 'muted' | 'warning' | 'destructive' + /** Offers "Move to managed server", which restarts the live relay terminals. */ + action?: 'move' +} export function sshHostServerStatusLine( target: Pick, @@ -93,7 +98,8 @@ function relayLine( 'auto.components.settings.sshHostServer.terminalsLive', 'Runs the relay until its {{count}} open terminals are closed, then moves to a managed server.', { count: status.terminals ?? 0 } - ) + ), + action: 'move' } case 'relay_terminals_unverifiable': return { diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts index f3a992552fc..c56cc5fac93 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts @@ -157,7 +157,7 @@ export function registerDirectSshStateIpcBridge( const store = useAppStore.getState() const previousManagedServer = store.sshConnectionStates.get(targetId)?.managedServer store.setSshConnectionState(targetId, state) - applySshManagedServerTransition(previousManagedServer, state.managedServer) + applySshManagedServerTransition(targetId, previousManagedServer, state.managedServer) if (canConnectSshStatus(state.status)) { routedAuthorityByTarget.delete(targetId) diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.test.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.test.ts new file mode 100644 index 00000000000..09f2e905456 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnectionState } from '../../../../shared/ssh-types' + +const mocks = vi.hoisted(() => ({ + toast: Object.assign(vi.fn(), { + error: vi.fn(), + success: vi.fn(), + loading: vi.fn(() => 'progress'), + dismiss: vi.fn() + }), + fetchReposForAllHosts: vi.fn(async () => undefined) +})) +vi.mock('sonner', () => ({ toast: mocks.toast })) +vi.mock('../../store', () => ({ + useAppStore: { + getState: () => ({ + fetchReposForAllHosts: mocks.fetchReposForAllHosts, + sshTargetLabels: new Map([['ssh-1', 'Box']]) + }) + } +})) + +const { applySshManagedServerTransition } = await import('./ssh-managed-server-state-effects') + +type Status = SshConnectionState['managedServer'] +type ToastOptions = { + action: { label: string; onClick: () => void } + cancel: { label: string; onClick: () => void } +} + +const offer: Status = { + kind: 'relay', + reason: 'relay_terminals_live', + terminals: 3, + offerMove: true +} +const moveToManagedServer = vi.fn() + +function lastToastOptions(): ToastOptions { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the offer toast always passes options shaped like sonner's ExternalToast. + return mocks.toast.mock.calls.at(-1)?.[1] as ToastOptions +} + +beforeEach(() => { + vi.clearAllMocks() + Object.assign(window, { api: { ssh: { moveToManagedServer } } }) +}) +afterEach(() => { + Reflect.deleteProperty(window, 'api') +}) + +describe('the one-time move offer toast', () => { + it('names the host and the terminals that will restart', () => { + applySshManagedServerTransition('ssh-1', undefined, offer) + expect(mocks.toast).toHaveBeenCalledTimes(1) + expect(mocks.toast.mock.calls[0][0]).toBe( + 'Move Box to a managed Orca server for more reliable connections. Its 3 open terminals will restart.' + ) + expect(lastToastOptions().action.label).toBe('Move') + expect(lastToastOptions().cancel.label).toBe('Not now') + }) + + it('shows only for a newly marked offer, never for a plain live-terminals status', () => { + applySshManagedServerTransition('ssh-1', offer, offer) + applySshManagedServerTransition('ssh-1', undefined, { + kind: 'relay', + reason: 'relay_terminals_live', + terminals: 3 + }) + expect(mocks.toast).not.toHaveBeenCalled() + }) + + it('stays silent where the move is not available', () => { + Object.assign(window, { api: { ssh: {} } }) + applySshManagedServerTransition('ssh-1', undefined, offer) + expect(mocks.toast).not.toHaveBeenCalled() + }) + + it('"Not now" only closes the toast; "Move" runs the move and reports it', async () => { + applySshManagedServerTransition('ssh-1', undefined, offer) + lastToastOptions().cancel.onClick() + expect(moveToManagedServer).not.toHaveBeenCalled() + + moveToManagedServer.mockResolvedValueOnce({ outcome: 'moved', environmentId: 'env-1' }) + lastToastOptions().action.onClick() + await vi.waitFor(() => expect(mocks.toast.success).toHaveBeenCalled()) + expect(moveToManagedServer).toHaveBeenCalledWith({ targetId: 'ssh-1' }) + expect(mocks.toast.success).toHaveBeenCalledWith('Box now runs a managed Orca server.', { + id: 'progress' + }) + }) + + it('reports a refusal from the move', async () => { + applySshManagedServerTransition('ssh-1', undefined, offer) + moveToManagedServer.mockResolvedValueOnce({ + outcome: 'refused', + verdict: 'unverifiable', + terminals: 1 + }) + lastToastOptions().action.onClick() + await vi.waitFor(() => expect(mocks.toast.error).toHaveBeenCalled()) + expect(mocks.toast.error.mock.calls[0][0]).toContain('couldn’t confirm that 1 terminals') + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts index df819286162..d37b5ccdde2 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts @@ -2,11 +2,19 @@ import { toast } from 'sonner' import type { SshConnectionState } from '../../../../shared/ssh-types' import { translate } from '@/i18n/i18n' +import { + canMoveSshHostToManagedServer, + managedServerMoveOfferText, + moveSshHostFromToast +} from '@/ssh/ssh-managed-server-move' import { useAppStore } from '../../store' +type ManagedServerStatus = SshConnectionState['managedServer'] + export function applySshManagedServerTransition( - previous: SshConnectionState['managedServer'], - next: SshConnectionState['managedServer'] + targetId: string, + previous: ManagedServerStatus, + next: ManagedServerStatus ): void { if ( next?.kind === 'managed' && @@ -16,6 +24,10 @@ export function applySshManagedServerTransition( void useAppStore.getState().fetchReposForAllHosts() return } + if (isNewMoveOffer(previous, next) && canMoveSshHostToManagedServer()) { + offerManagedServerMove(targetId, next.terminals ?? 0) + return + } if ( next?.kind === 'relay' && next.reason === 'refused' && @@ -34,3 +46,29 @@ export function applySshManagedServerTransition( ) } } + +/** Main marks only the first live-terminals stop per host per app version with `offerMove`. */ +function isNewMoveOffer( + previous: ManagedServerStatus, + next: ManagedServerStatus +): next is Extract, { kind: 'relay' }> { + return ( + next?.kind === 'relay' && + next.offerMove === true && + !(previous?.kind === 'relay' && previous.offerMove) + ) +} + +function offerManagedServerMove(targetId: string, terminals: number): void { + const host = useAppStore.getState().sshTargetLabels.get(targetId) ?? targetId + toast(managedServerMoveOfferText(host, terminals), { + id: `ssh-managed-server-move:${targetId}`, + duration: Infinity, + action: { + label: translate('auto.ssh.managedServerMove.confirm', 'Move'), + onClick: () => void moveSshHostFromToast(targetId, host) + }, + // "Not now" only closes the toast; the SSH Hosts status line keeps the action. + cancel: { label: translate('auto.ssh.managedServerMove.notNow', 'Not now'), onClick: () => {} } + }) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1145157085e..297ac38e600 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17658,6 +17658,19 @@ "retry": "Retry", "connect": "Connect", "connecting": "Connecting…" + }, + "managedServerMove": { + "offer": "Move {{host}} to a managed Orca server for more reliable connections. Its {{count}} open terminals will restart.", + "moved": "{{host}} now runs a managed Orca server.", + "refusedLive": "Not moved: {{count}} terminals on {{host}} are still running.", + "refusedUnverifiable": "Not moved: Orca couldn’t confirm that {{count}} terminals on {{host}} stopped.", + "stayed": "{{host}} still runs the relay. SSH Hosts in Settings shows why.", + "failed": "Could not move {{host}}: {{reason}}", + "running": "Moving {{host}} to a managed Orca server…", + "title": "Move to managed server", + "confirm": "Move", + "notNow": "Not now", + "close": "Close" } }, "main": { diff --git a/src/renderer/src/ssh/ssh-managed-server-move.ts b/src/renderer/src/ssh/ssh-managed-server-move.ts new file mode 100644 index 00000000000..0f7677579fc --- /dev/null +++ b/src/renderer/src/ssh/ssh-managed-server-move.ts @@ -0,0 +1,84 @@ +/** Moving an SSH host to its managed Orca server on request: the offer's words and the run. */ +import { toast } from 'sonner' +import type { SshManagedServerMoveResult } from '../../../shared/ssh-managed-server-move' +import { translate } from '@/i18n/i18n' + +export function canMoveSshHostToManagedServer(): boolean { + return typeof window.api.ssh.moveToManagedServer === 'function' +} + +export function managedServerMoveOfferText(host: string, terminals: number): string { + return translate( + 'auto.ssh.managedServerMove.offer', + 'Move {{host}} to a managed Orca server for more reliable connections. Its {{count}} open terminals will restart.', + { host, count: terminals } + ) +} + +export function describeManagedServerMove( + host: string, + result: SshManagedServerMoveResult +): { level: 'success' | 'error'; message: string } { + switch (result.outcome) { + case 'moved': + return { + level: 'success', + message: translate( + 'auto.ssh.managedServerMove.moved', + '{{host}} now runs a managed Orca server.', + { host } + ) + } + case 'refused': + return { + level: 'error', + message: + result.verdict === 'live' + ? translate( + 'auto.ssh.managedServerMove.refusedLive', + 'Not moved: {{count}} terminals on {{host}} are still running.', + { host, count: result.terminals } + ) + : translate( + 'auto.ssh.managedServerMove.refusedUnverifiable', + 'Not moved: Orca couldn’t confirm that {{count}} terminals on {{host}} stopped.', + { host, count: result.terminals } + ) + } + case 'stayed': + return { + level: 'error', + message: translate( + 'auto.ssh.managedServerMove.stayed', + '{{host}} still runs the relay. SSH Hosts in Settings shows why.', + { host } + ) + } + } +} + +export function managedServerMoveErrorText(host: string, error: unknown): string { + return translate('auto.ssh.managedServerMove.failed', 'Could not move {{host}}: {{reason}}', { + host, + reason: error instanceof Error ? error.message : String(error) + }) +} + +/** The toast's "Move" path: progress and outcome are reported as toasts. */ +export async function moveSshHostFromToast(targetId: string, host: string): Promise { + const move = window.api.ssh.moveToManagedServer + if (!move) { + return + } + const progressId = toast.loading( + translate('auto.ssh.managedServerMove.running', 'Moving {{host}} to a managed Orca server…', { + host + }) + ) + try { + const report = describeManagedServerMove(host, await move({ targetId })) + toast[report.level](report.message, { id: progressId }) + } catch (error) { + toast.error(managedServerMoveErrorText(host, error), { id: progressId }) + } +} diff --git a/src/shared/ssh-managed-server-move.ts b/src/shared/ssh-managed-server-move.ts new file mode 100644 index 00000000000..3267d8204b0 --- /dev/null +++ b/src/shared/ssh-managed-server-move.ts @@ -0,0 +1,7 @@ +/** Outcome of moving an SSH host to its managed Orca server when the user asks to. */ +export type SshManagedServerMoveResult = + | { outcome: 'moved'; environmentId: string } + /** Its relay terminals weren't proven exited after the stop, so nothing converted. */ + | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; terminals: number } + /** The terminals stopped but the connect kept the relay; the host's status line says why. */ + | { outcome: 'stayed' } diff --git a/src/shared/ssh-retained-payload-admission.test.ts b/src/shared/ssh-retained-payload-admission.test.ts index 179b639907d..bf626f47a60 100644 --- a/src/shared/ssh-retained-payload-admission.test.ts +++ b/src/shared/ssh-retained-payload-admission.test.ts @@ -52,6 +52,20 @@ describe('SSH retained payload admission', () => { expect(malformed).not.toHaveProperty('plainSsh') }) + it('admits only a literal move offer on a relay server status', () => { + const state = { targetId: 'ssh-a', status: 'connected', error: null, reconnectAttempt: 0 } + const relay = { kind: 'relay', reason: 'relay_terminals_live', terminals: 2 } + + expect( + admitSshConnectionState({ ...state, managedServer: { ...relay, offerMove: true } }, 'ssh-a') + ?.managedServer + ).toEqual({ ...relay, offerMove: true }) + expect( + admitSshConnectionState({ ...state, managedServer: { ...relay, offerMove: 'yes' } }, 'ssh-a') + ?.managedServer + ).toEqual(relay) + }) + it('rejects partial and malformed provider authority', () => { const state = { targetId: 'ssh-a', diff --git a/src/shared/ssh-retained-payload-admission.ts b/src/shared/ssh-retained-payload-admission.ts index 4deb47d154d..cd476dfee9b 100644 --- a/src/shared/ssh-retained-payload-admission.ts +++ b/src/shared/ssh-retained-payload-admission.ts @@ -132,7 +132,8 @@ function admitSshManagedServerStatus(value: unknown): { managedServer?: SshManag ...(detail ? { detail: clampUtf8TextPrefix(detail, SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) } : {}), - ...(isNonNegativeSafeInteger(terminals) ? { terminals } : {}) + ...(isNonNegativeSafeInteger(terminals) ? { terminals } : {}), + ...('offerMove' in value && value.offerMove === true ? { offerMove: true } : {}) } } } diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 4623a3d147f..b61117ba163 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -97,6 +97,8 @@ export type SshTarget = { */ /** Main-owned: managed orcad can't run on this host; retried once this app version changes. */ managedServerUnavailable?: { reason: string; appVersion: string } + /** Main-owned: the one-time "move now" offer was shown under this app version. */ + managedServerMoveOffered?: { appVersion: string } orcadFence?: { environmentId: string /** An older build changed the retained source rows: the host stays on the relay until moved again. */ @@ -107,7 +109,12 @@ export type SshTarget = { /** Renderer-authored target fields; registration generations are allocated and owned by main. */ export type SshTargetCreateInput = Omit< SshTarget, - 'id' | 'generation' | 'orcadProvisioning' | 'orcadFence' | 'managedServerUnavailable' + | 'id' + | 'generation' + | 'orcadProvisioning' + | 'orcadFence' + | 'managedServerUnavailable' + | 'managedServerMoveOffered' > export type SshTargetUpdateInput = Partial @@ -274,6 +281,8 @@ export type SshManagedServerStatus = detail?: string /** Relay terminals still running, when that is what keeps the host on the relay. */ terminals?: number + /** Show the one-time offer to move now, restarting those terminals. */ + offerMove?: boolean } /** Plain SSH terminals and SFTP browsing only; `reason` is the ladder's classified cause. */