Files
orca/src/main/runtime/rpc/core.ts
T

200 lines
7.0 KiB
TypeScript

// Why: single boundary between raw RPC frames and OrcaRuntimeService; keeps schema, handler, and result type on one object.
import { ZodError, type ZodType } from 'zod'
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
import type { OrcaRuntimeService } from '../orca-runtime'
import type {
DeviceCredentialInstalled,
PairingGetEndpointsParams,
PairingGetEndpointsResult,
PairingProvisionRelayParams
} from '../../../shared/mobile-relay-credential-contract'
import type { RuntimeCapability } from '../../../shared/protocol-version'
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
export type PairingRpcContext = {
getEndpoints(params: PairingGetEndpointsParams): Promise<PairingGetEndpointsResult>
provisionRelay(params: PairingProvisionRelayParams): Promise<DeviceCredentialInstalled>
}
export type RpcEnvelopeMeta = {
runtimeId: string
}
export type RpcSuccess = {
id: string
ok: true
result: unknown
streaming?: true
_meta: RpcEnvelopeMeta
}
export type RpcFailure = {
id: string
ok: false
error: {
code: string
message: string
data?: unknown
}
_meta: RpcEnvelopeMeta
}
export type RpcResponse = RpcSuccess | RpcFailure
export type RpcRequest = {
id: string
authToken: string
method: string
params?: unknown
orchestrationCapability?: string
orchestrationContractVersion?: number
orchestrationRequestId?: string
compatibilityInvocationId?: string
orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence
}
export type LegacyCoordinatorAuthorityProof = Readonly<{
runId: string
principalId: string | null
terminalHandle: string
paneKey: string
consumerGeneration: number
}>
export type RpcContext = {
runtime: OrcaRuntimeService
// Why: lets long-poll handlers release immediately on client disconnect instead of running down timeoutMs. See design doc §3.1.
signal?: AbortSignal
// Why: per-WebSocket key so the server reaps a closing socket's subscriptions without touching sibling sockets sharing the deviceToken.
connectionId?: string
// Why: shared-control multiplexes many logical streams over one socket; the frame id lets handlers register cleanup per logical stream.
requestId?: string
// Why: paired mobile device token; state-owning handlers use it to clean up when that device disconnects.
clientId?: string
// Why: navigation is keyed by revocable device identity, never by the bearer credential or transient socket id.
pairedDeviceId?: string
// Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip).
clientKind?: 'mobile' | 'runtime'
// Why: negotiation is bound to the authenticated socket, never asserted by a destructive request.
clientCapabilities?: readonly RuntimeCapability[]
// Why: Dispatch authority rides in the authenticated RPC envelope, never in user payload fields.
orchestrationCapability?: string
// Why: long-lived mutations such as ask can durably expose acceptance before their waiter settles.
recordMutationReceipt?: (receipt: unknown) => void
// Why: worker-start commits this identity with its starting Dispatch so crash recovery always has an inspectable operation.
orchestrationMutation?: {
callerFingerprint: string
requestId: string
method: string
payloadHash: string
}
// Why: only the compatibility authority router can set this trusted scope; user params cannot bypass Run consumer binding.
legacyCoordinatorRunId?: string
legacyCoordinatorAuthority?: LegacyCoordinatorAuthorityProof
revalidateLegacyCoordinator?: () => string
// Why: federation pins the authenticated saved-environment caller without exposing its token to handlers or storage.
authenticatedCallerFingerprint?: string
pairing?: PairingRpcContext
// Why: mobile terminal traffic bypasses JSON streaming; undefined on Unix/socket and non-E2EE WebSocket paths.
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
// Why: binary terminal frames arrive outside JSON-RPC once a stream is established; handlers register only the stream IDs they created.
registerBinaryStreamHandler?: (
streamId: number,
handler: (frame: TerminalStreamFrame) => void
) => () => void
}
export type RpcHandler<TParams> = (params: TParams, ctx: RpcContext) => Promise<unknown> | unknown
// Why: RpcMethod erases the param type; centralizing the cast in defineMethod sidesteps RpcHandler's contravariance.
export type RpcMethod = {
readonly name: string
readonly params: ZodType | null
readonly handler: (params: unknown, ctx: RpcContext) => Promise<unknown> | unknown
}
type DefineMethodSpec<TSchema extends ZodType | null> = {
name: string
params: TSchema
handler: RpcHandler<TSchema extends ZodType ? TSchema['_output'] : void>
}
export function defineMethod<TSchema extends ZodType | null>(
spec: DefineMethodSpec<TSchema>
): RpcMethod {
return {
name: spec.name,
params: spec.params,
handler: spec.handler as RpcMethod['handler']
}
}
export type RpcStreamingHandler<TParams> = (
params: TParams,
ctx: RpcContext,
emit: (result: unknown) => void
) => Promise<void>
// Why: the `stream` flag lets the dispatcher route these to the emit-based path instead of the one-shot Promise path.
export type RpcStreamingMethod = {
readonly name: string
readonly params: ZodType | null
readonly stream: true
readonly handler: (
params: unknown,
ctx: RpcContext,
emit: (result: unknown) => void
) => Promise<void>
}
type DefineStreamingMethodSpec<TSchema extends ZodType | null> = {
name: string
params: TSchema
handler: RpcStreamingHandler<TSchema extends ZodType ? TSchema['_output'] : void>
}
export function defineStreamingMethod<TSchema extends ZodType | null>(
spec: DefineStreamingMethodSpec<TSchema>
): RpcStreamingMethod {
return {
name: spec.name,
params: spec.params,
stream: true,
handler: spec.handler as RpcStreamingMethod['handler']
}
}
export type RpcAnyMethod = RpcMethod | RpcStreamingMethod
export function isStreamingMethod(method: RpcAnyMethod): method is RpcStreamingMethod {
return 'stream' in method && method.stream === true
}
export type RpcRegistry = ReadonlyMap<string, RpcAnyMethod>
export function buildRegistry(methods: readonly RpcAnyMethod[]): RpcRegistry {
const registry = new Map<string, RpcAnyMethod>()
for (const method of methods) {
if (registry.has(method.name)) {
throw new Error(`duplicate_rpc_method:${method.name}`)
}
registry.set(method.name, method)
}
return registry
}
export class InvalidArgumentError extends Error {
constructor(message: string) {
super(message)
this.name = 'InvalidArgumentError'
}
}
// Why: CLI surfaces one string; take the first issue's message, which each schema authors as the user-facing phrasing.
export function formatZodError(error: ZodError): string {
const first = error.issues[0]
return first?.message ?? 'invalid_argument'
}
export { ZodError }