From a7f8618e5f82bbc877acc7ae8787d0fda116526a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 4 Oct 2026 05:16:47 -0700 Subject: [PATCH] feat(cli): stop, update, roll back and recover managed Orca servers from the CLI (#25201) * feat(cli): stop, update, roll back and recover managed Orca servers from the CLI orca environment status|update|rollback|recover|stop|cancel-stop call the same managed-server actions as Settings > Managed servers, over new managedServer.* runtime RPC methods. The desktop main process registers those actions; the runtime advertises managedServer.v1 only then, and the CLI refuses on a runtime without it or one that answers method_not_found. stop requires --yes. * fix(ipc): keep a missing managed-server selector a rejection, not a synchronous throw --------- Co-authored-by: m4air --- src/cli/command-scoped-flag-help.ts | 6 + src/cli/handler-group-manifest.ts | 12 ++ src/cli/handlers/managed-server-format.ts | 27 +++ src/cli/handlers/managed-server.test.ts | 140 +++++++++++++++ src/cli/handlers/managed-server.ts | 160 ++++++++++++++++++ src/cli/specs/index.ts | 2 + src/cli/specs/managed-server.ts | 66 ++++++++ src/main/ipc/managed-orcad-actions.ts | 60 +++++++ .../ipc/orcad-runtime-maintenance-handlers.ts | 89 ++-------- .../managed-server-actions-registry.ts | 32 ++++ src/main/runtime/orca-runtime-get-status.ts | 5 + .../managed-server-capability.spec.ts | 21 +++ src/main/runtime/orca-runtime.test.ts | 1 + src/main/runtime/rpc/methods/index.ts | 2 + .../rpc/methods/managed-server.test.ts | 76 +++++++++ .../runtime/rpc/methods/managed-server.ts | 52 ++++++ src/shared/cli-argument-boundary.ts | 3 +- src/shared/protocol-version.ts | 2 + .../rpc-contract/managed-server-params.ts | 12 ++ .../rpc-params-catalog.generated.ts | 7 + src/shared/runtime-environment-store.ts | 2 +- 21 files changed, 702 insertions(+), 75 deletions(-) create mode 100644 src/cli/handlers/managed-server-format.ts create mode 100644 src/cli/handlers/managed-server.test.ts create mode 100644 src/cli/handlers/managed-server.ts create mode 100644 src/cli/specs/managed-server.ts create mode 100644 src/main/ipc/managed-orcad-actions.ts create mode 100644 src/main/runtime/managed-server-actions-registry.ts create mode 100644 src/main/runtime/orca-runtime-tests/managed-server-capability.spec.ts create mode 100644 src/main/runtime/rpc/methods/managed-server.test.ts create mode 100644 src/main/runtime/rpc/methods/managed-server.ts create mode 100644 src/shared/rpc-contract/managed-server-params.ts diff --git a/src/cli/command-scoped-flag-help.ts b/src/cli/command-scoped-flag-help.ts index d4b3abcfaf9..07a6e4ee94f 100644 --- a/src/cli/command-scoped-flag-help.ts +++ b/src/cli/command-scoped-flag-help.ts @@ -14,6 +14,12 @@ const COMMAND_SCOPED_FLAG_HELP: Record> = { reference: '--reference Print one bundled reference by name', references: '--references List the bundled reference names for a topic' }, + 'environment update': { + force: '--force Restart over running terminals instead of deferring the update' + }, + 'environment stop': { + yes: '--yes Confirm stopping the server and unlinking it from this machine' + }, 'file open': { focus: FILE_OPEN_FOCUS_HELP }, diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index b4f0473ee1e..e4bff998549 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -217,6 +217,18 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ ], load: async () => (await import('./handlers/environment.js')).ENVIRONMENT_HANDLERS }, + { + name: 'managed-server', + keys: [ + 'environment status', + 'environment update', + 'environment rollback', + 'environment recover', + 'environment stop', + 'environment cancel-stop' + ], + load: async () => (await import('./handlers/managed-server.js')).MANAGED_SERVER_HANDLERS + }, { name: 'linear', keys: [ diff --git a/src/cli/handlers/managed-server-format.ts b/src/cli/handlers/managed-server-format.ts new file mode 100644 index 00000000000..594a47ef79c --- /dev/null +++ b/src/cli/handlers/managed-server-format.ts @@ -0,0 +1,27 @@ +import type { OrcadManagedRuntimeStatus } from '../../shared/orcad-managed-runtime' + +function count(value: number | null): string { + return value === null ? 'unverifiable' : String(value) +} + +export function formatManagedServerStatus(status: OrcadManagedRuntimeStatus): string { + const lines = [ + `Active version: ${status.activeVersion ?? 'none'}`, + `Previous version: ${status.previousVersion ?? 'none'}${status.rollbackAvailable ? ' (rollback available)' : ''}`, + `Live terminals: ${count(status.terminals.liveSessions)}` + ] + if (status.recovery) { + lines.push( + `Interrupted ${status.recovery.operation} of ${status.recovery.version} (${status.recovery.phase}); run \`orca environment recover\`.` + ) + } + if (status.migration) { + lines.push(`Migration into this server: ${status.migration.phase}`) + } + if (status.deferredUpdate) { + lines.push( + `Deferred update to ${status.deferredUpdate.candidateVersion}: ${status.deferredUpdate.reason}` + ) + } + return lines.join('\n') +} diff --git a/src/cli/handlers/managed-server.test.ts b/src/cli/handlers/managed-server.test.ts new file mode 100644 index 00000000000..416c3163d65 --- /dev/null +++ b/src/cli/handlers/managed-server.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MANAGED_SERVER_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { RuntimeClientError } from '../runtime-client' +import { MANAGED_SERVER_HANDLERS } from './managed-server' + +function envelope(result: unknown) { + return { id: 'r', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function client(result: unknown, capabilities = [MANAGED_SERVER_RUNTIME_CAPABILITY]) { + return vi.fn(async (method: string) => + method === 'status.get' ? envelope({ capabilities }) : envelope(result) + ) +} + +async function run( + command: string, + call: ReturnType, + flags: [string, string | boolean][] +) { + const handler = MANAGED_SERVER_HANDLERS[command] + if (!handler) { + throw new Error(`no handler for ${command}`) + } + await handler({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers only call client.call. + client: { call } as never, + cwd: '/tmp', + flags: new Map([['environment', 'build-box'], ...flags]), + json: false + }) +} + +afterEach(() => vi.restoreAllMocks()) + +describe('managed server CLI verbs', () => { + it('refuses on a runtime that does not advertise managed servers, before calling the action', async () => { + const call = client({ outcome: 'none' }, []) + await expect(run('environment recover', call, [])).rejects.toMatchObject({ + code: 'incompatible_runtime' + }) + expect(call).toHaveBeenCalledTimes(1) + }) + + it('reads an older runtime’s method_not_found as the same refusal', async () => { + const call = vi.fn(async (method: string) => { + if (method === 'status.get') { + return envelope({ capabilities: [MANAGED_SERVER_RUNTIME_CAPABILITY] }) + } + throw new RuntimeClientError('method_not_found', 'Unknown method') + }) + await expect(run('environment status', call, [])).rejects.toMatchObject({ + code: 'incompatible_runtime' + }) + }) + + it('needs --yes before stopping, and then calls the same stop the settings use', async () => { + const stopped = { + outcome: 'unlinked', + verdict: 'exited', + environmentId: 'env-1', + sshTargetId: 'ssh-1', + stoppedVersion: '1.0.0', + retirement: null + } + const unconfirmed = client(stopped) + await expect(run('environment stop', unconfirmed, [])).rejects.toMatchObject({ + code: 'confirmation_required' + }) + expect(unconfirmed).not.toHaveBeenCalled() + + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const confirmed = client(stopped) + await run('environment stop', confirmed, [['yes', true]]) + expect(confirmed).toHaveBeenCalledWith('managedServer.stop', { selector: 'build-box' }) + expect(log).toHaveBeenCalledWith('Stopped build-box and unlinked it from this machine.') + }) + + it('fails the command with the refusal, so scripts see a non-zero exit', async () => { + const refusal = { + outcome: 'refused', + verdict: 'live', + code: 'orcad_stop_active_environment', + reason: 'Choose another Active Server in Advanced before stopping this server.' + } + await expect(run('environment stop', client(refusal), [['yes', true]])).rejects.toMatchObject({ + code: 'managed_server_refused', + message: refusal.reason, + data: refusal + }) + }) + + it('passes --force to an update, and reports a deferred one as unsettled', async () => { + const call = client({ + outcome: 'deferred', + candidateVersion: '2.0.0', + code: 'orcad_update_terminals_running', + reason: 'Terminals are running.' + }) + await expect(run('environment update', call, [])).rejects.toMatchObject({ + code: 'managed_server_deferred' + }) + expect(call).toHaveBeenCalledWith('managedServer.update', { + selector: 'build-box', + force: false + }) + + vi.spyOn(console, 'log').mockImplementation(() => undefined) + const forced = client({ + outcome: 'updated', + environment: { name: 'build-box' }, + activeVersion: '2.0.0' + }) + await run('environment update', forced, [['force', true]]) + expect(forced).toHaveBeenCalledWith('managedServer.update', { + selector: 'build-box', + force: true + }) + }) + + it('prints a readable status', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + await run( + 'environment status', + client({ + activeVersion: '1.0.0', + previousVersion: '0.9.0', + rollbackAvailable: true, + recovery: null, + terminals: { liveSessions: null }, + migration: null, + deferredUpdate: null + }), + [] + ) + expect(log.mock.calls[0]?.[0]).toBe( + 'Active version: 1.0.0\nPrevious version: 0.9.0 (rollback available)\nLive terminals: unverifiable' + ) + }) +}) diff --git a/src/cli/handlers/managed-server.ts b/src/cli/handlers/managed-server.ts new file mode 100644 index 00000000000..1fda01cd4be --- /dev/null +++ b/src/cli/handlers/managed-server.ts @@ -0,0 +1,160 @@ +/** + * `orca environment status|update|rollback|recover|stop|cancel-stop`: the Managed servers + * actions over runtime RPC. Each call is gated on the runtime's managedServer.v1 capability, and + * an older runtime's method_not_found reads the same as a missing capability. + */ +import type { + OrcadManagedCancelStopResult, + OrcadManagedDeployResult, + OrcadManagedRecoveryResult, + OrcadManagedRollbackResult, + OrcadManagedRuntimeStatus, + OrcadManagedStopResult +} from '../../shared/orcad-managed-runtime' +import { MANAGED_SERVER_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { RuntimeStatus } from '../../shared/runtime-types' +import type { CommandHandler, HandlerContext } from '../dispatch' +import { getRequiredStringFlag } from '../flags' +import { printResult } from '../format' +import { RuntimeClientError, type RuntimeRpcSuccess } from '../runtime-client' +import { formatManagedServerStatus } from './managed-server-format' + +const UNSUPPORTED_MESSAGE = + 'This Orca runtime cannot manage servers over SSH. Run this on the computer whose Orca desktop app deployed the server, after updating Orca there.' + +// Outcomes that changed nothing, or left work unfinished; they fail the command with the result. +const UNSETTLED_OUTCOMES = new Set(['refused', 'deferred', 'failed', 'pending']) + +function unsupported(): RuntimeClientError { + return new RuntimeClientError('incompatible_runtime', UNSUPPORTED_MESSAGE) +} + +async function callManagedServer( + { client }: HandlerContext, + method: string, + params: Record +): Promise> { + const status = await client.call('status.get') + if (!status.result.capabilities?.includes(MANAGED_SERVER_RUNTIME_CAPABILITY)) { + throw unsupported() + } + try { + return await client.call(method, params) + } catch (error) { + if (error instanceof RuntimeClientError && error.code === 'method_not_found') { + throw unsupported() + } + throw error + } +} + +function selectorOf(context: HandlerContext): { selector: string } { + return { selector: getRequiredStringFlag(context.flags, 'environment') } +} + +type Outcome = { outcome: string; code?: string; reason?: string } + +/** Prints a settled result; throws an unsettled one so scripts see a non-zero exit. */ +function report( + response: RuntimeRpcSuccess, + json: boolean, + done: (result: TResult) => string +): void { + const result = response.result + if (UNSETTLED_OUTCOMES.has(result.outcome)) { + throw new RuntimeClientError( + `managed_server_${result.outcome}`, + result.reason ?? `The managed Orca server action was ${result.outcome}.`, + result + ) + } + printResult(response, json, done) +} + +export const MANAGED_SERVER_HANDLERS: Record = { + 'environment status': async (context) => { + const response = await callManagedServer( + context, + 'managedServer.status', + selectorOf(context) + ) + printResult(response, context.json, formatManagedServerStatus) + }, + 'environment update': async (context) => { + const params = { ...selectorOf(context), force: context.flags.get('force') === true } + const response = await callManagedServer( + context, + 'managedServer.update', + params + ) + report(response, context.json, (result) => + result.outcome === 'deferred' + ? result.reason + : result.outcome === 'already-current' + ? `Already on ${result.activeVersion}.` + : `Updated ${result.environment.name} to ${result.activeVersion}.` + ) + }, + 'environment rollback': async (context) => { + const response = await callManagedServer( + context, + 'managedServer.rollback', + selectorOf(context) + ) + report(response, context.json, (result) => + result.outcome === 'rolled-back' + ? `Rolled ${result.environment.name} back to ${result.activeVersion}.` + : result.reason + ) + }, + 'environment recover': async (context) => { + const response = await callManagedServer( + context, + 'managedServer.recover', + selectorOf(context) + ) + report(response, context.json, (result) => + result.outcome === 'recovered' + ? `Recovered ${result.environment.name} (${result.resolution}); active version ${result.activeVersion ?? 'none'}.` + : 'Nothing to recover.' + ) + }, + 'environment stop': async (context) => { + const params = selectorOf(context) + if (context.flags.get('yes') !== true) { + throw new RuntimeClientError( + 'confirmation_required', + `Stopping ${params.selector} ends its terminals and unlinks it from this machine. Re-run with --yes to confirm.` + ) + } + const response = await callManagedServer( + context, + 'managedServer.stop', + params + ) + report(response, context.json, (result) => + result.outcome === 'unlinked' + ? `Stopped ${params.selector} and unlinked it from this machine.` + : result.reason + ) + }, + 'environment cancel-stop': async (context) => { + const response = await callManagedServer( + context, + 'managedServer.cancelStop', + selectorOf(context) + ) + report(response, context.json, (result) => { + switch (result.outcome) { + case 'canceled': + return `Stop withdrawn; the server keeps serving ${result.activeVersion}.` + case 'already-stopped': + return 'orcad had already exited. Run `orca environment stop --yes` to unlink it.' + case 'none': + return 'No stop is pending.' + case 'refused': + return result.reason + } + }) + } +} diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index b817749f425..8d72b3b53b9 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -9,6 +9,7 @@ import { PROJECT_COMMAND_SPECS } from './project' import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' import { COMPUTER_COMMAND_SPECS } from './computer' import { ENVIRONMENT_COMMAND_SPECS } from './environment' +import { MANAGED_SERVER_COMMAND_SPECS } from './managed-server' import { AGENT_HOOK_COMMAND_SPECS } from './agent-hooks' import { DIAGNOSTICS_COMMAND_SPECS } from './diagnostics' import { EMULATOR_COMMAND_SPECS } from './emulator' @@ -35,6 +36,7 @@ export const COMMAND_SPECS: CommandSpec[] = [ ...DIAGNOSTICS_COMMAND_SPECS, ...INTROSPECTION_COMMAND_SPECS, ...ENVIRONMENT_COMMAND_SPECS, + ...MANAGED_SERVER_COMMAND_SPECS, ...LINEAR_COMMAND_SPECS, ...VM_COMMAND_SPECS, ...EMULATOR_COMMAND_SPECS, diff --git a/src/cli/specs/managed-server.ts b/src/cli/specs/managed-server.ts new file mode 100644 index 00000000000..f41e31e8032 --- /dev/null +++ b/src/cli/specs/managed-server.ts @@ -0,0 +1,66 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +const SELECTOR_NOTE = + '--environment names a managed Orca server this machine deployed over SSH (see `orca environment list`); it is a selector here, not a routing flag.' +const DESKTOP_NOTE = + 'Runs on this machine’s Orca desktop app, the same action as Settings > Managed servers. A runtime without the SSH registry (headless `orca serve`) refuses it.' + +export const MANAGED_SERVER_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['environment', 'status'], + summary: 'Show a managed Orca server’s active version, terminals and pending work', + usage: 'orca environment status --environment [--json]', + allowedFlags: [...GLOBAL_FLAGS], + notes: [SELECTOR_NOTE, DESKTOP_NOTE], + examples: ['orca environment status --environment build-box'] + }, + { + path: ['environment', 'update'], + summary: 'Update a managed Orca server to this build’s version', + usage: 'orca environment update --environment [--force] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'force'], + notes: [ + 'Without --force, an update that would restart over running terminals is deferred and reported, not applied.', + SELECTOR_NOTE, + DESKTOP_NOTE + ], + examples: ['orca environment update --environment build-box'] + }, + { + path: ['environment', 'rollback'], + summary: 'Roll a managed Orca server back to its previous version', + usage: 'orca environment rollback --environment [--json]', + allowedFlags: [...GLOBAL_FLAGS], + notes: [SELECTOR_NOTE, DESKTOP_NOTE] + }, + { + path: ['environment', 'recover'], + summary: 'Finish or undo a managed Orca server’s interrupted update, rollback or stop', + usage: 'orca environment recover --environment [--json]', + allowedFlags: [...GLOBAL_FLAGS], + notes: [SELECTOR_NOTE, DESKTOP_NOTE] + }, + { + path: ['environment', 'stop'], + destructive: true, + summary: 'Stop a managed Orca server and unlink it from this machine (decommission)', + usage: 'orca environment stop --environment --yes [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'yes'], + notes: [ + 'Stops orcad on the SSH host and, once the host proves it exited, removes the server from this machine. Its terminals end. Requires --yes.', + 'If the host cannot prove orcad exited, the server stays linked and the refusal says why; nothing is removed on a guess.', + 'Pick another Active Server first if this one is active. `orca environment cancel-stop` withdraws a stop orcad has not acted on yet.', + SELECTOR_NOTE, + DESKTOP_NOTE + ], + examples: ['orca environment stop --environment build-box --yes'] + }, + { + path: ['environment', 'cancel-stop'], + summary: 'Withdraw a managed Orca server stop that orcad has not acted on yet', + usage: 'orca environment cancel-stop --environment [--json]', + allowedFlags: [...GLOBAL_FLAGS], + notes: [SELECTOR_NOTE, DESKTOP_NOTE] + } +] diff --git a/src/main/ipc/managed-orcad-actions.ts b/src/main/ipc/managed-orcad-actions.ts new file mode 100644 index 00000000000..0e5548be6ac --- /dev/null +++ b/src/main/ipc/managed-orcad-actions.ts @@ -0,0 +1,60 @@ +/** The managed-server actions behind both the Managed servers settings and runtime RPC. */ +import { + cancelManagedOrcadStop, + getManagedOrcadRuntimeStatus, + recoverManagedOrcadEnvironment, + rollbackManagedOrcadEnvironment, + stopManagedOrcadEnvironment, + updateManagedOrcadEnvironment +} from '../ssh/orcad-runtime-lifecycle' +import type { ManagedServerActions } from '../runtime/managed-server-actions-registry' +import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup' + +export type ManagedOrcadActionOptions = { + getUserDataPath: () => string + getActiveEnvironmentId: () => string | null | undefined + invalidateTransport: (environmentId: string) => Promise | void +} + +export function createManagedOrcadActions( + options: ManagedOrcadActionOptions +): ManagedServerActions { + const userDataPath = options.getUserDataPath + return { + status: (selector) => getManagedOrcadRuntimeStatus(userDataPath(), selector), + update: async (selector, force) => { + const result = await updateManagedOrcadEnvironment(userDataPath(), { selector, force }) + // Why: a restarted orcad drops the old connection; reconnect on the new one. + if (result.outcome === 'updated') { + await options.invalidateTransport(result.environment.id) + } + return result + }, + rollback: async (selector) => { + const result = await rollbackManagedOrcadEnvironment(userDataPath(), { selector }) + if (result.outcome === 'rolled-back') { + await options.invalidateTransport(result.environment.id) + } + return result + }, + recover: async (selector) => { + const result = await recoverManagedOrcadEnvironment(userDataPath(), { selector }) + if (result.outcome === 'recovered' && result.activeVersion) { + await options.invalidateTransport(result.environment.id) + } + return result + }, + stop: (selector) => + stopManagedOrcadEnvironment( + userDataPath(), + { selector }, + { + isActiveEnvironment: (environmentId) => + options.getActiveEnvironmentId() === environmentId, + retireLocalState: (environmentId) => + retireRemovedRuntimeEnvironment(environmentId, options.invalidateTransport) + } + ), + cancelStop: (selector) => cancelManagedOrcadStop(userDataPath(), { selector }) + } +} diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.ts index 0f16da4de07..53044e6869b 100644 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.ts +++ b/src/main/ipc/orcad-runtime-maintenance-handlers.ts @@ -1,86 +1,29 @@ import { ipcMain } from 'electron' -import type { - OrcadManagedCancelStopResult, - OrcadManagedDeployResult, - OrcadManagedRecoveryResult, - OrcadManagedRollbackResult, - OrcadManagedStopResult -} from '../../shared/orcad-managed-runtime' -import { - cancelManagedOrcadStop, - recoverManagedOrcadEnvironment, - rollbackManagedOrcadEnvironment, - stopManagedOrcadEnvironment, - updateManagedOrcadEnvironment -} from '../ssh/orcad-runtime-lifecycle' -import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup' +import { registerManagedServerActions } from '../runtime/managed-server-actions-registry' +import { createManagedOrcadActions, type ManagedOrcadActionOptions } from './managed-orcad-actions' import { requiredString } from './orcad-runtime-lifecycle-handlers' -export function registerOrcadRuntimeMaintenanceHandlers(options: { - getUserDataPath: () => string - getActiveEnvironmentId: () => string | null | undefined - invalidateTransport: (environmentId: string) => Promise | void -}): void { +export function registerOrcadRuntimeMaintenanceHandlers(options: ManagedOrcadActionOptions): void { + const actions = createManagedOrcadActions(options) + // Why: the CLI reaches these same actions over runtime RPC (managedServer.*). + registerManagedServerActions(actions) ipcMain.handle( 'runtimeEnvironments:updateOrcad', - async ( - _event, - args: { selector: string; force?: boolean } - ): Promise => { - const result = await updateManagedOrcadEnvironment(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server'), - force: args?.force === true - }) - // Why: a restarted orcad drops the old connection; reconnect on the new one. - if (result.outcome === 'updated') { - await options.invalidateTransport(result.environment.id) - } - return result - } + async (_event, args: { selector: string; force?: boolean }) => + actions.update(requiredString(args?.selector, 'Server'), args?.force === true) ) - ipcMain.handle( - 'runtimeEnvironments:rollbackOrcad', - async (_event, args: { selector: string }): Promise => { - const result = await rollbackManagedOrcadEnvironment(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server') - }) - if (result.outcome === 'rolled-back') { - await options.invalidateTransport(result.environment.id) - } - return result - } + ipcMain.handle('runtimeEnvironments:rollbackOrcad', async (_event, args: { selector: string }) => + actions.rollback(requiredString(args?.selector, 'Server')) ) - ipcMain.handle( - 'runtimeEnvironments:recoverOrcad', - async (_event, args: { selector: string }): Promise => { - const result = await recoverManagedOrcadEnvironment(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server') - }) - if (result.outcome === 'recovered' && result.activeVersion) { - await options.invalidateTransport(result.environment.id) - } - return result - } + ipcMain.handle('runtimeEnvironments:recoverOrcad', async (_event, args: { selector: string }) => + actions.recover(requiredString(args?.selector, 'Server')) ) - ipcMain.handle( - 'runtimeEnvironments:stopOrcad', - async (_event, args: { selector: string }): Promise => - stopManagedOrcadEnvironment( - options.getUserDataPath(), - { selector: requiredString(args?.selector, 'Server') }, - { - isActiveEnvironment: (environmentId) => - options.getActiveEnvironmentId() === environmentId, - retireLocalState: (environmentId) => - retireRemovedRuntimeEnvironment(environmentId, options.invalidateTransport) - } - ) + ipcMain.handle('runtimeEnvironments:stopOrcad', async (_event, args: { selector: string }) => + actions.stop(requiredString(args?.selector, 'Server')) ) ipcMain.handle( 'runtimeEnvironments:cancelOrcadStop', - async (_event, args: { selector: string }): Promise => - cancelManagedOrcadStop(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server') - }) + async (_event, args: { selector: string }) => + actions.cancelStop(requiredString(args?.selector, 'Server')) ) } diff --git a/src/main/runtime/managed-server-actions-registry.ts b/src/main/runtime/managed-server-actions-registry.ts new file mode 100644 index 00000000000..499a8692c86 --- /dev/null +++ b/src/main/runtime/managed-server-actions-registry.ts @@ -0,0 +1,32 @@ +/** + * The managed-server actions the Managed servers settings run, published by the desktop main + * process so runtime RPC can offer the same ones. A runtime with none registered (headless + * `orca serve`) has no SSH registry to manage servers from, and does not advertise them. + */ +import type { + OrcadManagedCancelStopResult, + OrcadManagedDeployResult, + OrcadManagedRecoveryResult, + OrcadManagedRollbackResult, + OrcadManagedRuntimeStatus, + OrcadManagedStopResult +} from '../../shared/orcad-managed-runtime' + +export type ManagedServerActions = { + status: (selector: string) => Promise + update: (selector: string, force: boolean) => Promise + rollback: (selector: string) => Promise + recover: (selector: string) => Promise + stop: (selector: string) => Promise + cancelStop: (selector: string) => Promise +} + +let registered: ManagedServerActions | null = null + +export function registerManagedServerActions(actions: ManagedServerActions | null): void { + registered = actions +} + +export function getManagedServerActions(): ManagedServerActions | null { + return registered +} diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index 39b8dbf0dcf..bb8be8b4cf8 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -10,6 +10,7 @@ import { BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, BROWSER_HEADLESS_RUNTIME_CAPABILITY, BROWSER_IDENTITY_RUNTIME_CAPABILITY, + MANAGED_SERVER_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, RUNTIME_CAPABILITIES, @@ -38,6 +39,7 @@ import { parsePaneKey } from '../../shared/stable-pane-id' import { wakeFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade-wake' import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' import { MACHINE_NAME_PUBLISH_WAIT_MS } from './runtime-machine-name' +import { getManagedServerActions } from './managed-server-actions-registry' type RuntimeStatusHost = { getAvailableAuthoritativeWindow(): unknown @@ -107,6 +109,9 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { if (canBrowse) { capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY) } + if (getManagedServerActions()) { + capabilities.push(MANAGED_SERVER_RUNTIME_CAPABILITY) + } // Why not a static capability: dev trees and `orca serve` installs may carry no // out/mobile-web, and advertising a bundle this install cannot produce would promise a // download that only ever answers mobile_web_bundle_unavailable. diff --git a/src/main/runtime/orca-runtime-tests/managed-server-capability.spec.ts b/src/main/runtime/orca-runtime-tests/managed-server-capability.spec.ts new file mode 100644 index 00000000000..a895c8e63da --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/managed-server-capability.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { createRuntime } from '../orca-runtime-test-fixtures.spec' +import { + registerManagedServerActions, + type ManagedServerActions +} from '../managed-server-actions-registry' + +describe('managed server capability', () => { + // A runtime without the desktop's SSH registry must not point clients at methods that refuse. + it('is advertised only where the desktop registered the managed-server actions', () => { + registerManagedServerActions(null) + expect(createRuntime().getStatus().capabilities).not.toContain('managedServer.v1') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: getStatus only checks that actions are registered. + registerManagedServerActions({} as ManagedServerActions) + try { + expect(createRuntime().getStatus().capabilities).toContain('managedServer.v1') + } finally { + registerManagedServerActions(null) + } + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 62ea5edf24a..8e60574af30 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -12,6 +12,7 @@ await import('./orca-runtime-tests/runtime-availability.spec') await import('./orca-runtime-tests/browser-capabilities.spec') await import('./orca-runtime-tests/browser-capabilities-part-02.spec') await import('./orca-runtime-tests/mobile-web-bundle-capability.spec') +await import('./orca-runtime-tests/managed-server-capability.spec') await import('./orca-runtime-tests/window-authority.spec') await import('./orca-runtime-tests/terminal-handles.spec') await import('./orca-runtime-tests/terminal-handles-part-02.spec') diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index d23c2d36675..509f922cc19 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -33,6 +33,7 @@ import { LINEAR_METHODS } from './linear' import { LINEAR_AGENT_ACCESS_METHODS } from './linear-agent-access' import { JIRA_METHODS } from './jira' import { SSH_METHODS } from './ssh' +import { MANAGED_SERVER_METHODS } from './managed-server' import { SPEECH_METHODS } from './speech' import { CLIENT_UI_METHODS } from './client-ui' import { CLIENT_EVENT_METHODS } from './client-events' @@ -96,6 +97,7 @@ export const ALL_RPC_METHODS = [ ...LINEAR_AGENT_ACCESS_METHODS, ...JIRA_METHODS, ...SSH_METHODS, + ...MANAGED_SERVER_METHODS, ...SPEECH_METHODS, ...WORKSPACE_PORT_METHODS, ...PLUGIN_METHODS, diff --git a/src/main/runtime/rpc/methods/managed-server.test.ts b/src/main/runtime/rpc/methods/managed-server.test.ts new file mode 100644 index 00000000000..599c6e60615 --- /dev/null +++ b/src/main/runtime/rpc/methods/managed-server.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { + registerManagedServerActions, + type ManagedServerActions +} from '../../managed-server-actions-registry' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import { MANAGED_SERVER_METHODS } from './managed-server' + +function request(method: string, params?: unknown): RpcRequest { + return { id: 'managed-1', authToken: 'token', method, params } +} + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers read no runtime member; only the reply envelope needs getRuntimeId. +const RUNTIME = { getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService + +function dispatcher(): RpcDispatcher { + return new RpcDispatcher({ runtime: RUNTIME, methods: MANAGED_SERVER_METHODS }) +} + +function actions(): ManagedServerActions { + return { + status: vi.fn(), + update: vi.fn(async () => ({ + outcome: 'deferred' as const, + candidateVersion: '2', + code: 'c', + reason: 'r' + })), + rollback: vi.fn(), + recover: vi.fn(async () => ({ outcome: 'none' as const })), + stop: vi.fn(async () => ({ + outcome: 'refused' as const, + verdict: 'live' as const, + code: 'c', + reason: 'r' + })), + cancelStop: vi.fn() + } +} + +afterEach(() => registerManagedServerActions(null)) + +describe('managed server RPC', () => { + it('refuses where no desktop runtime registered the actions', async () => { + const response = await dispatcher().dispatch( + request('managedServer.stop', { selector: 'build-box' }) + ) + expect(response).toMatchObject({ ok: false }) + }) + + it('forwards to the same actions the settings run', async () => { + const registered = actions() + registerManagedServerActions(registered) + await expect( + dispatcher().dispatch(request('managedServer.stop', { selector: 'build-box' })) + ).resolves.toMatchObject({ ok: true, result: { outcome: 'refused' } }) + expect(registered.stop).toHaveBeenCalledWith('build-box') + + await dispatcher().dispatch(request('managedServer.update', { selector: 'build-box' })) + expect(registered.update).toHaveBeenCalledWith('build-box', false) + await dispatcher().dispatch( + request('managedServer.update', { selector: 'build-box', force: true }) + ) + expect(registered.update).toHaveBeenLastCalledWith('build-box', true) + }) + + it('rejects a missing selector before reaching an action', async () => { + const registered = actions() + registerManagedServerActions(registered) + const response = await dispatcher().dispatch(request('managedServer.recover', {})) + expect(response).toMatchObject({ ok: false }) + expect(registered.recover).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/managed-server.ts b/src/main/runtime/rpc/methods/managed-server.ts new file mode 100644 index 00000000000..89b4e22b956 --- /dev/null +++ b/src/main/runtime/rpc/methods/managed-server.ts @@ -0,0 +1,52 @@ +import { + ManagedServerSelector, + ManagedServerUpdate +} from '../../../../shared/rpc-contract/managed-server-params' +import { + getManagedServerActions, + type ManagedServerActions +} from '../../managed-server-actions-registry' +import { defineMethod } from '../core' + +// Why a refusal and not a crash: only the desktop main process registers these actions, and a +// client must read their absence the same way it reads an older host's method_not_found. +function actions(): ManagedServerActions { + const registered = getManagedServerActions() + if (!registered) { + throw new Error('managed_server_unavailable') + } + return registered +} + +export const MANAGED_SERVER_METHODS = [ + defineMethod({ + name: 'managedServer.status', + params: ManagedServerSelector, + handler: ({ selector }) => actions().status(selector) + }), + defineMethod({ + name: 'managedServer.update', + params: ManagedServerUpdate, + handler: ({ selector, force }) => actions().update(selector, force === true) + }), + defineMethod({ + name: 'managedServer.rollback', + params: ManagedServerSelector, + handler: ({ selector }) => actions().rollback(selector) + }), + defineMethod({ + name: 'managedServer.recover', + params: ManagedServerSelector, + handler: ({ selector }) => actions().recover(selector) + }), + defineMethod({ + name: 'managedServer.stop', + params: ManagedServerSelector, + handler: ({ selector }) => actions().stop(selector) + }), + defineMethod({ + name: 'managedServer.cancelStop', + params: ManagedServerSelector, + handler: ({ selector }) => actions().cancelStop(selector) + }) +] as const diff --git a/src/shared/cli-argument-boundary.ts b/src/shared/cli-argument-boundary.ts index a1d02eb8ece..a4061bf46e3 100644 --- a/src/shared/cli-argument-boundary.ts +++ b/src/shared/cli-argument-boundary.ts @@ -50,7 +50,8 @@ export const CLI_BOOLEAN_FLAGS = new Set([ 'text-stdin', 'unread', 'value-stdin', - 'wait' + 'wait', + 'yes' ]) function commandPathStartsAt( diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 5c4947617be..553b50f622b 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -95,6 +95,8 @@ export const AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY = 'aiVault.session-title // clients never fall back to a local desktop browser tab for a remote-owned page. export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const export const BROWSER_IDENTITY_RUNTIME_CAPABILITY = 'browser.identity.v1' as const +// Why conditional: only a desktop runtime holds the SSH registry managed servers are run from. +export const MANAGED_SERVER_RUNTIME_CAPABILITY = 'managedServer.v1' as const export const BROWSER_SCREENCAST_RUNTIME_CAPABILITY = 'browser.screencast.v1' as const export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate-trust.v1' as const // Why: older hosts discard browser.tabCreate's page field, so clients may only diff --git a/src/shared/rpc-contract/managed-server-params.ts b/src/shared/rpc-contract/managed-server-params.ts new file mode 100644 index 00000000000..f923e92fddc --- /dev/null +++ b/src/shared/rpc-contract/managed-server-params.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { requiredString } from './rpc-param-primitives' + +/** A managed Orca server on this host, by environment name or id. */ +export const ManagedServerSelector = z.object({ + selector: requiredString('Missing required --environment') +}) + +export const ManagedServerUpdate = ManagedServerSelector.extend({ + // Restarts over live terminals when the update would otherwise defer. + force: z.boolean().optional() +}) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index ea165813321..c840454fff7 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -354,6 +354,7 @@ import { WorkspaceSelection } from './linear-params' import { CreateProject } from './linear-project-create-params' +import { ManagedServerSelector, ManagedServerUpdate } from './managed-server-params' import { NativeChatSession, NativeChatUnsubscribe } from './native-chat-params' import { NotificationGetMissedSinceParams, @@ -990,6 +991,12 @@ export const RPC_PARAMS_BY_METHOD = { 'linear.teamStates': TeamId, 'linear.testConnection': WorkspaceSelection, 'linear.updateIssue': IssueUpdateOfLinearParams, + 'managedServer.cancelStop': ManagedServerSelector, + 'managedServer.recover': ManagedServerSelector, + 'managedServer.rollback': ManagedServerSelector, + 'managedServer.status': ManagedServerSelector, + 'managedServer.stop': ManagedServerSelector, + 'managedServer.update': ManagedServerUpdate, 'markdown.readTab': ActivateTab, 'markdown.saveTab': SaveMarkdownTab, 'mobileWeb.bundle.chunk': MobileWebBundleChunkParamsSchema, diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index b4e2ac83d22..6c591510cb9 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -231,7 +231,7 @@ function assertNoIndependentSshAccess(environment: KnownRuntimeEnvironment): voi if (environment.orcadDeployment) { throw new RuntimeEnvironmentStoreError( 'invalid_argument', - 'This server is managed by Orca over SSH; its pairing cannot be replaced or removed here.' + 'This server is managed by Orca over SSH; its pairing cannot be replaced or removed here. To decommission it, run `orca environment stop --environment --yes` or use Settings > Managed servers.' ) } if (environment.sshAccess || environment.pendingSshAccessOperation) {