mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
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:
@@ -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
|
||||
},
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
@@ -50,7 +50,8 @@ export const CLI_BOOLEAN_FLAGS = new Set([
|
||||
'text-stdin',
|
||||
'unread',
|
||||
'value-stdin',
|
||||
'wait'
|
||||
'wait',
|
||||
'yes'
|
||||
])
|
||||
|
||||
function commandPathStartsAt(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user