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 { PairingOffer } from '../../shared/pairing' 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 { resolveRemotePairing } 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 { private readonly userDataPath: string private readonly requestTimeoutMs: number private readonly remotePairing: PairingOffer | null private readonly environmentSelector: string | null private readonly cliExecutable: string private readonly originalArgs: readonly string[] | undefined private readonly remoteCompat: RemoteRuntimeCompatGate private orchestrationContractCheck: Promise | null = null private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope( process.env ) // 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.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector) this.remoteCompat = new RemoteRuntimeCompatGate(userDataPath, environmentSelector) } get isRemote(): boolean { return this.remotePairing !== null } async call( method: string, params?: unknown, options?: { timeoutMs?: number legacyTerminalPrompt?: true terminalPromptPreflight?: { runtimeId: string | null } } & RuntimeOrchestrationEnvelope ): Promise> { 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.remotePairing) { const transport = await loadWebSocketTransport() let response try { response = await this.remoteCompat.send({ transport, pairing: this.remotePairing, 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) let response try { response = await sendRequest(metadata, method, params, effectiveTimeoutMs, envelope) } 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> { if (this.remotePairing) { const response = await this.call('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 { if (!this.orchestrationContractCheck) { this.orchestrationContractCheck = this.checkOrchestrationContractCompatibility(timeoutMs) } await this.orchestrationContractCheck } private async checkOrchestrationContractCompatibility(timeoutMs: number): Promise { const response = await this.call('status.get', undefined, { timeoutMs }) if (this.remotePairing) { 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> { const initial = await this.getCliStatus() if (this.remotePairing) { 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((resolve) => setTimeout(resolve, ms))