feat(settings): managed servers and "Move to managed server", behind an experimental setting (#16741 T6-6 + T6-10 UI) (#24590)

* feat(settings): managed servers and "Move to managed server", behind an experimental setting (#16741 T6-6 + T6-10 UI)

Adds a Managed servers section under Remote servers (deploy an empty server,
status with deferred-update and migration states, update, rollback, recover,
stop and cancel-stop, and SSH access for paired servers), and a Move to managed
server action on connected macOS and Linux SSH hosts with a preflight summary,
a terminals-closed confirmation and a resumable progress view. Main wires the
conversion to the relay's process list, the direct session and the T6-9 catalog
client. Everything is hidden until the new experimental setting is turned on,
and Windows SSH hosts are never offered. Merges the T6-9 branch (#24521) until
it lands on the integration branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(settings): align managed-server form controls and name the section the setting reveals

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(orcad): ask the relay with an absolute deadline via the W5 terminal-gate lister

The conversion wiring passed a relative 10 s as listProcesses' deadlineMs, which the
provider reads as an absolute time, so every relay inventory timed out after 1 ms and
the terminal gate could never prove exit. Adopt #24579's lister verbatim so the stacks
merge cleanly.

* feat(settings): name blocking saved state in plain, localized words

The move preview listed internal dependency ids such as workspace-session; each kind
now has its own catalog entry.

* feat(settings): offer managed servers and the move on Windows SSH hosts

W1-W5 are on the integration branch, so a Windows relay-hosted host can deploy, convert
and retire like a POSIX one. The move still waits for a connected relay that reported
its platform.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
OrcaWin
2026-10-02 11:41:22 -07:00
committed by GitHub
co-authored by m4air Claude Opus 5.5
parent 8393c1d6d0
commit a280d6aedd
33 changed files with 1879 additions and 31 deletions
@@ -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')
})
})
@@ -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<OrcadManagedConversionPreview> =>
previewConversion(requiredString(args?.sshTargetId, 'SSH target'))
)
ipcMain.handle(
'runtimeEnvironments:convertSshHostToManagedOrcad',
(
_event,
args: { sshTargetId: string; name: string }
): Promise<OrcadManagedConversionResult> => {
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<OrcadManagedConversionPreview> {
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 }
}
}
@@ -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'
@@ -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',
+2
View File
@@ -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,
@@ -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<OrcadManagedConversionArgs, 'destinationFor' | 'listRelayPtyIds' | 'releaseDirectSession'> {
return {
destinationFor: orcadMigrationDestinationFor,
listRelayPtyIds: orcadMigrationRelayPtyLister(sshTargetId),
releaseDirectSession: disconnectRegisteredSshTarget
}
}
+57
View File
@@ -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<OrcadManagedDeployResult>
getStatus: (args: { selector: string }) => Promise<OrcadManagedRuntimeStatus>
update: (args: { selector: string; force?: boolean }) => Promise<OrcadManagedDeployResult>
rollback: (args: { selector: string }) => Promise<OrcadManagedRollbackResult>
recover: (args: { selector: string }) => Promise<OrcadManagedRecoveryResult>
stop: (args: { selector: string }) => Promise<OrcadManagedStopResult>
cancelStop: (args: { selector: string }) => Promise<OrcadManagedCancelStopResult>
linkSshAccess: (args: RuntimeSshAccessLinkRequest) => Promise<PublicKnownRuntimeEnvironment>
unlinkSshAccess: (args: RuntimeSshAccessUnlinkRequest) => Promise<PublicKnownRuntimeEnvironment>
previewConversion: (args: { sshTargetId: string }) => Promise<OrcadManagedConversionPreview>
convertSshHost: (args: {
sshTargetId: string
name: string
}) => Promise<OrcadManagedConversionResult>
listPendingMigrations: () => Promise<OrcadManagedPendingMigrationRow[]>
}
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')
}
+3
View File
@@ -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<RuntimeEnvironmentSubscriptionHandle>
// Why optional: only the desktop preload deploys servers over SSH.
managedOrcad?: ManagedOrcadPreloadApi
}
wsl: {
isAvailable: () => Promise<boolean>
@@ -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<RuntimeHostStatusSnapshot[]> =>
@@ -106,5 +107,6 @@ export const runtimeEnvironmentsApi = {
onClose?: () => void
}
): Promise<RuntimeEnvironmentSubscriptionHandle> =>
subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks)
subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks),
managedOrcad: managedOrcadApi
} satisfies PreloadApi['runtimeEnvironments']
@@ -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({
<EphemeralVmsExperimentalSetting settings={settings} updateSettings={updateSettings} />
<ManagedServersExperimentalSetting settings={settings} updateSettings={updateSettings} />
{hiddenExperimentalUnlocked ? <HiddenExperimentalGroup /> : null}
</div>
)
@@ -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(<ManagedServerRow api={api} environment={environment} onChanged={() => {}} />)
})
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' })
})
})
@@ -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<OrcadManagedRuntimeStatus | null>(null)
const [statusError, setStatusError] = useState<string | null>(null)
const [busy, setBusy] = useState<ManagedServerAction | 'status' | null>(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<void> => {
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 (
<div className="space-y-2 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">{environment.name}</div>
<span className="shrink-0 text-[11px] text-muted-foreground">
{status?.activeVersion ??
translate('auto.components.settings.managedServers.row.notRunning', 'Not running')}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="ml-auto size-7"
disabled={disabled}
aria-label={translate(
'auto.components.settings.managedServers.row.refresh',
'Refresh status'
)}
onClick={() => void loadStatus()}
>
{busy === 'status' ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
</Button>
</div>
{statusError ? <p className="text-xs text-destructive">{statusError}</p> : null}
{status ? (
<div className="space-y-0.5 text-xs text-muted-foreground">
<p>{terminalCensusLabel(status.terminals)}</p>
{recovery ? <p>{recoveryLabel(recovery)}</p> : null}
{status.migration ? (
<p>
{translate(
'auto.components.settings.managedServers.row.migration',
'Migration: {{phase}}',
{
phase: migrationPhaseLabel(status.migration.phase)
}
)}
</p>
) : null}
{status.deferredUpdate ? (
<p>
{translate(
'auto.components.settings.managedServers.row.deferred',
'Update to {{version}} deferred: {{reason}}',
{
version: status.deferredUpdate.candidateVersion,
reason: status.deferredUpdate.reason
}
)}
</p>
) : null}
</div>
) : null}
<div className="flex flex-wrap items-center gap-1">
<Button
type="button"
size="xs"
variant="outline"
disabled={disabled}
onClick={() => void run('update')}
>
{translate('auto.components.settings.managedServers.row.update', 'Update')}
</Button>
{status?.rollbackAvailable ? (
<Button
type="button"
size="xs"
variant="outline"
disabled={disabled}
onClick={() => void run('rollback')}
>
{translate('auto.components.settings.managedServers.row.rollback', 'Roll back')}
</Button>
) : null}
{recovery ? (
<Button
type="button"
size="xs"
variant="outline"
disabled={disabled}
onClick={() => void run('recover')}
>
{translate('auto.components.settings.managedServers.row.recover', 'Recover')}
</Button>
) : null}
{recovery?.operation === 'decommission' ? (
<Button
type="button"
size="xs"
variant="outline"
disabled={disabled}
onClick={() => void run('cancelStop')}
>
{translate('auto.components.settings.managedServers.row.cancelStop', 'Cancel stop')}
</Button>
) : null}
{confirmingStop ? (
<>
<Button
type="button"
size="xs"
variant="destructive"
disabled={disabled}
onClick={() => {
setConfirmingStop(false)
void run('stop')
}}
>
{translate(
'auto.components.settings.managedServers.row.confirmStop',
'Stop and remove server'
)}
</Button>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => setConfirmingStop(false)}
>
{translate('auto.components.settings.managedServers.row.keep', 'Keep running')}
</Button>
</>
) : (
<Button
type="button"
size="xs"
variant="ghost"
disabled={disabled}
onClick={() => setConfirmingStop(true)}
>
{translate('auto.components.settings.managedServers.row.stop', 'Stop…')}
</Button>
)}
</div>
</div>
)
}
@@ -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<GlobalSettings>) => void
}
export function ManagedServersExperimentalSetting({
settings,
updateSettings
}: ManagedServersExperimentalSettingProps): React.JSX.Element {
const entry = getExperimentalSearchEntry().managedServers
const enabled = settings.experimentalManagedServers === true
return (
<SearchableSetting
title={entry.title}
description={entry.description}
keywords={entry.keywords}
className="max-w-none space-y-4 py-2"
id="managed-servers"
>
<div className="flex max-w-3xl items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>{entry.title}</Label>
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
</div>
<SettingsSwitch
checked={enabled}
ariaLabel={translate(
'auto.components.settings.managedServersExperimentalSetting.toggleLabel',
'Toggle Managed servers'
)}
onChange={() => updateSettings({ experimentalManagedServers: !enabled })}
/>
</div>
</SearchableSetting>
)
}
@@ -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<SshTarget[]>([])
const [pending, setPending] = useState<OrcadManagedPendingMigrationRow[]>([])
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<void> => {
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<void> => {
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 (
<div className="space-y-4" id="managed-servers">
<div className="space-y-1">
<h3 className="text-sm font-semibold">
{translate('auto.components.settings.managedServers.title', 'Managed servers')}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.managedServers.description',
'Orca installs and runs a server on an empty SSH host and keeps it up to date.'
)}
</p>
</div>
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-0 flex-1 space-y-1">
<Label>
{translate('auto.components.settings.managedServers.deploy.host', 'SSH host')}
</Label>
<SshTargetSelect
targets={targets}
value={targetId}
onChange={setTargetId}
placeholder={translate(
'auto.components.settings.managedServers.deploy.hostPlaceholder',
'Choose an empty SSH host'
)}
/>
</div>
<div className="min-w-0 flex-1 space-y-1">
<Label>
{translate('auto.components.settings.managedServers.deploy.name', 'Server name')}
</Label>
<Input value={name} onChange={(event) => setName(event.target.value)} />
</div>
<Button
type="button"
disabled={deploying || targetId === '' || name.trim() === ''}
onClick={() => void deploy()}
>
{translate('auto.components.settings.managedServers.deploy.submit', 'Deploy server')}
</Button>
</div>
{pending.length > 0 ? (
<div className="divide-y divide-border rounded-md border border-border">
{pending.map((row) => (
<div key={row.migrationId} className="flex items-center gap-2 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{row.name}</div>
<p className="text-xs text-muted-foreground">{migrationPhaseLabel(row.phase)}</p>
</div>
<Button type="button" size="xs" variant="outline" onClick={() => void resume(row)}>
{translate('auto.components.settings.managedServers.pending.resume', 'Resume')}
</Button>
</div>
))}
</div>
) : null}
{managed.length > 0 ? (
<div className="divide-y divide-border rounded-md border border-border">
{managed.map((environment) => (
<ManagedServerRow
key={environment.id}
api={api}
environment={environment}
onChanged={onChanged}
/>
))}
</div>
) : null}
{paired.length > 0 ? (
<div className="space-y-1">
<Label>
{translate(
'auto.components.settings.managedServers.access.title',
'SSH access for paired servers'
)}
</Label>
<div className="divide-y divide-border rounded-md border border-border">
{paired.map((environment) => (
<RuntimeSshAccessControl
key={environment.id}
api={api}
environment={environment}
targets={targets}
onChanged={onChanged}
/>
))}
</div>
</div>
) : null}
</div>
)
}
@@ -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 ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
const 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> = {}
): 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<HTMLElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(
<MoveToManagedServerDialog
api={managed}
target={target}
onClose={() => {}}
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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)
})
})
@@ -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<OrcadManagedConversionPreview | null>(null)
const [previewError, setPreviewError] = useState<string | null>(null)
const [name, setName] = useState('')
const [confirmed, setConfirmed] = useState(false)
const [phase, setPhase] = useState<Phase>('preview')
const [result, setResult] = useState<OrcadManagedConversionResult | null>(null)
const [runError, setRunError] = useState<string | null>(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<void> => {
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 (
<Dialog
open={target !== null}
onOpenChange={(open) => !open && phase !== 'running' && onClose()}
>
<DialogContent className="max-w-md sm:max-w-md">
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.settings.managedServers.move.title',
'Move to managed server'
)}
</DialogTitle>
<DialogDescription>
{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 ?? '' }
)}
</DialogDescription>
</DialogHeader>
{phase === 'preview' ? (
<div className="space-y-3 text-sm">
{previewError ? <p className="text-xs text-destructive">{previewError}</p> : null}
{!preview && !previewError ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : null}
{preview ? (
<>
<div className="space-y-0.5">
<Label>
{translate('auto.components.settings.managedServers.move.moves', 'What moves')}
</Label>
{conversionMovesSummary(preview.moves).map((line) => (
<p key={line} className="text-xs text-muted-foreground">
{line}
</p>
))}
</div>
{preview.terminals.verdict !== 'exited' ? (
<p className="text-xs text-destructive">{preview.terminals.reason}</p>
) : null}
{preview.blockers.map((blocker) => (
<p key={blocker.code} className="text-xs text-destructive">
{conversionBlockerLabel(blocker)}
</p>
))}
<div className="space-y-1">
<Label>
{translate(
'auto.components.settings.managedServers.deploy.name',
'Server name'
)}
</Label>
<Input value={name} onChange={(event) => setName(event.target.value)} />
</div>
<label className="flex items-start gap-2 text-xs">
<Checkbox
checked={confirmed}
onCheckedChange={(value) => setConfirmed(value === true)}
/>
<span>
{translate(
'auto.components.settings.managedServers.move.confirm',
'I have closed every terminal on this host. Running terminals cannot move and stop the move.'
)}
</span>
</label>
</>
) : null}
</div>
) : null}
{phase === 'running' ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{translate(
'auto.components.settings.managedServers.move.running',
'Installing the server and copying projects. You can close Settings; the move resumes from where it stopped.'
)}
</div>
) : null}
{phase === 'done' ? (
<p className="text-sm">
{runError ??
(result?.outcome === 'converted'
? translate(
'auto.components.settings.managedServers.move.converted',
'Moved. {{name}} now serves these projects.',
{
name: result.environment.name
}
)
: (result?.reason ?? ''))}
</p>
) : null}
<DialogFooter>
{phase === 'preview' ? (
<>
<Button type="button" variant="ghost" onClick={onClose}>
{translate('auto.components.settings.managedServers.move.cancel', 'Cancel')}
</Button>
<Button
type="button"
disabled={blocked || !confirmed || name.trim() === ''}
onClick={() => void move()}
>
{translate('auto.components.settings.managedServers.move.submit', 'Move host')}
</Button>
</>
) : null}
{phase === 'done' ? (
<>
{result?.outcome !== 'converted' ? (
<Button type="button" variant="outline" onClick={() => void move()}>
{translate('auto.components.settings.managedServers.move.resume', 'Resume')}
</Button>
) : null}
<Button type="button" onClick={onClose}>
{translate('auto.components.settings.managedServers.move.close', 'Close')}
</Button>
</>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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 ? (
<ManagedServersSection
environments={environments}
onChanged={() => void loadEnvironments()}
/>
) : null}
{visibleWorkflow === 'connect' ? <RuntimeServerTroubleshooting /> : null}
<RuntimeEnvironmentSwitchDialog
@@ -0,0 +1,105 @@
import { useState } from 'react'
import { toast } from 'sonner'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { SshTarget } from '../../../../shared/ssh-types'
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
import { translate } from '@/i18n/i18n'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { SshTargetSelect } from './SshTargetSelect'
const DEFAULT_REMOTE_PORT = '6768'
type RuntimeSshAccessControlProps = {
api: ManagedOrcadPreloadApi
environment: PublicKnownRuntimeEnvironment
targets: SshTarget[]
onChanged: () => 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<void> => {
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 (
<div className="flex items-center gap-2 px-4 py-3">
<div className="min-w-0 flex-1 truncate text-sm">
{translate('auto.components.settings.managedServers.access.linked', '{{name}} via SSH', {
name: environment.name
})}
</div>
<Button
type="button"
size="xs"
variant="outline"
disabled={busy}
onClick={() => void submit()}
>
{translate('auto.components.settings.managedServers.access.unlink', 'Stop using SSH')}
</Button>
</div>
)
}
const port = Number(remotePort)
const valid = targetId !== '' && Number.isInteger(port) && port >= 1 && port <= 65_535
return (
<div className="flex flex-wrap items-end gap-2 px-4 py-3">
<div className="min-w-0 flex-1 space-y-1">
<Label>{environment.name}</Label>
<SshTargetSelect
targets={targets}
value={targetId}
onChange={setTargetId}
placeholder={translate(
'auto.components.settings.managedServers.access.target',
'Reach through SSH host'
)}
/>
</div>
<div className="w-24 space-y-1">
<Label>
{translate('auto.components.settings.managedServers.access.port', 'Server port')}
</Label>
<Input
value={remotePort}
inputMode="numeric"
onChange={(event) => setRemotePort(event.target.value)}
/>
</div>
<Button type="button" disabled={busy || !valid} onClick={() => void submit()}>
{translate('auto.components.settings.managedServers.access.link', 'Use SSH')}
</Button>
</div>
)
}
@@ -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 ? (
<div className="flex items-center justify-center rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5 text-sm text-muted-foreground">
{translate(
'auto.components.settings.SshPane.c0f1c80166',
'No SSH targets configured.'
)}
</div>
<SshTargetsEmptyState />
) : (
<div className="space-y-2">
{targets.map((target) => (
<SshTargetCard
key={target.id}
target={target}
state={sshConnectionStates.get(target.id)}
testing={testingIds.has(target.id)}
busyAction={busyActionForTarget(target.id)}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onTerminateSessions={(id) =>
requestTerminateSessions({ id, label: target.label })
}
onResetRelay={(id) => requestResetRelay({ id, label: target.label })}
onTest={handleTest}
onEdit={handleEdit}
onRemove={(id) =>
requestRemoveTarget({ id, label: target.label }, requestRemove)
}
/>
<div key={target.id} className="space-y-1">
<SshTargetCard
target={target}
state={sshConnectionStates.get(target.id)}
testing={testingIds.has(target.id)}
busyAction={busyActionForTarget(target.id)}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onTerminateSessions={(id) =>
requestTerminateSessions({ id, label: target.label })
}
onResetRelay={(id) => requestResetRelay({ id, label: target.label })}
onTest={handleTest}
onEdit={handleEdit}
onRemove={(id) =>
requestRemoveTarget({ id, label: target.label }, requestRemove)
}
/>
<SshTargetManagedServerAction target={target} onMoved={loadTargets} />
</div>
))}
</div>
)}
@@ -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 (
<>
<div className="flex justify-end px-1">
<Button type="button" size="xs" variant="ghost" onClick={() => setOpen(true)}>
<ArrowRightLeft />
{translate(
'auto.components.settings.managedServers.move.action',
'Move to managed server'
)}
</Button>
</div>
<MoveToManagedServerDialog
api={api}
target={open ? target : null}
onClose={() => setOpen(false)}
onFinished={() => void onMoved()}
/>
</>
)
}
@@ -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 (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{targets.map((target) => (
<SelectItem key={target.id} value={target.id}>
{target.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
@@ -0,0 +1,9 @@
import { translate } from '@/i18n/i18n'
export function SshTargetsEmptyState(): React.JSX.Element {
return (
<div className="flex items-center justify-center rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5 text-sm text-muted-foreground">
{translate('auto.components.settings.SshPane.c0f1c80166', 'No SSH targets configured.')}
</div>
)
}
@@ -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
}
@@ -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)
}
})
})
@@ -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<OrcadManagedRuntimeStatus['recovery']>
): 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
}
@@ -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'
)
}
}
@@ -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
}
@@ -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'
}))
+2 -5
View File
@@ -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",
+104
View File
@@ -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": {
+1
View File
@@ -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,
+2
View File
@@ -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. */
+27
View File
@@ -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
}