Files
orca/src/cli/runtime/client.ts
T
0fac887dbc feat(terminal): show a paired server's terminal in this desktop on terminal switch (#28023)
* fix(terminal): log a failed pane reveal instead of rethrowing it

The bridge already replies false when the reveal throws, so the rethrow only
surfaced as an uncaught renderer error after the caller had its answer.

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

* fix(terminal): refuse terminal.reveal from paired clients

It moves this host's window. terminal.focus lets a paired client do that only
by asking for host navigation, and the callers this method serves (the CLI and
companion apps on the same machine) never arrive as paired clients.

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

* feat(terminal): show a paired server's terminal in this desktop on terminal switch

A paired `orca serve` host has no window, so `orca terminal switch --environment <name>`
resolved the handle there, answered `navigated: false`, and the desktop stayed put (#24688).

Add `terminal.reveal`, a desktop runtime method that names a pane by worktree, tab and leaf, as
the owning host knows them, and runs the Agent Dashboard's click-to-focus sequence before raising
the window. When a paired server cannot navigate, `terminal switch` asks the desktop on this
machine to reveal the terminal instead.

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

* test(e2e): terminal switch on a paired server moves the desktop on this machine

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

* test: update terminal manifest count after main and assert the revealed pane renders

Main added a terminal method since this branch was cut, so the characterization
count is 39 with terminal.reveal. The e2e now proves the target pane is visible
in the DOM, not only selected in the store.

Co-authored-by: Velm14 <Velm14@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: Velm14 <Velm14@users.noreply.github.com>
2026-10-15 23:26:59 -07:00

328 lines
13 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state'
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
import {
isDurableMutation,
isOrchestrationMutation,
isTerminalPromptMutation,
orchestrationMigrationData
} from '../../shared/orchestration-rpc-contract'
import type { PairedRuntimeConnection } from '../../shared/paired-runtime-connection'
import { launchOrcaApp } from './launch'
import { getDefaultUserDataPath, readMetadata } from './metadata'
import { getCliStatus, projectRemoteAppStatus } from './status'
import { sendRequest } from './transport'
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
import {
attachDurableMutationRecovery,
attachLegacyTerminalPromptRecovery,
attachUnverifiedTerminalPromptRecovery,
didAnotherRuntimeHandleTerminalPrompt
} from './terminal-prompt-mutation-recovery'
import { markEnvironmentUsed } from './environments'
import { createRuntimeSourceFence, type RuntimeSourceFence } from './runtime-source-fence'
import { resolveRemoteConnection } from './runtime-remote-pairing'
import {
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
ORCHESTRATION_CONTRACT_VERSION
} from '../../shared/protocol-version'
import { RemoteRuntimeCompatGate } from './remote-runtime-compat-gate'
import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope'
import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout'
import {
isWorkerStartTimeoutWithinTimerLimit,
resolveWorkerStartClientTimeoutMs,
resolveWorkerStartReadinessTimeoutMs
} from '../../shared/orchestration-timing-budgets'
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
import {
buildOrchestrationRecoveryCommand,
resolveOrchestrationCliExecutable
} from './orchestration-recovery-command'
const LONG_POLL_CLIENT_GRACE_MS = 10_000
const loadWebSocketTransport = async () => await import('./websocket-transport.js')
export class RuntimeClient {
readonly userDataPath: string
private readonly requestTimeoutMs: number
private readonly remote: PairedRuntimeConnection | null
readonly environmentSelector: string | null
private readonly cliExecutable: string
private readonly originalArgs: readonly string[] | undefined
private readonly remoteCompat: RemoteRuntimeCompatGate
private orchestrationContractCheck: Promise<void> | null = null
private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope(
process.env
)
private readonly runtimeSourceFence: RuntimeSourceFence
// Why: browser commands trigger first-time session init (agent-browser connect +
// CDP proxy setup) which can take 15-30s. 60s accommodates cold start without
// being so large that genuine hangs go unnoticed.
constructor(
userDataPath = getDefaultUserDataPath(),
requestTimeoutMs = 60_000,
remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null,
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null,
cliExecutable = resolveOrchestrationCliExecutable(),
originalArgs?: readonly string[]
) {
this.userDataPath = userDataPath
this.requestTimeoutMs = requestTimeoutMs
this.environmentSelector = environmentSelector
this.cliExecutable = cliExecutable
this.originalArgs = originalArgs ? [...originalArgs] : undefined
this.remote = resolveRemoteConnection(userDataPath, remotePairingCode, environmentSelector)
this.remoteCompat = new RemoteRuntimeCompatGate(userDataPath, environmentSelector)
this.runtimeSourceFence = createRuntimeSourceFence(process.env, userDataPath, requestTimeoutMs)
}
get isRemote(): boolean {
return this.remote !== null
}
async call<TResult>(
method: string,
params?: unknown,
options?: {
timeoutMs?: number
legacyTerminalPrompt?: true
terminalPromptPreflight?: { runtimeId: string | null }
} & RuntimeOrchestrationEnvelope
): Promise<RuntimeRpcSuccess<TResult>> {
const effectiveTimeoutMs = options?.timeoutMs ?? this.resolveMethodTimeoutMs(method, params)
const orchestrationMutation = isOrchestrationMutation(method, params)
const terminalPromptMutation = isTerminalPromptMutation(method, params)
const legacyTerminalPrompt = options?.legacyTerminalPrompt === true && terminalPromptMutation
const durableMutation = !legacyTerminalPrompt && isDurableMutation(method, params)
if (orchestrationMutation) {
await this.ensureOrchestrationContractCompatible(effectiveTimeoutMs)
}
const orchestrationRequestId = durableMutation
? (options?.orchestrationRequestId ?? randomUUID())
: undefined
const originalCommand = durableMutation
? buildOrchestrationRecoveryCommand(method, params, this.cliExecutable, this.originalArgs)
: undefined
const recover = (error: unknown, targetRuntimeId: string | null) => {
if (legacyTerminalPrompt) {
return attachLegacyTerminalPromptRecovery(error)
}
if (
terminalPromptMutation &&
options?.terminalPromptPreflight &&
didAnotherRuntimeHandleTerminalPrompt(
error,
options.terminalPromptPreflight.runtimeId,
targetRuntimeId
)
) {
return attachUnverifiedTerminalPromptRecovery(error)
}
return attachDurableMutationRecovery(error, orchestrationRequestId, originalCommand, method)
}
const compatibilityEnvelope = method.startsWith('orchestration.')
? {
...this.orchestrationCompatibility,
compatibilityInvocationId:
orchestrationRequestId ?? this.orchestrationCompatibility.compatibilityInvocationId
}
: {}
const envelope = {
orchestrationCapability: options?.orchestrationCapability,
orchestrationContractVersion: method.startsWith('orchestration.')
? ORCHESTRATION_CONTRACT_VERSION
: undefined,
orchestrationRequestId,
...compatibilityEnvelope
}
if (this.remote) {
const transport = await loadWebSocketTransport()
let response
try {
response = await this.remoteCompat.send<TResult>({
transport,
connection: this.remote,
method,
params,
timeoutMs: effectiveTimeoutMs,
envelope
})
} catch (error) {
throw recover(error, null)
}
if (response.ok === false) {
throw recover(new RuntimeRpcFailureError(response), null)
}
if (this.environmentSelector) {
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
runtimeId: response._meta.runtimeId
})
}
return response
}
const metadata = readMetadata(this.userDataPath)
// Why outside recover(): a refusal here happens before anything is sent, so there is nothing to retry.
const expectedRuntimeSource = await this.runtimeSourceFence.check(metadata, method)
let response
try {
response = await sendRequest<TResult>(
metadata,
method,
params,
effectiveTimeoutMs,
envelope,
expectedRuntimeSource
)
} catch (error) {
throw recover(error, metadata.runtimeId ?? null)
}
if (response.ok === false) {
throw recover(new RuntimeRpcFailureError(response), metadata.runtimeId ?? null)
}
return response
}
// Why: centralises the per-method timeout policy. Long-poll inner waiter
// budgets live in `params.timeoutMs`; widen the client-side socket timeout
// to `timeoutMs + grace` so it doesn't fire before the server has a chance
// to resolve. Without this, a 5 min wait would still die at the 60 s default.
// See design doc §3.1.
private resolveMethodTimeoutMs(method: string, params?: unknown): number {
if (method === 'orchestration.workerStart') {
const requestedValue = getTimeoutMsParam(params)
const requested = typeof requestedValue === 'number' ? requestedValue : Number(requestedValue)
if (!isWorkerStartTimeoutWithinTimerLimit(requested)) {
throw new RuntimeClientError(
'invalid_argument',
`--timeout-ms is too large for worker-start transport grace; the derived timeout must be <= ${MAX_TIMER_DELAY_MS}ms.`
)
}
const readiness = resolveWorkerStartReadinessTimeoutMs(requested)
return Math.max(resolveWorkerStartClientTimeoutMs(readiness), this.requestTimeoutMs)
}
if (
(method === 'orchestration.check' && isWaitingCheck(params)) ||
method === 'terminal.wait'
) {
const inner = Number(getTimeoutMsParam(params))
if (Number.isFinite(inner) && inner > 0) {
return Math.max(inner + LONG_POLL_CLIENT_GRACE_MS, this.requestTimeoutMs)
}
}
return this.requestTimeoutMs
}
async getCliStatus(): Promise<RuntimeRpcSuccess<CliStatusResult>> {
if (this.remote) {
const response = await this.call<RuntimeStatus>('status.get')
this.remoteCompat.noteVerifiedStatus(response.result)
const graphState = response.result.graphStatus
return {
id: response.id,
ok: true,
result: {
target: {
kind: 'environment',
environment: this.environmentSelector ?? 'pairing-code'
},
app: projectRemoteAppStatus(response.result),
runtime: {
state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
reachable: true,
connectionState: runtimeHostConnectionState({
hasStatusEntry: true,
status: response.result
}),
runtimeId: response.result.runtimeId,
...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}),
...(response.result.remoteUpdateSupport
? { remoteUpdateSupport: response.result.remoteUpdateSupport }
: {}),
...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}),
...(response.result.degradations ? { degradations: response.result.degradations } : {})
},
graph: {
state: graphState
}
},
_meta: response._meta
}
}
return getCliStatus(this.userDataPath)
}
private async ensureOrchestrationContractCompatible(timeoutMs: number): Promise<void> {
if (!this.orchestrationContractCheck) {
this.orchestrationContractCheck = this.checkOrchestrationContractCompatibility(
timeoutMs
).catch((error: unknown) => {
// Why: a failed probe must not be cached, or a retry after a brief outage never reaches the app.
this.orchestrationContractCheck = null
throw error
})
}
await this.orchestrationContractCheck
}
private async checkOrchestrationContractCompatibility(timeoutMs: number): Promise<void> {
const response = await this.call<RuntimeStatus>('status.get', undefined, { timeoutMs })
if (this.remote) {
this.remoteCompat.noteVerifiedStatus(response.result)
}
if (!response.result.capabilities?.includes(ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY)) {
throw new RuntimeClientError(
'orchestration_migration_required',
'The connected Orca runtime does not support the current orchestration contract. No effects were applied.',
orchestrationMigrationData('runtime_capability_missing')
)
}
}
async openOrca(timeoutMs = 15_000): Promise<RuntimeRpcSuccess<CliStatusResult>> {
const initial = await this.getCliStatus()
if (this.remote) {
return initial
}
// Why: a blocked runtime can't open a window, so spawning the app would
// only hit the single-instance lock and exit — bail before launching.
if (initial.result.app.desktopWindowStatus === 'blocked') {
throwDesktopActivationBlocked()
}
launchOrcaApp()
if (initial.result.app.desktopWindowStatus === 'available') {
return initial
}
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const status = await this.getCliStatus()
if (status.result.app.desktopWindowStatus === 'blocked') {
throwDesktopActivationBlocked()
}
if (status.result.app.desktopWindowStatus === 'available') {
return status
}
await delay(250)
}
throw new RuntimeClientError(
'runtime_open_timeout',
'Timed out waiting for an Orca desktop window. The runtime may still be running headlessly.'
)
}
}
function throwDesktopActivationBlocked(): never {
throw new RuntimeClientError(
'desktop_activation_blocked',
'Orca is running headlessly, but it cannot open a desktop window safely because the persistent terminal provider is unavailable. Quit Orca normally and start the app again; do not use open -n.'
)
}
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))