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:
105
packages/core-runner-protocol/src/envelope.test.ts
Normal file
105
packages/core-runner-protocol/src/envelope.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
24
packages/core-runner-protocol/src/envelope.ts
Normal file
24
packages/core-runner-protocol/src/envelope.ts
Normal 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>;
|
||||
@@ -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";
|
||||
|
||||
310
packages/core-runner-protocol/src/messages.test.ts
Normal file
310
packages/core-runner-protocol/src/messages.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
172
packages/core-runner-protocol/src/messages.ts
Normal file
172
packages/core-runner-protocol/src/messages.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user