runa/vscode/src/protocol.ts

382 lines
14 KiB
TypeScript
Raw Normal View History

import { parse, stringify } from "lossless-json";
export const PROTOCOL_VERSION = 5;
export const MAX_MESSAGE_BYTES = 1024 * 1024;
export const WEBSOCKET_SUBPROTOCOL = "runa-debug";
const ACTOR_REF = /^[A-Za-z0-9_-]{21}$/;
export type Value =
| { type: "null" }
| { type: "bool"; data: boolean }
| { type: "integer"; data: bigint }
| { type: "string" | "actor_ref"; data: string }
| { type: "list"; data: Value[] }
| { type: "record"; data: Record<string, Value> };
export interface ActorSnapshot {
log_level: LogLevel;
actor_ref: string;
mailbox_id: bigint;
implementation_id: string;
incarnation: bigint;
state: Value;
status: {
status: "idle" | "running" | "awaiting" | "paused" | "stopped" | "failed";
message?: string;
};
execution: {
vm_steps: bigint;
instruction_pointer: bigint;
call_depth: bigint;
} | null;
queued: Value[];
reserved: Value | null;
}
export type SystemKind = "stop" | "kill" | "restart" | "pause" | "resume" | "reload";
export type SystemMessage = { kind: SystemKind } | { kind: "become"; path: string };
export const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const;
export type LogLevel = (typeof LOG_LEVELS)[number];
export interface SourceFile {
path: string;
source_id: string;
source: string;
}
export interface SourceCommit {
revision: string;
source_id: string;
}
export type Command =
| { operation: "set_log_level"; parameters: { actor_ref: string; level: LogLevel } }
| {
operation: "edit_actor";
parameters: {
actor_ref: string;
state: Value | null;
messages: { reserved: Value | null; queued: Value[] } | null;
};
}
| { operation: "list_actors" | "list_sources" }
| { operation: "read_source" | "commit_source" | "source_history"; parameters: { path: string } }
| {
operation: "write_source";
parameters: { path: string; expected_source_id: string | null; source: string };
}
| {
operation: "revision_source" | "rollback_source";
parameters: { path: string; revision: string };
}
| { operation: "system"; parameters: { actor_ref: string; message: SystemMessage } }
| { operation: "inspect_actor"; parameters: { actor_ref: string } }
| {
operation: "spawn";
parameters: { path: string; state: Value; mailbox_capacity: number };
}
| { operation: "send"; parameters: { actor_ref: string; message: Value } }
| { operation: "tick"; parameters: { ticks: number; instruction_budget: number } };
export class ProtocolError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(`${code}: ${message}`);
}
}
export function isActorRef(value: unknown): value is string {
return typeof value === "string" && ACTOR_REF.test(value);
}
/** Canonical image source path, e.g. `actors/counter` (no `source/` root, no `.rn`). */
export function isSourcePath(value: unknown): value is string {
return (
typeof value === "string" &&
value.length <= 128 &&
!value.startsWith("source/") &&
value.split("/").every((part) => /^[A-Za-z0-9_-]+$/.test(part))
);
}
export function isSourceRevision(value: unknown): value is string {
return typeof value === "string" && /^[A-Za-z0-9]{1,64}$/.test(value);
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function object(value: unknown): Record<string, unknown> {
if (!isObject(value)) {
throw new Error("Expected a protocol object");
}
return value;
}
function text(value: unknown): string {
if (typeof value !== "string") throw new Error("Expected a protocol string");
return value;
}
function uint(value: unknown): bigint {
if (typeof value !== "bigint" || value < 0n || value > 18446744073709551615n) {
throw new Error("Expected an unsigned 64-bit protocol integer");
}
return value;
}
function array(value: unknown): unknown[] {
if (!Array.isArray(value)) throw new Error("Expected a protocol array");
return value;
}
export function decodeValue(input: unknown): Value {
let nodes = 0;
let bytes = 0;
const visit = (input: unknown, depth: number): Value => {
if (++nodes > 4096 || depth > 32) throw new Error("Value exceeds node/depth limits");
const value = object(input);
if (Object.keys(value).some((key) => key !== "type" && key !== "data")) {
throw new Error("Unknown portable value field");
}
const type = value.type;
switch (type) {
case "null":
if ("data" in value) throw new Error("Null must not carry data");
return { type };
case "bool":
if (typeof value.data !== "boolean") throw new Error("Invalid boolean value");
return { type, data: value.data };
case "integer":
if (
typeof value.data !== "bigint" ||
value.data < -9223372036854775808n ||
value.data > 9223372036854775807n
) {
throw new Error("Invalid signed 64-bit value");
}
return { type, data: value.data };
case "string": {
const data = text(value.data);
bytes += Buffer.byteLength(data);
if (bytes > MAX_MESSAGE_BYTES) throw new Error("Value exceeds string/key byte limit");
return { type, data };
}
case "actor_ref":
if (!isActorRef(value.data)) throw new Error("Invalid actor reference value");
return { type, data: value.data };
case "list":
return { type, data: array(value.data).map((child) => visit(child, depth + 1)) };
case "record": {
const entries = Object.entries(object(value.data)).map(([key, child]) => {
bytes += Buffer.byteLength(key);
if (bytes > MAX_MESSAGE_BYTES) throw new Error("Value exceeds string/key byte limit");
return [key, visit(child, depth + 1)];
});
return { type, data: Object.fromEntries(entries) };
}
default:
throw new Error(`Unsupported portable value type: ${String(type)}`);
}
};
return visit(input, 0);
}
function decodeActor(input: unknown): ActorSnapshot {
const actor = object(input);
if (!isActorRef(actor.actor_ref)) throw new Error("Invalid actor reference in snapshot");
const status = object(actor.status);
const name = status.status;
if (
name !== "idle" &&
name !== "running" &&
name !== "awaiting" &&
name !== "paused" &&
name !== "stopped" &&
name !== "failed"
) {
throw new Error("Unknown actor status");
}
const execution = actor.execution === null ? null : object(actor.execution);
const level = LOG_LEVELS.find((level) => level === actor.log_level);
if (level === undefined) throw new Error("Invalid actor log level");
return {
log_level: level,
actor_ref: actor.actor_ref,
mailbox_id: uint(actor.mailbox_id),
implementation_id: text(actor.implementation_id),
incarnation: uint(actor.incarnation),
state: decodeValue(actor.state),
status: name === "failed" ? { status: name, message: text(status.message) } : { status: name },
execution: execution && {
vm_steps: uint(execution.vm_steps),
instruction_pointer: uint(execution.instruction_pointer),
call_depth: uint(execution.call_depth),
},
queued: array(actor.queued).map(decodeValue),
reserved: actor.reserved === null ? null : decodeValue(actor.reserved),
};
}
function decodeSourceFile(input: unknown): SourceFile {
const file = object(input);
if (!isSourcePath(file.path)) throw new Error("Invalid source path");
const source_id = text(file.source_id);
if (!source_id) throw new Error("Invalid source identity");
return { path: file.path, source_id, source: text(file.source) };
}
function decodeSourceCommit(input: unknown): SourceCommit {
const commit = object(input);
if (!isSourceRevision(commit.revision)) throw new Error("Invalid source revision");
const source_id = text(commit.source_id);
if (!source_id) throw new Error("Invalid source identity");
return { revision: commit.revision, source_id };
}
export type Reply =
| { kind: "actors"; data: ActorSnapshot[] }
| { kind: "actor"; data: ActorSnapshot }
| { kind: "sources"; data: SourceFile[] }
| { kind: "source"; data: SourceFile }
| { kind: "source_committed"; data: SourceCommit }
| { kind: "source_history"; data: SourceCommit[] }
| { kind: "actor_spawned"; data: { actor_ref: string } }
| { kind: "acknowledged" }
| { kind: "behavior_source"; data: { source: string } }
| { kind: "tick_completed"; data: { ticks: bigint } };
function decodeEnvelope(message: string): Record<string, unknown> {
const envelope = object(
parse(message, undefined, (literal) => {
if (!/^-?(0|[1-9]\d*)$/.test(literal)) throw new Error("Non-integer protocol number");
return BigInt(literal);
}),
);
if (envelope.version !== BigInt(PROTOCOL_VERSION)) throw new Error("Protocol version mismatch");
return envelope;
}
export function decodeNotification(message: string): bigint | undefined {
const envelope = decodeEnvelope(message);
if (!Object.hasOwn(envelope, "event")) return undefined;
if (Object.keys(envelope).length !== 2) throw new Error("Invalid notification envelope");
const event = object(envelope.event);
if (event.event !== "image_changed" || Object.keys(event).length !== 2) {
throw new Error("Unsupported server event");
}
const data = object(event.data);
const revision = uint(data.revision);
if (Object.keys(data).length !== 1 || revision === 0n) {
throw new Error("Invalid image revision");
}
return revision;
}
export function decodeResponse(line: string, requestId: string, command: Command): Reply {
const response = decodeEnvelope(line);
if (response.request_id !== requestId) throw new Error("Response request ID mismatch");
const result = object(response.result);
if (Object.keys(result).length !== 1) throw new Error("Invalid response result");
if (Object.hasOwn(result, "Err")) {
const error = object(result.Err);
const code = text(error.code);
if (
![
"invalid_request",
"unsupported_version",
"not_found",
"conflict",
"capacity_exceeded",
"runtime_failure",
].includes(code)
) {
throw new Error("Unknown protocol error code");
}
throw new ProtocolError(code, text(error.message));
}
const reply = object(result.Ok);
if (command.operation === "revision_source" && reply.kind === "behavior_source") {
return { kind: "behavior_source", data: { source: text(object(reply.data).source) } };
}
if (command.operation === "list_sources" && reply.kind === "sources") {
const data = array(reply.data).map(decodeSourceFile);
if (new Set(data.map((file) => file.path)).size !== data.length) {
throw new Error("Duplicate source paths");
}
return { kind: "sources", data };
}
if (
(command.operation === "read_source" ||
command.operation === "write_source" ||
command.operation === "rollback_source") &&
reply.kind === "source"
) {
const data = decodeSourceFile(reply.data);
if (data.path !== command.parameters.path) throw new Error("Source path mismatch");
if (command.operation === "write_source" && data.source !== command.parameters.source) {
throw new Error("Written source mismatch");
}
return { kind: "source", data };
}
if (command.operation === "commit_source" && reply.kind === "source_committed") {
return { kind: "source_committed", data: decodeSourceCommit(reply.data) };
}
if (command.operation === "source_history" && reply.kind === "source_history") {
const data = array(reply.data).map(decodeSourceCommit);
if (new Set(data.map((commit) => commit.revision)).size !== data.length) {
throw new Error("Duplicate source revisions");
}
return { kind: "source_history", data };
}
if (command.operation === "spawn" && reply.kind === "actor_spawned") {
const data = object(reply.data);
if (!isActorRef(data.actor_ref)) throw new Error("Invalid spawned actor reference");
return { kind: "actor_spawned", data: { actor_ref: data.actor_ref } };
}
if (command.operation === "tick" && reply.kind === "tick_completed") {
const ticks = uint(object(reply.data).ticks);
if (ticks !== BigInt(command.parameters.ticks)) throw new Error("Tick count mismatch");
return { kind: "tick_completed", data: { ticks } };
}
if (
(command.operation === "send" ||
command.operation === "system" ||
command.operation === "set_log_level" ||
command.operation === "edit_actor") &&
reply.kind === "acknowledged"
)
return { kind: "acknowledged" };
if (command.operation === "list_actors" && reply.kind === "actors") {
const data = array(reply.data).map(decodeActor);
if (new Set(data.map((actor) => actor.actor_ref)).size !== data.length) {
throw new Error("Duplicate actor references in snapshot");
}
return { kind: "actors", data };
}
if (command.operation === "inspect_actor" && reply.kind === "actor") {
const data = decodeActor(reply.data);
if (data.actor_ref !== command.parameters.actor_ref) throw new Error("Snapshot actor mismatch");
return { kind: "actor", data };
}
throw new Error("Unexpected reply kind");
}
export function formatSnapshot(
actor: ActorSnapshot,
endpoint: string,
capturedAt: string,
connected: boolean,
): string {
const result = stringify(
{ connection: { endpoint, connected }, captured_at: capturedAt, actor },
null,
2,
);
if (result === undefined) throw new Error("Could not format actor snapshot");
return result + "\n";
}