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:
parent
36c6a9241f
commit
fef3f7d2f8
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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])
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export { RuntimeClient } from './client'
|
||||
export { getDefaultUserDataPath } from './metadata'
|
||||
export {
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError,
|
||||
type RuntimeRpcFailure,
|
||||
type RuntimeRpcResponse,
|
||||
type RuntimeRpcSuccess
|
||||
} from './types'
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue