diff --git a/docs/development/task-backlog.md b/docs/development/task-backlog.md index e5d8a93..a04e16e 100644 --- a/docs/development/task-backlog.md +++ b/docs/development/task-backlog.md @@ -378,10 +378,10 @@ Status markers: **Tasks:** -- [ ] Define node types: `start`, `agent`, `tool`, `condition`, `humanApproval`, and `end`. -- [ ] Define edges, variables, permissions, status, version, and timestamps. -- [ ] Validate exactly one start node and at least one end node. -- [ ] Commit with message `feat: define workflow dag schema`. +- [x] Define node types: `start`, `agent`, `tool`, `condition`, `humanApproval`, and `end`. +- [x] Define edges, variables, permissions, status, version, and timestamps. +- [x] Validate exactly one start node and at least one end node. +- [x] Commit with message `feat: define workflow dag schema`. **Acceptance Criteria:** diff --git a/packages/workflow-core/src/index.ts b/packages/workflow-core/src/index.ts index 6db5932..9c3244e 100644 --- a/packages/workflow-core/src/index.ts +++ b/packages/workflow-core/src/index.ts @@ -1 +1,25 @@ export const workflowCorePackageName = "@agentdeck/workflow-core"; + +export { + WORKFLOW_NODE_TYPES, + WORKFLOW_STATUSES, + createWorkflowDefinition, + validateWorkflowDefinition, +} from "./workflow-schema.js"; +export type { + AgentNode, + ConditionNode, + EndNode, + HumanApprovalNode, + StartNode, + ToolNode, + WorkflowDefinition, + WorkflowEdge, + WorkflowNode, + WorkflowNodeBase, + WorkflowNodeType, + WorkflowPermissions, + WorkflowSchemaResult, + WorkflowStatus, + WorkflowValidationResult, +} from "./workflow-schema.js"; diff --git a/packages/workflow-core/src/workflow-schema.test.ts b/packages/workflow-core/src/workflow-schema.test.ts new file mode 100644 index 0000000..35bd907 --- /dev/null +++ b/packages/workflow-core/src/workflow-schema.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { + WORKFLOW_NODE_TYPES, + WORKFLOW_STATUSES, + createWorkflowDefinition, + validateWorkflowDefinition, + type WorkflowDefinition, +} from "./workflow-schema.js"; + +const validWorkflowInput = { + id: "workflow-review", + name: "Review workflow", + version: 1, + status: "draft", + variables: { + branch: "main", + }, + 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: "condition-risk", type: "condition", label: "Risk?", expression: "risk > 0" }, + { id: "approval", type: "humanApproval", label: "Approve", approverRole: "maintainer" }, + { id: "end", type: "end", label: "Done" }, + ], + 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: "condition-risk" }, + { id: "edge-4", from: "condition-risk", to: "approval", condition: "needsApproval" }, + { id: "edge-5", from: "approval", to: "end" }, + ], + createdAt: "2026-05-29T00:00:00.000Z", + updatedAt: "2026-05-29T00:00:00.000Z", +} as const; + +describe("workflow schema constants", () => { + it("defines MVP workflow node types and statuses", () => { + expect(WORKFLOW_NODE_TYPES).toEqual([ + "start", + "agent", + "tool", + "condition", + "humanApproval", + "end", + ]); + expect(WORKFLOW_STATUSES).toEqual(["draft", "active", "paused", "archived"]); + }); +}); + +describe("createWorkflowDefinition", () => { + it("normalizes workflow fields, nodes, edges, variables, permissions, status, version, and timestamps", () => { + const result = createWorkflowDefinition(validWorkflowInput); + + expect(result.success).toBe(true); + if (!result.success) { + throw new Error(result.errors.join("\n")); + } + + expect(result.value).toEqual(validWorkflowInput); + }); + + it("rejects invalid node types, missing start node, and missing end node", () => { + const result = createWorkflowDefinition({ + ...validWorkflowInput, + nodes: [{ id: "agent-review", type: "agent", label: "Review", agentId: "reviewer" }], + edges: [], + }); + + expect(result).toEqual({ + success: false, + errors: [ + "workflow must contain exactly one start node.", + "workflow must contain at least one end node.", + ], + }); + + expect( + validateWorkflowDefinition({ + ...validWorkflowInput, + nodes: [{ id: "bad", type: "timer", label: "Timer" }], + } as unknown as WorkflowDefinition), + ).toEqual({ + success: false, + errors: [ + 'node "bad" has invalid type "timer".', + "workflow must contain exactly one start node.", + "workflow must contain at least one end node.", + ], + }); + }); + + it("rejects workflows with duplicate start nodes", () => { + const result = createWorkflowDefinition({ + ...validWorkflowInput, + nodes: [ + { id: "start-1", type: "start", label: "Start 1" }, + { id: "start-2", type: "start", label: "Start 2" }, + { id: "end", type: "end", label: "Done" }, + ], + }); + + expect(result).toEqual({ + success: false, + errors: ["workflow must contain exactly one start node."], + }); + }); +}); diff --git a/packages/workflow-core/src/workflow-schema.ts b/packages/workflow-core/src/workflow-schema.ts new file mode 100644 index 0000000..c9f862c --- /dev/null +++ b/packages/workflow-core/src/workflow-schema.ts @@ -0,0 +1,187 @@ +export const WORKFLOW_NODE_TYPES = [ + "start", + "agent", + "tool", + "condition", + "humanApproval", + "end", +] as const; + +export const WORKFLOW_STATUSES = ["draft", "active", "paused", "archived"] as const; + +export type WorkflowNodeType = (typeof WORKFLOW_NODE_TYPES)[number]; +export type WorkflowStatus = (typeof WORKFLOW_STATUSES)[number]; + +export interface WorkflowPermissions { + readonly read: boolean; + readonly write: boolean; + readonly install: boolean; + readonly arbitraryCommands: boolean; + readonly commit: boolean; + readonly push: boolean; +} + +export interface WorkflowNodeBase { + readonly id: string; + readonly type: TType; + readonly label: string; +} + +export type StartNode = WorkflowNodeBase<"start">; +export type EndNode = WorkflowNodeBase<"end">; + +export interface AgentNode extends WorkflowNodeBase<"agent"> { + readonly agentId: string; +} + +export interface ToolNode extends WorkflowNodeBase<"tool"> { + readonly toolId: string; +} + +export interface ConditionNode extends WorkflowNodeBase<"condition"> { + readonly expression: string; +} + +export interface HumanApprovalNode extends WorkflowNodeBase<"humanApproval"> { + readonly approverRole: string; +} + +export type WorkflowNode = + | StartNode + | AgentNode + | ToolNode + | ConditionNode + | HumanApprovalNode + | EndNode; + +export interface WorkflowEdge { + readonly id: string; + readonly from: string; + readonly to: string; + readonly condition?: string; +} + +export interface WorkflowDefinition { + readonly id: string; + readonly name: string; + readonly version: number; + readonly status: WorkflowStatus; + readonly variables: Readonly>; + readonly permissions: WorkflowPermissions; + readonly nodes: readonly WorkflowNode[]; + readonly edges: readonly WorkflowEdge[]; + readonly createdAt: string; + readonly updatedAt: string; +} + +export type WorkflowSchemaResult = + | { + readonly success: true; + readonly value: T; + } + | { + readonly success: false; + readonly errors: readonly string[]; + }; + +export interface WorkflowValidationResult { + readonly success: boolean; + readonly errors: readonly string[]; +} + +export function createWorkflowDefinition( + input: WorkflowDefinition, +): WorkflowSchemaResult { + const workflow: WorkflowDefinition = { + id: input.id.trim(), + name: input.name.trim(), + version: input.version, + status: input.status, + variables: { ...input.variables }, + permissions: { ...input.permissions }, + nodes: input.nodes.map(normalizeNode), + edges: input.edges.map(normalizeEdge), + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }; + const validation = validateWorkflowDefinition(workflow); + + if (!validation.success) { + return { + success: false, + errors: validation.errors, + }; + } + + return { + success: true, + value: workflow, + }; +} + +export function validateWorkflowDefinition(workflow: WorkflowDefinition): WorkflowValidationResult { + const errors: string[] = []; + + if (!workflow.id.trim()) { + errors.push("id is required."); + } + + if (!workflow.name.trim()) { + errors.push("name is required."); + } + + if (!Number.isInteger(workflow.version) || workflow.version < 1) { + errors.push("version must be a positive integer."); + } + + if (!isWorkflowStatus(workflow.status)) { + errors.push(`workflow status "${workflow.status}" is invalid.`); + } + + for (const node of workflow.nodes) { + if (!isWorkflowNodeType(node.type)) { + errors.push(`node "${node.id}" has invalid type "${node.type}".`); + } + } + + const startNodeCount = workflow.nodes.filter((node) => node.type === "start").length; + const endNodeCount = workflow.nodes.filter((node) => node.type === "end").length; + + if (startNodeCount !== 1) { + errors.push("workflow must contain exactly one start node."); + } + + if (endNodeCount < 1) { + errors.push("workflow must contain at least one end node."); + } + + return { + success: errors.length === 0, + errors, + }; +} + +function normalizeNode(node: WorkflowNode): WorkflowNode { + return { + ...node, + id: node.id.trim(), + label: node.label.trim(), + }; +} + +function normalizeEdge(edge: WorkflowEdge): WorkflowEdge { + return { + id: edge.id.trim(), + from: edge.from.trim(), + to: edge.to.trim(), + ...(edge.condition ? { condition: edge.condition.trim() } : {}), + }; +} + +function isWorkflowNodeType(value: string): value is WorkflowNodeType { + return WORKFLOW_NODE_TYPES.includes(value as WorkflowNodeType); +} + +function isWorkflowStatus(value: string): value is WorkflowStatus { + return WORKFLOW_STATUSES.includes(value as WorkflowStatus); +}