diff --git a/docs/development/task-backlog.md b/docs/development/task-backlog.md index a04e16e..a46e4ff 100644 --- a/docs/development/task-backlog.md +++ b/docs/development/task-backlog.md @@ -398,11 +398,11 @@ Status markers: **Tasks:** -- [ ] Detect cycles. -- [ ] Validate node input/output compatibility. -- [ ] Validate referenced agents and tools exist through injected resolvers. -- [ ] Validate requested permissions do not exceed workflow permissions. -- [ ] Commit with message `feat: add workflow validator`. +- [x] Detect cycles. +- [x] Validate node input/output compatibility. +- [x] Validate referenced agents and tools exist through injected resolvers. +- [x] Validate requested permissions do not exceed workflow permissions. +- [x] Commit with message `feat: add workflow validator`. **Acceptance Criteria:** diff --git a/packages/workflow-core/src/index.ts b/packages/workflow-core/src/index.ts index 9c3244e..5389b46 100644 --- a/packages/workflow-core/src/index.ts +++ b/packages/workflow-core/src/index.ts @@ -23,3 +23,13 @@ export type { WorkflowStatus, WorkflowValidationResult, } from "./workflow-schema.js"; + +export { validateWorkflowSafety } from "./validator.js"; +export type { + WorkflowIoContract, + WorkflowPermissionRequest, + WorkflowSafetyError, + WorkflowSafetyResult, + WorkflowSafetyValidatorOptions, + WorkflowValidationErrorCode, +} from "./validator.js"; diff --git a/packages/workflow-core/src/validator.test.ts b/packages/workflow-core/src/validator.test.ts new file mode 100644 index 0000000..a3e03b9 --- /dev/null +++ b/packages/workflow-core/src/validator.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; + +import { validateWorkflowSafety } from "./validator.js"; +import type { WorkflowDefinition } from "./workflow-schema.js"; + +const baseWorkflow: WorkflowDefinition = { + id: "workflow-review", + name: "Review workflow", + version: 1, + status: "draft", + variables: {}, + permissions: { + read: true, + write: false, + install: false, + arbitraryCommands: false, + commit: false, + push: false, + }, + nodes: [ + { id: "start", type: "start", label: "Start" }, + { id: "agent-review", type: "agent", label: "Review", agentId: "reviewer" }, + { id: "tool-diff", type: "tool", label: "Diff", toolId: "git.diff" }, + { id: "end", type: "end", label: "End" }, + ], + edges: [ + { id: "edge-1", from: "start", to: "agent-review" }, + { id: "edge-2", from: "agent-review", to: "tool-diff" }, + { id: "edge-3", from: "tool-diff", to: "end" }, + ], + createdAt: "2026-05-29T00:00:00.000Z", + updatedAt: "2026-05-29T00:00:00.000Z", +}; + +describe("validateWorkflowSafety", () => { + it("accepts a valid workflow with resolved agents and tools", async () => { + const result = await validateWorkflowSafety(baseWorkflow, { + resolveAgent: async (agentId) => agentId === "reviewer", + resolveTool: async (toolId) => toolId === "git.diff", + toolPermissions: { + "git.diff": { read: true }, + }, + toolIo: { + "git.diff": { + accepts: ["text"], + produces: ["diff"], + }, + }, + nodeIo: { + "agent-review": { + produces: ["text"], + }, + }, + }); + + expect(result).toEqual({ success: true, errors: [] }); + }); + + it("returns schema errors before safety errors", async () => { + const result = await validateWorkflowSafety({ + ...baseWorkflow, + nodes: [{ id: "agent-review", type: "agent", label: "Review", agentId: "reviewer" }], + edges: [], + }); + + expect(result).toEqual({ + success: false, + errors: [ + { + code: "schema.missingStart", + message: "workflow must contain exactly one start node.", + }, + { + code: "schema.missingEnd", + message: "workflow must contain at least one end node.", + }, + ], + }); + }); + + it("detects cycles", async () => { + const result = await validateWorkflowSafety({ + ...baseWorkflow, + edges: [...baseWorkflow.edges, { id: "edge-cycle", from: "tool-diff", to: "agent-review" }], + }); + + expect(result.errors).toContainEqual({ + code: "dag.cycle", + message: 'workflow contains a cycle involving node "agent-review".', + }); + }); + + it("validates node input and output compatibility", async () => { + const result = await validateWorkflowSafety(baseWorkflow, { + resolveAgent: async () => true, + resolveTool: async () => true, + toolIo: { + "git.diff": { + accepts: ["json"], + produces: ["diff"], + }, + }, + nodeIo: { + "agent-review": { + produces: ["text"], + }, + }, + }); + + expect(result.errors).toContainEqual({ + code: "io.incompatible", + message: 'edge "edge-2" sends text to node "tool-diff", which expects json.', + }); + }); + + it("validates referenced agents and tools through injected resolvers", async () => { + const result = await validateWorkflowSafety(baseWorkflow, { + resolveAgent: async () => false, + resolveTool: async () => false, + }); + + expect(result.errors).toEqual([ + { + code: "reference.agentMissing", + message: 'agent "reviewer" referenced by node "agent-review" does not exist.', + }, + { + code: "reference.toolMissing", + message: 'tool "git.diff" referenced by node "tool-diff" does not exist.', + }, + ]); + }); + + it("validates requested permissions do not exceed workflow permissions", async () => { + const result = await validateWorkflowSafety(baseWorkflow, { + resolveAgent: async () => true, + resolveTool: async () => true, + toolPermissions: { + "git.diff": { + read: true, + write: true, + }, + }, + }); + + expect(result.errors).toContainEqual({ + code: "permission.exceedsWorkflow", + message: 'tool "git.diff" requests write permission, but workflow does not allow it.', + }); + }); +}); diff --git a/packages/workflow-core/src/validator.ts b/packages/workflow-core/src/validator.ts new file mode 100644 index 0000000..18e80b7 --- /dev/null +++ b/packages/workflow-core/src/validator.ts @@ -0,0 +1,248 @@ +import { + validateWorkflowDefinition, + type AgentNode, + type ToolNode, + type WorkflowDefinition, + type WorkflowNode, + type WorkflowPermissions, +} from "./workflow-schema.js"; + +export type WorkflowValidationErrorCode = + | "schema.missingStart" + | "schema.missingEnd" + | "schema.invalid" + | "dag.cycle" + | "io.incompatible" + | "reference.agentMissing" + | "reference.toolMissing" + | "permission.exceedsWorkflow"; + +export interface WorkflowSafetyError { + readonly code: WorkflowValidationErrorCode; + readonly message: string; +} + +export interface WorkflowSafetyResult { + readonly success: boolean; + readonly errors: readonly WorkflowSafetyError[]; +} + +export interface WorkflowIoContract { + readonly accepts?: readonly string[]; + readonly produces?: readonly string[]; +} + +export type WorkflowPermissionRequest = Partial; + +export interface WorkflowSafetyValidatorOptions { + readonly resolveAgent?: (agentId: string) => Promise | boolean; + readonly resolveTool?: (toolId: string) => Promise | boolean; + readonly toolPermissions?: Readonly>; + readonly toolIo?: Readonly>; + readonly nodeIo?: Readonly>; +} + +export async function validateWorkflowSafety( + workflow: WorkflowDefinition, + options: WorkflowSafetyValidatorOptions = {}, +): Promise { + const schemaErrors = mapSchemaErrors(validateWorkflowDefinition(workflow).errors); + if (schemaErrors.length > 0) { + return { + success: false, + errors: schemaErrors, + }; + } + + const errors: WorkflowSafetyError[] = []; + errors.push(...(await validateReferences(workflow, options))); + errors.push(...validatePermissions(workflow, options)); + errors.push(...validateIoCompatibility(workflow, options)); + errors.push(...validateAcyclic(workflow)); + + return { + success: errors.length === 0, + errors, + }; +} + +function mapSchemaErrors(errors: readonly string[]): WorkflowSafetyError[] { + return errors.map((message) => { + if (message === "workflow must contain exactly one start node.") { + return { + code: "schema.missingStart", + message, + }; + } + + if (message === "workflow must contain at least one end node.") { + return { + code: "schema.missingEnd", + message, + }; + } + + return { + code: "schema.invalid", + message, + }; + }); +} + +async function validateReferences( + workflow: WorkflowDefinition, + options: WorkflowSafetyValidatorOptions, +): Promise { + const errors: WorkflowSafetyError[] = []; + + for (const node of workflow.nodes) { + if (isAgentNode(node) && options.resolveAgent && !(await options.resolveAgent(node.agentId))) { + errors.push({ + code: "reference.agentMissing", + message: `agent "${node.agentId}" referenced by node "${node.id}" does not exist.`, + }); + } + + if (isToolNode(node) && options.resolveTool && !(await options.resolveTool(node.toolId))) { + errors.push({ + code: "reference.toolMissing", + message: `tool "${node.toolId}" referenced by node "${node.id}" does not exist.`, + }); + } + } + + return errors; +} + +function validatePermissions( + workflow: WorkflowDefinition, + options: WorkflowSafetyValidatorOptions, +): WorkflowSafetyError[] { + const errors: WorkflowSafetyError[] = []; + + for (const node of workflow.nodes) { + if (!isToolNode(node)) { + continue; + } + + const requestedPermissions = options.toolPermissions?.[node.toolId] ?? {}; + for (const permission of Object.keys(requestedPermissions) as Array< + keyof WorkflowPermissions + >) { + if (requestedPermissions[permission] && !workflow.permissions[permission]) { + errors.push({ + code: "permission.exceedsWorkflow", + message: `tool "${node.toolId}" requests ${permission} permission, but workflow does not allow it.`, + }); + } + } + } + + return errors; +} + +function validateIoCompatibility( + workflow: WorkflowDefinition, + options: WorkflowSafetyValidatorOptions, +): WorkflowSafetyError[] { + const errors: WorkflowSafetyError[] = []; + const nodesById = new Map(workflow.nodes.map((node) => [node.id, node])); + + for (const edge of workflow.edges) { + const fromNode = nodesById.get(edge.from); + const toNode = nodesById.get(edge.to); + if (!fromNode || !toNode) { + continue; + } + + const fromProduces = getNodeIoContract(fromNode, options).produces ?? []; + const toAccepts = getNodeIoContract(toNode, options).accepts ?? []; + if (fromProduces.length === 0 || toAccepts.length === 0) { + continue; + } + + const compatible = fromProduces.some((output) => toAccepts.includes(output)); + if (!compatible) { + errors.push({ + code: "io.incompatible", + message: `edge "${edge.id}" sends ${fromProduces.join(" or ")} to node "${toNode.id}", which expects ${toAccepts.join(" or ")}.`, + }); + } + } + + return errors; +} + +function validateAcyclic(workflow: WorkflowDefinition): WorkflowSafetyError[] { + const adjacency = new Map(); + for (const node of workflow.nodes) { + adjacency.set(node.id, []); + } + + for (const edge of workflow.edges) { + adjacency.get(edge.from)?.push(edge.to); + } + + const visiting = new Set(); + const visited = new Set(); + + for (const node of workflow.nodes) { + const cycleNode = findCycleNode(node.id, adjacency, visiting, visited); + if (cycleNode) { + return [ + { + code: "dag.cycle", + message: `workflow contains a cycle involving node "${cycleNode}".`, + }, + ]; + } + } + + return []; +} + +function findCycleNode( + nodeId: string, + adjacency: ReadonlyMap, + visiting: Set, + visited: Set, +): string | undefined { + if (visiting.has(nodeId)) { + return nodeId; + } + + if (visited.has(nodeId)) { + return undefined; + } + + visiting.add(nodeId); + for (const nextNodeId of adjacency.get(nodeId) ?? []) { + const cycleNode = findCycleNode(nextNodeId, adjacency, visiting, visited); + if (cycleNode) { + return cycleNode; + } + } + visiting.delete(nodeId); + visited.add(nodeId); + + return undefined; +} + +function getNodeIoContract( + node: WorkflowNode, + options: WorkflowSafetyValidatorOptions, +): WorkflowIoContract { + if (isToolNode(node)) { + return options.toolIo?.[node.toolId] ?? options.nodeIo?.[node.id] ?? {}; + } + + return options.nodeIo?.[node.id] ?? {}; +} + +function isAgentNode(node: WorkflowNode): node is AgentNode { + return node.type === "agent"; +} + +function isToolNode(node: WorkflowNode): node is ToolNode { + return node.type === "tool"; +}