diff --git a/src/main/ipc/orcad-runtime-conversion-handlers.test.ts b/src/main/ipc/orcad-runtime-conversion-handlers.test.ts new file mode 100644 index 00000000000..e5535988d68 --- /dev/null +++ b/src/main/ipc/orcad-runtime-conversion-handlers.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + handle: vi.fn(), + convert: vi.fn(), + pending: vi.fn(), + preflight: vi.fn(), + manifest: vi.fn(), + terminals: vi.fn(), + inventory: vi.fn() +})) + +vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) +vi.mock('../ssh/orcad-runtime-conversion', () => ({ + convertSshTargetToManagedOrcad: mocks.convert +})) +vi.mock('../ssh/orcad-managed-migration-status', () => ({ + listPendingManagedOrcadMigrations: mocks.pending +})) +vi.mock('../ssh/orcad-managed-runtime-context', () => ({ + requireManagedOrcadInfrastructure: () => ({ + targetStore: { + getOrcadMigrationSource: () => ({ getSshTarget: () => ({ id: 'ssh-1', label: 'Builder' }) }) + } + }) +})) +vi.mock('../ssh/ssh-target-orcad-preflight', () => ({ + preflightOrcadMigrationExport: mocks.preflight +})) +vi.mock('../ssh/orcad-migration-manifest-export', () => ({ + createOrcadMigrationManifest: mocks.manifest +})) +vi.mock('../ssh/orcad-migration-terminal-gate', () => ({ + assessOrcadMigrationTerminals: mocks.terminals +})) +vi.mock('../ssh/orcad-migration-relay-pty-lister', () => ({ + orcadMigrationRelayPtyLister: mocks.inventory +})) +vi.mock('../ssh/orcad-runtime-conversion-wiring', () => ({ + conversionCollaborators: () => ({ marker: 'live-collaborators' }) +})) + +const { registerOrcadRuntimeConversionHandlers } = + await import('./orcad-runtime-conversion-handlers') + +function handler(channel: string): (_event: unknown, args?: unknown) => unknown { + const registration = mocks.handle.mock.calls.find(([name]) => name === channel) + if (!registration) { + throw new Error(`${channel} handler was not registered`) + } + return registration[1] +} + +describe('managed server conversion IPC', () => { + beforeEach(() => { + vi.clearAllMocks() + registerOrcadRuntimeConversionHandlers(() => '/profile') + }) + + it('previews what moves and keeps only blockers that stop the move', async () => { + mocks.preflight.mockReturnValue({ + targetLabel: 'Builder', + blockers: [ + { code: 'orcad_migration_direct_ssh_repositories', category: 'drainable-static-state' }, + { code: 'orcad_migration_saved_port_forwards', category: 'client-owned-state' }, + { code: 'orcad_migration_target_owned', category: 'exclusive-ownership' } + ] + }) + mocks.manifest.mockReturnValue({ + payload: { + repositories: [{}, {}], + projectGroups: [{}], + folderWorkspaces: [{}], + dormantState: { automations: [{}], workspaceSession: {} } + } + }) + mocks.terminals.mockResolvedValue({ verdict: 'live', ptyIds: ['p'], reason: 'running' }) + await expect( + handler('runtimeEnvironments:previewOrcadConversion')(null, { sshTargetId: 'ssh-1' }) + ).resolves.toEqual({ + sshTargetId: 'ssh-1', + targetLabel: 'Builder', + moves: { + repositories: 2, + projectGroups: 1, + folderWorkspaces: 1, + automations: 1, + workspaceSession: true + }, + blockers: [{ code: 'orcad_migration_target_owned', category: 'exclusive-ownership' }], + terminals: { verdict: 'live', ptyIds: ['p'], reason: 'running' } + }) + expect(mocks.inventory).toHaveBeenCalledWith('ssh-1') + }) + + it('converts with the live collaborators and lists pending migrations from the profile', async () => { + mocks.convert.mockResolvedValue({ outcome: 'converted' }) + await handler('runtimeEnvironments:convertSshHostToManagedOrcad')(null, { + sshTargetId: ' ssh-1 ', + name: 'Builder' + }) + expect(mocks.convert).toHaveBeenCalledWith('/profile', { + sshTargetId: 'ssh-1', + name: 'Builder', + marker: 'live-collaborators' + }) + handler('runtimeEnvironments:listPendingOrcadMigrations')(null) + expect(mocks.pending).toHaveBeenCalledWith('/profile') + }) +}) diff --git a/src/main/ipc/orcad-runtime-conversion-handlers.ts b/src/main/ipc/orcad-runtime-conversion-handlers.ts new file mode 100644 index 00000000000..26542bf34c3 --- /dev/null +++ b/src/main/ipc/orcad-runtime-conversion-handlers.ts @@ -0,0 +1,79 @@ +import { ipcMain } from 'electron' +import type { + OrcadManagedConversionPreview, + OrcadManagedConversionResult, + OrcadManagedPendingMigrationRow +} from '../../shared/orcad-managed-runtime' +import { listPendingManagedOrcadMigrations } from '../ssh/orcad-managed-migration-status' +import { requireManagedOrcadInfrastructure } from '../ssh/orcad-managed-runtime-context' +import { createOrcadMigrationManifest } from '../ssh/orcad-migration-manifest-export' +import { assessOrcadMigrationTerminals } from '../ssh/orcad-migration-terminal-gate' +import { convertSshTargetToManagedOrcad } from '../ssh/orcad-runtime-conversion' +import { orcadMigrationRelayPtyLister } from '../ssh/orcad-migration-relay-pty-lister' +import { conversionCollaborators } from '../ssh/orcad-runtime-conversion-wiring' +import { preflightOrcadMigrationExport } from '../ssh/ssh-target-orcad-preflight' +import { requiredString } from './orcad-runtime-lifecycle-handlers' + +export function registerOrcadRuntimeConversionHandlers(getUserDataPath: () => string): void { + ipcMain.handle( + 'runtimeEnvironments:previewOrcadConversion', + (_event, args: { sshTargetId: string }): Promise => + previewConversion(requiredString(args?.sshTargetId, 'SSH target')) + ) + ipcMain.handle( + 'runtimeEnvironments:convertSshHostToManagedOrcad', + ( + _event, + args: { sshTargetId: string; name: string } + ): Promise => { + const sshTargetId = requiredString(args?.sshTargetId, 'SSH target') + return convertSshTargetToManagedOrcad(getUserDataPath(), { + sshTargetId, + name: requiredString(args?.name, 'Server name'), + ...conversionCollaborators(sshTargetId) + }) + } + ) + ipcMain.handle( + 'runtimeEnvironments:listPendingOrcadMigrations', + (): OrcadManagedPendingMigrationRow[] => listPendingManagedOrcadMigrations(getUserDataPath()) + ) +} + +/** Read-only: exports nothing and fences nothing. */ +async function previewConversion(sshTargetId: string): Promise { + const { targetStore } = requireManagedOrcadInfrastructure() + const store = targetStore.getOrcadMigrationSource() + const target = store.getSshTarget(sshTargetId) + if (!target) { + throw new Error('SSH target is required.') + } + const preflight = preflightOrcadMigrationExport(store, sshTargetId) + const manifest = createOrcadMigrationManifest(store, target) + const dormant = manifest.payload.dormantState + const terminals = await assessOrcadMigrationTerminals( + store, + sshTargetId, + orcadMigrationRelayPtyLister(sshTargetId) + ) + return { + sshTargetId, + targetLabel: preflight.targetLabel, + moves: { + repositories: manifest.payload.repositories.length, + projectGroups: manifest.payload.projectGroups.length, + folderWorkspaces: manifest.payload.folderWorkspaces.length, + automations: dormant?.automations?.length ?? 0, + workspaceSession: Boolean(dormant?.workspaceSession) + }, + blockers: preflight.blockers.filter( + (blocker) => + blocker.category !== 'drainable-static-state' && + blocker.code !== 'orcad_migration_saved_port_forwards' + ), + terminals: + terminals.verdict === 'exited' + ? { verdict: 'exited' } + : { verdict: terminals.verdict, ptyIds: terminals.ptyIds, reason: terminals.reason } + } +} diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts index 3af5e61a64c..21864ce6e6e 100644 --- a/src/main/ipc/runtime-environment-handler-channels.ts +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -22,6 +22,9 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:recoverOrcad', 'runtimeEnvironments:stopOrcad', 'runtimeEnvironments:cancelOrcadStop', + 'runtimeEnvironments:previewOrcadConversion', + 'runtimeEnvironments:convertSshHostToManagedOrcad', + 'runtimeEnvironments:listPendingOrcadMigrations', 'runtimeEnvironments:createOrcadSshHost', 'runtimeEnvironments:resumeOrcadSshHost', 'runtimeEnvironments:listPendingOrcadSshProvisioning' diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index b1dc936acf8..78542e46e73 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -159,6 +159,9 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:listPendingOrcadSshProvisioning', 'runtimeEnvironments:deployOrcad', 'runtimeEnvironments:getOrcadStatus', + 'runtimeEnvironments:previewOrcadConversion', + 'runtimeEnvironments:convertSshHostToManagedOrcad', + 'runtimeEnvironments:listPendingOrcadMigrations', 'runtimeEnvironments:updateOrcad', 'runtimeEnvironments:rollbackOrcad', 'runtimeEnvironments:recoverOrcad', @@ -199,6 +202,9 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:recoverOrcad', 'runtimeEnvironments:stopOrcad', 'runtimeEnvironments:cancelOrcadStop', + 'runtimeEnvironments:previewOrcadConversion', + 'runtimeEnvironments:convertSshHostToManagedOrcad', + 'runtimeEnvironments:listPendingOrcadMigrations', 'runtimeEnvironments:createOrcadSshHost', 'runtimeEnvironments:resumeOrcadSshHost', 'runtimeEnvironments:listPendingOrcadSshProvisioning', diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 3117a81a067..d7912f48bd3 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -19,6 +19,7 @@ import { } from './runtime-environment-subscriptions' import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels' import { registerOrcadRuntimeLifecycleHandlers } from './orcad-runtime-lifecycle-handlers' +import { registerOrcadRuntimeConversionHandlers } from './orcad-runtime-conversion-handlers' import { registerOrcadRuntimeMaintenanceHandlers } from './orcad-runtime-maintenance-handlers' import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers' import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime' @@ -75,6 +76,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { invalidateTransport: invalidateRuntimeEnvironmentTransport }) registerOrcadRuntimeLifecycleHandlers({ getUserDataPath }) + registerOrcadRuntimeConversionHandlers(getUserDataPath) registerOrcadRuntimeMaintenanceHandlers({ getUserDataPath, getActiveEnvironmentId: () => store.getSettings().activeRuntimeEnvironmentId, diff --git a/src/main/ssh/orcad-runtime-conversion-wiring.ts b/src/main/ssh/orcad-runtime-conversion-wiring.ts new file mode 100644 index 00000000000..52e837547f2 --- /dev/null +++ b/src/main/ssh/orcad-runtime-conversion-wiring.ts @@ -0,0 +1,42 @@ +/** The live collaborators a host conversion needs: the relay, the direct session and the server. */ +import { encodePairingOffer } from '../../shared/pairing' +import { + getPreferredPairingOffer, + type KnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import { disconnectRegisteredSshTarget } from '../ipc/ssh-session-teardown' +import type { OrcadMigrationDestinationCatalog } from './orcad-migration-cutover-coordinator' +import { + abortRemoteOrcadMigrationCatalog, + commitRemoteOrcadMigrationCatalog, + readRemoteOrcadMigrationCatalogState, + stageRemoteOrcadMigrationCatalog, + stageRemoteOrcadMigrationSnapshotChunk +} from './orcad-migration-catalog-client' +import { orcadMigrationRelayPtyLister } from './orcad-migration-relay-pty-lister' +import type { OrcadManagedConversionArgs } from './orcad-runtime-conversion' + +/** The T6-9 client against this server, pinned to the runtime it paired with. */ +export function orcadMigrationDestinationFor( + environment: KnownRuntimeEnvironment +): OrcadMigrationDestinationCatalog { + const pairingCode = encodePairingOffer(getPreferredPairingOffer(environment)) + const options = environment.runtimeId ? { expectedRuntimeId: environment.runtimeId } : {} + return { + readState: (manifest) => readRemoteOrcadMigrationCatalogState(pairingCode, manifest, options), + stage: (manifest) => stageRemoteOrcadMigrationCatalog(pairingCode, manifest, options), + commit: (manifest) => commitRemoteOrcadMigrationCatalog(pairingCode, manifest, options), + abort: (manifest) => abortRemoteOrcadMigrationCatalog(pairingCode, manifest, options), + stageChunk: (request) => stageRemoteOrcadMigrationSnapshotChunk(pairingCode, request, options) + } +} + +export function conversionCollaborators( + sshTargetId: string +): Pick { + return { + destinationFor: orcadMigrationDestinationFor, + listRelayPtyIds: orcadMigrationRelayPtyLister(sshTargetId), + releaseDirectSession: disconnectRegisteredSshTarget + } +} diff --git a/src/preload/api/managed-orcad-api.ts b/src/preload/api/managed-orcad-api.ts new file mode 100644 index 00000000000..caaffe77c84 --- /dev/null +++ b/src/preload/api/managed-orcad-api.ts @@ -0,0 +1,57 @@ +/** Managed orcad servers over SSH; desktop-only, so web clients leave it undefined. */ +import { ipcRenderer } from 'electron' +import type { + OrcadManagedCancelStopResult, + OrcadManagedConversionPreview, + OrcadManagedConversionResult, + OrcadManagedDeployResult, + OrcadManagedPendingMigrationRow, + OrcadManagedRecoveryResult, + OrcadManagedRollbackResult, + OrcadManagedRuntimeStatus, + OrcadManagedStopResult +} from '../../shared/orcad-managed-runtime' +import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' +import type { + RuntimeSshAccessLinkRequest, + RuntimeSshAccessUnlinkRequest +} from '../../shared/runtime-ssh-access' + +export type ManagedOrcadPreloadApi = { + deploy: (args: { + name: string + sshTargetId: string + force?: boolean + }) => Promise + getStatus: (args: { selector: string }) => Promise + update: (args: { selector: string; force?: boolean }) => Promise + rollback: (args: { selector: string }) => Promise + recover: (args: { selector: string }) => Promise + stop: (args: { selector: string }) => Promise + cancelStop: (args: { selector: string }) => Promise + linkSshAccess: (args: RuntimeSshAccessLinkRequest) => Promise + unlinkSshAccess: (args: RuntimeSshAccessUnlinkRequest) => Promise + previewConversion: (args: { sshTargetId: string }) => Promise + convertSshHost: (args: { + sshTargetId: string + name: string + }) => Promise + listPendingMigrations: () => Promise +} + +export const managedOrcadApi: ManagedOrcadPreloadApi = { + deploy: (args) => ipcRenderer.invoke('runtimeEnvironments:deployOrcad', args), + getStatus: (args) => ipcRenderer.invoke('runtimeEnvironments:getOrcadStatus', args), + update: (args) => ipcRenderer.invoke('runtimeEnvironments:updateOrcad', args), + rollback: (args) => ipcRenderer.invoke('runtimeEnvironments:rollbackOrcad', args), + recover: (args) => ipcRenderer.invoke('runtimeEnvironments:recoverOrcad', args), + stop: (args) => ipcRenderer.invoke('runtimeEnvironments:stopOrcad', args), + cancelStop: (args) => ipcRenderer.invoke('runtimeEnvironments:cancelOrcadStop', args), + linkSshAccess: (args) => ipcRenderer.invoke('runtimeEnvironments:linkSshAccess', args), + unlinkSshAccess: (args) => ipcRenderer.invoke('runtimeEnvironments:unlinkSshAccess', args), + previewConversion: (args) => + ipcRenderer.invoke('runtimeEnvironments:previewOrcadConversion', args), + convertSshHost: (args) => + ipcRenderer.invoke('runtimeEnvironments:convertSshHostToManagedOrcad', args), + listPendingMigrations: () => ipcRenderer.invoke('runtimeEnvironments:listPendingOrcadMigrations') +} diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index c39740b73a8..77b987f4b2f 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -1,3 +1,4 @@ +import type { ManagedOrcadPreloadApi } from './managed-orcad-api' import type { RuntimeHostStatusSnapshot } from '../../shared/runtime-host-status' import type { RuntimeBrowserDriverState, @@ -141,6 +142,8 @@ export type RuntimeApi = { onClose?: () => void } ) => Promise + // Why optional: only the desktop preload deploys servers over SSH. + managedOrcad?: ManagedOrcadPreloadApi } wsl: { isAvailable: () => Promise diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts index f1da42f3bbd..2b287deea91 100644 --- a/src/preload/api/runtime-environments-bridge.ts +++ b/src/preload/api/runtime-environments-bridge.ts @@ -14,6 +14,7 @@ import { type RuntimeEnvironmentSubscriptionHandle } from '../runtime-environment-subscriptions' import type { PreloadApi } from '../api-types' +import { managedOrcadApi } from './managed-orcad-api' export const runtimeEnvironmentsApi = { getStatusSnapshots: (): Promise => @@ -106,5 +107,6 @@ export const runtimeEnvironmentsApi = { onClose?: () => void } ): Promise => - subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks) + subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks), + managedOrcad: managedOrcadApi } satisfies PreloadApi['runtimeEnvironments'] diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index e755db7e41b..a1a3354f50a 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -11,6 +11,7 @@ import { translate } from '@/i18n/i18n' import { NativeChatExperimentalSetting } from './NativeChatExperimentalSetting' import { AgentDashboardExperimentalSetting } from './AgentDashboardExperimentalSetting' import { EphemeralVmsExperimentalSetting } from './EphemeralVmsExperimentalSetting' +import { ManagedServersExperimentalSetting } from './ManagedServersExperimentalSetting' import { MAX_AGENT_HIBERNATION_IDLE_MS, MIN_AGENT_HIBERNATION_IDLE_MS, @@ -268,6 +269,8 @@ export function ExperimentalPane({ + + {hiddenExperimentalUnlocked ? : null} ) diff --git a/src/renderer/src/components/settings/ManagedServerRow.test.tsx b/src/renderer/src/components/settings/ManagedServerRow.test.tsx new file mode 100644 index 00000000000..a89945722f1 --- /dev/null +++ b/src/renderer/src/components/settings/ManagedServerRow.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OrcadManagedRuntimeStatus } from '../../../../shared/orcad-managed-runtime' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api' +import { ManagedServerRow } from './ManagedServerRow' + +vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: vi.fn() } })) + +const roots: Root[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()) + } + document.body.innerHTML = '' +}) + +const status: OrcadManagedRuntimeStatus = { + environmentId: 'env-1', + sshTargetId: 'ssh-1', + activeVersion: '1.2.0', + previousVersion: '1.1.0', + activatedAt: '2026-10-01T00:00:00.000Z', + rollbackAvailable: false, + recovery: null, + terminals: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: null }, + migration: { migrationId: 'm-1', phase: 'destination-staged', startedAt: 'now' }, + deferredUpdate: { + outcome: 'deferred', + candidateVersion: '1.3.0', + code: 'orcad_update_terminal_census_unavailable', + reason: 'the daemon did not answer', + deferredAt: 'now' + } +} + +async function render() { + const stop = vi.fn(async () => ({ outcome: 'unlinked' })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the row calls only getStatus and stop here. + const api = { getStatus: vi.fn(async () => status), stop } as unknown as ManagedOrcadPreloadApi + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the row reads only id and name. + const environment = { id: 'env-1', name: 'Builder' } as PublicKnownRuntimeEnvironment + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( {}} />) + }) + return { container, stop } +} + +const button = (container: HTMLElement, label: string) => + [...container.querySelectorAll('button')].find((entry) => entry.textContent === label) + +describe('managed server row', () => { + it('reports unknown terminals, the running migration and the deferred update', async () => { + const { container } = await render() + expect(container.textContent).toContain('Running terminals: unknown') + expect(container.textContent).toContain('Migration: Copy staged') + expect(container.textContent).toContain('Update to 1.3.0 deferred: the daemon did not answer') + expect(button(container, 'Roll back')).toBeUndefined() + }) + + it('stops only after a second, explicit confirmation', async () => { + const { container, stop } = await render() + await act(async () => button(container, 'Stop…')?.click()) + expect(stop).not.toHaveBeenCalled() + await act(async () => button(container, 'Stop and remove server')?.click()) + expect(stop).toHaveBeenCalledWith({ selector: 'env-1' }) + }) +}) diff --git a/src/renderer/src/components/settings/ManagedServerRow.tsx b/src/renderer/src/components/settings/ManagedServerRow.tsx new file mode 100644 index 00000000000..624ee8fe4e2 --- /dev/null +++ b/src/renderer/src/components/settings/ManagedServerRow.tsx @@ -0,0 +1,234 @@ +import { Loader2, RefreshCw } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' +import type { OrcadManagedRuntimeStatus } from '../../../../shared/orcad-managed-runtime' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { migrationPhaseLabel, recoveryLabel, terminalCensusLabel } from './managed-server-copy' + +type ManagedServerAction = 'update' | 'rollback' | 'recover' | 'stop' | 'cancelStop' + +type ManagedServerRowProps = { + api: ManagedOrcadPreloadApi + environment: PublicKnownRuntimeEnvironment + onChanged: () => void +} + +/** Turns a refusal or deferral into the one line the user acts on. */ +function outcomeMessage(result: { outcome: string; reason?: string }): string | null { + return result.outcome === 'deferred' || + result.outcome === 'refused' || + result.outcome === 'failed' + ? (result.reason ?? result.outcome) + : null +} + +export function ManagedServerRow({ + api, + environment, + onChanged +}: ManagedServerRowProps): React.JSX.Element { + const mountedRef = useMountedRef() + const [status, setStatus] = useState(null) + const [statusError, setStatusError] = useState(null) + const [busy, setBusy] = useState(null) + const [confirmingStop, setConfirmingStop] = useState(false) + + const loadStatus = useCallback(async () => { + setBusy('status') + try { + const next = await api.getStatus({ selector: environment.id }) + if (mountedRef.current) { + setStatus(next) + setStatusError(null) + } + } catch (error) { + if (mountedRef.current) { + setStatusError(error instanceof Error ? error.message : String(error)) + } + } finally { + if (mountedRef.current) { + setBusy(null) + } + } + }, [api, environment.id, mountedRef]) + + useEffect(() => { + void loadStatus() + }, [loadStatus]) + + const run = async (action: ManagedServerAction): Promise => { + setBusy(action) + try { + const selector = { selector: environment.id } + const result = + action === 'update' + ? await api.update(selector) + : action === 'rollback' + ? await api.rollback(selector) + : action === 'recover' + ? await api.recover(selector) + : action === 'stop' + ? await api.stop(selector) + : await api.cancelStop(selector) + const message = outcomeMessage(result) + if (message) { + toast.message(message) + } + onChanged() + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + if (mountedRef.current) { + setBusy(null) + } + void loadStatus() + } + } + + const recovery = status?.recovery ?? null + const disabled = busy !== null + return ( +
+
+
{environment.name}
+ + {status?.activeVersion ?? + translate('auto.components.settings.managedServers.row.notRunning', 'Not running')} + + +
+ {statusError ?

{statusError}

: null} + {status ? ( +
+

{terminalCensusLabel(status.terminals)}

+ {recovery ?

{recoveryLabel(recovery)}

: null} + {status.migration ? ( +

+ {translate( + 'auto.components.settings.managedServers.row.migration', + 'Migration: {{phase}}', + { + phase: migrationPhaseLabel(status.migration.phase) + } + )} +

+ ) : null} + {status.deferredUpdate ? ( +

+ {translate( + 'auto.components.settings.managedServers.row.deferred', + 'Update to {{version}} deferred: {{reason}}', + { + version: status.deferredUpdate.candidateVersion, + reason: status.deferredUpdate.reason + } + )} +

+ ) : null} +
+ ) : null} +
+ + {status?.rollbackAvailable ? ( + + ) : null} + {recovery ? ( + + ) : null} + {recovery?.operation === 'decommission' ? ( + + ) : null} + {confirmingStop ? ( + <> + + + + ) : ( + + )} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/ManagedServersExperimentalSetting.tsx b/src/renderer/src/components/settings/ManagedServersExperimentalSetting.tsx new file mode 100644 index 00000000000..76fec896576 --- /dev/null +++ b/src/renderer/src/components/settings/ManagedServersExperimentalSetting.tsx @@ -0,0 +1,49 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { translate } from '@/i18n/i18n' +import { Label } from '../ui/label' +import { getExperimentalSearchEntry } from './experimental-search' +import { SearchableSetting } from './SearchableSetting' +import { SettingsSwitch } from './SettingsFormControls' + +type ManagedServersExperimentalSettingProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +export function ManagedServersExperimentalSetting({ + settings, + updateSettings +}: ManagedServersExperimentalSettingProps): React.JSX.Element { + const entry = getExperimentalSearchEntry().managedServers + const enabled = settings.experimentalManagedServers === true + + return ( + +
+
+ +

+ {translate( + 'auto.components.settings.managedServersExperimentalSetting.description', + 'Shows Managed servers under Remote Orca Servers, and Move to managed server on SSH hosts. macOS and Linux hosts only.' + )} +

+
+ updateSettings({ experimentalManagedServers: !enabled })} + /> +
+
+ ) +} diff --git a/src/renderer/src/components/settings/ManagedServersSection.tsx b/src/renderer/src/components/settings/ManagedServersSection.tsx new file mode 100644 index 00000000000..2cc1c7da823 --- /dev/null +++ b/src/renderer/src/components/settings/ManagedServersSection.tsx @@ -0,0 +1,187 @@ +import { useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' +import type { OrcadManagedPendingMigrationRow } from '../../../../shared/orcad-managed-runtime' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { SshTarget } from '../../../../shared/ssh-types' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { ManagedServerRow } from './ManagedServerRow' +import { RuntimeSshAccessControl } from './RuntimeSshAccessControl' +import { SshTargetSelect } from './SshTargetSelect' +import { migrationPhaseLabel } from './managed-server-copy' + +type ManagedServersSectionProps = { + environments: PublicKnownRuntimeEnvironment[] + onChanged: () => void +} + +export function ManagedServersSection({ + environments, + onChanged +}: ManagedServersSectionProps): React.JSX.Element | null { + const api = window.api.runtimeEnvironments.managedOrcad + const mountedRef = useMountedRef() + const [targets, setTargets] = useState([]) + const [pending, setPending] = useState([]) + const [name, setName] = useState('') + const [targetId, setTargetId] = useState('') + const [deploying, setDeploying] = useState(false) + + const reload = useCallback(async () => { + if (!api) { + return + } + const [nextTargets, nextPending] = await Promise.all([ + window.api.ssh.listTargets(), + api.listPendingMigrations() + ]) + if (mountedRef.current) { + setTargets(nextTargets) + setPending(nextPending) + } + }, [api, mountedRef]) + + useEffect(() => { + void reload().catch(() => undefined) + }, [reload]) + + if (!api) { + return null + } + const managed = environments.filter((environment) => environment.orcadDeployment) + const paired = environments.filter((environment) => !environment.orcadDeployment) + + const deploy = async (): Promise => { + setDeploying(true) + try { + const result = await api.deploy({ name: name.trim(), sshTargetId: targetId }) + if (result.outcome === 'deferred') { + toast.message(result.reason) + } else { + setName('') + setTargetId('') + } + onChanged() + await reload() + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + if (mountedRef.current) { + setDeploying(false) + } + } + } + + const resume = async (row: OrcadManagedPendingMigrationRow): Promise => { + try { + const result = await api.convertSshHost({ sshTargetId: row.sshTargetId, name: row.name }) + if (result.outcome !== 'converted') { + toast.message(result.reason) + } + onChanged() + await reload() + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } + } + + return ( +
+
+

+ {translate('auto.components.settings.managedServers.title', 'Managed servers')} +

+

+ {translate( + 'auto.components.settings.managedServers.description', + 'Orca installs and runs a server on an empty SSH host and keeps it up to date.' + )} +

+
+ +
+
+ + +
+
+ + setName(event.target.value)} /> +
+ +
+ + {pending.length > 0 ? ( +
+ {pending.map((row) => ( +
+
+
{row.name}
+

{migrationPhaseLabel(row.phase)}

+
+ +
+ ))} +
+ ) : null} + + {managed.length > 0 ? ( +
+ {managed.map((environment) => ( + + ))} +
+ ) : null} + + {paired.length > 0 ? ( +
+ +
+ {paired.map((environment) => ( + + ))} +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/MoveToManagedServerDialog.test.tsx b/src/renderer/src/components/settings/MoveToManagedServerDialog.test.tsx new file mode 100644 index 00000000000..df0341b2103 --- /dev/null +++ b/src/renderer/src/components/settings/MoveToManagedServerDialog.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OrcadManagedConversionPreview } from '../../../../shared/orcad-managed-runtime' +import type { SshTarget } from '../../../../shared/ssh-types' +import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api' +import { MoveToManagedServerDialog } from './MoveToManagedServerDialog' +import { + redactRuntimeEnvironment, + createEnvironmentFromPairingOffer +} from '../../../../shared/runtime-environments' +import { canMoveHostToManagedServer } from './managed-server-hosts' + +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 roots: Root[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()) + } + document.body.innerHTML = '' +}) + +function createEnvironment() { + return redactRuntimeEnvironment( + createEnvironmentFromPairingOffer({ + id: 'env-1', + name: 'Builder', + now: 1, + offer: { v: 2, endpoint: 'ws://127.0.0.1:1', deviceToken: 't', publicKeyB64: 'k' } + }) + ) +} + +const target: SshTarget = { id: 'ssh-1', label: 'Builder', host: 'b', port: 22, username: 'dev' } + +function preview( + overrides: Partial = {} +): OrcadManagedConversionPreview { + return { + sshTargetId: 'ssh-1', + targetLabel: 'Builder', + moves: { + repositories: 2, + projectGroups: 1, + folderWorkspaces: 1, + automations: 0, + workspaceSession: true + }, + blockers: [], + terminals: { verdict: 'exited' }, + ...overrides + } +} + +function api(next: OrcadManagedConversionPreview): ManagedOrcadPreloadApi { + const unused = vi.fn(async () => { + throw new Error('not used by the move dialog') + }) + return { + deploy: unused, + getStatus: unused, + update: unused, + rollback: unused, + recover: unused, + stop: unused, + cancelStop: unused, + linkSshAccess: unused, + unlinkSshAccess: unused, + previewConversion: vi.fn(async () => next), + convertSshHost: vi.fn(async () => ({ + outcome: 'converted' as const, + environment: createEnvironment(), + migrationId: 'm-1' + })), + listPendingMigrations: vi.fn(async () => []) + } +} + +async function render(managed: ManagedOrcadPreloadApi): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + {}} + onFinished={() => {}} + /> + ) + }) + return container +} + +const moveButton = (container: HTMLElement) => + [...container.querySelectorAll('button')].find((button) => button.textContent === 'Move host') + +describe('Move to managed server', () => { + it('summarizes what moves and only enables the move after the terminal confirmation', async () => { + const managed = api(preview()) + const container = await render(managed) + expect(container.textContent).toContain('Repositories: 2') + expect(container.textContent).toContain('Folder workspaces: 1') + expect(moveButton(container)?.disabled).toBe(true) + await act(async () => { + container.querySelector('[role="checkbox"]')?.click() + }) + expect(moveButton(container)?.disabled).toBe(false) + await act(async () => { + moveButton(container)?.click() + }) + expect(managed.convertSshHost).toHaveBeenCalledWith({ sshTargetId: 'ssh-1', name: 'Builder' }) + expect(container.textContent).toContain('Moved.') + }) + + it('names each blocker and keeps the move disabled', async () => { + const container = await render( + api( + preview({ + blockers: [ + { + code: 'orcad_migration_dependency_unverifiable', + category: 'live-or-unverifiable', + sources: ['automation'] + } + ], + terminals: { verdict: 'unverifiable', ptyIds: ['p'], reason: 'the relay did not answer' } + }) + ) + ) + expect(container.textContent).toContain('could not read its saved automations, so') + expect(container.textContent).toContain('the relay did not answer') + await act(async () => { + container.querySelector('[role="checkbox"]')?.click() + }) + expect(moveButton(container)?.disabled).toBe(true) + }) +}) + +describe('managed server host eligibility', () => { + it('offers a move on any connected host that reported its platform, Windows included', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: eligibility reads only status and remotePlatform. + const state = (status: string, remotePlatform?: string) => ({ status, remotePlatform }) as never + expect(canMoveHostToManagedServer(state('connected', 'linux'))).toBe(true) + expect(canMoveHostToManagedServer(state('connected', 'darwin'))).toBe(true) + expect(canMoveHostToManagedServer(state('connected', 'win32'))).toBe(true) + expect(canMoveHostToManagedServer(state('disconnected', 'win32'))).toBe(false) + expect(canMoveHostToManagedServer(state('connected'))).toBe(false) + expect(canMoveHostToManagedServer(undefined)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/settings/MoveToManagedServerDialog.tsx b/src/renderer/src/components/settings/MoveToManagedServerDialog.tsx new file mode 100644 index 00000000000..adc35f3af3b --- /dev/null +++ b/src/renderer/src/components/settings/MoveToManagedServerDialog.tsx @@ -0,0 +1,223 @@ +import { Loader2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import type { + OrcadManagedConversionPreview, + OrcadManagedConversionResult +} from '../../../../shared/orcad-managed-runtime' +import type { SshTarget } from '../../../../shared/ssh-types' +import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { Checkbox } from '../ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { conversionBlockerLabel, conversionMovesSummary } from './managed-server-copy' + +type Phase = 'preview' | 'running' | 'done' + +type MoveToManagedServerDialogProps = { + api: ManagedOrcadPreloadApi + target: SshTarget | null + onClose: () => void + onFinished: () => void +} + +export function MoveToManagedServerDialog({ + api, + target, + onClose, + onFinished +}: MoveToManagedServerDialogProps): React.JSX.Element { + const mountedRef = useMountedRef() + const [preview, setPreview] = useState(null) + const [previewError, setPreviewError] = useState(null) + const [name, setName] = useState('') + const [confirmed, setConfirmed] = useState(false) + const [phase, setPhase] = useState('preview') + const [result, setResult] = useState(null) + const [runError, setRunError] = useState(null) + + useEffect(() => { + if (!target) { + return + } + setPreview(null) + setPreviewError(null) + setName(target.label) + setConfirmed(false) + setPhase('preview') + setResult(null) + setRunError(null) + api.previewConversion({ sshTargetId: target.id }).then( + (next) => mountedRef.current && setPreview(next), + (error: unknown) => + mountedRef.current && + setPreviewError(error instanceof Error ? error.message : String(error)) + ) + }, [api, mountedRef, target]) + + const blocked = !preview || preview.blockers.length > 0 || preview.terminals.verdict !== 'exited' + + const move = async (): Promise => { + if (!target) { + return + } + setPhase('running') + setRunError(null) + try { + const next = await api.convertSshHost({ sshTargetId: target.id, name: name.trim() }) + if (mountedRef.current) { + setResult(next) + } + onFinished() + } catch (error) { + if (mountedRef.current) { + setRunError(error instanceof Error ? error.message : String(error)) + } + } finally { + if (mountedRef.current) { + setPhase('done') + } + } + } + + return ( + !open && phase !== 'running' && onClose()} + > + + + + {translate( + 'auto.components.settings.managedServers.move.title', + 'Move to managed server' + )} + + + {translate( + 'auto.components.settings.managedServers.move.description', + 'Orca installs a server on {{host}} and moves this host’s projects onto it. The host keeps everything until the server confirms it has a copy.', + { host: target?.label ?? '' } + )} + + + + {phase === 'preview' ? ( +
+ {previewError ?

{previewError}

: null} + {!preview && !previewError ? ( + + ) : null} + {preview ? ( + <> +
+ + {conversionMovesSummary(preview.moves).map((line) => ( +

+ {line} +

+ ))} +
+ {preview.terminals.verdict !== 'exited' ? ( +

{preview.terminals.reason}

+ ) : null} + {preview.blockers.map((blocker) => ( +

+ {conversionBlockerLabel(blocker)} +

+ ))} +
+ + setName(event.target.value)} /> +
+ + + ) : null} +
+ ) : null} + + {phase === 'running' ? ( +
+ + {translate( + 'auto.components.settings.managedServers.move.running', + 'Installing the server and copying projects. You can close Settings; the move resumes from where it stopped.' + )} +
+ ) : null} + + {phase === 'done' ? ( +

+ {runError ?? + (result?.outcome === 'converted' + ? translate( + 'auto.components.settings.managedServers.move.converted', + 'Moved. {{name}} now serves these projects.', + { + name: result.environment.name + } + ) + : (result?.reason ?? ''))} +

+ ) : null} + + + {phase === 'preview' ? ( + <> + + + + ) : null} + {phase === 'done' ? ( + <> + {result?.outcome !== 'converted' ? ( + + ) : null} + + + ) : null} + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 363cc2bbc48..f8b9e73e19c 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -5,6 +5,7 @@ import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { SearchableSetting } from './SearchableSetting' import { EphemeralVmRuntimesSection } from './EphemeralVmRuntimesSection' +import { ManagedServersSection } from './ManagedServersSection' import { CloudVmSetupGuide } from './CloudVmSetupGuide' import { getRuntimeEnvironmentsSearchEntry, @@ -266,6 +267,13 @@ export function RuntimeEnvironmentsPane({ /> ) : null} + {visibleWorkflow === 'connect' && settings.experimentalManagedServers === true ? ( + void loadEnvironments()} + /> + ) : null} + {visibleWorkflow === 'connect' ? : null} void +} + +/** Reaches an already-paired server through an SSH tunnel instead of its network address. */ +export function RuntimeSshAccessControl({ + api, + environment, + targets, + onChanged +}: RuntimeSshAccessControlProps): React.JSX.Element { + const [targetId, setTargetId] = useState('') + const [remotePort, setRemotePort] = useState(DEFAULT_REMOTE_PORT) + const [busy, setBusy] = useState(false) + const linked = environment.sshAccess + + const submit = async (): Promise => { + setBusy(true) + try { + const requestId = createBrowserUuid() + await (linked + ? api.unlinkSshAccess({ selector: environment.id, requestId }) + : api.linkSshAccess({ + selector: environment.id, + requestId, + sshTargetId: targetId, + remotePort: Number(remotePort) + })) + onChanged() + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + } + + if (linked) { + return ( +
+
+ {translate('auto.components.settings.managedServers.access.linked', '{{name}} via SSH', { + name: environment.name + })} +
+ +
+ ) + } + const port = Number(remotePort) + const valid = targetId !== '' && Number.isInteger(port) && port >= 1 && port <= 65_535 + return ( +
+
+ + +
+
+ + setRemotePort(event.target.value)} + /> +
+ +
+ ) +} diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index 3424abb4644..57e5ca3a6a4 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -11,6 +11,8 @@ import { terminateSshSessionsWithReconnect } from './ssh-session-termination' import { SshTargetCard } from './SshTargetCard' +import { SshTargetManagedServerAction } from './SshTargetManagedServerAction' +import { SshTargetsEmptyState } from './SshTargetsEmptyState' import { SshTargetDestructiveActions } from './SshTargetDestructiveActions' import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm' import { getEditingTargetForSshTarget } from './ssh-target-draft' @@ -375,33 +377,30 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem <> {/* Target list */} {targets.length === 0 ? ( -
- {translate( - 'auto.components.settings.SshPane.c0f1c80166', - 'No SSH targets configured.' - )} -
+ ) : (
{targets.map((target) => ( - - requestTerminateSessions({ id, label: target.label }) - } - onResetRelay={(id) => requestResetRelay({ id, label: target.label })} - onTest={handleTest} - onEdit={handleEdit} - onRemove={(id) => - requestRemoveTarget({ id, label: target.label }, requestRemove) - } - /> +
+ + requestTerminateSessions({ id, label: target.label }) + } + onResetRelay={(id) => requestResetRelay({ id, label: target.label })} + onTest={handleTest} + onEdit={handleEdit} + onRemove={(id) => + requestRemoveTarget({ id, label: target.label }, requestRemove) + } + /> + +
))}
)} diff --git a/src/renderer/src/components/settings/SshTargetManagedServerAction.tsx b/src/renderer/src/components/settings/SshTargetManagedServerAction.tsx new file mode 100644 index 00000000000..d2de6a1eb89 --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetManagedServerAction.tsx @@ -0,0 +1,46 @@ +import { ArrowRightLeft } from 'lucide-react' +import { useState } from 'react' +import type { SshTarget } from '../../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { Button } from '../ui/button' +import { MoveToManagedServerDialog } from './MoveToManagedServerDialog' +import { canMoveHostToManagedServer } from './managed-server-hosts' + +type SshTargetManagedServerActionProps = { + target: SshTarget + onMoved: () => unknown +} + +/** "Move to managed server" under an SSH host, behind the Managed servers experiment. */ +export function SshTargetManagedServerAction({ + target, + onMoved +}: SshTargetManagedServerActionProps): React.JSX.Element | null { + const enabled = useAppStore((s) => s.settings?.experimentalManagedServers === true) + const state = useAppStore((s) => s.sshConnectionStates.get(target.id)) + const api = window.api.runtimeEnvironments.managedOrcad + const [open, setOpen] = useState(false) + if (!enabled || !api || !canMoveHostToManagedServer(state)) { + return null + } + return ( + <> +
+ +
+ setOpen(false)} + onFinished={() => void onMoved()} + /> + + ) +} diff --git a/src/renderer/src/components/settings/SshTargetSelect.tsx b/src/renderer/src/components/settings/SshTargetSelect.tsx new file mode 100644 index 00000000000..40d6760aabf --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetSelect.tsx @@ -0,0 +1,31 @@ +import type { SshTarget } from '../../../../shared/ssh-types' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' + +type SshTargetSelectProps = { + targets: SshTarget[] + value: string + onChange: (targetId: string) => void + placeholder: string +} + +export function SshTargetSelect({ + targets, + value, + onChange, + placeholder +}: SshTargetSelectProps): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/settings/SshTargetsEmptyState.tsx b/src/renderer/src/components/settings/SshTargetsEmptyState.tsx new file mode 100644 index 00000000000..336e27607bc --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetsEmptyState.tsx @@ -0,0 +1,9 @@ +import { translate } from '@/i18n/i18n' + +export function SshTargetsEmptyState(): React.JSX.Element { + return ( +
+ {translate('auto.components.settings.SshPane.c0f1c80166', 'No SSH targets configured.')} +
+ ) +} diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index 594469fa200..eea8e031304 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -5,6 +5,7 @@ import { translateSearchKeyword } from './settings-search-keywords' import { getNewWorktreeCardStyleSearchEntry } from './new-worktree-card-style-search-entry' import { getNativeChatExperimentalSearchEntry } from './native-chat-experimental-search-entry' import { getEphemeralVmsSearchEntry } from './ephemeral-vms-search' +import { getManagedServersSearchEntry } from './managed-servers-search' export const getExperimentalPaneSearchEntries = createLocalizedCatalog( (): SettingsSearchEntry[] => [ @@ -180,7 +181,8 @@ export const getExperimentalPaneSearchEntries = createLocalizedCatalog( ] }, getNewWorktreeCardStyleSearchEntry(), - getEphemeralVmsSearchEntry() + getEphemeralVmsSearchEntry(), + getManagedServersSearchEntry() ] ) @@ -224,6 +226,9 @@ export function getExperimentalSearchEntry() { ), ephemeralVms: findEntry( translate('auto.components.settings.ephemeralVms.search.cloudVmTitle', 'Cloud VM') + ), + managedServers: findEntry( + translate('auto.components.settings.managedServers.search.title', 'Managed servers') ) } as const } diff --git a/src/renderer/src/components/settings/managed-server-copy.test.ts b/src/renderer/src/components/settings/managed-server-copy.test.ts new file mode 100644 index 00000000000..fc26ab16b01 --- /dev/null +++ b/src/renderer/src/components/settings/managed-server-copy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { ORCAD_MIGRATION_DEPENDENCY_KINDS } from '../../../../shared/orcad-migration-preflight' +import { conversionBlockerLabel } from './managed-server-copy' +import { dependencyKindLabel } from './managed-server-dependency-kinds' + +describe('managed server blocker copy', () => { + it('names dependent state in plain words, never the internal kind id', () => { + expect( + conversionBlockerLabel({ + code: 'orcad_migration_dependent_state', + category: 'client-owned-state', + dependencies: [{ kind: 'workspace-session', count: 1 }] + }) + ).toBe('State that cannot move yet: open tabs and layout (1).') + expect( + conversionBlockerLabel({ + code: 'orcad_migration_dependency_unverifiable', + category: 'live-or-unverifiable', + sources: ['automation', 'ui-routing'] + }) + ).toContain('automations, sidebar and filter settings') + }) + + it('has a label for every dependency kind', () => { + for (const kind of ORCAD_MIGRATION_DEPENDENCY_KINDS) { + expect(dependencyKindLabel(kind)).not.toBe(kind) + } + }) +}) diff --git a/src/renderer/src/components/settings/managed-server-copy.ts b/src/renderer/src/components/settings/managed-server-copy.ts new file mode 100644 index 00000000000..ca5d6bc1e34 --- /dev/null +++ b/src/renderer/src/components/settings/managed-server-copy.ts @@ -0,0 +1,148 @@ +/** User-facing words for managed-server states; every string goes through the catalog. */ +import type { + OrcadManagedConversionPreview, + OrcadManagedPendingMigrationRow, + OrcadManagedRuntimeStatus +} from '../../../../shared/orcad-managed-runtime' +import type { OrcadMigrationBlocker } from '../../../../shared/orcad-migration-preflight' +import { translate } from '@/i18n/i18n' +import { dependencyKindLabel } from './managed-server-dependency-kinds' + +export function migrationPhaseLabel(phase: OrcadManagedPendingMigrationRow['phase']): string { + switch (phase) { + case 'source-fenced': + return translate('auto.components.settings.managedServers.phase.fenced', 'Host locked') + case 'destination-staged': + return translate('auto.components.settings.managedServers.phase.staged', 'Copy staged') + case 'destination-committed': + return translate('auto.components.settings.managedServers.phase.committed', 'Copy committed') + case 'source-retired': + return translate('auto.components.settings.managedServers.phase.retired', 'Finished') + } +} + +export function recoveryLabel( + recovery: NonNullable +): string { + switch (recovery.operation) { + case 'activate': + return translate( + 'auto.components.settings.managedServers.recovery.activate', + 'An update was interrupted' + ) + case 'rollback': + return translate( + 'auto.components.settings.managedServers.recovery.rollback', + 'A rollback was interrupted' + ) + case 'decommission': + return translate( + 'auto.components.settings.managedServers.recovery.decommission', + 'A stop was interrupted' + ) + } +} + +/** `null` counts are unknown, never zero: the server did not answer. */ +export function terminalCensusLabel(terminals: OrcadManagedRuntimeStatus['terminals']): string { + if (terminals.liveSessions === null) { + return translate( + 'auto.components.settings.managedServers.terminals.unknown', + 'Running terminals: unknown' + ) + } + return translate( + 'auto.components.settings.managedServers.terminals.count', + 'Running terminals: {{count}}', + { count: terminals.liveSessions } + ) +} + +export function conversionBlockerLabel(blocker: OrcadMigrationBlocker): string { + switch (blocker.code) { + case 'orcad_migration_target_not_found': + return translate( + 'auto.components.settings.managedServers.blocker.notFound', + 'The SSH host is gone.' + ) + case 'orcad_migration_target_owned': + case 'orcad_migration_owner_unrecorded': + return translate( + 'auto.components.settings.managedServers.blocker.owned', + 'This SSH host already belongs to a managed server.' + ) + case 'orcad_migration_direct_ssh_repositories': + case 'orcad_migration_direct_ssh_folder_workspaces': + return translate( + 'auto.components.settings.managedServers.blocker.catalog', + 'Projects on this host cannot move yet.' + ) + case 'orcad_migration_direct_ssh_terminal_leases': + return translate( + 'auto.components.settings.managedServers.blocker.terminals', + 'Terminals on this host are still running ({{count}}). Close them first.', + { count: blocker.terminalLeases.length } + ) + case 'orcad_migration_saved_port_forwards': + return translate( + 'auto.components.settings.managedServers.blocker.portForwards', + 'Saved port forwards stay with the SSH host.' + ) + case 'orcad_migration_dependent_state': + return translate( + 'auto.components.settings.managedServers.blocker.dependents', + 'State that cannot move yet: {{kinds}}.', + { + kinds: blocker.dependencies + .map((dependency) => `${dependencyKindLabel(dependency.kind)} (${dependency.count})`) + .join(', ') + } + ) + case 'orcad_migration_dependency_unverifiable': + return translate( + 'auto.components.settings.managedServers.blocker.unverifiable', + 'Orca could not read its saved {{sources}}, so it cannot tell what would move.', + { sources: blocker.sources.map(dependencyKindLabel).join(', ') } + ) + } +} + +export function conversionMovesSummary(moves: OrcadManagedConversionPreview['moves']): string[] { + const lines = [ + translate( + 'auto.components.settings.managedServers.moves.repositories', + 'Repositories: {{count}}', + { + count: moves.repositories + } + ), + translate( + 'auto.components.settings.managedServers.moves.folderWorkspaces', + 'Folder workspaces: {{count}}', + { count: moves.folderWorkspaces } + ), + translate( + 'auto.components.settings.managedServers.moves.projectGroups', + 'Project groups: {{count}}', + { + count: moves.projectGroups + } + ), + translate( + 'auto.components.settings.managedServers.moves.automations', + 'Automations: {{count}}', + { + count: moves.automations + } + ) + ] + if (moves.workspaceSession) { + lines.push( + translate( + 'auto.components.settings.managedServers.moves.session', + 'Closed tabs and saved layouts' + ) + ) + } + return lines +} diff --git a/src/renderer/src/components/settings/managed-server-dependency-kinds.ts b/src/renderer/src/components/settings/managed-server-dependency-kinds.ts new file mode 100644 index 00000000000..351c357b57b --- /dev/null +++ b/src/renderer/src/components/settings/managed-server-dependency-kinds.ts @@ -0,0 +1,64 @@ +/** Plain names for the kinds of saved state that can keep a host from moving. */ +import type { OrcadMigrationDependencyKind } from '../../../../shared/orcad-migration-preflight' +import { translate } from '@/i18n/i18n' + +export function dependencyKindLabel(kind: OrcadMigrationDependencyKind): string { + switch (kind) { + case 'saved-port-forward': + return translate('auto.components.settings.managedServers.kind.portForward', 'port forwards') + case 'terminal-lease': + return translate('auto.components.settings.managedServers.kind.terminalLease', 'terminals') + case 'terminal-recovery': + return translate( + 'auto.components.settings.managedServers.kind.terminalRecovery', + 'terminal recovery records' + ) + case 'workspace-session': + return translate( + 'auto.components.settings.managedServers.kind.workspaceSession', + 'open tabs and layout' + ) + case 'worktree-metadata': + return translate( + 'auto.components.settings.managedServers.kind.worktreeMetadata', + 'worktree details' + ) + case 'worktree-lineage': + return translate( + 'auto.components.settings.managedServers.kind.worktreeLineage', + 'worktree history' + ) + case 'workspace-lineage': + return translate( + 'auto.components.settings.managedServers.kind.workspaceLineage', + 'workspace history' + ) + case 'sparse-preset': + return translate( + 'auto.components.settings.managedServers.kind.sparsePreset', + 'sparse checkout presets' + ) + case 'retired-worktree-name': + return translate( + 'auto.components.settings.managedServers.kind.retiredWorktreeName', + 'retired worktree names' + ) + case 'automation': + return translate('auto.components.settings.managedServers.kind.automation', 'automations') + case 'automation-run': + return translate( + 'auto.components.settings.managedServers.kind.automationRun', + 'automation runs' + ) + case 'mobile-tab-selection': + return translate( + 'auto.components.settings.managedServers.kind.mobileTabSelection', + 'mobile tab selections' + ) + case 'ui-routing': + return translate( + 'auto.components.settings.managedServers.kind.uiRouting', + 'sidebar and filter settings' + ) + } +} diff --git a/src/renderer/src/components/settings/managed-server-hosts.ts b/src/renderer/src/components/settings/managed-server-hosts.ts new file mode 100644 index 00000000000..f9e5e7af0d6 --- /dev/null +++ b/src/renderer/src/components/settings/managed-server-hosts.ts @@ -0,0 +1,6 @@ +import type { SshConnectionState } from '../../../../shared/ssh-types' + +/** "Move to managed server" needs a connected relay that has reported its platform (any of them). */ +export function canMoveHostToManagedServer(state: SshConnectionState | undefined): boolean { + return state?.status === 'connected' && state.remotePlatform !== undefined +} diff --git a/src/renderer/src/components/settings/managed-servers-search.ts b/src/renderer/src/components/settings/managed-servers-search.ts new file mode 100644 index 00000000000..7d892c5edb1 --- /dev/null +++ b/src/renderer/src/components/settings/managed-servers-search.ts @@ -0,0 +1,28 @@ +import type { SettingsSearchEntry } from './settings-search' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' + +export const getManagedServersSearchEntry = createLocalizedCatalog((): SettingsSearchEntry => ({ + title: translate('auto.components.settings.managedServers.search.title', 'Managed servers'), + description: translate( + 'auto.components.settings.managedServers.search.description', + 'Deploy and run an Orca server on your own SSH hosts, and move an SSH host’s projects onto one.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.0d24759f14', + 'experimental' + ), + ...translateSearchKeyword('auto.components.settings.managedServers.search.keywordSsh', 'ssh'), + ...translateSearchKeyword( + 'auto.components.settings.managedServers.search.keywordServer', + 'server' + ), + ...translateSearchKeyword( + 'auto.components.settings.managedServers.search.keywordMigrate', + 'migrate' + ) + ], + targetSectionId: 'managed-servers' +})) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 72118c9ab68..f1ccca25d34 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2803,16 +2803,13 @@ } }, "status": { + "failedAfter": "Failed after {{value0}}", + "interruptedAfter": "Interrupted after {{value0}}", "responding": "Agent is responding", "toggleDetails": "Toggle turn details", "workedFor": "Worked for {{value0}}", - "interruptedAfter": "Interrupted after {{value0}}", - "failedAfter": "Failed after {{value0}}", "workingFor": "Working for {{value0}}" }, - "subagents": { - "unnamed": "Subagent" - }, "tool": { "countN": "{{value0}} tool calls", "countOne": "1 tool call", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 21fcdf4beea..2215d620052 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12254,6 +12254,110 @@ }, "UnsealedCredentialNotice": { "body": "{{credential}} is stored unencrypted — this system has no OS keyring Orca can use. Anyone who can read your disk or a backup of it can read the credential. Install and unlock gnome-keyring or kwallet, then save it again to seal it." + }, + "managedServers": { + "row": { + "notRunning": "Not running", + "refresh": "Refresh status", + "migration": "Migration: {{phase}}", + "deferred": "Update to {{version}} deferred: {{reason}}", + "update": "Update", + "rollback": "Roll back", + "recover": "Recover", + "cancelStop": "Cancel stop", + "confirmStop": "Stop and remove server", + "keep": "Keep running", + "stop": "Stop…" + }, + "title": "Managed servers", + "description": "Orca installs and runs a server on an empty SSH host and keeps it up to date.", + "deploy": { + "host": "SSH host", + "hostPlaceholder": "Choose an empty SSH host", + "name": "Server name", + "submit": "Deploy server" + }, + "pending": { + "resume": "Resume" + }, + "access": { + "title": "SSH access for paired servers", + "linked": "{{name}} via SSH", + "unlink": "Stop using SSH", + "target": "Reach through SSH host", + "port": "Server port", + "link": "Use SSH" + }, + "move": { + "title": "Move to managed server", + "description": "Orca installs a server on {{host}} and moves this host’s projects onto it. The host keeps everything until the server confirms it has a copy.", + "moves": "What moves", + "confirm": "I have closed every terminal on this host. Running terminals cannot move and stop the move.", + "running": "Installing the server and copying projects. You can close Settings; the move resumes from where it stopped.", + "converted": "Moved. {{name}} now serves these projects.", + "cancel": "Cancel", + "submit": "Move host", + "resume": "Resume", + "close": "Close", + "action": "Move to managed server" + }, + "search": { + "title": "Managed servers", + "description": "Deploy and run an Orca server on your own SSH hosts, and move an SSH host’s projects onto one.", + "keywordSsh": "ssh", + "keywordServer": "server", + "keywordMigrate": "migrate" + }, + "phase": { + "fenced": "Host locked", + "staged": "Copy staged", + "committed": "Copy committed", + "retired": "Finished" + }, + "recovery": { + "activate": "An update was interrupted", + "rollback": "A rollback was interrupted", + "decommission": "A stop was interrupted" + }, + "terminals": { + "unknown": "Running terminals: unknown", + "count": "Running terminals: {{count}}" + }, + "blocker": { + "notFound": "The SSH host is gone.", + "owned": "This SSH host already belongs to a managed server.", + "catalog": "Projects on this host cannot move yet.", + "terminals": "Terminals on this host are still running ({{count}}). Close them first.", + "portForwards": "Saved port forwards stay with the SSH host.", + "dependents": "State that cannot move yet: {{kinds}}.", + "unverifiable": "Orca could not read its saved {{sources}}, so it cannot tell what would move." + }, + "moves": { + "repositories": "Repositories: {{count}}", + "folderWorkspaces": "Folder workspaces: {{count}}", + "projectGroups": "Project groups: {{count}}", + "automations": "Automations: {{count}}", + "session": "Closed tabs and saved layouts" + }, + "kind": { + "portForward": "port forwards", + "terminalLease": "terminals", + "terminalRecovery": "terminal recovery records", + "workspaceSession": "open tabs and layout", + "worktreeMetadata": "worktree details", + "worktreeLineage": "worktree history", + "workspaceLineage": "workspace history", + "sparsePreset": "sparse checkout presets", + "retiredWorktreeName": "retired worktree names", + "automation": "automations", + "automationRun": "automation runs", + "mobileTabSelection": "mobile tab selections", + "uiRouting": "sidebar and filter settings" + } + }, + "managedServersExperimentalSetting": { + "description": "Shows Managed servers under Remote Orca Servers, and Move to managed server on SSH hosts. macOS and Linux hosts only.", + "toggleLabel": "Toggle Managed servers" } }, "right": { diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 3c6a9d2aa59..1c1f1fd0f3a 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -252,6 +252,7 @@ export function buildDefaultSettings(args: { agentHibernationIdleMs: 30 * 60 * 1000, experimentalNewWorktreeCardStyle: false, experimentalEphemeralVms: false, + experimentalManagedServers: false, compactWorktreeCards: false, // Why: local desktop stays the default until the user picks a saved runtime environment. activeRuntimeEnvironmentId: null, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index c1a9b7d2df1..dae70940c4b 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -497,6 +497,8 @@ export type GlobalSettings = { experimentalNewWorktreeCardStyle?: boolean /** Experimental: per-workspace on-demand environment recipes and setup surface. */ experimentalEphemeralVms?: boolean + /** Experimental: deploy managed Orca servers over SSH and move SSH hosts onto them. */ + experimentalManagedServers?: boolean /** Compact worktree cards: hide the metadata row when title and branch say the same thing. */ compactWorktreeCards: boolean /** Legacy persisted key from the Experimental rollout; new writes use compactWorktreeCards. */ diff --git a/src/shared/orcad-managed-runtime.ts b/src/shared/orcad-managed-runtime.ts index 4b1064faefb..cc8dfa9f2fc 100644 --- a/src/shared/orcad-managed-runtime.ts +++ b/src/shared/orcad-managed-runtime.ts @@ -1,5 +1,6 @@ import type { PublicKnownRuntimeEnvironment } from './runtime-environments' import type { OrcadTerminalCensus } from './orcad-terminal-census' +import type { OrcadMigrationBlocker } from './orcad-migration-preflight' export const ORCAD_MANAGED_REMOTE_PORT = 6_768 @@ -118,3 +119,29 @@ export type OrcadManagedConversionResult = code: string reason: string } + +/** What converting an SSH host would move, and what stops it, before anything is touched. */ +export type OrcadManagedConversionPreview = { + sshTargetId: string + targetLabel: string | null + moves: { + repositories: number + projectGroups: number + folderWorkspaces: number + automations: number + workspaceSession: boolean + } + blockers: OrcadMigrationBlocker[] + terminals: + | { verdict: 'exited' } + | { verdict: 'live' | 'unverifiable'; ptyIds: string[]; reason: string } +} + +export type OrcadManagedPendingMigrationRow = { + migrationId: string + environmentId: string + name: string + sshTargetId: string + phase: 'source-fenced' | 'destination-staged' | 'destination-committed' | 'source-retired' + startedAt: string +}