Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/development/task-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
24 changes: 24 additions & 0 deletions packages/workflow-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
117 changes: 117 additions & 0 deletions packages/workflow-core/src/workflow-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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."],
});
});
});
187 changes: 187 additions & 0 deletions packages/workflow-core/src/workflow-schema.ts
Original file line number Diff line number Diff line change
@@ -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<TType extends WorkflowNodeType = WorkflowNodeType> {
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<Record<string, string>>;
readonly permissions: WorkflowPermissions;
readonly nodes: readonly WorkflowNode[];
readonly edges: readonly WorkflowEdge[];
readonly createdAt: string;
readonly updatedAt: string;
}

export type WorkflowSchemaResult<T> =
| {
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<WorkflowDefinition> {
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);
}