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 <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-04 05:16:47 -07:00
committed by GitHub
co-authored by m4air
parent ce5c58f0d2
commit a7f8618e5f
21 changed files with 702 additions and 75 deletions
+6
View File
@@ -14,6 +14,12 @@ const COMMAND_SCOPED_FLAG_HELP: Record<string, Record<string, string>> = {
reference: '--reference <name> 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
},
+12
View File
@@ -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: [
+27
View File
@@ -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')
}
+140
View File
@@ -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<typeof client>,
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'
)
})
})
+160
View File
@@ -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<TResult>(
{ client }: HandlerContext,
method: string,
params: Record<string, unknown>
): Promise<RuntimeRpcSuccess<TResult>> {
const status = await client.call<RuntimeStatus>('status.get')
if (!status.result.capabilities?.includes(MANAGED_SERVER_RUNTIME_CAPABILITY)) {
throw unsupported()
}
try {
return await client.call<TResult>(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<TResult extends Outcome>(
response: RuntimeRpcSuccess<TResult>,
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<string, CommandHandler> = {
'environment status': async (context) => {
const response = await callManagedServer<OrcadManagedRuntimeStatus>(
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<OrcadManagedDeployResult>(
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<OrcadManagedRollbackResult>(
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<OrcadManagedRecoveryResult>(
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<OrcadManagedStopResult>(
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<OrcadManagedCancelStopResult>(
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
}
})
}
}
+2
View File
@@ -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,
+66
View File
@@ -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 <selector> [--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 <selector> [--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 <selector> [--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 <selector> [--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 <selector> --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 <selector> [--json]',
allowedFlags: [...GLOBAL_FLAGS],
notes: [SELECTOR_NOTE, DESKTOP_NOTE]
}
]
+60
View File
@@ -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> | 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 })
}
}
@@ -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
}): 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<OrcadManagedDeployResult> => {
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<OrcadManagedRollbackResult> => {
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<OrcadManagedRecoveryResult> => {
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<OrcadManagedStopResult> =>
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<OrcadManagedCancelStopResult> =>
cancelManagedOrcadStop(options.getUserDataPath(), {
selector: requiredString(args?.selector, 'Server')
})
async (_event, args: { selector: string }) =>
actions.cancelStop(requiredString(args?.selector, 'Server'))
)
}
@@ -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<OrcadManagedRuntimeStatus>
update: (selector: string, force: boolean) => Promise<OrcadManagedDeployResult>
rollback: (selector: string) => Promise<OrcadManagedRollbackResult>
recover: (selector: string) => Promise<OrcadManagedRecoveryResult>
stop: (selector: string) => Promise<OrcadManagedStopResult>
cancelStop: (selector: string) => Promise<OrcadManagedCancelStopResult>
}
let registered: ManagedServerActions | null = null
export function registerManagedServerActions(actions: ManagedServerActions | null): void {
registered = actions
}
export function getManagedServerActions(): ManagedServerActions | null {
return registered
}
@@ -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.
@@ -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)
}
})
})
+1
View File
@@ -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')
+2
View File
@@ -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,
@@ -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()
})
})
@@ -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
+2 -1
View File
@@ -50,7 +50,8 @@ export const CLI_BOOLEAN_FLAGS = new Set([
'text-stdin',
'unread',
'value-stdin',
'wait'
'wait',
'yes'
])
function commandPathStartsAt(
+2
View File
@@ -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
@@ -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()
})
+7
View File
@@ -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,
+1 -1
View File
@@ -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 <name> --yes` or use Settings > Managed servers.'
)
}
if (environment.sshAccess || environment.pendingSshAccessOperation) {