blob: 0922df6642adfa6918feb5e28cb8b448a5728c60 [file]
import { TextDecoder } from 'node:util';
import { requireCount, requireId, requireRecord, requireString } from './codec.js';
import { invalidProtocolFrame, RuntimeHostProtocolError } from './errors.js';
import { requireHostLifecycleState } from './host-status.js';
import {
decodeSubscriptionFrame,
isSubscriptionFrameKind,
type SubscriptionFrame,
} from './session-continuity.js';
import {
decodeClientCapabilityClientFrame,
decodeClientCapabilityHostFrame,
isClientCapabilityClientFrameKind,
isClientCapabilityHostFrameKind,
type ClientCapabilityClientFrame,
type ClientCapabilityHostFrame,
} from './client-capability.js';
import {
decodeConfigurationChangedFrame,
type ConfigurationChangedFrame,
} from './configuration-change.js';
import {
decodeSessionCatalogChangedFrame,
type SessionCatalogChangedFrame,
} from './session-catalog-change.js';
import {
decodeRequestFrame,
decodeResponseFrame,
type HostLifecycleState,
type RequestFrame,
type ResponseFrame,
} from './operations.js';
export { RuntimeHostProtocolError } from './errors.js';
export * from './agent-graph.js';
export * from './interaction.js';
export * from './automation.js';
export * from './daily-review.js';
export * from './client-capability.js';
export * from './configuration-change.js';
export * from './goal.js';
export * from './plan.js';
export * from './execution-inspect.js';
export * from './external-session.js';
export * from './message.js';
export * from './operations.js';
export * from './runtime-resource.js';
export * from './session-continuity.js';
export * from './session-catalog-change.js';
export * from './session-retirement.js';
export * from './session-transcript.js';
export * from './task-ledger.js';
export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// The wire version remains v0 before the first release. This independent epoch
// lets a new Client retire a stale same-version Host whose closed schema is no
// longer safe to use.
// 10: request header operations and transport-safe credential export changed the closed schema.
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 10 as const;
// A legal sandbox-boundary expansion can consume 64 KiB before its Interaction
// envelope and independently bounded justification are added. Keep transport
// capacity large enough to represent that domain value; narrower surfaces such
// as Session continuity retain their own limits.
export const RUNTIME_HOST_MAX_FRAME_BYTES = 96 * 1024;
export const RUNTIME_HOST_MAX_IN_FLIGHT_DOMAIN_REQUESTS = 64;
export type ClientSurface = 'desktop' | 'tui' | 'run' | 'activation' | 'bot' | 'inspect';
export interface ProtocolRange {
min: number;
max: number;
}
export interface ClientHello {
kind: 'hello';
clientInstanceId: string;
surface: ClientSurface;
protocolMin: number;
protocolMax: number;
/** A missing pre-epoch v0 wire field is decoded as epoch 0. */
compatibilityEpoch: number;
}
export interface HostAccepted {
kind: 'accepted';
hostEpoch: string;
connectionId: string;
selectedProtocol: number;
/** A missing pre-epoch v0 wire field is decoded as epoch 0. */
compatibilityEpoch: number;
state: Exclude<HostLifecycleState, 'draining'>;
}
export interface HostIncompatible {
kind: 'incompatible';
hostEpoch: string;
protocolMin: number;
protocolMax: number;
/** A missing pre-epoch v0 wire field is decoded as epoch 0. */
compatibilityEpoch: number;
state: HostLifecycleState;
replacement: 'blocked_by_residency' | 'wait_for_idle_exit';
}
export interface HostDraining {
kind: 'draining';
hostEpoch: string;
}
export type HostHandshakeResult = HostAccepted | HostIncompatible | HostDraining;
export type ClientFrame = ClientHello | RequestFrame | ClientCapabilityClientFrame;
export type HostFrame =
| HostHandshakeResult
| ResponseFrame
| SubscriptionFrame
| ClientCapabilityHostFrame
| ConfigurationChangedFrame
| SessionCatalogChangedFrame;
export interface HostRegistration {
kind: 'maka-runtime-host';
schemaVersion: typeof RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION;
rootId: string;
hostEpoch: string;
endpoint: string;
protocolMin: number;
protocolMax: number;
/** A missing pre-epoch v0 registration field is decoded as epoch 0. */
compatibilityEpoch: number;
state: HostLifecycleState;
pid: number;
createdAt: string;
}
export function negotiateProtocol(client: ProtocolRange, host: ProtocolRange): number | undefined {
validateProtocolRange(client);
validateProtocolRange(host);
const selected = Math.min(client.max, host.max);
return selected >= Math.max(client.min, host.min) ? selected : undefined;
}
export function validateProtocolRange(range: ProtocolRange): void {
if (
!Number.isSafeInteger(range.min) ||
!Number.isSafeInteger(range.max) ||
range.min < 0 ||
range.max < range.min
) {
throw invalidProtocolFrame('Invalid protocol range');
}
}
export function requireClientInstanceId(value: unknown): string {
return requireId(value, 'clientInstanceId');
}
export function decodeClientFrame(value: unknown): ClientFrame {
const frame = requireRecord(value, 'client frame');
if (frame.kind === 'hello') {
const protocolMin = requireProtocolVersion(frame.protocolMin, 'protocolMin');
const protocolMax = requireProtocolVersion(frame.protocolMax, 'protocolMax');
validateProtocolRange({ min: protocolMin, max: protocolMax });
return {
kind: 'hello',
clientInstanceId: requireClientInstanceId(frame.clientInstanceId),
surface: requireSurface(frame.surface),
protocolMin,
protocolMax,
compatibilityEpoch: decodeCompatibilityEpoch(frame.compatibilityEpoch),
} satisfies ClientHello;
}
if (isClientCapabilityClientFrameKind(frame.kind)) {
return decodeClientCapabilityClientFrame(frame);
}
return decodeRequestFrame(frame);
}
export function decodeHostFrame(value: unknown): HostFrame {
const frame = requireRecord(value, 'host frame');
if (frame.kind === 'accepted') {
return {
kind: 'accepted',
hostEpoch: requireId(frame.hostEpoch, 'hostEpoch'),
connectionId: requireId(frame.connectionId, 'connectionId'),
selectedProtocol: requireProtocolVersion(frame.selectedProtocol, 'selectedProtocol'),
compatibilityEpoch: decodeCompatibilityEpoch(frame.compatibilityEpoch),
state: requireAcceptedState(frame.state),
} satisfies HostAccepted;
}
if (frame.kind === 'incompatible') {
const protocolMin = requireProtocolVersion(frame.protocolMin, 'protocolMin');
const protocolMax = requireProtocolVersion(frame.protocolMax, 'protocolMax');
validateProtocolRange({ min: protocolMin, max: protocolMax });
return {
kind: 'incompatible',
hostEpoch: requireId(frame.hostEpoch, 'hostEpoch'),
protocolMin,
protocolMax,
compatibilityEpoch: decodeCompatibilityEpoch(frame.compatibilityEpoch),
state: requireHostLifecycleState(frame.state),
replacement: requireReplacement(frame.replacement),
} satisfies HostIncompatible;
}
if (frame.kind === 'draining') {
return {
kind: 'draining',
hostEpoch: requireId(frame.hostEpoch, 'hostEpoch'),
};
}
if (isSubscriptionFrameKind(frame.kind)) return decodeSubscriptionFrame(frame);
if (isClientCapabilityHostFrameKind(frame.kind)) {
return decodeClientCapabilityHostFrame(frame);
}
if (frame.kind === 'configuration.changed') return decodeConfigurationChangedFrame(frame);
if (frame.kind === 'session.catalog.changed') return decodeSessionCatalogChangedFrame(frame);
return decodeResponseFrame(frame);
}
export function decodeHostRegistration(value: unknown): HostRegistration {
const registration = requireRecord(value, 'host registration');
if (registration.kind !== 'maka-runtime-host') {
throw invalidProtocolFrame('Invalid registration kind');
}
if (registration.schemaVersion !== RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION) {
throw invalidProtocolFrame('Unsupported registration schema');
}
const protocolMin = requireProtocolVersion(registration.protocolMin, 'protocolMin');
const protocolMax = requireProtocolVersion(registration.protocolMax, 'protocolMax');
validateProtocolRange({ min: protocolMin, max: protocolMax });
const rootId = requireString(registration.rootId, 'rootId', 128);
if (!/^[a-f0-9]{64}$/.test(rootId)) throw invalidProtocolFrame('Invalid rootId');
const pid = requireCount(registration.pid, 'pid');
if (pid === 0) throw invalidProtocolFrame('Invalid pid');
return {
kind: 'maka-runtime-host',
schemaVersion: RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
rootId,
hostEpoch: requireId(registration.hostEpoch, 'hostEpoch'),
endpoint: requireString(registration.endpoint, 'endpoint', 512),
protocolMin,
protocolMax,
compatibilityEpoch:
registration.compatibilityEpoch === undefined
? 0
: requireCompatibilityEpoch(registration.compatibilityEpoch),
state: requireHostLifecycleState(registration.state),
pid,
createdAt: requireString(registration.createdAt, 'createdAt', 64),
};
}
export function encodeProtocolFrame(value: ClientFrame | HostFrame): Buffer {
const encoded = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8');
if (encoded.byteLength > RUNTIME_HOST_MAX_FRAME_BYTES) {
throw new RuntimeHostProtocolError(
'frame_too_large',
'Runtime Host frame exceeds the byte limit',
);
}
return encoded;
}
export class ProtocolFrameDecoder {
readonly #decoder = new TextDecoder('utf-8', { fatal: true });
#pending = Buffer.alloc(0);
push(chunk: Uint8Array): unknown[] {
const frames: unknown[] = [];
let offset = 0;
while (offset < chunk.byteLength) {
const newline = chunk.indexOf(0x0a, offset);
const end = newline === -1 ? chunk.byteLength : newline;
const segment = Buffer.from(chunk.subarray(offset, end));
const delimiterBytes = newline === -1 ? 0 : 1;
if (
this.#pending.byteLength + segment.byteLength + delimiterBytes >
RUNTIME_HOST_MAX_FRAME_BYTES
) {
throw new RuntimeHostProtocolError(
'frame_too_large',
'Runtime Host frame exceeds the byte limit',
);
}
if (segment.byteLength > 0) this.#pending = Buffer.concat([this.#pending, segment]);
if (newline === -1) break;
frames.push(this.#decodePending());
this.#pending = Buffer.alloc(0);
offset = newline + 1;
}
return frames;
}
end(): void {
if (this.#pending.byteLength !== 0) {
throw new RuntimeHostProtocolError(
'invalid_frame',
'Runtime Host stream ended with a partial frame',
);
}
}
#decodePending(): unknown {
if (this.#pending.byteLength === 0) {
throw invalidProtocolFrame('Runtime Host frame is empty');
}
let text: string;
try {
const bytes = this.#pending.at(-1) === 0x0d ? this.#pending.subarray(0, -1) : this.#pending;
text = this.#decoder.decode(bytes);
} catch {
throw new RuntimeHostProtocolError('invalid_utf8', 'Runtime Host frame is not valid UTF-8');
}
try {
return JSON.parse(text) as unknown;
} catch {
throw new RuntimeHostProtocolError('invalid_json', 'Runtime Host frame is not valid JSON');
}
}
}
function requireProtocolVersion(value: unknown, label: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
return value as number;
}
function requireCompatibilityEpoch(value: unknown): number {
const epoch = requireProtocolVersion(value, 'compatibilityEpoch');
if (epoch > 1_000_000) throw invalidProtocolFrame('Invalid compatibilityEpoch');
return epoch;
}
function decodeCompatibilityEpoch(value: unknown): number {
return value === undefined ? 0 : requireCompatibilityEpoch(value);
}
function requireSurface(value: unknown): ClientSurface {
if (
value === 'desktop' ||
value === 'tui' ||
value === 'run' ||
value === 'activation' ||
value === 'bot' ||
value === 'inspect'
)
return value;
throw invalidProtocolFrame('Invalid surface');
}
function requireAcceptedState(value: unknown): Exclude<HostLifecycleState, 'draining'> {
const state = requireHostLifecycleState(value);
if (state === 'draining') throw invalidProtocolFrame('Accepted Host cannot be draining');
return state;
}
function requireReplacement(value: unknown): HostIncompatible['replacement'] {
if (value === 'blocked_by_residency' || value === 'wait_for_idle_exit') return value;
throw invalidProtocolFrame('Invalid replacement disposition');
}