From 52f3b19dfea8bfc10439c89b3c2783c06c834b3c Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Sun, 12 Jul 2026 21:42:43 +0200 Subject: [PATCH] feat(core-runner-protocol): v0 message schemas with round-trip tests Envelope pins protocolVersion "0" and carries the workspace-scoped auth token; discriminated union on type covers hello/ready, clone, install, scan, adapter-start, render-frame, status (stage + elapsed), and error (named causes). All .strict() so the control plane, editor, and runner cannot drift apart silently (ADR-027, PRD user story 7). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK --- .../core-runner-protocol/src/envelope.test.ts | 105 ++++++ packages/core-runner-protocol/src/envelope.ts | 24 ++ packages/core-runner-protocol/src/index.ts | 29 ++ .../core-runner-protocol/src/messages.test.ts | 310 ++++++++++++++++++ packages/core-runner-protocol/src/messages.ts | 172 ++++++++++ 5 files changed, 640 insertions(+) create mode 100644 packages/core-runner-protocol/src/envelope.test.ts create mode 100644 packages/core-runner-protocol/src/envelope.ts create mode 100644 packages/core-runner-protocol/src/messages.test.ts create mode 100644 packages/core-runner-protocol/src/messages.ts diff --git a/packages/core-runner-protocol/src/envelope.test.ts b/packages/core-runner-protocol/src/envelope.test.ts new file mode 100644 index 0000000..a156535 --- /dev/null +++ b/packages/core-runner-protocol/src/envelope.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { envelopeSchema, type Envelope } from "@/envelope"; +import type { RunnerMessage } from "@/messages"; + +function wrap(message: RunnerMessage): Envelope { + return { protocolVersion: "0", token: "wst_workspace-scoped", message }; +} + +/** Serialize → deserialize → parse: the honest wire round-trip. */ +function wireRoundTrip(value: Envelope): Envelope { + return envelopeSchema.parse(JSON.parse(JSON.stringify(value))); +} + +const oneOfEach: RunnerMessage[] = [ + { type: "hello" }, + { type: "ready" }, + { type: "clone", gitUrl: "https://git.example.com/acme.git", pat: "tok" }, + { type: "install" }, + { type: "scan" }, + { type: "adapter-start" }, + { type: "render-frame", componentId: "button", props: { label: "Go" } }, + { type: "status", stage: "installing", elapsedMs: 64_000 }, + { + type: "error", + cause: "install-failed", + message: "pnpm install exited 1", + stage: "installing", + }, +]; + +describe("envelopeSchema", () => { + it.each(oneOfEach.map((msg) => [msg.type, msg] as const))( + "accepts a wire round-trip carrying a %s message", + (_type, msg) => { + const envelope = wrap(msg); + expect(wireRoundTrip(envelope)).toEqual(envelope); + }, + ); + + it('rejects a protocolVersion other than "0"', () => { + expect(() => + envelopeSchema.parse({ + protocolVersion: "1", + token: "wst_x", + message: { type: "hello" }, + }), + ).toThrow(); + }); + + it("rejects a missing protocolVersion", () => { + expect(() => + envelopeSchema.parse({ token: "wst_x", message: { type: "hello" } }), + ).toThrow(); + }); + + it("rejects a missing token", () => { + expect(() => + envelopeSchema.parse({ + protocolVersion: "0", + message: { type: "hello" }, + }), + ).toThrow(); + }); + + it("rejects an empty token", () => { + expect(() => + envelopeSchema.parse({ + protocolVersion: "0", + token: "", + message: { type: "hello" }, + }), + ).toThrow(); + }); + + it("rejects a missing message", () => { + expect(() => + envelopeSchema.parse({ protocolVersion: "0", token: "wst_x" }), + ).toThrow(); + }); + + it("rejects an invalid inner message", () => { + expect(() => + envelopeSchema.parse({ + protocolVersion: "0", + token: "wst_x", + message: { type: "clone" }, + }), + ).toThrow(); + }); + + it("rejects unknown envelope fields", () => { + expect(() => + envelopeSchema.parse({ + protocolVersion: "0", + token: "wst_x", + message: { type: "hello" }, + workspaceId: "ws_1", + }), + ).toThrow(); + }); + + it("rejects a bare message without an envelope", () => { + expect(() => envelopeSchema.parse({ type: "hello" })).toThrow(); + }); +}); diff --git a/packages/core-runner-protocol/src/envelope.ts b/packages/core-runner-protocol/src/envelope.ts new file mode 100644 index 0000000..bf441a8 --- /dev/null +++ b/packages/core-runner-protocol/src/envelope.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { PROTOCOL_VERSION } from "./protocol-version"; +import { runnerMessageSchema } from "./messages"; + +/** + * The runner-protocol envelope (ADR-027): every wire message — both + * directions — is wrapped in it. Carries the pinned protocol version and + * the workspace-scoped auth token that the hello/ready handshake (and + * every subsequent message) is gated on. + */ +export const envelopeSchema = z + .object({ + protocolVersion: z.literal(PROTOCOL_VERSION), + /** + * Workspace-scoped auth token. Scoped to exactly one workspace — + * possession authorizes talking to that workspace's runner and + * nothing else. + */ + token: z.string().min(1), + message: runnerMessageSchema, + }) + .strict(); + +export type Envelope = z.infer; diff --git a/packages/core-runner-protocol/src/index.ts b/packages/core-runner-protocol/src/index.ts index adfa473..237f5c3 100644 --- a/packages/core-runner-protocol/src/index.ts +++ b/packages/core-runner-protocol/src/index.ts @@ -1 +1,30 @@ export { PROTOCOL_VERSION, type ProtocolVersion } from "./protocol-version"; +export { + RUNNER_STAGES, + runnerStageSchema, + type RunnerStage, + RUNNER_ERROR_CAUSES, + runnerErrorCauseSchema, + type RunnerErrorCause, + helloMessageSchema, + type HelloMessage, + readyMessageSchema, + type ReadyMessage, + cloneMessageSchema, + type CloneMessage, + installMessageSchema, + type InstallMessage, + scanMessageSchema, + type ScanMessage, + adapterStartMessageSchema, + type AdapterStartMessage, + renderFrameMessageSchema, + type RenderFrameMessage, + statusMessageSchema, + type StatusMessage, + errorMessageSchema, + type ErrorMessage, + runnerMessageSchema, + type RunnerMessage, +} from "./messages"; +export { envelopeSchema, type Envelope } from "./envelope"; diff --git a/packages/core-runner-protocol/src/messages.test.ts b/packages/core-runner-protocol/src/messages.test.ts new file mode 100644 index 0000000..a64303c --- /dev/null +++ b/packages/core-runner-protocol/src/messages.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect } from "vitest"; +import type { z } from "zod"; +import { + RUNNER_STAGES, + RUNNER_ERROR_CAUSES, + helloMessageSchema, + readyMessageSchema, + cloneMessageSchema, + installMessageSchema, + scanMessageSchema, + adapterStartMessageSchema, + renderFrameMessageSchema, + statusMessageSchema, + errorMessageSchema, + runnerMessageSchema, + type RunnerMessage, +} from "@/messages"; + +/** Serialize → deserialize → parse: the honest wire round-trip. */ +function wireRoundTrip(schema: z.ZodTypeAny, value: unknown): unknown { + return schema.parse(JSON.parse(JSON.stringify(value))); +} + +describe("helloMessageSchema", () => { + it("accepts a wire round-trip", () => { + const msg = { type: "hello" }; + expect(wireRoundTrip(helloMessageSchema, msg)).toEqual(msg); + }); + + it("rejects unknown fields", () => { + expect(() => + helloMessageSchema.parse({ type: "hello", extra: true }), + ).toThrow(); + }); + + it("rejects a wrong type literal", () => { + expect(() => helloMessageSchema.parse({ type: "ready" })).toThrow(); + }); +}); + +describe("readyMessageSchema", () => { + it("accepts a wire round-trip", () => { + const msg = { type: "ready" }; + expect(wireRoundTrip(readyMessageSchema, msg)).toEqual(msg); + }); + + it("rejects unknown fields", () => { + expect(() => + readyMessageSchema.parse({ type: "ready", capabilities: [] }), + ).toThrow(); + }); +}); + +describe("cloneMessageSchema", () => { + it("accepts a wire round-trip with a PAT", () => { + const msg = { + type: "clone", + gitUrl: "https://git.example.com/acme/design-system.git", + pat: "ghp_workspace-scoped", + }; + expect(wireRoundTrip(cloneMessageSchema, msg)).toEqual(msg); + }); + + it("accepts a public clone without a PAT", () => { + const msg = { type: "clone", gitUrl: "https://git.example.com/oss.git" }; + expect(wireRoundTrip(cloneMessageSchema, msg)).toEqual(msg); + }); + + it("rejects a missing gitUrl", () => { + expect(() => cloneMessageSchema.parse({ type: "clone" })).toThrow(); + }); + + it("rejects an empty gitUrl", () => { + expect(() => + cloneMessageSchema.parse({ type: "clone", gitUrl: "" }), + ).toThrow(); + }); + + it("rejects unknown fields", () => { + expect(() => + cloneMessageSchema.parse({ + type: "clone", + gitUrl: "https://git.example.com/x.git", + branch: "main", + }), + ).toThrow(); + }); +}); + +describe("installMessageSchema", () => { + it("accepts a wire round-trip", () => { + const msg = { type: "install" }; + expect(wireRoundTrip(installMessageSchema, msg)).toEqual(msg); + }); + + it("rejects unknown fields", () => { + expect(() => + installMessageSchema.parse({ type: "install", packageManager: "pnpm" }), + ).toThrow(); + }); +}); + +describe("scanMessageSchema", () => { + it("accepts a wire round-trip", () => { + const msg = { type: "scan" }; + expect(wireRoundTrip(scanMessageSchema, msg)).toEqual(msg); + }); + + it("rejects unknown fields", () => { + expect(() => + scanMessageSchema.parse({ type: "scan", glob: "src/**" }), + ).toThrow(); + }); +}); + +describe("adapterStartMessageSchema", () => { + it("accepts a wire round-trip", () => { + const msg = { type: "adapter-start" }; + expect(wireRoundTrip(adapterStartMessageSchema, msg)).toEqual(msg); + }); + + it("rejects unknown fields", () => { + expect(() => + adapterStartMessageSchema.parse({ type: "adapter-start", port: 5173 }), + ).toThrow(); + }); +}); + +describe("renderFrameMessageSchema", () => { + it("accepts a wire round-trip with props", () => { + const msg = { + type: "render-frame", + componentId: "button", + props: { label: "Save", disabled: false }, + }; + expect(wireRoundTrip(renderFrameMessageSchema, msg)).toEqual(msg); + }); + + it("accepts default props by omission", () => { + const msg = { type: "render-frame", componentId: "button" }; + expect(wireRoundTrip(renderFrameMessageSchema, msg)).toEqual(msg); + }); + + it("rejects a missing componentId", () => { + expect(() => + renderFrameMessageSchema.parse({ type: "render-frame" }), + ).toThrow(); + }); + + it("rejects unknown fields", () => { + expect(() => + renderFrameMessageSchema.parse({ + type: "render-frame", + componentId: "button", + viewport: "mobile", + }), + ).toThrow(); + }); +}); + +describe("statusMessageSchema", () => { + it.each(RUNNER_STAGES)("accepts a wire round-trip for stage %s", (stage) => { + const msg = { type: "status", stage, elapsedMs: 1200 }; + expect(wireRoundTrip(statusMessageSchema, msg)).toEqual(msg); + }); + + it("rejects an unknown stage", () => { + expect(() => + statusMessageSchema.parse({ + type: "status", + stage: "compiling", + elapsedMs: 0, + }), + ).toThrow(); + }); + + it("rejects a missing elapsedMs", () => { + expect(() => + statusMessageSchema.parse({ type: "status", stage: "cloning" }), + ).toThrow(); + }); + + it("rejects a negative elapsedMs", () => { + expect(() => + statusMessageSchema.parse({ + type: "status", + stage: "cloning", + elapsedMs: -1, + }), + ).toThrow(); + }); + + it("rejects a non-integer elapsedMs", () => { + expect(() => + statusMessageSchema.parse({ + type: "status", + stage: "cloning", + elapsedMs: 3.5, + }), + ).toThrow(); + }); + + it("rejects unknown fields", () => { + expect(() => + statusMessageSchema.parse({ + type: "status", + stage: "cloning", + elapsedMs: 0, + percent: 40, + }), + ).toThrow(); + }); +}); + +describe("errorMessageSchema", () => { + it.each(RUNNER_ERROR_CAUSES)( + "accepts a wire round-trip for cause %s", + (cause) => { + const msg = { type: "error", cause, message: "named cause surfaced" }; + expect(wireRoundTrip(errorMessageSchema, msg)).toEqual(msg); + }, + ); + + it("accepts an error pinned to a stage", () => { + const msg = { + type: "error", + cause: "install-failed", + message: "pnpm install exited 1", + stage: "installing", + }; + expect(wireRoundTrip(errorMessageSchema, msg)).toEqual(msg); + }); + + it("rejects an unknown cause", () => { + expect(() => + errorMessageSchema.parse({ + type: "error", + cause: "mystery", + message: "?", + }), + ).toThrow(); + }); + + it("rejects an empty message", () => { + expect(() => + errorMessageSchema.parse({ + type: "error", + cause: "clone-failed", + message: "", + }), + ).toThrow(); + }); + + it("rejects an unknown stage", () => { + expect(() => + errorMessageSchema.parse({ + type: "error", + cause: "clone-failed", + message: "x", + stage: "compiling", + }), + ).toThrow(); + }); + + it("rejects unknown fields", () => { + expect(() => + errorMessageSchema.parse({ + type: "error", + cause: "clone-failed", + message: "x", + retryable: true, + }), + ).toThrow(); + }); +}); + +describe("runnerMessageSchema (discriminated union)", () => { + const oneOfEach: RunnerMessage[] = [ + { type: "hello" }, + { type: "ready" }, + { type: "clone", gitUrl: "https://git.example.com/acme.git", pat: "tok" }, + { type: "install" }, + { type: "scan" }, + { type: "adapter-start" }, + { type: "render-frame", componentId: "button", props: { label: "Go" } }, + { type: "status", stage: "cloning", elapsedMs: 42 }, + { type: "error", cause: "clone-failed", message: "boom", stage: "cloning" }, + ]; + + it.each(oneOfEach.map((msg) => [msg.type, msg] as const))( + "routes %s through the union on a wire round-trip", + (_type, msg) => { + expect(wireRoundTrip(runnerMessageSchema, msg)).toEqual(msg); + }, + ); + + it("rejects an unknown type", () => { + expect(() => runnerMessageSchema.parse({ type: "watch" })).toThrow(); + }); + + it("rejects a missing type", () => { + expect(() => runnerMessageSchema.parse({})).toThrow(); + }); + + it("rejects a known type with another type's fields", () => { + expect(() => + runnerMessageSchema.parse({ type: "install", stage: "installing" }), + ).toThrow(); + }); +}); diff --git a/packages/core-runner-protocol/src/messages.ts b/packages/core-runner-protocol/src/messages.ts new file mode 100644 index 0000000..1006187 --- /dev/null +++ b/packages/core-runner-protocol/src/messages.ts @@ -0,0 +1,172 @@ +import { z } from "zod"; + +/** + * Runner-protocol message set, protocol version "0" (ADR-027). + * + * A discriminated union on `type`. Every message travels inside the + * envelope (`envelope.ts`) — nothing on the wire is ever parsed against a + * bare message schema. All object schemas are `.strict()` so unknown + * fields are rejected: the three sides (control plane, editor, runner) + * cannot drift apart silently. + */ + +// --- Staged progress (ui-gap §5) -------------------------------------- + +/** + * The walking-skeleton stage sequence: cloning → installing → scanning → + * starting-preview. Later protocol versions extend this behind a version + * bump, never by loosening the enum. + */ +export const RUNNER_STAGES = [ + "cloning", + "installing", + "scanning", + "starting-preview", +] as const; + +export const runnerStageSchema = z.enum(RUNNER_STAGES); + +export type RunnerStage = z.infer; + +// --- Named error causes (PRD user story 5) ---------------------------- + +/** + * Every runner failure surfaces as one of these named causes — the UI + * maps them to doctor playbook strings; "unknown cause" is not a state. + */ +export const RUNNER_ERROR_CAUSES = [ + "unauthorized", + "unsupported-protocol-version", + "invalid-message", + "invalid-git-url", + "auth-failed", + "clone-failed", + "install-failed", + "scan-failed", + "adapter-start-failed", + "render-failed", +] as const; + +export const runnerErrorCauseSchema = z.enum(RUNNER_ERROR_CAUSES); + +export type RunnerErrorCause = z.infer; + +// --- Handshake --------------------------------------------------------- + +/** Client → runner: opens the session. Auth rides on the envelope token. */ +export const helloMessageSchema = z + .object({ + type: z.literal("hello"), + }) + .strict(); + +export type HelloMessage = z.infer; + +/** Runner → client: handshake accepted; the runner will take commands. */ +export const readyMessageSchema = z + .object({ + type: z.literal("ready"), + }) + .strict(); + +export type ReadyMessage = z.infer; + +// --- Commands (control plane / editor → runner) ------------------------ + +/** Clone the workspace repository into the runner. */ +export const cloneMessageSchema = z + .object({ + type: z.literal("clone"), + gitUrl: z.string().min(1), + /** + * Personal access token for private repos. Travels only inside the + * protocol; the runner feeds it to git via an ephemeral credential + * helper — never URLs, argv, logs, or `.git/config` (spec §6). + */ + pat: z.string().min(1).optional(), + }) + .strict(); + +export type CloneMessage = z.infer; + +/** Install dependencies; the runner detects the package manager itself. */ +export const installMessageSchema = z + .object({ + type: z.literal("install"), + }) + .strict(); + +export type InstallMessage = z.infer; + +/** Run component discovery over the clone. */ +export const scanMessageSchema = z + .object({ + type: z.literal("scan"), + }) + .strict(); + +export type ScanMessage = z.infer; + +/** Start the preview adapter (dedicated port = dedicated origin, spec §9). */ +export const adapterStartMessageSchema = z + .object({ + type: z.literal("adapter-start"), + }) + .strict(); + +export type AdapterStartMessage = z.infer; + +/** Ask the adapter's frame host to render one discovered component. */ +export const renderFrameMessageSchema = z + .object({ + type: z.literal("render-frame"), + componentId: z.string().min(1), + /** Prop overrides; omitted means the component's default props. */ + props: z.record(z.unknown()).optional(), + }) + .strict(); + +export type RenderFrameMessage = z.infer; + +// --- Events (runner → control plane / editor) --------------------------- + +/** Staged, honest progress: stage + elapsed (ui-gap §5). */ +export const statusMessageSchema = z + .object({ + type: z.literal("status"), + stage: runnerStageSchema, + /** Milliseconds since the stage started. */ + elapsedMs: z.number().int().nonnegative(), + }) + .strict(); + +export type StatusMessage = z.infer; + +/** A named failure — never a blank board (PRD user story 5). */ +export const errorMessageSchema = z + .object({ + type: z.literal("error"), + cause: runnerErrorCauseSchema, + message: z.string().min(1), + /** The stage that failed, when the error maps to one (ui-gap §5). */ + stage: runnerStageSchema.optional(), + }) + .strict(); + +export type ErrorMessage = z.infer; + +// --- The union ---------------------------------------------------------- + +export const runnerMessageSchema = z.discriminatedUnion("type", [ + helloMessageSchema, + readyMessageSchema, + cloneMessageSchema, + installMessageSchema, + scanMessageSchema, + adapterStartMessageSchema, + renderFrameMessageSchema, + statusMessageSchema, + errorMessageSchema, +]); + +export type RunnerMessage = z.infer;