diff --git a/src/cli/runtime-client.ts b/src/cli/runtime-client.ts index 3f66235e5..480637847 100644 --- a/src/cli/runtime-client.ts +++ b/src/cli/runtime-client.ts @@ -1,413 +1,13 @@ -/* eslint-disable max-lines -- Why: the runtime client owns the full local IPC contract, launch fallback, and response validation in one place so the CLI does not drift from the app runtime. */ -import { createConnection } from 'net' -import { randomUUID } from 'crypto' -import { homedir } from 'os' -import { dirname, join } from 'path' -import { readFileSync } from 'fs' -import { spawn as spawnProcess } from 'child_process' -import type { CliStatusResult, RuntimeStatus } from '../shared/runtime-types' -import { - getRuntimeMetadataPath, - type RuntimeMetadata, - type RuntimeTransportMetadata -} from '../shared/runtime-bootstrap' - -export type RuntimeRpcSuccess = { - id: string - ok: true - result: TResult - _meta: { - runtimeId: string - } -} - -export type RuntimeRpcFailure = { - id: string - ok: false - error: { - code: string - message: string - data?: unknown - } - _meta?: { - runtimeId: string | null - } -} - -type RuntimeRpcResponse = RuntimeRpcSuccess | RuntimeRpcFailure - -export class RuntimeClientError extends Error { - readonly code: string - - constructor(code: string, message: string) { - super(message) - this.code = code - } -} - -export class RuntimeRpcFailureError extends RuntimeClientError { - readonly response: RuntimeRpcFailure - - constructor(response: RuntimeRpcFailure) { - super(response.error.code, response.error.message) - this.response = response - } -} - -export class RuntimeClient { - private readonly userDataPath: string - private readonly requestTimeoutMs: number - - // 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) { - this.userDataPath = userDataPath - this.requestTimeoutMs = requestTimeoutMs - } - - async call( - method: string, - params?: unknown, - options?: { - timeoutMs?: number - } - ): Promise> { - const metadata = this.readMetadata() - const response = await this.sendRequest(metadata, method, params, options?.timeoutMs) - if (!response.ok) { - throw new RuntimeRpcFailureError(response) - } - return response - } - - async getCliStatus(): Promise> { - const metadata = this.tryReadMetadata() - if (!metadata?.transport || !metadata.authToken) { - return buildCliStatusResponse({ - app: { - running: false, - pid: null - }, - runtime: { - // Why: distinguishing "never started" from "was running but died" - // gives the user a better signal about what happened. If the metadata - // file exists, Orca was running at some point. - state: metadata ? 'stale_bootstrap' : 'not_running', - reachable: false, - runtimeId: null - }, - graph: { - state: 'not_running' - } - }) - } - - try { - const response = await this.sendRequest( - metadata, - 'status.get', - undefined, - 1000 - ) - if (!response.ok) { - throw new RuntimeRpcFailureError(response) - } - const graphState = response.result.graphStatus - return buildCliStatusResponse({ - app: { - running: true, - pid: metadata.pid - }, - runtime: { - state: graphState === 'ready' ? 'ready' : 'graph_not_ready', - reachable: true, - runtimeId: response.result.runtimeId - }, - graph: { - state: graphState - } - }) - } catch { - const running = isProcessRunning(metadata.pid) - return buildCliStatusResponse({ - app: { - running, - pid: running ? metadata.pid : null - }, - runtime: { - state: running ? 'starting' : 'stale_bootstrap', - reachable: false, - runtimeId: null - }, - graph: { - state: running ? 'starting' : 'not_running' - } - }) - } - } - - async openOrca(timeoutMs = 15_000): Promise> { - const initial = await this.getCliStatus() - if (initial.result.runtime.reachable) { - return initial - } - - launchOrcaApp() - const startedAt = Date.now() - while (Date.now() - startedAt < timeoutMs) { - const status = await this.getCliStatus() - if (status.result.runtime.reachable) { - return status - } - await delay(250) - } - - throw new RuntimeClientError( - 'runtime_open_timeout', - 'Timed out waiting for Orca to start. Run the Orca app manually and try again.' - ) - } - - private readMetadata(): RuntimeMetadata { - const metadataPath = getRuntimeMetadataPath(this.userDataPath) - try { - const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null - if (!metadata?.transport || !metadata.authToken) { - throw new RuntimeClientError( - 'runtime_unavailable', - `Orca runtime metadata is incomplete at ${metadataPath}` - ) - } - return metadata - } catch (error) { - if (error instanceof RuntimeClientError) { - throw error - } - throw new RuntimeClientError( - 'runtime_unavailable', - `Could not read Orca runtime metadata at ${metadataPath}. Start the Orca app first.` - ) - } - } - - private tryReadMetadata(): RuntimeMetadata | null { - const metadataPath = getRuntimeMetadataPath(this.userDataPath) - try { - return JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null - } catch { - return null - } - } - - private async sendRequest( - metadata: RuntimeMetadata, - method: string, - params?: unknown, - timeoutMs?: number - ): Promise> { - return await new Promise((resolve, reject) => { - const socket = createConnection(getTransportEndpoint(metadata.transport!)) - let buffer = '' - const requestId = randomUUID() - - const timeout = setTimeout(() => { - socket.destroy() - reject( - new RuntimeClientError( - 'runtime_timeout', - 'Timed out waiting for the Orca runtime to respond.' - ) - ) - }, timeoutMs ?? this.requestTimeoutMs) - - socket.setEncoding('utf8') - socket.once('error', () => { - clearTimeout(timeout) - reject( - new RuntimeClientError( - 'runtime_unavailable', - 'Could not connect to the running Orca app. Restart Orca and try again.' - ) - ) - }) - socket.on('data', (chunk) => { - buffer += chunk - const newlineIndex = buffer.indexOf('\n') - if (newlineIndex === -1) { - return - } - const message = buffer.slice(0, newlineIndex) - socket.end() - clearTimeout(timeout) - try { - const response = JSON.parse(message) as RuntimeRpcResponse - if (response.id !== requestId) { - reject( - new RuntimeClientError( - 'invalid_runtime_response', - 'The Orca runtime returned a mismatched response id.' - ) - ) - return - } - if (response._meta?.runtimeId && response._meta.runtimeId !== metadata.runtimeId) { - reject( - new RuntimeClientError( - 'runtime_unavailable', - 'The Orca runtime changed while the request was in flight. Retry the command.' - ) - ) - return - } - resolve(response) - } catch { - reject( - new RuntimeClientError( - 'invalid_runtime_response', - 'The Orca runtime returned an invalid response frame.' - ) - ) - } - }) - socket.on('connect', () => { - socket.write( - `${JSON.stringify({ - id: requestId, - authToken: metadata.authToken, - method, - params - })}\n` - ) - }) - }) - } -} - -function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess { - return { - id: 'local-status', - ok: true, - result, - _meta: { - runtimeId: result.runtime.runtimeId ?? 'none' - } - } -} - -function isProcessRunning(pid: number | null | undefined): boolean { - if (!pid || pid <= 0) { - return false - } - try { - process.kill(pid, 0) - return true - } catch { - return false - } -} - -function launchOrcaApp(): void { - const overrideCommand = process.env.ORCA_OPEN_COMMAND - if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) { - spawnProcess(overrideCommand, { - detached: true, - stdio: 'ignore', - shell: true - }).unref() - return - } - - const overrideExecutable = process.env.ORCA_APP_EXECUTABLE - if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) { - spawnProcess(overrideExecutable, [], { - detached: true, - stdio: 'ignore', - env: stripElectronRunAsNode(process.env) - }).unref() - return - } - - if (process.env.ELECTRON_RUN_AS_NODE === '1') { - if (process.platform === 'darwin') { - const appBundlePath = getMacAppBundlePath(process.execPath) - if (appBundlePath) { - // Why: launching the inner MacOS binary directly can trigger macOS app - // launch failures and bypass normal bundle lifecycle. The public - // packaged CLI should re-open the .app the same way Finder does. - spawnProcess('open', [appBundlePath], { - detached: true, - stdio: 'ignore', - env: stripElectronRunAsNode(process.env) - }).unref() - return - } - } - - spawnProcess(process.execPath, [], { - detached: true, - stdio: 'ignore', - env: stripElectronRunAsNode(process.env) - }).unref() - return - } - - throw new RuntimeClientError( - 'runtime_open_failed', - 'Could not determine how to launch Orca. Start Orca manually and try again.' - ) -} - -function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const next = { ...env } - delete next.ELECTRON_RUN_AS_NODE - return next -} - -function getMacAppBundlePath(execPath: string): string | null { - if (process.platform !== 'darwin') { - return null - } - const macOsDir = dirname(execPath) - const contentsDir = dirname(macOsDir) - const appBundlePath = dirname(contentsDir) - return appBundlePath.endsWith('.app') ? appBundlePath : null -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -function getTransportEndpoint(transport: RuntimeTransportMetadata): string { - return transport.endpoint -} - -export function getDefaultUserDataPath( - platform: NodeJS.Platform = process.platform, - homeDir = homedir() -): string { - // Why: in dev mode (and for parallel Orca instances), the Electron app writes - // runtime metadata to a separate userData directory (e.g. `orca-dev`) to avoid - // clobbering the production app's metadata. The CLI needs to find the same - // metadata file, so this env var lets the CLI target a specific instance. - if (process.env.ORCA_USER_DATA_PATH) { - return process.env.ORCA_USER_DATA_PATH - } - if (platform === 'darwin') { - return join(homeDir, 'Library', 'Application Support', 'orca') - } - if (platform === 'win32') { - const appData = process.env.APPDATA - if (!appData) { - throw new RuntimeClientError( - 'runtime_unavailable', - 'APPDATA is not set, so the Orca runtime metadata path cannot be resolved.' - ) - } - return join(appData, 'orca') - } - // Why: the CLI must find the same metadata file Electron writes in packaged - // runs, so this mirrors Electron's default userData base instead of inventing - // a CLI-specific config path. - return join(process.env.XDG_CONFIG_HOME || join(homeDir, '.config'), 'orca') -} +// Why: the runtime client used to live here as a single file. It was split +// into ./runtime/{types,metadata,transport,status,launch,client}.ts so each +// concern can be tested in isolation. This barrel preserves the original +// import surface so call sites (src/cli/index.ts, tests) remain unchanged. +export { + RuntimeClient, + RuntimeClientError, + RuntimeRpcFailureError, + getDefaultUserDataPath, + type RuntimeRpcFailure, + type RuntimeRpcResponse, + type RuntimeRpcSuccess +} from './runtime/index' diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts new file mode 100644 index 000000000..e256bcdab --- /dev/null +++ b/src/cli/runtime/client.ts @@ -0,0 +1,69 @@ +import type { CliStatusResult } from '../../shared/runtime-types' +import { launchOrcaApp } from './launch' +import { getDefaultUserDataPath, readMetadata } from './metadata' +import { getCliStatus } from './status' +import { sendRequest } from './transport' +import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' + +export class RuntimeClient { + private readonly userDataPath: string + private readonly requestTimeoutMs: number + + // 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) { + this.userDataPath = userDataPath + this.requestTimeoutMs = requestTimeoutMs + } + + async call( + method: string, + params?: unknown, + options?: { + timeoutMs?: number + } + ): Promise> { + const metadata = readMetadata(this.userDataPath) + const response = await sendRequest( + metadata, + method, + params, + options?.timeoutMs ?? this.requestTimeoutMs + ) + if (!response.ok) { + throw new RuntimeRpcFailureError(response) + } + return response + } + + async getCliStatus(): Promise> { + return getCliStatus(this.userDataPath) + } + + async openOrca(timeoutMs = 15_000): Promise> { + const initial = await this.getCliStatus() + if (initial.result.runtime.reachable) { + return initial + } + + launchOrcaApp() + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + const status = await this.getCliStatus() + if (status.result.runtime.reachable) { + return status + } + await delay(250) + } + + throw new RuntimeClientError( + 'runtime_open_timeout', + 'Timed out waiting for Orca to start. Run the Orca app manually and try again.' + ) + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/cli/runtime/envelope-schema.test.ts b/src/cli/runtime/envelope-schema.test.ts new file mode 100644 index 000000000..57f92ada2 --- /dev/null +++ b/src/cli/runtime/envelope-schema.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeRpcEnvelopeSchema } from './envelope-schema' + +describe('RuntimeRpcEnvelopeSchema', () => { + it('accepts a well-formed success envelope', () => { + const parsed = RuntimeRpcEnvelopeSchema.safeParse({ + id: 'req-1', + ok: true, + result: { anything: 1 }, + _meta: { runtimeId: 'runtime-1' } + }) + expect(parsed.success).toBe(true) + }) + + it('accepts a well-formed failure envelope', () => { + const parsed = RuntimeRpcEnvelopeSchema.safeParse({ + id: 'req-1', + ok: false, + error: { code: 'not_found', message: 'not_found' }, + _meta: { runtimeId: 'runtime-1' } + }) + expect(parsed.success).toBe(true) + }) + + it('accepts a failure envelope without _meta', () => { + // Why: the runtime may fail before it has resolved its own runtimeId, in + // which case _meta is omitted. The schema must tolerate that rather than + // rejecting a legitimate failure as an invalid frame. + const parsed = RuntimeRpcEnvelopeSchema.safeParse({ + id: 'req-1', + ok: false, + error: { code: 'runtime_unavailable', message: 'runtime_unavailable' } + }) + expect(parsed.success).toBe(true) + }) + + it('rejects a frame missing the ok discriminator', () => { + const parsed = RuntimeRpcEnvelopeSchema.safeParse({ + id: 'req-1', + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + expect(parsed.success).toBe(false) + }) + + it('rejects a frame with a non-string id', () => { + const parsed = RuntimeRpcEnvelopeSchema.safeParse({ + id: 123, + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + expect(parsed.success).toBe(false) + }) + + it('rejects a frame with unrelated fields only', () => { + const parsed = RuntimeRpcEnvelopeSchema.safeParse({ hello: 'world' }) + expect(parsed.success).toBe(false) + }) +}) diff --git a/src/cli/runtime/envelope-schema.ts b/src/cli/runtime/envelope-schema.ts new file mode 100644 index 000000000..eeec35360 --- /dev/null +++ b/src/cli/runtime/envelope-schema.ts @@ -0,0 +1,39 @@ +// Why: the Orca runtime is a separate process and may drift in version from +// the CLI (older CLI talking to newer app, or vice versa during dev HMR). A +// Zod schema at the decode boundary means a malformed frame surfaces as a +// single legible error instead of a silent mis-typed access downstream. +// +// The envelope shape mirrors src/main/runtime/rpc/core.ts. `result` is left +// unknown here — method-level types are checked by the caller via generics — +// so only the frame is validated, not the payload. +import { z } from 'zod' + +const MetaSuccess = z.object({ + runtimeId: z.string() +}) + +const MetaFailure = z + .object({ + runtimeId: z.union([z.string(), z.null()]) + }) + .optional() + +const Success = z.object({ + id: z.string(), + ok: z.literal(true), + result: z.unknown(), + _meta: MetaSuccess +}) + +const Failure = z.object({ + id: z.string(), + ok: z.literal(false), + error: z.object({ + code: z.string(), + message: z.string(), + data: z.unknown().optional() + }), + _meta: MetaFailure +}) + +export const RuntimeRpcEnvelopeSchema = z.discriminatedUnion('ok', [Success, Failure]) diff --git a/src/cli/runtime/index.ts b/src/cli/runtime/index.ts new file mode 100644 index 000000000..b061c042b --- /dev/null +++ b/src/cli/runtime/index.ts @@ -0,0 +1,9 @@ +export { RuntimeClient } from './client' +export { getDefaultUserDataPath } from './metadata' +export { + RuntimeClientError, + RuntimeRpcFailureError, + type RuntimeRpcFailure, + type RuntimeRpcResponse, + type RuntimeRpcSuccess +} from './types' diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts new file mode 100644 index 000000000..b86cfb9b4 --- /dev/null +++ b/src/cli/runtime/launch.ts @@ -0,0 +1,70 @@ +import { spawn as spawnProcess } from 'child_process' +import { dirname } from 'path' +import { RuntimeClientError } from './types' + +export function launchOrcaApp(): void { + const overrideCommand = process.env.ORCA_OPEN_COMMAND + if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) { + spawnProcess(overrideCommand, { + detached: true, + stdio: 'ignore', + shell: true + }).unref() + return + } + + const overrideExecutable = process.env.ORCA_APP_EXECUTABLE + if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) { + spawnProcess(overrideExecutable, [], { + detached: true, + stdio: 'ignore', + env: stripElectronRunAsNode(process.env) + }).unref() + return + } + + if (process.env.ELECTRON_RUN_AS_NODE === '1') { + if (process.platform === 'darwin') { + const appBundlePath = getMacAppBundlePath(process.execPath) + if (appBundlePath) { + // Why: launching the inner MacOS binary directly can trigger macOS app + // launch failures and bypass normal bundle lifecycle. The public + // packaged CLI should re-open the .app the same way Finder does. + spawnProcess('open', [appBundlePath], { + detached: true, + stdio: 'ignore', + env: stripElectronRunAsNode(process.env) + }).unref() + return + } + } + + spawnProcess(process.execPath, [], { + detached: true, + stdio: 'ignore', + env: stripElectronRunAsNode(process.env) + }).unref() + return + } + + throw new RuntimeClientError( + 'runtime_open_failed', + 'Could not determine how to launch Orca. Start Orca manually and try again.' + ) +} + +function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const next = { ...env } + delete next.ELECTRON_RUN_AS_NODE + return next +} + +function getMacAppBundlePath(execPath: string): string | null { + if (process.platform !== 'darwin') { + return null + } + const macOsDir = dirname(execPath) + const contentsDir = dirname(macOsDir) + const appBundlePath = dirname(contentsDir) + return appBundlePath.endsWith('.app') ? appBundlePath : null +} diff --git a/src/cli/runtime/metadata.ts b/src/cli/runtime/metadata.ts new file mode 100644 index 000000000..cd690ab0f --- /dev/null +++ b/src/cli/runtime/metadata.ts @@ -0,0 +1,66 @@ +import { homedir } from 'os' +import { join } from 'path' +import { readFileSync } from 'fs' +import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { RuntimeClientError } from './types' + +export function readMetadata(userDataPath: string): RuntimeMetadata { + const metadataPath = getRuntimeMetadataPath(userDataPath) + try { + const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null + if (!metadata?.transport || !metadata.authToken) { + throw new RuntimeClientError( + 'runtime_unavailable', + `Orca runtime metadata is incomplete at ${metadataPath}` + ) + } + return metadata + } catch (error) { + if (error instanceof RuntimeClientError) { + throw error + } + throw new RuntimeClientError( + 'runtime_unavailable', + `Could not read Orca runtime metadata at ${metadataPath}. Start the Orca app first.` + ) + } +} + +export function tryReadMetadata(userDataPath: string): RuntimeMetadata | null { + const metadataPath = getRuntimeMetadataPath(userDataPath) + try { + return JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null + } catch { + return null + } +} + +export function getDefaultUserDataPath( + platform: NodeJS.Platform = process.platform, + homeDir = homedir() +): string { + // Why: in dev mode (and for parallel Orca instances), the Electron app writes + // runtime metadata to a separate userData directory (e.g. `orca-dev`) to avoid + // clobbering the production app's metadata. The CLI needs to find the same + // metadata file, so this env var lets the CLI target a specific instance. + if (process.env.ORCA_USER_DATA_PATH) { + return process.env.ORCA_USER_DATA_PATH + } + if (platform === 'darwin') { + return join(homeDir, 'Library', 'Application Support', 'orca') + } + if (platform === 'win32') { + const appData = process.env.APPDATA + if (!appData) { + throw new RuntimeClientError( + 'runtime_unavailable', + 'APPDATA is not set, so the Orca runtime metadata path cannot be resolved.' + ) + } + return join(appData, 'orca') + } + // Why: the CLI must find the same metadata file Electron writes in packaged + // runs, so this mirrors Electron's default userData base instead of inventing + // a CLI-specific config path. + return join(process.env.XDG_CONFIG_HOME || join(homeDir, '.config'), 'orca') +} diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts new file mode 100644 index 000000000..00daf4bfe --- /dev/null +++ b/src/cli/runtime/status.ts @@ -0,0 +1,90 @@ +import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' +import { tryReadMetadata } from './metadata' +import { sendRequest } from './transport' +import { RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' + +export async function getCliStatus( + userDataPath: string +): Promise> { + const metadata = tryReadMetadata(userDataPath) + if (!metadata?.transport || !metadata.authToken) { + return buildCliStatusResponse({ + app: { + running: false, + pid: null + }, + runtime: { + // Why: distinguishing "never started" from "was running but died" + // gives the user a better signal about what happened. If the metadata + // file exists, Orca was running at some point. + state: metadata ? 'stale_bootstrap' : 'not_running', + reachable: false, + runtimeId: null + }, + graph: { + state: 'not_running' + } + }) + } + + try { + const response = await sendRequest(metadata, 'status.get', undefined, 1000) + if (!response.ok) { + throw new RuntimeRpcFailureError(response) + } + const graphState = response.result.graphStatus + return buildCliStatusResponse({ + app: { + running: true, + pid: metadata.pid + }, + runtime: { + state: graphState === 'ready' ? 'ready' : 'graph_not_ready', + reachable: true, + runtimeId: response.result.runtimeId + }, + graph: { + state: graphState + } + }) + } catch { + const running = isProcessRunning(metadata.pid) + return buildCliStatusResponse({ + app: { + running, + pid: running ? metadata.pid : null + }, + runtime: { + state: running ? 'starting' : 'stale_bootstrap', + reachable: false, + runtimeId: null + }, + graph: { + state: running ? 'starting' : 'not_running' + } + }) + } +} + +function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess { + return { + id: 'local-status', + ok: true, + result, + _meta: { + runtimeId: result.runtime.runtimeId ?? 'none' + } + } +} + +function isProcessRunning(pid: number | null | undefined): boolean { + if (!pid || pid <= 0) { + return false + } + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts new file mode 100644 index 000000000..bc39de0d3 --- /dev/null +++ b/src/cli/runtime/transport.ts @@ -0,0 +1,110 @@ +import { createConnection } from 'net' +import { randomUUID } from 'crypto' +import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' +import { RuntimeRpcEnvelopeSchema } from './envelope-schema' +import { RuntimeClientError, type RuntimeRpcResponse } from './types' + +export async function sendRequest( + metadata: RuntimeMetadata, + method: string, + params: unknown, + timeoutMs: number +): Promise> { + return await new Promise((resolve, reject) => { + const socket = createConnection(getTransportEndpoint(metadata.transport!)) + let buffer = '' + const requestId = randomUUID() + + const timeout = setTimeout(() => { + socket.destroy() + reject( + new RuntimeClientError( + 'runtime_timeout', + 'Timed out waiting for the Orca runtime to respond.' + ) + ) + }, timeoutMs) + + socket.setEncoding('utf8') + socket.once('error', () => { + clearTimeout(timeout) + reject( + new RuntimeClientError( + 'runtime_unavailable', + 'Could not connect to the running Orca app. Restart Orca and try again.' + ) + ) + }) + socket.on('data', (chunk) => { + buffer += chunk + const newlineIndex = buffer.indexOf('\n') + if (newlineIndex === -1) { + return + } + const message = buffer.slice(0, newlineIndex) + socket.end() + clearTimeout(timeout) + let response: RuntimeRpcResponse + try { + const raw: unknown = JSON.parse(message) + // Why: validate the envelope shape (id, ok, result/error, _meta) at + // the decode boundary so version skew between the CLI and the Orca + // main runtime surfaces as a single invalid_runtime_response instead + // of a downstream mis-typed field access. `result` is left as + // unknown — the TResult generic is the caller's responsibility. + const parsed = RuntimeRpcEnvelopeSchema.safeParse(raw) + if (!parsed.success) { + reject( + new RuntimeClientError( + 'invalid_runtime_response', + 'The Orca runtime returned an invalid response frame.' + ) + ) + return + } + response = parsed.data as RuntimeRpcResponse + } catch { + reject( + new RuntimeClientError( + 'invalid_runtime_response', + 'The Orca runtime returned an invalid response frame.' + ) + ) + return + } + if (response.id !== requestId) { + reject( + new RuntimeClientError( + 'invalid_runtime_response', + 'The Orca runtime returned a mismatched response id.' + ) + ) + return + } + if (response._meta?.runtimeId && response._meta.runtimeId !== metadata.runtimeId) { + reject( + new RuntimeClientError( + 'runtime_unavailable', + 'The Orca runtime changed while the request was in flight. Retry the command.' + ) + ) + return + } + resolve(response) + }) + socket.on('connect', () => { + socket.write( + `${JSON.stringify({ + id: requestId, + authToken: metadata.authToken, + method, + params + })}\n` + ) + }) + }) +} + +function getTransportEndpoint(transport: RuntimeTransportMetadata): string { + return transport.endpoint +} diff --git a/src/cli/runtime/types.ts b/src/cli/runtime/types.ts new file mode 100644 index 000000000..e427c87b5 --- /dev/null +++ b/src/cli/runtime/types.ts @@ -0,0 +1,46 @@ +// Why: the RPC envelope shape is the contract the CLI shares with the main +// runtime. Keeping the types and error classes in one leaf module lets every +// other runtime module depend on them without pulling in transport or launch +// code. + +export type RuntimeRpcSuccess = { + id: string + ok: true + result: TResult + _meta: { + runtimeId: string + } +} + +export type RuntimeRpcFailure = { + id: string + ok: false + error: { + code: string + message: string + data?: unknown + } + _meta?: { + runtimeId: string | null + } +} + +export type RuntimeRpcResponse = RuntimeRpcSuccess | RuntimeRpcFailure + +export class RuntimeClientError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.code = code + } +} + +export class RuntimeRpcFailureError extends RuntimeClientError { + readonly response: RuntimeRpcFailure + + constructor(response: RuntimeRpcFailure) { + super(response.error.code, response.error.message) + this.response = response + } +}