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. */