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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 21:42:43 +02:00
parent 15dc6b9128
commit 52f3b19dfe
5 changed files with 640 additions and 0 deletions

View File

@@ -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();
});
});

View File

@@ -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<typeof envelopeSchema>;

View File

@@ -1 +1,30 @@
export { PROTOCOL_VERSION, type ProtocolVersion } from "./protocol-version"; 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";

View File

@@ -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();
});
});

View File

@@ -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<typeof runnerStageSchema>;
// --- 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<typeof runnerErrorCauseSchema>;
// --- 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<typeof helloMessageSchema>;
/** Runner → client: handshake accepted; the runner will take commands. */
export const readyMessageSchema = z
.object({
type: z.literal("ready"),
})
.strict();
export type ReadyMessage = z.infer<typeof readyMessageSchema>;
// --- 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<typeof cloneMessageSchema>;
/** Install dependencies; the runner detects the package manager itself. */
export const installMessageSchema = z
.object({
type: z.literal("install"),
})
.strict();
export type InstallMessage = z.infer<typeof installMessageSchema>;
/** Run component discovery over the clone. */
export const scanMessageSchema = z
.object({
type: z.literal("scan"),
})
.strict();
export type ScanMessage = z.infer<typeof scanMessageSchema>;
/** 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<typeof adapterStartMessageSchema>;
/** 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<typeof renderFrameMessageSchema>;
// --- 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<typeof statusMessageSchema>;
/** 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<typeof errorMessageSchema>;
// --- The union ----------------------------------------------------------
export const runnerMessageSchema = z.discriminatedUnion("type", [
helloMessageSchema,
readyMessageSchema,
cloneMessageSchema,
installMessageSchema,
scanMessageSchema,
adapterStartMessageSchema,
renderFrameMessageSchema,
statusMessageSchema,
errorMessageSchema,
]);
export type RunnerMessage = z.infer<typeof runnerMessageSchema>;