mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
197 lines
6.4 KiB
TypeScript
197 lines
6.4 KiB
TypeScript
import { createConnection } from 'node:net'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
|
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
|
import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema'
|
|
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
|
|
import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../../shared/timer-delay'
|
|
|
|
export async function sendRequest<TResult>(
|
|
metadata: RuntimeMetadata,
|
|
method: string,
|
|
params: unknown,
|
|
timeoutMs: number,
|
|
envelope?: RuntimeOrchestrationEnvelope
|
|
): Promise<RuntimeRpcResponse<TResult>> {
|
|
if (!isSafeTimerDelayMs(timeoutMs)) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Runtime request timeout must be an integer between 0 and ${MAX_TIMER_DELAY_MS}ms.`
|
|
)
|
|
}
|
|
return await new Promise((resolve, reject) => {
|
|
const transport = findTransport(metadata, 'unix', 'named-pipe')
|
|
if (!transport) {
|
|
reject(
|
|
new RuntimeClientError(
|
|
'runtime_unavailable',
|
|
'No compatible transport found in Orca runtime metadata.'
|
|
)
|
|
)
|
|
return
|
|
}
|
|
const socket = createConnection(transport.endpoint)
|
|
let buffer = ''
|
|
let settled = false
|
|
const requestId = randomUUID()
|
|
|
|
const timeout = setTimeout(() => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
socket.destroy()
|
|
reject(
|
|
new RuntimeClientError(
|
|
'runtime_timeout',
|
|
'Timed out waiting for the Orca runtime to respond.'
|
|
)
|
|
)
|
|
}, timeoutMs)
|
|
|
|
const finish = (
|
|
result: { ok: true; response: RuntimeRpcResponse<TResult> } | { ok: false; error: Error }
|
|
): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
clearTimeout(timeout)
|
|
socket.end()
|
|
if (result.ok === false) {
|
|
reject(result.error)
|
|
} else {
|
|
resolve(result.response)
|
|
}
|
|
}
|
|
|
|
socket.setEncoding('utf8')
|
|
socket.once('error', () => {
|
|
finish({
|
|
ok: false,
|
|
error: new RuntimeClientError(
|
|
'runtime_unavailable',
|
|
'Could not connect to the running Orca app. Restart Orca and try again.'
|
|
)
|
|
})
|
|
})
|
|
// Why: a clean peer close (FIN, no 'error') before a terminal frame never
|
|
// settles the promise, so the call would otherwise hang until the full
|
|
// timeout fires. Reject promptly. finish() guards double-settle, so this
|
|
// no-ops on the normal success/error paths that already called socket.end().
|
|
socket.once('close', () => {
|
|
finish({
|
|
ok: false,
|
|
error: new RuntimeClientError(
|
|
'runtime_unavailable',
|
|
'The Orca runtime closed the connection before responding. Restart Orca and try again.'
|
|
)
|
|
})
|
|
})
|
|
socket.on('data', (chunk) => {
|
|
buffer += chunk
|
|
// Why: the server may interleave `{"_keepalive":true}\n` frames with the
|
|
// final success/failure frame to keep both idle timers alive during a
|
|
// long-poll (see design doc §3.1). Read frames in a loop until we see a
|
|
// terminal frame. Each keepalive refreshes the client-side timer so a
|
|
// 10 min wait doesn't trip the 60 s default ceiling.
|
|
let newlineIndex = buffer.indexOf('\n')
|
|
while (newlineIndex !== -1 && !settled) {
|
|
const line = buffer.slice(0, newlineIndex)
|
|
buffer = buffer.slice(newlineIndex + 1)
|
|
if (line.trim().length === 0) {
|
|
newlineIndex = buffer.indexOf('\n')
|
|
continue
|
|
}
|
|
|
|
let raw: unknown
|
|
try {
|
|
raw = JSON.parse(line)
|
|
} catch {
|
|
finish({
|
|
ok: false,
|
|
error: new RuntimeClientError(
|
|
'invalid_runtime_response',
|
|
'The Orca runtime returned an invalid response frame.'
|
|
)
|
|
})
|
|
return
|
|
}
|
|
|
|
// Fast-path: ignore keepalives without running the full schema.
|
|
// setTimeout().refresh() is stable since Node 10 (Orca ships on
|
|
// Node 20+ via Electron and the standalone CLI targets the same
|
|
// major). See §7 risk #9.
|
|
if (isKeepaliveFrame(raw)) {
|
|
timeout.refresh()
|
|
newlineIndex = buffer.indexOf('\n')
|
|
continue
|
|
}
|
|
|
|
// 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) {
|
|
finish({
|
|
ok: false,
|
|
error: new RuntimeClientError(
|
|
'invalid_runtime_response',
|
|
'The Orca runtime returned an invalid response frame.'
|
|
)
|
|
})
|
|
return
|
|
}
|
|
|
|
// Narrow out keepalive (already filtered above) so TS can see a
|
|
// Success|Failure shape here.
|
|
const frame = parsed.data
|
|
if ('_keepalive' in frame) {
|
|
timeout.refresh()
|
|
newlineIndex = buffer.indexOf('\n')
|
|
continue
|
|
}
|
|
|
|
const response = frame as RuntimeRpcResponse<TResult>
|
|
if (response.id !== requestId) {
|
|
finish({
|
|
ok: false,
|
|
error: new RuntimeClientError(
|
|
'invalid_runtime_response',
|
|
'The Orca runtime returned a mismatched response id.'
|
|
)
|
|
})
|
|
return
|
|
}
|
|
if (response._meta?.runtimeId && response._meta.runtimeId !== metadata.runtimeId) {
|
|
finish({
|
|
ok: false,
|
|
error: new RuntimeClientError(
|
|
'runtime_unavailable',
|
|
'The Orca runtime changed while the request was in flight. Retry the command.'
|
|
)
|
|
})
|
|
return
|
|
}
|
|
finish({ ok: true, response })
|
|
return
|
|
}
|
|
})
|
|
socket.on('connect', () => {
|
|
socket.write(
|
|
`${JSON.stringify({
|
|
id: requestId,
|
|
authToken: metadata.authToken,
|
|
method,
|
|
params,
|
|
orchestrationCapability: envelope?.orchestrationCapability,
|
|
orchestrationContractVersion: envelope?.orchestrationContractVersion,
|
|
orchestrationRequestId: envelope?.orchestrationRequestId
|
|
})}\n`
|
|
)
|
|
})
|
|
})
|
|
}
|