diff --git a/docs/decisions/0020-zod-runtime-schema-library.md b/docs/decisions/0020-zod-runtime-schema-library.md new file mode 100644 index 00000000..b29f82e8 --- /dev/null +++ b/docs/decisions/0020-zod-runtime-schema-library.md @@ -0,0 +1,84 @@ +# ADR-0020: Zod as the runtime schema and validation library + +- **Status**: Accepted +- **Date**: 2026-06-04 +- **Related**: [0011-internal-llm-abstraction.md](0011-internal-llm-abstraction.md), [0003-pure-ts-engine-not-langgraph-python.md](0003-pure-ts-engine-not-langgraph-python.md), [../tech-stack.md](../tech-stack.md), [../reference/contracts/workflow-yaml-spec.md](../reference/contracts/workflow-yaml-spec.md), [../project-structure.md](../project-structure.md) + +## Context + +`@relavium/shared` is the single source of truth for every contract — workflow/agent +YAML, the run-event union, the run record, and config ([project-structure.md](../project-structure.md)). +These contracts must do double duty: be **inferred TypeScript types** the whole monorepo +codes against, *and* be **runtime validators** at every trust boundary the engine ingests +untrusted data from (a parsed `.relavium.yaml`, an IPC payload, a provider response, a +config file). CLAUDE.md rule 1 forbids `any` and mandates `unknown` + a guard at +boundaries; the standards repeatedly require "parse with a Zod schema at the edge, then +trust inside the core." + +Doing this by hand — keeping a TypeScript `interface` and a separate runtime validator in +sync for ~30 schemas — is exactly the drift the project's "one canonical home" rule exists +to prevent. So the contract layer needs **one** library that derives the type *from* the +validator (or vice versa). [tech-stack.md](../tech-stack.md) already lists Zod under +"Schemas / types", and the Phase-0 plan names it as `@relavium/shared`'s sole runtime +dependency — but a runtime dependency requires an ADR (CLAUDE.md rule 2), and none existed. +This ADR records and authorizes that choice and its drivers. + +## Decision + +**`@relavium/shared` uses Zod (`zod`, pinned in [tech-stack.md](../tech-stack.md) via the +pnpm catalog) as its schema-and-validation library, and it is the package's only runtime +dependency.** Schemas are authored in Zod; the inferred TS types are derived with +`z.infer`, so the type and the validator can never diverge. Untrusted input is parsed with +the relevant schema at the boundary and trusted thereafter. + +Considered options: + +1. **Zod** — TypeScript-first, type *inferred from* the schema (no drift), zero runtime + dependencies of its own, discriminated unions + refinements for the cross-field rules + the contracts need, MIT, very widely adopted and actively maintained. *Chosen.* +2. **Hand-rolled validators + separate `interface`s** — no dependency, but doubles every + contract and invites exactly the type↔validator drift the package exists to eliminate. + *Rejected.* +3. **TypeBox / `io-ts` / `valibot`** — all viable schema libraries. TypeBox centres on + JSON-Schema/AJV (useful later for MCP tool schemas, but heavier for the inferred-type + ergonomics we want everywhere); `io-ts` carries an `fp-ts` idiom that is foreign to the + rest of the codebase; `valibot` is leaner but less battle-tested and ecosystem-thin for + our needs. *Rejected for the contract layer*, though nothing here precludes using a + JSON-Schema tool **inside** a specific seam (e.g. validating MCP tool schemas) where + that format is the native one. + +This is consistent with the engineering principles ([0003](0003-pure-ts-engine-not-langgraph-python.md), +[0011](0011-internal-llm-abstraction.md)): one language (TypeScript), a small vetted +dependency we wrap behind our own canonical schemas rather than a framework that owns our +control flow. Zod is a *library* (data in, validated data out), not a framework, so it +does not compromise the build-in-house posture. + +**Compatibility / maintenance.** Pinned to Zod 3.x in [tech-stack.md](../tech-stack.md); a +move to Zod 4 is a deliberate, tested version bump (the schemas already prefer the +forward-compatible two-argument `z.record(key, value)` form). Zod has no transitive runtime +dependencies, so it adds no supply-chain surface beyond itself. It runs in every host the +engine runs in (Node, the Tauri WebView, the VS Code extension host, the Phase-2 Bun API), +preserving the engine's zero-platform-imports guarantee. + +## Consequences + +### Positive + +- One artifact per contract: the Zod schema **is** the validator and the source of the + inferred type — no hand-maintained `interface` to drift from the runtime check. +- Real boundary safety: YAML, IPC, provider responses, and config are parsed with a schema + at the edge, satisfying the `unknown`-at-boundaries rule without `any`. +- Cross-field contract rules (discriminated node/trigger unions, `merge_fn`-requires-custom, + id uniqueness, transport-specific MCP fields) are expressed and enforced in the schema. +- `@relavium/shared` stays minimal — `zod` is its **only** runtime dependency. + +### Negative + +- A runtime dependency in the contract package that every other package transitively + pulls in; mitigated by Zod being dependency-free, MIT, and widely maintained, and by the + schemas being the package's entire reason to exist. +- Zod's inference has compile-time cost on very large schemas and some sharp edges (e.g. + discriminated-union members may not carry refinements); handled by keeping cross-object + rules in a parent `superRefine` rather than per-variant. +- A future major (Zod 4) is a coordinated bump; bounded by pinning the version centrally in + [tech-stack.md](../tech-stack.md) and by the conformance the schema test-suite provides. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 857284ac..e733bdd5 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -63,6 +63,7 @@ flowchart TD | 0017 | [Bun as the `apps/api` runtime](0017-cloud-runtime-bun.md) | Accepted | 2026-06-04 | | 0018 | [Desktop execution model — engine in WebView, Rust-delegated LLM egress](0018-desktop-execution-and-rust-egress.md) | Accepted | 2026-06-04 | | 0019 | [Node-side OS-keychain access for the CLI — a maintained library, not the archived keytar](0019-cli-node-keychain-library.md) | Accepted | 2026-06-04 | +| 0020 | [Zod as the runtime schema and validation library](0020-zod-runtime-schema-library.md) | Accepted | 2026-06-04 | ## Creating a new ADR diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index 176ed659..d919763e 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -58,19 +58,25 @@ export type RunEvent = | `run:started` | A run began. | `workflowId`, `inputs` (secret-typed inputs **masked** — see [Security](#security-event-payloads-never-carry-secrets)), `executionMode: 'local' \| 'cloud' \| 'managed'` | | `node:started` | A node began executing. | `nodeId`, `nodeType` | | `agent:token` | A streaming LLM token from an agent node. | `nodeId`, `token`, `model` | -| `agent:tool_call` | An agent invoked a tool. | `nodeId`, `toolId`, `toolInput` (sanitized — no secrets) | +| `agent:tool_call` | An agent invoked a tool. | `nodeId`, `model` (the invoking model — so a tool call is attributable across a failover), `toolId`, `toolInput` (sanitized — no secrets) | | `agent:tool_result` | A tool returned. | `nodeId`, `toolId`, `success`, `outputSummary` (truncated for UI) | -| `cost:updated` | A node's token cost was tallied (drives the cost waterfall). | `nodeId`, `model`, `inputTokens`, `outputTokens`, `costMicrocents`, `cumulativeCostMicrocents` (integer micro-cents — canonical unit in [llm-provider-seam.md](../shared-core/llm-provider-seam.md#6-usage)) | +| `cost:updated` | A node's token cost was tallied (drives the cost waterfall). | `nodeId`, `model`, `inputTokens`, `outputTokens`, `costMicrocents`, `cumulativeCostMicrocents` (integer micro-cents — canonical unit in [llm-provider-seam.md](../shared-core/llm-provider-seam.md#6-usage)), `attemptNumber?` (1-based retry attempt this cost belongs to, so per-attempt cost is reconstructable) | | `node:completed` | A node finished successfully. | `nodeId`, `output`, `tokensUsed: {input, output, model}`, `durationMs` | | `node:failed` | A node failed. | `nodeId`, `error: {code, message, retryable}` | | `human_gate:paused` | Execution suspended at a human gate. | `nodeId`, `gateId`, `gateType: 'approval' \| 'input' \| 'review'`, `message`, `assignee?`, `timeoutMs?`, `expiresAt?` | | `human_gate:resumed` | A gate decision was applied; execution continues. | `nodeId`, `decision: 'approved' \| 'rejected' \| 'input_provided'`, `decidedBy`, `payload?` | -| `run:completed` | The run finished. | `outputs`, `totalTokensUsed`, `durationMs` | +| `run:completed` | The run finished. | `outputs`, `totalTokensUsed`, `totalCostMicrocents` (integer micro-cents closing total for the whole run), `durationMs` | | `run:failed` | The run failed. | `error: {code, message, nodeId?}`, `partialOutputs` | | `run:cancelled` | The run was cancelled. | (base only) | ### Selected definitions +> These TypeScript shapes are **illustrative**. The enforced, runtime-validated source +> of truth is the Zod schema set in `@relavium/shared` (`run-event.ts`), from which the +> TS types are inferred ([ADR-0020](../../decisions/0020-zod-runtime-schema-library.md)). +> This document remains the canonical **contract** (the human-readable spec the schema +> implements); if the two ever diverge, this spec wins and the schema is corrected to it. + ```ts export interface AgentTokenEvent extends BaseEvent { type: 'agent:token'; @@ -87,6 +93,7 @@ export interface CostUpdatedEvent extends BaseEvent { outputTokens: number; costMicrocents: number; // integer micro-cents (canonical unit defined in llm-provider-seam.md); this attempt, from Relavium's pricing table (never the provider) cumulativeCostMicrocents: number; // integer micro-cents running total for the whole run + attemptNumber?: number; // 1-based retry attempt this cost belongs to (per-attempt cost attribution) } export interface NodeCompletedEvent extends BaseEvent { diff --git a/docs/reference/desktop/database-schema.md b/docs/reference/desktop/database-schema.md index f05b977f..8a81bda5 100644 --- a/docs/reference/desktop/database-schema.md +++ b/docs/reference/desktop/database-schema.md @@ -174,6 +174,8 @@ CREATE INDEX idx_workflows_active ON workflows (is_active, updated_at DESC) One row per workflow execution. `workflow_definition_snapshot` freezes the exact graph that ran, so a run can be replayed or inspected even after the YAML file changes. Cost is stored as integer micro-cents. +> **Logical `Run` vs persisted `RunRow`.** `@relavium/shared` exports `RunSchema` — the **narrow, engine-/surface-facing** view of a run (status, trigger, inputs/outputs, token + cost totals, timestamps). This `runs` table is the **persistence** shape and carries additional columns that are a database concern, modeled by `@relavium/db` as a distinct `RunRow` mirroring the DDL below: `workflow_definition_snapshot` (the frozen graph for replay/resume), `trigger_metadata`, `workflow_path`/`project_root`, and the `deleted_at` soft-delete cursor. Those are intentionally absent from the logical `RunSchema`; a consumer that needs them reads the `RunRow`. The split keeps the engine view free of storage details while `@relavium/db` owns the row ↔ column mapping. + | Column | Type | Constraints | |--------|------|-------------| | `id` | TEXT | PRIMARY KEY (UUID) | @@ -182,7 +184,8 @@ One row per workflow execution. `workflow_definition_snapshot` freezes the exact | `project_root` | TEXT | NULL — workspace that owned the run | | `workflow_definition_snapshot` | TEXT (JSON) | NOT NULL | | `status` | TEXT | NOT NULL DEFAULT `'pending'` — `CHECK (status IN ('pending','running','paused','completed','failed','cancelled'))` | -| `trigger_type` | TEXT | NOT NULL DEFAULT `'manual'` (`manual`, `file_change`; `webhook`/`schedule` are Phase 2) | +| `execution_mode` | TEXT | NOT NULL DEFAULT `'local'` — `CHECK (execution_mode IN ('local','cloud','managed'))`; which mode the run used (cost/billing attribution + history) | +| `trigger_type` | TEXT | NOT NULL DEFAULT `'manual'` (`manual`, `file_change`, `mcp_call`; `webhook`/`schedule` are Phase 2) | | `trigger_metadata` | TEXT (JSON) | NOT NULL DEFAULT `'{}'` | | `input_json` | TEXT (JSON) | NOT NULL DEFAULT `'{}'` | | `output_json` | TEXT (JSON) | NULL | diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 66203bdb..c5825725 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -13,13 +13,15 @@ actions. The full phase plan and the global milestone spine are in ## Where we are -**Documentation and design: complete. The monorepo scaffold (Phase 0 workstreams -0.A–0.C) has landed and is green.** The repository (`github.com/HodeTech/Relavium`) -now holds the `docs/` tree **plus** the Turborepo + pnpm workspace, the strict -`tsconfig` bases, the root ESLint/Prettier/Vitest spine, and a buildable -`@relavium/shared` scaffold — `pnpm install && pnpm turbo run lint typecheck test build` -is green and `format:check` is clean. The remaining Phase-0 workstreams (0.D/0.E -schemas, 0.F seam fence, 0.G CI, 0.H docs, 0.I `@relavium/db`) are still open before +**Documentation and design: complete. Phase 0 workstreams 0.A–0.E have landed and are +green — the toolchain (M1) and the shared schemas (M2).** The repository +(`github.com/HodeTech/Relavium`) now holds the `docs/` tree **plus** the Turborepo + +pnpm workspace, the strict `tsconfig` bases, the root ESLint/Prettier/Vitest spine, and +`@relavium/shared` with the **full Zod schema set** (`WorkflowSchema`, `AgentSchema`, +`NodeSchema`, `EdgeSchema`, the colon-namespaced `RunEvent` union, `RunSchema`, config — +114 tests, reference round-trip with no drift). `pnpm install && pnpm turbo run lint +typecheck test build` is green and `format:check` is clean. The remaining Phase-0 +workstreams (0.F seam fence, 0.G CI, 0.H docs, 0.I `@relavium/db`) are still open before **M0**. The foundation is settled and recorded: - Product vision, UVP, and hard constraints (desktop is agent-management, not an @@ -55,10 +57,11 @@ schemas, 0.F seam fence, 0.G CI, 0.H docs, 0.I `@relavium/db`) are still open be ## What is active now -The project is in **build-order step 1: scaffolding the monorepo** — the toolchain -half (0.A–0.C) is **done and green**, and the next work is the critical-path schemas -(0.D/0.E). This is **[Phase 0 — foundations](phases/phase-0-foundations.md)** (Product -Phase 1). Phase 0 ships **types and tooling, not features**; its job is to make +The project is in **build-order step 1: foundations** — the toolchain (0.A–0.C, M1) and +the critical-path shared schemas (0.D–0.E, M2) are **done and green**; the next work is +the **M0 close-out** (0.F seam fence, 0.G CI, 0.H docs, 0.I `@relavium/db`). This is +**[Phase 0 — foundations](phases/phase-0-foundations.md)** (Product Phase 1). Phase 0 +ships **types and tooling, not features**; its job is to make [Phase 1 — the engine critical path](phases/phase-1-engine-and-llm.md) safe to start against a frozen contract and a green CI gate. Until Phase 0's [exit criteria](phases/phase-0-foundations.md#exit-criteria-go--no-go) pass, no @@ -74,9 +77,11 @@ The first workstreams of Phase 0, in order. `0.A → 0.B → 0.C → 0.D` are se task lists and acceptance criteria are in [phase-0-foundations.md](phases/phase-0-foundations.md#work-breakdown). -> **✅ 0.A / 0.B / 0.C are landed and green** (the monorepo + toolchain spine). The -> active focus is now **[0.D → 0.E]** — `@relavium/shared`'s Zod schemas. Items 1–3 -> below are kept for the record with their acceptance met. +> **✅ 0.A–0.E are landed and green** (the monorepo + toolchain spine **and** the full +> `@relavium/shared` Zod schema set with reference round-trip + event-name pins). The +> active focus is now **[0.F → 0.I]** — the seam-fence lint zone, GitHub Actions CI, docs +> wiring, and the `@relavium/db` scaffold — to close out **M0**. Items 1–4 below are kept +> for the record with their acceptance met. 1. **[0.A] Scaffold the Turborepo + pnpm workspace** per [../project-structure.md](../project-structure.md): a `private` root diff --git a/docs/roadmap/phases/phase-0-foundations.md b/docs/roadmap/phases/phase-0-foundations.md index ae5bae58..d7020424 100644 --- a/docs/roadmap/phases/phase-0-foundations.md +++ b/docs/roadmap/phases/phase-0-foundations.md @@ -113,6 +113,8 @@ flowchart TD ### 0.A — Turborepo + pnpm workspace skeleton +> **✅ Done** — landed in PR #1 (merged 2026-06-04). + Create the empty-but-correct monorepo so every later package has a home and a consistent toolchain. Tooling-only at the root; no app/package logic yet. @@ -138,6 +140,10 @@ peer-dep errors. ### 0.B — Shared `tsconfig` bases +> **✅ Done** — landed in PR #1 (merged 2026-06-04). Base is **`NodeNext`** so ESM +> relative imports must carry explicit `.js` extensions (Vite surfaces override to +> `bundler` at their phase). + One strict TypeScript base every package extends, so strictness can never silently drift per package ([../../standards/code-style-typescript.md](../../standards/code-style-typescript.md#strictness)). @@ -160,6 +166,10 @@ cosmetic). ### 0.C — ESLint + Prettier + Vitest (root, shared) +> **✅ Done** — landed in PR #1 (merged 2026-06-04). `format:check` runs as a turbo +> root task; a `coverage` script wires V8 branch coverage; dev-tool versions are a +> single-source pnpm `catalog`. + Configure formatting, linting, and the test runner **once** at the root and share them across every package — Prettier owns formatting, ESLint owns correctness. @@ -182,6 +192,10 @@ introducing an `any` or a floating promise fails lint locally and would fail CI. ### 0.D — `packages/shared` package scaffold +> **✅ Done** — `@relavium/shared` ships `zod` (catalog, the sole runtime dep) and a +> `src/` laid out by contract (`constants`, `common`, `node`, `edge`, `agent`, +> `workflow`, `run-event`, `run`, `config`, curated `index`). + Create the `@relavium/shared` package shell — the first real, fully built-out package and the dependency root of the whole graph. @@ -201,6 +215,14 @@ workspace package with full types; its only runtime dependency is `zod`. ### 0.E — Shared Zod schemas + round-trip tests +> **✅ Done** — `WorkflowSchema`, `AgentSchema`, `NodeSchema`, `EdgeSchema`, the 13-variant +> colon-namespaced `RunEvent` union (+ `CostUpdatedEvent`, gate events, `GateDecision`), +> `RunSchema`, and the config schemas, with inferred types. **114 tests** cover accept + +> reject per schema, the canonical reference workflow/agent round-trip with no drift, and +> a type-level + runtime pin of the event names and the `cost:updated` payload. +> The reference example is round-tripped as a parsed **object** (YAML→object parsing is +> `@relavium/core`'s job, Phase 1), so shared's only runtime dep stays `zod`. + The heart of the phase: encode the frozen reference contracts as Zod schemas and prove they round-trip the canonical example YAML with zero drift. **Critical path.** @@ -342,8 +364,8 @@ spine milestone **M0** for this phase. | In-phase milestone | Means | Completed by | |--------------------|-------|--------------| -| 0.M1 — Toolchain green | `pnpm install` + `turbo run lint typecheck test` pass on the empty scaffold | 0.A, 0.B, 0.C | -| 0.M2 — Schemas round-trip | `@relavium/shared` exports the full schema set and round-trips the reference YAML with no drift; run-event names pinned | 0.D, 0.E | +| **0.M1 — Toolchain green ✅** | `pnpm install` + `turbo run lint typecheck test` pass on the empty scaffold | 0.A, 0.B, 0.C *(done, PR #1)* | +| **0.M2 — Schemas round-trip ✅** | `@relavium/shared` exports the full schema set and round-trips the reference example with no drift; run-event names pinned | 0.D, 0.E *(done)* | | 0.M3 — **M0: Foundations green** | CI green on push with remote cache; the seam lint fence and the standards are enforced; docs wired; `@relavium/db` scaffolded (schema + migrations + SQLite client) | 0.F, 0.G, 0.H, 0.I | ## Dependencies diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 02230480..3ddd9c5e 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -64,7 +64,7 @@ locked. - **Monorepo**: Turborepo + pnpm workspaces (see [project-structure.md](project-structure.md)) - **UI**: shadcn/ui + Radix on Tailwind, shared via `packages/ui` - **Canvas**: ReactFlow (custom node types in `packages/ui`) -- **Schemas / types**: Zod (shared via `packages/shared`) +- **Schemas / types**: Zod (shared via `packages/shared` — `@relavium/shared`'s only runtime dependency) — see [ADR-0020](decisions/0020-zod-runtime-schema-library.md) - **Testing**: Vitest (unit), Playwright (e2e) > Phase-2-only rows (PostgreSQL/Redis/BullMQ, Better Auth) are marked explicitly. diff --git a/packages/shared/package.json b/packages/shared/package.json index d27da865..5f05268d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -21,6 +21,9 @@ "lint": "eslint src", "test": "vitest run" }, + "dependencies": { + "zod": "catalog:" + }, "devDependencies": { "eslint": "catalog:", "typescript": "catalog:", diff --git a/packages/shared/src/agent.test.ts b/packages/shared/src/agent.test.ts new file mode 100644 index 00000000..ae1163ea --- /dev/null +++ b/packages/shared/src/agent.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { AgentSchema, McpServerRefSchema, MemorySchema } from './agent.js'; + +/** The reference agent example from docs/reference/contracts/agent-yaml-spec.md. */ +const summarizer = { + id: 'summarizer', + name: 'Summarizer Agent', + description: 'Produces a concise 3-bullet summary focused on a context-supplied area.', + model: 'claude-sonnet-4-6', + provider: 'anthropic', + system_prompt: 'You are a concise summarizer. Summarize the input in 3 bullet points.\n', + temperature: 0.3, + max_tokens: 512, + tools: [], + retry: { max: 3, backoff: 'exponential' }, + fallback_chain: [ + { model: 'gpt-4o', provider: 'openai', max_attempts: 2 }, + { model: 'gemini-2.5-pro', provider: 'gemini', max_attempts: 1 }, + ], +}; + +describe('AgentSchema', () => { + it('accepts and round-trips the reference agent with no drift', () => { + const once = AgentSchema.parse(summarizer); + expect(once).toEqual(summarizer); + }); + + it('rejects a missing model', () => { + expect(AgentSchema.safeParse({ ...summarizer, model: undefined }).success).toBe(false); + }); + + it('rejects an unknown provider', () => { + expect(AgentSchema.safeParse({ ...summarizer, provider: 'cohere' }).success).toBe(false); + }); + + it('rejects a fallback entry with a non-positive max_attempts', () => { + expect( + AgentSchema.safeParse({ + ...summarizer, + fallback_chain: [{ model: 'gpt-4o', provider: 'openai', max_attempts: 0 }], + }).success, + ).toBe(false); + }); + + it('rejects an empty system_prompt', () => { + expect(AgentSchema.safeParse({ ...summarizer, system_prompt: '' }).success).toBe(false); + }); + + it('rejects duplicate mcp_servers ids within an agent', () => { + const server = { id: 'gh', transport: 'stdio', command: 'npx' }; + expect( + AgentSchema.safeParse({ ...summarizer, mcp_servers: [server, { ...server }] }).success, + ).toBe(false); + }); + + it('accepts a minimal agent with only the required fields', () => { + expect( + AgentSchema.safeParse({ + id: 'minimal', + model: 'claude-sonnet-4-6', + provider: 'anthropic', + system_prompt: 'Be helpful.', + }).success, + ).toBe(true); + }); + + it('accepts optional fields independently present', () => { + const min = { id: 'a', model: 'm', provider: 'anthropic', system_prompt: 'p' }; + expect(AgentSchema.safeParse({ ...min, temperature: 0.7 }).success).toBe(true); + expect( + AgentSchema.safeParse({ ...min, memory: { type: 'window', window_size: 5 } }).success, + ).toBe(true); + expect(AgentSchema.safeParse({ ...min, retry: { max: 2, backoff: 'linear' } }).success).toBe( + true, + ); + }); + + it('accepts zero or one mcp_servers (uniqueness boundary)', () => { + const min = { id: 'a', model: 'm', provider: 'anthropic', system_prompt: 'p' }; + expect(AgentSchema.safeParse({ ...min, mcp_servers: [] }).success).toBe(true); + expect( + AgentSchema.safeParse({ + ...min, + mcp_servers: [{ id: 'one', transport: 'stdio', command: 'npx' }], + }).success, + ).toBe(true); + }); +}); + +describe('MemorySchema', () => { + it('accepts none and summary without a window_size', () => { + expect(MemorySchema.safeParse({ type: 'none' }).success).toBe(true); + expect(MemorySchema.safeParse({ type: 'summary' }).success).toBe(true); + }); + + it('requires window_size only when type is window', () => { + expect(MemorySchema.safeParse({ type: 'window', window_size: 10 }).success).toBe(true); + expect(MemorySchema.safeParse({ type: 'window' }).success).toBe(false); + expect(MemorySchema.safeParse({ type: 'window', window_size: 0 }).success).toBe(false); + }); + + it('rejects an unknown memory type', () => { + expect(MemorySchema.safeParse({ type: 'episodic' }).success).toBe(false); + }); +}); + +describe('McpServerRefSchema', () => { + it('requires command for stdio transport', () => { + expect( + McpServerRefSchema.safeParse({ id: 'github', transport: 'stdio', command: 'npx' }).success, + ).toBe(true); + expect(McpServerRefSchema.safeParse({ id: 'github', transport: 'stdio' }).success).toBe(false); + }); + + it('requires url for sse / websocket transports', () => { + expect( + McpServerRefSchema.safeParse({ + id: 'docs', + transport: 'sse', + url: 'http://localhost:4000/mcp', + }).success, + ).toBe(true); + expect(McpServerRefSchema.safeParse({ id: 'docs', transport: 'sse' }).success).toBe(false); + expect(McpServerRefSchema.safeParse({ id: 'docs', transport: 'websocket' }).success).toBe( + false, + ); + }); + + it('rejects an unknown transport', () => { + expect( + McpServerRefSchema.safeParse({ id: 'x', transport: 'grpc', url: 'http://x' }).success, + ).toBe(false); + }); + + it('rejects a malformed url', () => { + expect( + McpServerRefSchema.safeParse({ id: 'd', transport: 'sse', url: 'not-a-url' }).success, + ).toBe(false); + }); +}); diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts new file mode 100644 index 00000000..9c0a1d32 --- /dev/null +++ b/packages/shared/src/agent.ts @@ -0,0 +1,119 @@ +import { z } from 'zod'; + +import { kebabIdSchema, nonEmptyString, positiveInt } from './common.js'; +import { LLM_PROVIDERS } from './constants.js'; + +/** + * Agent schema (agent-yaml-spec.md). An agent is a named, reusable LLM + * configuration: model, provider, system prompt, generation params, tools, and + * resilience (retry + multi-provider fallback). Validated by `AgentSchema`. + * + * Agents never contain API keys — provider credentials resolve from the secret + * store at call time and are never schema-representable. + */ + +/** The supported provider id (the `LLMProvider` seam's closed set). */ +export const ProviderSchema = z.enum(LLM_PROVIDERS); + +/** Backoff curve for `retry` and for engine-side retry config. */ +export const BackoffStrategySchema = z.enum(['linear', 'exponential']); + +/** Transient-error retry on the *same* model. */ +export const RetrySchema = z.object({ + max: positiveInt, + backoff: BackoffStrategySchema, +}); +export type Retry = z.infer; + +/** Transport for an agent-declared MCP server (mcp-integration.md). */ +export const McpTransportSchema = z.enum(['stdio', 'sse', 'websocket']); + +/** + * A reference to an MCP server an agent consumes (`McpServerRef`). The transport + * dictates which connection field is required (mcp-integration.md): `stdio` needs a + * `command`; `sse`/`websocket` need a `url`. Enforced at the contract boundary so a + * mis-declared server is rejected at parse time, not at engine connect time. + * + * Intentionally distinct from the **config-level** `McpServerRegistrationSchema` + * (config.ts), which *registers* a server by `name` with a `stdio | http` transport + * (config-spec.md). These are separate contracts (agent consumption vs global + * registration) and are kept apart on purpose rather than factored together. + */ +export const McpServerRefSchema = z + .object({ + id: kebabIdSchema, + transport: McpTransportSchema, + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + url: z.string().url().optional(), + tools_allowlist: z.array(nonEmptyString).optional(), + }) + .superRefine((ref, ctx) => { + if (ref.transport === 'stdio' && !ref.command) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "command is required for the 'stdio' transport", + path: ['command'], + }); + } + if ((ref.transport === 'sse' || ref.transport === 'websocket') && !ref.url) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `url is required for the '${ref.transport}' transport`, + path: ['url'], + }); + } + }); +export type McpServerRef = z.infer; + +/** + * Conversational memory policy. Modeled as a discriminated union so that the retention + * depth (`window_size`) is required exactly when `type` is `window` (agent-yaml-spec.md): + * `none`/`summary` carry no depth; `window` must specify one. + */ +export const MemorySchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('none') }), + z.object({ type: z.literal('summary') }), + z.object({ type: z.literal('window'), window_size: positiveInt }), +]); +export type Memory = z.infer; + +/** One ordered alternate tried after the primary model is exhausted. */ +export const FallbackChainEntrySchema = z.object({ + model: nonEmptyString, + provider: ProviderSchema, + max_attempts: positiveInt, +}); +export type FallbackChainEntry = z.infer; + +/** A reusable agent definition (`.agent.yaml` or an inline `agents:` entry). */ +export const AgentSchema = z + .object({ + id: kebabIdSchema, + name: z.string().optional(), + description: z.string().optional(), + model: nonEmptyString, + provider: ProviderSchema, + system_prompt: nonEmptyString, + temperature: z.number().optional(), + max_tokens: positiveInt.optional(), + tools: z.array(nonEmptyString).optional(), + mcp_servers: z.array(McpServerRefSchema).optional(), + memory: MemorySchema.optional(), + retry: RetrySchema.optional(), + fallback_chain: z.array(FallbackChainEntrySchema).optional(), + }) + .superRefine((agent, ctx) => { + // MCP server ids must be unique within an agent (they namespace the registered tools). + const ids = (agent.mcp_servers ?? []).map((server) => server.id); + const duplicates = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))]; + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `duplicate mcp_servers id(s): ${duplicates.join(', ')}`, + path: ['mcp_servers'], + }); + } + }); +export type Agent = z.infer; diff --git a/packages/shared/src/common.ts b/packages/shared/src/common.ts new file mode 100644 index 00000000..8a06cb78 --- /dev/null +++ b/packages/shared/src/common.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +/** + * Generic Zod primitives reused across the domain schemas. These are internal + * building blocks — they are intentionally **not** re-exported from `index.ts` + * (the public surface is the named domain schemas, not these helpers). + */ + +/** + * The kebab-case body pattern (lowercase alphanumerics in dash-separated segments). + * Exported so the edge schema can build the `nodeId(:handle)?` form from the same + * source of truth rather than duplicating the regex. + */ +export const KEBAB_PATTERN = '[a-z0-9]+(?:-[a-z0-9]+)*'; + +/** A kebab-case id (`workflow.id`, `node.id`, `agent.id`, `agent_ref`). */ +export const kebabIdSchema = z + .string() + .regex( + new RegExp(`^${KEBAB_PATTERN}$`), + 'must be kebab-case (lowercase alphanumerics, dash-separated)', + ); + +/** A non-empty string. */ +export const nonEmptyString = z.string().min(1); + +/** A positive integer (>= 1). */ +export const positiveInt = z.number().int().positive(); + +/** A non-negative integer (>= 0). */ +export const nonNegativeInt = z.number().int().nonnegative(); diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts new file mode 100644 index 00000000..07e07b27 --- /dev/null +++ b/packages/shared/src/config.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { GlobalConfigSchema, ProjectConfigSchema } from './config.js'; + +describe('config schemas', () => { + it('accepts a global config.toml shape', () => { + expect( + GlobalConfigSchema.safeParse({ + update_channel: 'stable', + preferences: { default_model: 'claude-sonnet-4-6', theme: 'dark' }, + mcp_servers: [ + { + name: 'filesystem', + transport: 'stdio', + command: 'npx', + args: ['-y', 'x'], + autostart: true, + }, + ], + }).success, + ).toBe(true); + }); + + it('rejects an unknown update_channel', () => { + expect(GlobalConfigSchema.safeParse({ update_channel: 'nightly' }).success).toBe(false); + }); + + it('accepts a project.toml / workspace.toml shape', () => { + expect( + ProjectConfigSchema.safeParse({ + defaults: { model: 'gpt-4o', fs_scope: 'sandboxed' }, + variables: { focus_area: 'security and type safety' }, + }).success, + ).toBe(true); + }); + + it('rejects an unknown fs_scope tier', () => { + expect(ProjectConfigSchema.safeParse({ defaults: { fs_scope: 'everything' } }).success).toBe( + false, + ); + }); + + it('accepts empty configs (every field optional)', () => { + expect(GlobalConfigSchema.safeParse({}).success).toBe(true); + expect(ProjectConfigSchema.safeParse({}).success).toBe(true); + }); + + it('accepts an http MCP registration with a url', () => { + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [{ name: 'remote', transport: 'http', url: 'http://localhost:4000' }], + }).success, + ).toBe(true); + }); + + it('enforces transport-specific required fields on MCP registrations', () => { + expect( + GlobalConfigSchema.safeParse({ mcp_servers: [{ name: 'x', transport: 'stdio' }] }).success, + ).toBe(false); // stdio needs command + expect( + GlobalConfigSchema.safeParse({ mcp_servers: [{ name: 'x', transport: 'http' }] }).success, + ).toBe(false); // http needs url + }); + + it('accepts project-scoped MCP registrations (merge with global)', () => { + expect( + ProjectConfigSchema.safeParse({ + mcp_servers: [{ name: 'local-fs', transport: 'stdio', command: 'npx' }], + }).success, + ).toBe(true); + }); + + it('rejects a malformed url in an http MCP registration', () => { + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [{ name: 'r', transport: 'http', url: 'not-a-url' }], + }).success, + ).toBe(false); + }); +}); diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts new file mode 100644 index 00000000..e9fa83b4 --- /dev/null +++ b/packages/shared/src/config.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; + +import { nonEmptyString } from './common.js'; + +/** + * Configuration schemas (config-spec.md). Validation only — no file IO. The global + * `config.toml` and the per-project `project.toml` / `workspace.toml` are stable, + * versioned, committed formats; the per-project layer overrides the global one. + */ + +export const UpdateChannelSchema = z.enum(['stable', 'beta']); + +/** Filesystem permission tier (built-in-tools.md). */ +export const FsScopeSchema = z.enum(['sandboxed', 'project', 'full']); + +/** + * An MCP server registration (`[[mcp_servers]]`). The transport dictates the required + * connection field: `stdio` needs a `command`; `http` needs a `url`. + */ +export const McpServerRegistrationSchema = z + .object({ + name: nonEmptyString, + transport: z.enum(['stdio', 'http']), + command: z.string().optional(), + args: z.array(z.string()).optional(), + autostart: z.boolean().optional(), + url: z.string().url().optional(), + env: z.record(z.string(), z.string()).optional(), + }) + .superRefine((server, ctx) => { + if (server.transport === 'stdio' && !server.command) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "command is required for the 'stdio' transport", + path: ['command'], + }); + } + if (server.transport === 'http' && !server.url) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "url is required for the 'http' transport", + path: ['url'], + }); + } + }); + +/** `~/.relavium/config.toml` — global preferences + MCP registrations. */ +export const GlobalConfigSchema = z.object({ + update_channel: UpdateChannelSchema.optional(), + preferences: z + .object({ + default_model: z.string().optional(), + theme: z.string().optional(), + }) + .optional(), + mcp_servers: z.array(McpServerRegistrationSchema).optional(), +}); +export type GlobalConfig = z.infer; + +/** `project.toml` / `workspace.toml` — project defaults, variables, project-scoped MCP. */ +export const ProjectConfigSchema = z.object({ + defaults: z + .object({ + model: z.string().optional(), + fs_scope: FsScopeSchema.optional(), + }) + .optional(), + variables: z.record(z.string(), z.string()).optional(), + // Project-scoped MCP registrations merge with the global ones (config-spec.md §resolution). + mcp_servers: z.array(McpServerRegistrationSchema).optional(), +}); +export type ProjectConfig = z.infer; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts new file mode 100644 index 00000000..aaec832e --- /dev/null +++ b/packages/shared/src/constants.ts @@ -0,0 +1,56 @@ +/** + * Canonical literal constants shared across the schema set. These are the single + * source of truth for the closed vocabularies the rest of the package validates + * against (event names, node types, providers, execution modes). + */ + +/** The workflow/agent YAML schema version this package targets. */ +export const SCHEMA_VERSION = '1.0'; +export type SchemaVersion = typeof SCHEMA_VERSION; + +/** + * The canonical, **colon-namespaced** run-event type names (sse-event-schema.md). + * Never the legacy dotted names (`node.started`), never `node:error`/`run:error`, + * and the per-event ordinal is always `sequenceNumber`, never `seqNo`. + */ +export const RUN_EVENT_TYPES = [ + 'run:started', + 'node:started', + 'agent:token', + 'agent:tool_call', + 'agent:tool_result', + 'cost:updated', + 'node:completed', + 'node:failed', + 'human_gate:paused', + 'human_gate:resumed', + 'run:completed', + 'run:failed', + 'run:cancelled', +] as const; +export type RunEventType = (typeof RUN_EVENT_TYPES)[number]; + +/** + * The eight **authored** YAML node types (workflow-yaml-spec.md v1.0). The richer + * canvas-component / engine-enum taxonomy (which adds `tool`, `loop`, `subworkflow`) + * is reconciled in node-types.md; these eight are the user-authored surface. + */ +export const WORKFLOW_NODE_TYPES = [ + 'input', + 'agent', + 'human_gate', + 'condition', + 'transform', + 'parallel', + 'merge', + 'output', +] as const; +export type WorkflowNodeType = (typeof WORKFLOW_NODE_TYPES)[number]; + +/** The four supported LLM providers (the `LLMProvider` seam's closed id set). */ +export const LLM_PROVIDERS = ['anthropic', 'openai', 'gemini', 'deepseek'] as const; +export type LlmProviderId = (typeof LLM_PROVIDERS)[number]; + +/** The three execution modes (local BYOK, cloud BYOK-central, managed gateway). */ +export const EXECUTION_MODES = ['local', 'cloud', 'managed'] as const; +export type ExecutionMode = (typeof EXECUTION_MODES)[number]; diff --git a/packages/shared/src/edge.test.ts b/packages/shared/src/edge.test.ts new file mode 100644 index 00000000..97aab218 --- /dev/null +++ b/packages/shared/src/edge.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { EdgeSchema } from './edge.js'; + +const accepts = (edge: unknown): boolean => EdgeSchema.safeParse(edge).success; + +describe('EdgeSchema', () => { + it('accepts a plain node-to-node edge', () => { + expect(accepts({ from: 'input', to: 'fan-out' })).toBe(true); + }); + + it('accepts a branch-handle source (nodeId:handle)', () => { + expect(accepts({ from: 'severity-gate:true', to: 'human-approval' })).toBe(true); + expect(accepts({ from: 'severity-gate:7', to: 'escalate' })).toBe(true); + }); + + it('accepts optional label and condition', () => { + expect(accepts({ from: 'a', to: 'b', label: 'ok', condition: 'x > 1' })).toBe(true); + }); + + it('rejects a non-kebab-case target', () => { + expect(accepts({ from: 'a', to: 'My Node' })).toBe(false); + expect(accepts({ from: 'a', to: 'Node_2' })).toBe(false); + expect(accepts({ from: 'a', to: 'a:handle' })).toBe(false); // `to` may not carry a handle + }); + + it('rejects a malformed source node id', () => { + expect(accepts({ from: 'Bad From', to: 'b' })).toBe(false); + expect(accepts({ from: 'UPPER', to: 'b' })).toBe(false); + }); + + it('rejects missing from / to', () => { + expect(accepts({ to: 'b' })).toBe(false); + expect(accepts({ from: 'a' })).toBe(false); + expect(accepts({})).toBe(false); + }); +}); diff --git a/packages/shared/src/edge.ts b/packages/shared/src/edge.ts new file mode 100644 index 00000000..7d489c3e --- /dev/null +++ b/packages/shared/src/edge.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +import { KEBAB_PATTERN } from './common.js'; + +/** + * A directed connection between two nodes (workflow-yaml-spec.md). The contract narrows + * the *shape* of these ids: `to` is a node id (kebab-case); `from` is a node id, + * optionally suffixed with `:handle` (a condition branch's `when` value). The schema + * validates that **format** at the contract boundary (reusing the shared kebab pattern); + * node-existence and handle resolution stay the engine's job (it has the full node graph). + */ + +/** `nodeId` or `nodeId:handle`. */ +const fromSchema = z + .string() + .regex( + new RegExp(`^${KEBAB_PATTERN}(?::.+)?$`), + 'from must be a node id, optionally "nodeId:handle"', + ); + +/** `nodeId` (kebab-case). */ +const toSchema = z + .string() + .regex(new RegExp(`^${KEBAB_PATTERN}$`), 'to must be a kebab-case node id'); + +export const EdgeSchema = z.object({ + from: fromSchema, + to: toSchema, + label: z.string().optional(), + condition: z.string().optional(), +}); +export type Edge = z.infer; diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 4523d075..bc9ec59b 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -1,9 +1,38 @@ import { describe, expect, it } from 'vitest'; -import { SCHEMA_VERSION } from './index.js'; +import * as shared from './index.js'; -describe('@relavium/shared scaffold', () => { - it('pins the schema version at 1.0', () => { - expect(SCHEMA_VERSION).toBe('1.0'); +describe('@relavium/shared public surface', () => { + it('exports the canonical constants', () => { + expect(shared.SCHEMA_VERSION).toBe('1.0'); + expect(shared.RUN_EVENT_TYPES).toContain('cost:updated'); + expect(shared.WORKFLOW_NODE_TYPES).toContain('human_gate'); + expect(shared.LLM_PROVIDERS).toEqual(['anthropic', 'openai', 'gemini', 'deepseek']); + expect(shared.EXECUTION_MODES).toEqual(['local', 'cloud', 'managed']); + }); + + it('exports the full canonical schema set', () => { + const names = [ + 'WorkflowSchema', + 'AgentSchema', + 'NodeSchema', + 'EdgeSchema', + 'RunEventSchema', + 'CostUpdatedEventSchema', + 'GateDecisionSchema', + 'RunSchema', + 'GlobalConfigSchema', + 'ProjectConfigSchema', + ] as const; + for (const name of names) { + expect(shared[name]).toBeDefined(); + } + }); + + it('does not leak internal primitives from common.ts', () => { + const exported = Object.keys(shared); + for (const internal of ['kebabIdSchema', 'nonEmptyString', 'positiveInt', 'nonNegativeInt']) { + expect(exported).not.toContain(internal); + } }); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 534c65d0..e70057b4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,18 +2,19 @@ * `@relavium/shared` — Zod schemas + inferred types, the contract source of truth * for every Relavium package and surface. * - * **Phase 0 scaffold.** The full schema set — `WorkflowSchema`, `AgentSchema`, - * `NodeSchema`, `EdgeSchema`, the colon-namespaced `RunEvent` union, `CostUpdatedEvent`, - * the human-gate events, `RunSchema`, and the config schemas — lands in Phase 0 - * workstream 0.E, driven directly from the frozen reference contracts under - * `docs/reference/contracts/`. This entry currently exports only the schema-version - * constant so the package is a real, buildable dependency root for the graph. - * - * The public surface is curated here: never `export *` of internals. + * Each schema is driven directly from its canonical reference contract under + * `docs/reference/contracts/` (workflow / agent YAML, the run-event stream, config) + * and `docs/reference/shared-core/`. The schemas are a **public API**: breaking a + * field is a versioned `schema_version` event with a migration path, never a silent + * change. The internal Zod primitives in `common.ts` are deliberately not exported — + * the public surface is the named domain schemas and their inferred types below. */ -/** The workflow/agent YAML schema version this package targets. */ -export const SCHEMA_VERSION = '1.0' as const; - -/** Inferred type of {@link SCHEMA_VERSION}. */ -export type SchemaVersion = typeof SCHEMA_VERSION; +export * from './constants.js'; +export * from './agent.js'; +export * from './node.js'; +export * from './edge.js'; +export * from './workflow.js'; +export * from './run-event.js'; +export * from './run.js'; +export * from './config.js'; diff --git a/packages/shared/src/node.test.ts b/packages/shared/src/node.test.ts new file mode 100644 index 00000000..fbb04fd2 --- /dev/null +++ b/packages/shared/src/node.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { WORKFLOW_NODE_TYPES } from './constants.js'; +import { NodeSchema } from './node.js'; + +describe('NodeSchema', () => { + it('the union has exactly the eight authored node types', () => { + // Each type's acceptance is proven by "accepts a minimal valid node of each type" + // below (so a rename fails there); the count catches an extra/missing variant without + // reading Zod internals. + expect(NodeSchema.options).toHaveLength(WORKFLOW_NODE_TYPES.length); + }); + + it('accepts a minimal valid node of each authored type', () => { + const samples: unknown[] = [ + { id: 'in', type: 'input' }, + { id: 'a', type: 'agent', agent_ref: 'my-agent' }, + { id: 'g', type: 'human_gate', gate_type: 'approval' }, + { + id: 'c', + type: 'condition', + expression: 'x > 1', + branches: [{ when: true, target_node: 'a' }], + }, + { id: 't', type: 'transform', transform: '{ x: 1 }' }, + { id: 'p', type: 'parallel', parallel_of: ['a'] }, + { id: 'm', type: 'merge', merge_strategy: 'concat' }, + { id: 'o', type: 'output' }, + ]; + for (const sample of samples) { + expect(NodeSchema.safeParse(sample).success).toBe(true); + } + }); + + it('rejects a reserved/engine-only type that is not authorable in v1.0', () => { + expect(NodeSchema.safeParse({ id: 'l', type: 'loop' }).success).toBe(false); + expect(NodeSchema.safeParse({ id: 's', type: 'subworkflow' }).success).toBe(false); + expect(NodeSchema.safeParse({ id: 't', type: 'tool' }).success).toBe(false); + }); + + it('rejects a non-kebab-case node id', () => { + expect(NodeSchema.safeParse({ id: 'My_Node', type: 'input' }).success).toBe(false); + }); + + it('rejects an agent node missing agent_ref', () => { + expect(NodeSchema.safeParse({ id: 'a', type: 'agent' }).success).toBe(false); + }); + + it('rejects a human_gate with an invalid timeout_action', () => { + expect( + NodeSchema.safeParse({ + id: 'g', + type: 'human_gate', + gate_type: 'approval', + timeout_action: 'fail', + }).success, + ).toBe(false); + }); + + // Each case omits exactly one required field; the error must land on THAT field, so a + // reject can't pass for an unrelated reason. + const missingRequired: [string, unknown][] = [ + ['gate_type', { id: 'g', type: 'human_gate' }], + ['expression', { id: 'c', type: 'condition', branches: [{ when: true, target_node: 'a' }] }], + ['branches', { id: 'c', type: 'condition', expression: 'x > 1' }], + ['transform', { id: 't', type: 'transform' }], + ['parallel_of', { id: 'p', type: 'parallel' }], + ['merge_strategy', { id: 'm', type: 'merge' }], + ['agent_ref', { id: 'a', type: 'agent' }], + ]; + it.each(missingRequired)( + 'rejects a node missing required %s, with the error on that field', + (field, node) => { + const result = NodeSchema.safeParse(node); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.path.includes(field))).toBe(true); + } + }, + ); + + it('rejects empty / reserved control-node values', () => { + // condition needs >= 1 branch; parallel needs >= 1 branch; best_of_n is reserved. + expect( + NodeSchema.safeParse({ id: 'c', type: 'condition', expression: 'x', branches: [] }).success, + ).toBe(false); + expect(NodeSchema.safeParse({ id: 'p', type: 'parallel', parallel_of: [] }).success).toBe( + false, + ); + expect( + NodeSchema.safeParse({ id: 'm', type: 'merge', merge_strategy: 'best_of_n' }).success, + ).toBe(false); + }); + + it('accepts an agent node with optional overrides, and a minimal one without', () => { + expect(NodeSchema.safeParse({ id: 'a', type: 'agent', agent_ref: 'ag' }).success).toBe(true); + expect( + NodeSchema.safeParse({ + id: 'a', + type: 'agent', + agent_ref: 'ag', + prompt_template: 'p', + model: 'gpt-4o', + temperature: 0.5, + max_tokens: 500, + tools: ['read_file'], + timeout_ms: 30000, + retry: { max: 2, backoff: 'linear' }, + }).success, + ).toBe(true); + }); + + it('rejects a condition branch with a non-kebab target_node', () => { + expect( + NodeSchema.safeParse({ + id: 'c', + type: 'condition', + expression: 'x > 1', + branches: [{ when: true, target_node: 'Not Kebab' }], + }).success, + ).toBe(false); + }); +}); diff --git a/packages/shared/src/node.ts b/packages/shared/src/node.ts new file mode 100644 index 00000000..3314c20c --- /dev/null +++ b/packages/shared/src/node.ts @@ -0,0 +1,111 @@ +import { z } from 'zod'; + +import { kebabIdSchema, nonEmptyString, positiveInt } from './common.js'; +import { RetrySchema } from './agent.js'; + +/** + * The eight authored workflow node types (workflow-yaml-spec.md v1.0), modeled as + * a discriminated union on `type`. Each node carries a kebab-case `id` unique within + * the workflow. The richer canvas/engine taxonomy is reconciled in node-types.md; + * `NodeSchema` validates the user-authored YAML surface only. + */ + +/** Expression language for `condition` / `transform` (default `js`, engine-side). */ +export const ExpressionTypeSchema = z.enum(['js', 'jmespath', 'jsonlogic']); + +/** Human-gate kind. */ +export const GateTypeSchema = z.enum(['approval', 'input', 'review']); + +/** What a human gate does when its timeout elapses (canonical enum). */ +export const TimeoutActionSchema = z.enum(['reject', 'approve', 'escalate']); + +/** How a `merge` node combines its inputs (`best_of_n` is reserved, not v1.0). */ +export const MergeStrategySchema = z.enum(['concat', 'object_merge', 'first', 'custom']); + +export const InputNodeSchema = z.object({ + id: kebabIdSchema, + type: z.literal('input'), + label: z.string().optional(), +}); + +export const AgentNodeSchema = z.object({ + id: kebabIdSchema, + type: z.literal('agent'), + agent_ref: kebabIdSchema, + prompt_template: z.string().optional(), + tools: z.array(nonEmptyString).optional(), + model: nonEmptyString.optional(), + temperature: z.number().optional(), + max_tokens: positiveInt.optional(), + timeout_ms: positiveInt.optional(), + retry: RetrySchema.optional(), +}); + +export const HumanGateNodeSchema = z.object({ + id: kebabIdSchema, + type: z.literal('human_gate'), + gate_type: GateTypeSchema, + assignee: z.string().optional(), + message_template: z.string().optional(), + timeout_ms: positiveInt.optional(), + timeout_action: TimeoutActionSchema.optional(), +}); + +/** A branch of a `condition` node: `when` value → `target_node`. */ +export const ConditionBranchSchema = z.object({ + when: z.union([z.boolean(), z.string(), z.number()]), + target_node: kebabIdSchema, +}); + +export const ConditionNodeSchema = z.object({ + id: kebabIdSchema, + type: z.literal('condition'), + expression: nonEmptyString, + expression_type: ExpressionTypeSchema.optional(), + branches: z + .array(ConditionBranchSchema) + .min(1, 'a condition node must declare at least one branch'), + default: kebabIdSchema.optional(), +}); + +export const TransformNodeSchema = z.object({ + id: kebabIdSchema, + type: z.literal('transform'), + transform: nonEmptyString, + expression_type: ExpressionTypeSchema.optional(), +}); + +export const ParallelNodeSchema = z.object({ + id: kebabIdSchema, + type: z.literal('parallel'), + // Authoritative for branch membership; the parser materializes a fan-out edge per entry. + parallel_of: z.array(kebabIdSchema).min(1), +}); + +export const MergeNodeSchema = z.object({ + id: kebabIdSchema, + type: z.literal('merge'), + merge_strategy: MergeStrategySchema, + // Required only when merge_strategy = custom; enforced at the workflow level + // (a discriminated-union option cannot carry a cross-field refinement). + merge_fn: z.string().optional(), +}); + +export const OutputNodeSchema = z.object({ + id: kebabIdSchema, + type: z.literal('output'), + output_format: z.string().optional(), +}); + +/** The authored node discriminated union. */ +export const NodeSchema = z.discriminatedUnion('type', [ + InputNodeSchema, + AgentNodeSchema, + HumanGateNodeSchema, + ConditionNodeSchema, + TransformNodeSchema, + ParallelNodeSchema, + MergeNodeSchema, + OutputNodeSchema, +]); +export type WorkflowNode = z.infer; diff --git a/packages/shared/src/run-event.test.ts b/packages/shared/src/run-event.test.ts new file mode 100644 index 00000000..3eb82297 --- /dev/null +++ b/packages/shared/src/run-event.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import { RUN_EVENT_TYPES } from './constants.js'; +import { CostUpdatedEventSchema, RunEventSchema } from './run-event.js'; +import type { RunEvent, RunEventType } from './index.js'; + +const env = { runId: 'run-1', timestamp: '2026-06-04T00:00:00.000Z', sequenceNumber: 7 }; + +/** One canonical valid payload per RunEvent variant (sse-event-schema.md). */ +const valid: Record> = { + 'run:started': { + type: 'run:started', + ...env, + workflowId: 'wf', + inputs: {}, + executionMode: 'local', + }, + 'node:started': { type: 'node:started', ...env, nodeId: 'n', nodeType: 'agent' }, + 'agent:token': { + type: 'agent:token', + ...env, + nodeId: 'n', + token: 'hi', + model: 'claude-sonnet-4-6', + }, + 'agent:tool_call': { + type: 'agent:tool_call', + ...env, + nodeId: 'n', + model: 'claude-sonnet-4-6', + toolId: 'read_file', + toolInput: { path: 'x' }, + }, + 'agent:tool_result': { + type: 'agent:tool_result', + ...env, + nodeId: 'n', + toolId: 'read_file', + success: true, + outputSummary: 'ok', + }, + 'cost:updated': { + type: 'cost:updated', + ...env, + nodeId: 'n', + model: 'claude-sonnet-4-6', + inputTokens: 100, + outputTokens: 50, + costMicrocents: 1234, + cumulativeCostMicrocents: 5678, + }, + 'node:completed': { + type: 'node:completed', + ...env, + nodeId: 'n', + output: {}, + tokensUsed: { input: 1, output: 2, model: 'm' }, + durationMs: 100, + }, + 'node:failed': { + type: 'node:failed', + ...env, + nodeId: 'n', + error: { code: 'E_X', message: 'boom', retryable: false }, + }, + 'human_gate:paused': { + type: 'human_gate:paused', + ...env, + nodeId: 'n', + gateId: 'g1', + gateType: 'approval', + message: 'approve?', + }, + 'human_gate:resumed': { + type: 'human_gate:resumed', + ...env, + nodeId: 'n', + decision: 'approved', + decidedBy: 'user-1', + }, + 'run:completed': { + type: 'run:completed', + ...env, + outputs: {}, + totalTokensUsed: { input: 1, output: 2 }, + totalCostMicrocents: 999, + durationMs: 100, + }, + 'run:failed': { + type: 'run:failed', + ...env, + error: { code: 'E_X', message: 'boom' }, + partialOutputs: {}, + }, + 'run:cancelled': { type: 'run:cancelled', ...env }, +}; + +/** One targeted invalid payload per variant (a missing/invalid required field). */ +const reject: Record> = { + 'run:started (bad executionMode)': { + type: 'run:started', + ...env, + workflowId: 'wf', + inputs: {}, + executionMode: 'turbo', + }, + 'node:started (missing nodeType)': { type: 'node:started', ...env, nodeId: 'n' }, + 'agent:token (missing model)': { type: 'agent:token', ...env, nodeId: 'n', token: 'hi' }, + 'agent:tool_call (missing toolId)': { + type: 'agent:tool_call', + ...env, + nodeId: 'n', + model: 'm', + toolInput: {}, + }, + 'agent:tool_call (missing model)': { + type: 'agent:tool_call', + ...env, + nodeId: 'n', + toolId: 'read_file', + toolInput: {}, + }, + 'agent:tool_result (missing success)': { + type: 'agent:tool_result', + ...env, + nodeId: 'n', + toolId: 't', + outputSummary: 'ok', + }, + 'cost:updated (float costMicrocents)': { ...valid['cost:updated'], costMicrocents: 12.5 }, + 'node:completed (bad tokensUsed)': { + type: 'node:completed', + ...env, + nodeId: 'n', + output: {}, + tokensUsed: { input: 1 }, + durationMs: 100, + }, + 'node:failed (missing error)': { type: 'node:failed', ...env, nodeId: 'n' }, + 'human_gate:paused (bad gateType)': { + type: 'human_gate:paused', + ...env, + nodeId: 'n', + gateId: 'g', + gateType: 'sign-off', + message: 'm', + }, + 'human_gate:resumed (bad decision)': { + type: 'human_gate:resumed', + ...env, + nodeId: 'n', + decision: 'maybe', + decidedBy: 'u', + }, + 'run:completed (missing outputs)': { + type: 'run:completed', + ...env, + totalTokensUsed: { input: 1, output: 2 }, + totalCostMicrocents: 0, + durationMs: 100, + }, + 'run:completed (missing totalCostMicrocents)': { + type: 'run:completed', + ...env, + outputs: {}, + totalTokensUsed: { input: 1, output: 2 }, + durationMs: 100, + }, + 'run:failed (missing partialOutputs)': { + type: 'run:failed', + ...env, + error: { code: 'E', message: 'm' }, + }, + 'run:cancelled (negative sequenceNumber)': { type: 'run:cancelled', ...env, sequenceNumber: -1 }, +}; + +describe('RunEvent union — every variant', () => { + it.each(Object.keys(valid))('accepts a valid %s', (name) => { + expect(RunEventSchema.safeParse(valid[name]).success).toBe(true); + }); + + it.each(Object.keys(reject))('rejects %s', (name) => { + expect(RunEventSchema.safeParse(reject[name]).success).toBe(false); + }); + + it('covers exactly the 13 canonical colon-namespaced names, pinned to a literal list', () => { + // A hardcoded contract list — independent of RUN_EVENT_TYPES — so the union and the + // constant cannot silently drift together. + const CONTRACT_NAMES = [ + 'run:started', + 'node:started', + 'agent:token', + 'agent:tool_call', + 'agent:tool_result', + 'cost:updated', + 'node:completed', + 'node:failed', + 'human_gate:paused', + 'human_gate:resumed', + 'run:completed', + 'run:failed', + 'run:cancelled', + ]; + // The matrix above proves each canonical name's valid payload parses (so a + // renamed/missing variant fails there); the union member count catches an *extra* + // variant — without reaching into Zod's internal schema representation. + expect(RunEventSchema.options).toHaveLength(CONTRACT_NAMES.length); + expect(new Set(RUN_EVENT_TYPES)).toEqual(new Set(CONTRACT_NAMES)); + expect(Object.keys(valid)).toEqual(CONTRACT_NAMES); // the matrix covers all 13 + }); + + it('pins the RunEvent discriminant to RunEventType (type-level)', () => { + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe('cost:updated and sequenceNumber invariants', () => { + it('pins cost:updated to integer micro-cents', () => { + const ok = valid['cost:updated']; + expect(CostUpdatedEventSchema.safeParse(ok).success).toBe(true); + expect(CostUpdatedEventSchema.safeParse({ ...ok, costMicrocents: 12.5 }).success).toBe(false); + expect(CostUpdatedEventSchema.safeParse({ ...ok, cumulativeCostMicrocents: -1 }).success).toBe( + false, + ); + }); + + it('accepts an optional 1-based attemptNumber on cost:updated, rejects non-positive', () => { + const ok = valid['cost:updated']; + expect(CostUpdatedEventSchema.safeParse({ ...ok, attemptNumber: 2 }).success).toBe(true); + expect(CostUpdatedEventSchema.safeParse({ ...ok, attemptNumber: 0 }).success).toBe(false); + }); + + it('accepts sequenceNumber 0 but rejects negative / fractional', () => { + const cancelled = { type: 'run:cancelled', ...env }; + expect(RunEventSchema.safeParse({ ...cancelled, sequenceNumber: 0 }).success).toBe(true); + expect(RunEventSchema.safeParse({ ...cancelled, sequenceNumber: -1 }).success).toBe(false); + expect(RunEventSchema.safeParse({ ...cancelled, sequenceNumber: 1.5 }).success).toBe(false); + }); + + it('accepts the human-gate events with their optional fields present', () => { + expect( + RunEventSchema.safeParse({ + ...valid['human_gate:paused'], + assignee: 'reviewer@example.com', + timeoutMs: 1000, + expiresAt: '2026-06-04T01:00:00.000Z', + }).success, + ).toBe(true); + expect( + RunEventSchema.safeParse({ ...valid['human_gate:resumed'], payload: { input: 'yes' } }) + .success, + ).toBe(true); + }); + + it('rejects a non-ISO-8601 timestamp', () => { + expect( + RunEventSchema.safeParse({ ...env, type: 'run:cancelled', timestamp: 'June 4 2026' }).success, + ).toBe(false); + }); + + it('rejects legacy dotted and non-canonical event names', () => { + expect( + RunEventSchema.safeParse({ ...valid['cost:updated'], type: 'cost.update' }).success, + ).toBe(false); + expect(RunEventSchema.safeParse({ ...env, type: 'node:error', nodeId: 'n' }).success).toBe( + false, + ); + expect(RunEventSchema.safeParse({ ...env, type: 'run:error' }).success).toBe(false); + }); +}); diff --git a/packages/shared/src/run-event.ts b/packages/shared/src/run-event.ts new file mode 100644 index 00000000..6ee64d4f --- /dev/null +++ b/packages/shared/src/run-event.ts @@ -0,0 +1,189 @@ +import { z } from 'zod'; + +import { nonEmptyString, nonNegativeInt, positiveInt } from './common.js'; +import { EXECUTION_MODES } from './constants.js'; +import { GateTypeSchema } from './node.js'; + +/** + * The run-event stream contract (sse-event-schema.md). Every run produces one ordered + * stream of `RunEvent` objects, identical on every surface and transport. Event names + * are the canonical **colon-namespaced** form; the per-event ordinal is `sequenceNumber`. + */ + +/** Fields every event carries (the `BaseEvent` envelope), minus the discriminator. */ +const baseFields = { + runId: nonEmptyString, + timestamp: z.string().datetime({ offset: true }), // ISO 8601 (UTC `Z` or an offset) + sequenceNumber: nonNegativeInt, +}; + +/** The common envelope, exported for consumers that need the base shape alone. */ +export const BaseEventSchema = z.object({ type: z.string(), ...baseFields }); +export type BaseEvent = z.infer; + +export const TokensUsedSchema = z.object({ + input: nonNegativeInt, + output: nonNegativeInt, + model: nonEmptyString, +}); +export type TokensUsed = z.infer; + +/** A gate decision value, shared by the resumed event and `GateDecision`. */ +export const GateDecisionValueSchema = z.enum(['approved', 'rejected', 'input_provided']); +export type GateDecisionValue = z.infer; + +export const RunStartedEventSchema = z.object({ + type: z.literal('run:started'), + ...baseFields, + workflowId: nonEmptyString, + inputs: z.record(z.string(), z.unknown()), // secret-typed inputs are masked at emit time + executionMode: z.enum(EXECUTION_MODES), +}); + +export const NodeStartedEventSchema = z.object({ + type: z.literal('node:started'), + ...baseFields, + nodeId: nonEmptyString, + nodeType: nonEmptyString, +}); + +export const AgentTokenEventSchema = z.object({ + type: z.literal('agent:token'), + ...baseFields, + nodeId: nonEmptyString, + token: z.string(), + model: nonEmptyString, +}); + +export const AgentToolCallEventSchema = z.object({ + type: z.literal('agent:tool_call'), + ...baseFields, + nodeId: nonEmptyString, + model: nonEmptyString, // the invoking model — attributable across a failover + toolId: nonEmptyString, + toolInput: z.unknown(), // sanitized — no secrets +}); + +export const AgentToolResultEventSchema = z.object({ + type: z.literal('agent:tool_result'), + ...baseFields, + nodeId: nonEmptyString, + toolId: nonEmptyString, + success: z.boolean(), + outputSummary: z.string(), +}); + +export const CostUpdatedEventSchema = z.object({ + type: z.literal('cost:updated'), + ...baseFields, + nodeId: nonEmptyString, + model: nonEmptyString, + inputTokens: nonNegativeInt, + outputTokens: nonNegativeInt, + costMicrocents: nonNegativeInt, // integer micro-cents (canonical unit); from Relavium's pricing table, never the provider + cumulativeCostMicrocents: nonNegativeInt, + attemptNumber: positiveInt.optional(), // 1-based retry attempt this cost belongs to +}); +export type CostUpdatedEvent = z.infer; + +export const NodeCompletedEventSchema = z.object({ + type: z.literal('node:completed'), + ...baseFields, + nodeId: nonEmptyString, + output: z.unknown(), + tokensUsed: TokensUsedSchema, + durationMs: nonNegativeInt, +}); + +export const NodeFailedEventSchema = z.object({ + type: z.literal('node:failed'), + ...baseFields, + nodeId: nonEmptyString, + error: z.object({ code: nonEmptyString, message: z.string(), retryable: z.boolean() }), +}); + +export const HumanGatePausedEventSchema = z.object({ + type: z.literal('human_gate:paused'), + ...baseFields, + nodeId: nonEmptyString, + gateId: nonEmptyString, + gateType: GateTypeSchema, + message: z.string(), + assignee: z.string().optional(), + timeoutMs: nonNegativeInt.optional(), + expiresAt: z.string().datetime({ offset: true }).optional(), +}); +export type HumanGatePausedEvent = z.infer; + +export const HumanGateResumedEventSchema = z.object({ + type: z.literal('human_gate:resumed'), + ...baseFields, + nodeId: nonEmptyString, + decision: GateDecisionValueSchema, + decidedBy: nonEmptyString, + payload: z.unknown().optional(), +}); +export type HumanGateResumedEvent = z.infer; + +/** Either human-gate event (convenience union). */ +export type HumanGateEvent = HumanGatePausedEvent | HumanGateResumedEvent; + +export const RunCompletedEventSchema = z.object({ + type: z.literal('run:completed'), + ...baseFields, + outputs: z.record(z.string(), z.unknown()), + totalTokensUsed: z.object({ input: nonNegativeInt, output: nonNegativeInt }), + totalCostMicrocents: nonNegativeInt, // integer micro-cents closing total for the run + durationMs: nonNegativeInt, +}); + +export const RunFailedEventSchema = z.object({ + type: z.literal('run:failed'), + ...baseFields, + error: z.object({ code: nonEmptyString, message: z.string(), nodeId: z.string().optional() }), + partialOutputs: z.record(z.string(), z.unknown()), +}); + +export const RunCancelledEventSchema = z.object({ + type: z.literal('run:cancelled'), + ...baseFields, +}); + +/** The full discriminated union every surface consumes. */ +export const RunEventSchema = z.discriminatedUnion('type', [ + RunStartedEventSchema, + NodeStartedEventSchema, + AgentTokenEventSchema, + AgentToolCallEventSchema, + AgentToolResultEventSchema, + CostUpdatedEventSchema, + NodeCompletedEventSchema, + NodeFailedEventSchema, + HumanGatePausedEventSchema, + HumanGateResumedEventSchema, + RunCompletedEventSchema, + RunFailedEventSchema, + RunCancelledEventSchema, +]); +export type RunEvent = z.infer; + +// Per-variant inferred types, for consumers that handle a specific event. +export type RunStartedEvent = z.infer; +export type NodeStartedEvent = z.infer; +export type AgentTokenEvent = z.infer; +export type AgentToolCallEvent = z.infer; +export type AgentToolResultEvent = z.infer; +export type NodeCompletedEvent = z.infer; +export type NodeFailedEvent = z.infer; +export type RunCompletedEvent = z.infer; +export type RunFailedEvent = z.infer; +export type RunCancelledEvent = z.infer; + +/** The decision applied to resume a human gate (`engine.resume(runId, gateId, decision)`). */ +export const GateDecisionSchema = z.object({ + decision: GateDecisionValueSchema, + decidedBy: nonEmptyString, // user id or 'timeout_escalation' + payload: z.unknown().optional(), + comment: z.string().optional(), +}); +export type GateDecision = z.infer; diff --git a/packages/shared/src/run.test.ts b/packages/shared/src/run.test.ts new file mode 100644 index 00000000..06480554 --- /dev/null +++ b/packages/shared/src/run.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { RunSchema, RunStatusSchema } from './run.js'; + +const run = { + id: '3a398e0e-0000-4000-8000-000000000000', + workflowId: 'code-review-pipeline', + status: 'running', + executionMode: 'local', + triggerType: 'manual', + inputs: { file_path: 'src/x.ts' }, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + createdAt: 1717459200000, + updatedAt: 1717459200000, +}; + +describe('RunSchema', () => { + it('accepts a run record', () => { + expect(RunSchema.safeParse(run).success).toBe(true); + }); + + it('pins the run-status set to the DB CHECK enum', () => { + expect(RunStatusSchema.options).toEqual([ + 'pending', + 'running', + 'paused', + 'completed', + 'failed', + 'cancelled', + ]); + }); + + it('rejects an unknown status', () => { + expect(RunSchema.safeParse({ ...run, status: 'frozen' }).success).toBe(false); + }); + + it('rejects an unknown execution mode', () => { + expect(RunSchema.safeParse({ ...run, executionMode: 'turbo' }).success).toBe(false); + }); + + it('rejects a fractional token count (integers only)', () => { + expect(RunSchema.safeParse({ ...run, totalInputTokens: 1.5 }).success).toBe(false); + }); + + it('requires a UUID run id', () => { + expect(RunSchema.safeParse({ ...run, id: 'not-a-uuid' }).success).toBe(false); + }); + + it('accepts a completed run with outputs, and a running run without', () => { + expect(RunSchema.safeParse(run).success).toBe(true); // no outputs + expect( + RunSchema.safeParse({ + ...run, + status: 'completed', + outputs: { report: 'done' }, + completedAt: 1717459260000, + }).success, + ).toBe(true); + }); + + it('enforces temporal invariants (completedAt >= startedAt, updatedAt >= createdAt)', () => { + expect(RunSchema.safeParse({ ...run, startedAt: 2000, completedAt: 1000 }).success).toBe(false); + expect(RunSchema.safeParse({ ...run, createdAt: 2000, updatedAt: 1000 }).success).toBe(false); + expect(RunSchema.safeParse({ ...run, startedAt: 1000, completedAt: 2000 }).success).toBe(true); + }); + + it('accepts the optional completion fields (error, startedAt, completedAt) when present', () => { + // Absence is covered by the base `run` fixture (a running run with none of them). + expect( + RunSchema.safeParse({ + ...run, + status: 'failed', + error: { code: 'E_FAIL', message: 'boom', nodeId: 'scan' }, + startedAt: 1717459210000, + completedAt: 1717459260000, + }).success, + ).toBe(true); + }); +}); diff --git a/packages/shared/src/run.ts b/packages/shared/src/run.ts new file mode 100644 index 00000000..177af9a9 --- /dev/null +++ b/packages/shared/src/run.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; + +import { kebabIdSchema, nonEmptyString, nonNegativeInt } from './common.js'; +import { EXECUTION_MODES } from './constants.js'; +import { TriggerTypeSchema } from './workflow.js'; + +/** + * The logical run record (`RunSchema`) — the **engine-/surface-facing** shape of a + * workflow execution. + * + * **Boundary (logical vs persisted).** `RunSchema` is deliberately the *narrow* view. + * The persisted row carries additional columns that are a **persistence concern owned + * by `@relavium/db`** (workstream 0.I), modeled there as a distinct `RunRow` mirroring + * the canonical DDL in + * [database-schema.md](../../../docs/reference/desktop/database-schema.md): notably + * `workflow_definition_snapshot` (the frozen graph for replay/resume — an engine + * deliverable), `trigger_metadata`, `workflow_path`/`project_root`, and the + * `deleted_at` soft-delete cursor. Those do not belong on the logical run view and are + * intentionally absent here; a consumer that needs them reads the `RunRow` from + * `@relavium/db`. Timestamps are epoch-milliseconds; money is integer micro-cents. + */ + +/** Run lifecycle status (matches the `runs.status` CHECK in database-schema.md). */ +export const RunStatusSchema = z.enum([ + 'pending', + 'running', + 'paused', + 'completed', + 'failed', + 'cancelled', +]); +export type RunStatus = z.infer; + +export const RunSchema = z + .object({ + id: z.string().uuid(), // run id (UUID, generated in application code) + workflowId: kebabIdSchema, + status: RunStatusSchema, + // Which mode the run used — persisted (`runs.execution_mode`) for cost/billing + // attribution and history, matching the `run:started` event's `executionMode`. + executionMode: z.enum(EXECUTION_MODES), + // What triggered this run — the canonical trigger vocabulary (the runs table sets no + // strict CHECK on trigger_type, so all five values are valid). + triggerType: TriggerTypeSchema, + inputs: z.record(z.string(), z.unknown()), + outputs: z.record(z.string(), z.unknown()).optional(), + error: z + .object({ code: nonEmptyString, message: z.string(), nodeId: z.string().optional() }) + .optional(), + startedAt: nonNegativeInt.optional(), // epoch ms + completedAt: nonNegativeInt.optional(), + totalInputTokens: nonNegativeInt, + totalOutputTokens: nonNegativeInt, + totalCostMicrocents: nonNegativeInt, + createdAt: nonNegativeInt, + updatedAt: nonNegativeInt, + }) + .superRefine((run, ctx) => { + // Temporal invariants: a run cannot finish before it starts, or be updated before it was created. + if ( + run.startedAt !== undefined && + run.completedAt !== undefined && + run.completedAt < run.startedAt + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'completedAt must be >= startedAt', + path: ['completedAt'], + }); + } + if (run.updatedAt < run.createdAt) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'updatedAt must be >= createdAt', + path: ['updatedAt'], + }); + } + }); +export type Run = z.infer; diff --git a/packages/shared/src/workflow.test.ts b/packages/shared/src/workflow.test.ts new file mode 100644 index 00000000..47e1b2cf --- /dev/null +++ b/packages/shared/src/workflow.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from 'vitest'; + +import { WorkflowSchema } from './workflow.js'; + +/** + * The canonical reference workflow example, transcribed verbatim from the "Complete + * example" in docs/reference/contracts/workflow-yaml-spec.md (as the parsed object — + * YAML→object parsing is `@relavium/core`'s responsibility). This fixture is the + * **no-drift anchor**: the schema must accept it and round-trip it unchanged. + */ +const codeReviewPipeline = { + schema_version: '1.0', + workflow: { + id: 'code-review-pipeline', + version: '1.2.0', + name: 'Code Review Pipeline', + description: 'Three-stage code review: security scan, style review, and human approval gate.\n', + tags: ['engineering', 'review', 'security'], + trigger: { + type: 'file_change', + file_change: { glob: 'src/**/*.ts', debounce_ms: 2000 }, + }, + inputs: [ + { + name: 'file_path', + type: 'file_path', + required: true, + description: 'Path to the TypeScript file to review', + }, + { + name: 'reviewer_email', + type: 'string', + required: false, + default: 'team@example.com', + description: 'Email to notify when human gate is reached', + }, + ], + context: [ + { key: 'focus_area', value: 'security vulnerabilities and type safety' }, + { key: 'code_content', value: '{{inputs.file_path | read_file}}' }, + ], + agents: [ + { + id: 'security-scanner', + name: 'Security Scanner', + model: 'claude-sonnet-4-6', + provider: 'anthropic', + system_prompt: 'You are a security-focused code reviewer. Return JSON.\n', + temperature: 0.1, + max_tokens: 1024, + retry: { max: 3, backoff: 'exponential' }, + fallback_chain: [{ model: 'gpt-4o', provider: 'openai', max_attempts: 2 }], + }, + { + id: 'style-reviewer', + name: 'Style Reviewer', + model: 'claude-sonnet-4-6', + provider: 'anthropic', + system_prompt: 'You are a TypeScript style and architecture reviewer. Return JSON.\n', + temperature: 0.2, + max_tokens: 1024, + }, + { + id: 'report-synthesizer', + name: 'Report Synthesizer', + model: 'claude-sonnet-4-6', + provider: 'anthropic', + system_prompt: 'Combine the security scan and style review into one markdown report.\n', + temperature: 0.3, + max_tokens: 2048, + }, + ], + nodes: [ + { id: 'input', type: 'input' }, + { id: 'fan-out', type: 'parallel', parallel_of: ['security-scan-node', 'style-review-node'] }, + { + id: 'security-scan-node', + type: 'agent', + agent_ref: 'security-scanner', + prompt_template: 'Review this TypeScript file for security issues.\n', + timeout_ms: 60000, + }, + { + id: 'style-review-node', + type: 'agent', + agent_ref: 'style-reviewer', + prompt_template: 'Review this TypeScript file for style and architecture.\n', + timeout_ms: 60000, + }, + { id: 'merge', type: 'merge', merge_strategy: 'object_merge' }, + { + id: 'severity-gate', + type: 'condition', + expression: 'run.outputs["security-scan-node"].score < 7', + branches: [ + { when: true, target_node: 'human-approval' }, + { when: false, target_node: 'synthesize-report' }, + ], + default: 'synthesize-report', + }, + { + id: 'human-approval', + type: 'human_gate', + gate_type: 'approval', + assignee: '{{inputs.reviewer_email}}', + message_template: 'Security scan flagged issues. Approve to continue.\n', + timeout_ms: 86400000, + timeout_action: 'reject', + }, + { + id: 'synthesize-report', + type: 'agent', + agent_ref: 'report-synthesizer', + prompt_template: 'Security results and style results follow.\n', + timeout_ms: 45000, + }, + { id: 'output', type: 'output', output_format: 'markdown' }, + ], + edges: [ + { from: 'input', to: 'fan-out' }, + { from: 'fan-out', to: 'security-scan-node' }, + { from: 'fan-out', to: 'style-review-node' }, + { from: 'security-scan-node', to: 'merge' }, + { from: 'style-review-node', to: 'merge' }, + { from: 'merge', to: 'severity-gate' }, + { from: 'severity-gate:true', to: 'human-approval' }, + { from: 'severity-gate:false', to: 'synthesize-report' }, + { from: 'human-approval', to: 'synthesize-report' }, + { from: 'synthesize-report', to: 'output' }, + ], + }, +}; + +const base = codeReviewPipeline; +/** Build an invalid/variant doc (typed `unknown`, fed to `safeParse`). */ +const withWorkflow = (over: Record): unknown => ({ + ...base, + workflow: { ...base.workflow, ...over }, +}); +const accepts = (doc: unknown): boolean => WorkflowSchema.safeParse(doc).success; + +describe('WorkflowSchema', () => { + it('accepts the canonical reference example', () => { + expect(() => WorkflowSchema.parse(codeReviewPipeline)).not.toThrow(); + }); + + it('round-trips the reference example with no drift', () => { + const once = WorkflowSchema.parse(codeReviewPipeline); + // No fields stripped or injected: the parsed output equals the source object. + expect(once).toEqual(codeReviewPipeline); + // Idempotent through a serialize → re-parse cycle. + const twice = WorkflowSchema.parse(JSON.parse(JSON.stringify(once)) as unknown); + expect(twice).toEqual(once); + }); + + it('rejects a missing schema_version', () => { + expect(accepts({ workflow: base.workflow })).toBe(false); + }); + + it('rejects an unknown schema_version (the literal is the migration anchor)', () => { + expect(accepts({ ...base, schema_version: '2.0' })).toBe(false); + }); + + it('rejects an unknown node type', () => { + expect( + accepts( + withWorkflow({ nodes: [...base.workflow.nodes, { id: 'mystery', type: 'frobnicate' }] }), + ), + ).toBe(false); + }); + + it('rejects duplicate node ids', () => { + expect( + accepts(withWorkflow({ nodes: [...base.workflow.nodes, { id: 'output', type: 'output' }] })), + ).toBe(false); + }); + + it('rejects a merge node with merge_strategy=custom but no merge_fn', () => { + const nodes = base.workflow.nodes.map((n) => + n.id === 'merge' ? { ...n, merge_strategy: 'custom' } : n, + ); + expect(accepts(withWorkflow({ nodes }))).toBe(false); + }); + + it('accepts a custom merge when merge_fn is provided', () => { + const nodes = base.workflow.nodes.map((n) => + n.id === 'merge' ? { ...n, merge_strategy: 'custom', merge_fn: '{ ...a, ...b }' } : n, + ); + expect(accepts(withWorkflow({ nodes }))).toBe(true); + }); + + it('rejects a non-kebab-case workflow id', () => { + expect(accepts(withWorkflow({ id: 'Code_Review_Pipeline' }))).toBe(false); + }); + + it('rejects an unknown trigger type', () => { + expect(accepts(withWorkflow({ trigger: { type: 'cron' } }))).toBe(false); + }); + + it('rejects a webhook trigger missing its required sub-fields', () => { + expect(accepts(withWorkflow({ trigger: { type: 'webhook', webhook: { path: '/x' } } }))).toBe( + false, + ); + expect( + accepts( + withWorkflow({ trigger: { type: 'webhook', webhook: { path: '/x', secret_env: 'S' } } }), + ), + ).toBe(true); + }); + + it('rejects an unknown input type', () => { + expect(accepts(withWorkflow({ inputs: [{ name: 'when', type: 'datetime' }] }))).toBe(false); + }); + + it('rejects an explicit fan-out edge that contradicts parallel_of', () => { + // `output` is not in the fan-out node's parallel_of. + expect( + accepts(withWorkflow({ edges: [...base.workflow.edges, { from: 'fan-out', to: 'output' }] })), + ).toBe(false); + }); + + it('rejects a trigger type whose required payload is absent', () => { + expect(accepts(withWorkflow({ trigger: { type: 'webhook' } }))).toBe(false); + expect(accepts(withWorkflow({ trigger: { type: 'file_change' } }))).toBe(false); + expect(accepts(withWorkflow({ trigger: { type: 'manual' } }))).toBe(true); + }); + + it('rejects duplicate input names', () => { + expect( + accepts( + withWorkflow({ + inputs: [ + { name: 'x', type: 'string' }, + { name: 'x', type: 'number' }, + ], + }), + ), + ).toBe(false); + }); + + it('rejects duplicate context keys', () => { + expect( + accepts( + withWorkflow({ + context: [ + { key: 'k', value: 'a' }, + { key: 'k', value: 'b' }, + ], + }), + ), + ).toBe(false); + }); + + it('rejects duplicate agent ids', () => { + const agent = { + id: 'dup', + model: 'claude-sonnet-4-6', + provider: 'anthropic', + system_prompt: 'p', + }; + expect(accepts(withWorkflow({ agents: [agent, { ...agent }] }))).toBe(false); + }); + + it('accepts a minimal workflow with only required fields', () => { + // No version/name/tags/trigger/inputs/context/agents/tools — exercises the optional + // paths and the `?? []` fallbacks in the uniqueness checks. + expect( + accepts({ + schema_version: '1.0', + workflow: { id: 'min', nodes: [{ id: 'only', type: 'input' }], edges: [] }, + }), + ).toBe(true); + }); + + it('strips the handle when checking fan-out vs parallel_of agreement', () => { + // `fan-out:x` must resolve to the `fan-out` node before the parallel_of check; + // `output` is not a branch, so it still rejects. + expect( + accepts( + withWorkflow({ edges: [...base.workflow.edges, { from: 'fan-out:x', to: 'output' }] }), + ), + ).toBe(false); + }); +}); diff --git a/packages/shared/src/workflow.ts b/packages/shared/src/workflow.ts new file mode 100644 index 00000000..840e8ed3 --- /dev/null +++ b/packages/shared/src/workflow.ts @@ -0,0 +1,181 @@ +import { z } from 'zod'; + +import { kebabIdSchema, nonEmptyString, nonNegativeInt } from './common.js'; +import { SCHEMA_VERSION } from './constants.js'; +import { AgentSchema } from './agent.js'; +import { NodeSchema } from './node.js'; +import { EdgeSchema } from './edge.js'; + +/** + * Workflow YAML schema v1.0 (workflow-yaml-spec.md). A workflow is a + * git-committable directed graph of nodes; it is a **public API**, so `WorkflowSchema` + * is the migration anchor (`schema_version`) and unknown extra keys are **silently + * stripped** (Zod's default `z.object` behavior) rather than rejected — so a newer + * file's added optional fields never break an older parser (forward-compatible parsing). + */ + +/** How a run is initiated. */ +export const TriggerTypeSchema = z.enum([ + 'manual', + 'webhook', + 'schedule', + 'file_change', + 'mcp_call', +]); + +// `TriggerTypeSchema` above is the flat enum (used by the run record's `triggerType`). +// `TriggerSchema` is the *authored* form: a discriminated union so each type carries +// exactly its required payload (e.g. `webhook` must include `{ path, secret_env }`, +// `manual`/`mcp_call` carry none). +export const TriggerSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('manual') }), + z.object({ + type: z.literal('webhook'), + webhook: z.object({ path: nonEmptyString, secret_env: nonEmptyString }), + }), + z.object({ type: z.literal('schedule'), schedule: z.string() }), // cron expression + z.object({ + type: z.literal('file_change'), + file_change: z.object({ glob: nonEmptyString, debounce_ms: nonNegativeInt }), + }), + z.object({ type: z.literal('mcp_call') }), +]); +export type Trigger = z.infer; + +/** A typed workflow input declaration. */ +export const InputTypeSchema = z.enum([ + 'string', + 'number', + 'boolean', + 'file_path', + 'code_diff', + 'secret', +]); + +export const WorkflowInputSchema = z.object({ + name: nonEmptyString, + type: InputTypeSchema, + required: z.boolean().optional(), + default: z.unknown().optional(), + description: z.string().optional(), +}); +export type WorkflowInput = z.infer; + +/** A shared variable exposed as `{{ctx.key}}`. */ +export const ContextEntrySchema = z.object({ + key: nonEmptyString, + value: z.string(), +}); +export type ContextEntry = z.infer; + +/** Workflow-wide tool guardrails (the canonical home for the command allowlist). */ +export const ToolPolicySchema = z.object({ + allowedCommands: z.array(z.string()).optional(), + allowedDomains: z.array(z.string()).optional(), +}); +export type ToolPolicy = z.infer; + +/** The body under the top-level `workflow:` key. */ +export const WorkflowSpecSchema = z.object({ + id: kebabIdSchema, + version: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + tags: z.array(z.string()).optional(), + trigger: TriggerSchema.optional(), + inputs: z.array(WorkflowInputSchema).optional(), + context: z.array(ContextEntrySchema).optional(), + agents: z.array(AgentSchema).optional(), + tools: ToolPolicySchema.optional(), + nodes: z.array(NodeSchema), + edges: z.array(EdgeSchema), +}); +export type WorkflowSpec = z.infer; + +/** The complete workflow document: `schema_version` + `workflow`. */ +export const WorkflowSchema = z + .object({ + schema_version: z.literal(SCHEMA_VERSION), + workflow: WorkflowSpecSchema, + }) + .superRefine((doc, ctx) => { + const { nodes } = doc.workflow; + + // `merge_fn` is required when `merge_strategy` is `custom`. + nodes.forEach((node, i) => { + if (node.type === 'merge' && node.merge_strategy === 'custom' && !node.merge_fn) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'merge_fn is required when merge_strategy is "custom"', + path: ['workflow', 'nodes', i, 'merge_fn'], + }); + } + }); + + // Referenced identifiers must be unique within a workflow — node ids, input names, + // context keys, and agent ids are each addressed by reference (edges, `{{inputs.*}}`, + // `{{ctx.*}}`, `agent_ref`), so a duplicate is an ambiguity, not a forward-compat field. + const reportDuplicates = (values: string[], label: string, path: (string | number)[]) => { + const seen = new Set(); + const duplicates = new Set(); + for (const value of values) { + if (seen.has(value)) duplicates.add(value); + seen.add(value); + } + if (duplicates.size > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `duplicate ${label}: ${[...duplicates].join(', ')}`, + path, + }); + } + }; + reportDuplicates( + nodes.map((n) => n.id), + 'node id(s)', + ['workflow', 'nodes'], + ); + reportDuplicates( + (doc.workflow.inputs ?? []).map((i) => i.name), + 'input name(s)', + ['workflow', 'inputs'], + ); + reportDuplicates( + (doc.workflow.context ?? []).map((c) => c.key), + 'context key(s)', + ['workflow', 'context'], + ); + reportDuplicates( + (doc.workflow.agents ?? []).map((a) => a.id), + 'agent id(s)', + ['workflow', 'agents'], + ); + + // `parallel_of` is authoritative for branch membership: an explicit edge out of a + // `parallel` node must target a node listed in that node's `parallel_of` + // (workflow-yaml-spec.md). Explicit fan-out edges are redundant with `parallel_of`, + // but if present they must not contradict it. + const branchesByParallelId = new Map(); + for (const node of nodes) { + if (node.type === 'parallel') branchesByParallelId.set(node.id, node.parallel_of); + } + doc.workflow.edges.forEach((edge, i) => { + // Strip an optional `:handle` to the base node id. `split` always yields >= 1 + // element at runtime; the `?? edge.from` satisfies `noUncheckedIndexedAccess` + // (which types `[0]` as `string | undefined`) so `fromId` is a plain `string`. + const fromId = edge.from.split(':')[0] ?? edge.from; + const branches = branchesByParallelId.get(fromId); + if (branches && !branches.includes(edge.to)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `edge from parallel node '${fromId}' targets '${edge.to}', which is not in its parallel_of`, + path: ['workflow', 'edges', i, 'to'], + }); + } + }); + + // Note: `agent_ref` → agent resolution and `agents` presence are NOT validated here. + // An agent node's agent may be declared inline, in a sibling `.agent.yaml`, or in the + // workspace agent registry — only the engine, with the full registry, can resolve it. + }); +export type Workflow = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64e666a4..7186c917 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ catalogs: vitest: specifier: ^3.0.0 version: 3.2.6 + zod: + specifier: ^3.23.0 + version: 3.25.76 importers: @@ -67,6 +70,10 @@ importers: version: 3.2.6 packages/shared: + dependencies: + zod: + specifier: 'catalog:' + version: 3.25.76 devDependencies: eslint: specifier: 'catalog:' @@ -1263,6 +1270,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@ampproject/remapping@2.3.0': @@ -2368,3 +2378,5 @@ snapshots: strip-ansi: 7.2.0 yocto-queue@0.1.0: {} + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 844a2a62..48f25fe5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,3 +15,5 @@ catalog: typescript: ^5.7.2 typescript-eslint: ^8.18.1 vitest: ^3.0.0 + # Runtime + zod: ^3.23.0