refactor(cli): split runtime-client.ts and add envelope schema validation (#1090)

* refactor(cli): split runtime-client.ts into runtime/ subsystem

Break the 413-line src/cli/runtime-client.ts into focused modules under
src/cli/runtime/:

- types.ts       — RuntimeRpcSuccess/Failure, RuntimeClientError,
                   RuntimeRpcFailureError
- metadata.ts    — readMetadata / tryReadMetadata /
                   getDefaultUserDataPath
- transport.ts   — sendRequest: Unix-socket newline-framed JSON with
                   id and runtimeId verification and timeout handling
- status.ts      — getCliStatus + buildCliStatusResponse +
                   isProcessRunning
- launch.ts      — launchOrcaApp + macOS .app-bundle resolution +
                   ELECTRON_RUN_AS_NODE env handling
- client.ts      — RuntimeClient class, now a thin composer
- index.ts       — subsystem barrel

runtime-client.ts becomes a backward-compat re-export barrel so
src/cli/index.ts and the existing tests import the same symbols from
the same path. No behavior changes.

Motivation: the file had an eslint-disable max-lines override and
mixed five concerns (envelope types, wire transport, metadata I/O,
status aggregation, cross-platform app launch). Splitting them makes
each concern independently testable and unblocks adding schema
validation at the RPC boundary.

* feat(cli): validate runtime RPC envelope with Zod at decode boundary

Add RuntimeRpcEnvelopeSchema and apply it inside sendRequest so every
response frame is validated against the id/ok/result/error/_meta shape
before the CLI hands it to the caller. The payload (`result`) is left
as unknown — the TResult generic remains the caller's responsibility —
so only the envelope itself is the contract this schema enforces.

Motivation: the CLI and the Orca main runtime are separate processes
and can drift in version (older CLI vs newer app, or vice versa during
dev HMR). A malformed or partial frame used to risk mis-typed field
access downstream; it now surfaces as a single structured
`invalid_runtime_response` error.

Behavior:
- Well-formed success and failure frames continue to decode unchanged.
- Failure frames without `_meta` are accepted (the runtime may fail
  before resolving its own runtimeId).
- Valid JSON that does not match the envelope shape now rejects with
  `invalid_runtime_response`, matching the existing error code for
  non-JSON frames.

Tests: adds a pure schema test file
(src/cli/runtime/envelope-schema.test.ts) covering accept/reject cases.
The existing integration tests in runtime-client.test.ts continue to
pass unchanged.
This commit is contained in:
Neil 2026-04-25 13:51:50 -07:00 committed by GitHub
parent 36c6a9241f
commit fef3f7d2f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 572 additions and 413 deletions

View File

@ -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<TResult> = {
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<TResult> = RuntimeRpcSuccess<TResult> | 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<TResult>(
method: string,
params?: unknown,
options?: {
timeoutMs?: number
}
): Promise<RuntimeRpcSuccess<TResult>> {
const metadata = this.readMetadata()
const response = await this.sendRequest<TResult>(metadata, method, params, options?.timeoutMs)
if (!response.ok) {
throw new RuntimeRpcFailureError(response)
}
return response
}
async getCliStatus(): Promise<RuntimeRpcSuccess<CliStatusResult>> {
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<RuntimeStatus>(
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<RuntimeRpcSuccess<CliStatusResult>> {
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<TResult>(
metadata: RuntimeMetadata,
method: string,
params?: unknown,
timeoutMs?: number
): Promise<RuntimeRpcResponse<TResult>> {
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<TResult>
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<CliStatusResult> {
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<void> {
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'

69
src/cli/runtime/client.ts Normal file
View File

@ -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<TResult>(
method: string,
params?: unknown,
options?: {
timeoutMs?: number
}
): Promise<RuntimeRpcSuccess<TResult>> {
const metadata = readMetadata(this.userDataPath)
const response = await sendRequest<TResult>(
metadata,
method,
params,
options?.timeoutMs ?? this.requestTimeoutMs
)
if (!response.ok) {
throw new RuntimeRpcFailureError(response)
}
return response
}
async getCliStatus(): Promise<RuntimeRpcSuccess<CliStatusResult>> {
return getCliStatus(this.userDataPath)
}
async openOrca(timeoutMs = 15_000): Promise<RuntimeRpcSuccess<CliStatusResult>> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@ -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)
})
})

View File

@ -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])

9
src/cli/runtime/index.ts Normal file
View File

@ -0,0 +1,9 @@
export { RuntimeClient } from './client'
export { getDefaultUserDataPath } from './metadata'
export {
RuntimeClientError,
RuntimeRpcFailureError,
type RuntimeRpcFailure,
type RuntimeRpcResponse,
type RuntimeRpcSuccess
} from './types'

70
src/cli/runtime/launch.ts Normal file
View File

@ -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
}

View File

@ -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')
}

90
src/cli/runtime/status.ts Normal file
View File

@ -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<RuntimeRpcSuccess<CliStatusResult>> {
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<RuntimeStatus>(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<CliStatusResult> {
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
}
}

View File

@ -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<TResult>(
metadata: RuntimeMetadata,
method: string,
params: unknown,
timeoutMs: number
): Promise<RuntimeRpcResponse<TResult>> {
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<TResult>
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<TResult>
} 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
}

46
src/cli/runtime/types.ts Normal file
View File

@ -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<TResult> = {
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<TResult> = RuntimeRpcSuccess<TResult> | 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
}
}