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 { 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