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
6 changes: 6 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,9 @@ export type {
WorkflowRegistryService,
WorkflowUpdateInput,
} from "./workflows/workflow-service.js";
export { createWorkflowRoutes } from "./workflows/workflow-routes.js";
export type {
WorkflowMutationResponse,
WorkflowRouteResponse,
WorkflowRoutes,
} from "./workflows/workflow-routes.js";
116 changes: 116 additions & 0 deletions apps/server/src/workflows/workflow-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import type { WorkflowDefinition } from "@agentdeck/workflow-core";
import { afterEach, describe, expect, it } from "vitest";

import { createWorkflowRoutes } from "./workflow-routes.js";
import { createWorkflowRegistryService } from "./workflow-service.js";

const tempDirs: string[] = [];

async function createTempStorePath(): Promise<string> {
const tempDir = await mkdtemp(join(tmpdir(), "agentdeck-workflow-routes-"));
tempDirs.push(tempDir);
return join(tempDir, "workflows.json");
}

const baseWorkflow: WorkflowDefinition = {
id: "review-flow",
name: "Review Flow",
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: "end", type: "end", label: "End" },
],
edges: [
{ id: "edge-start-review", from: "start", to: "agent-review" },
{ id: "edge-review-end", from: "agent-review", to: "end" },
],
createdAt: "2026-05-29T00:00:00.000Z",
updatedAt: "2026-05-29T00:00:00.000Z",
};

afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});

describe("createWorkflowRoutes", () => {
it("exposes route handlers for workflow create, list, get, update, and delete", async () => {
const service = createWorkflowRegistryService({
storePath: await createTempStorePath(),
now: () => "2026-05-29T01:00:00.000Z",
});
const routes = createWorkflowRoutes({ service });

const createResponse = await routes.createWorkflow(baseWorkflow);
const updateResponse = await routes.updateWorkflow({
id: "review-flow",
workflow: { name: "Review Flow v2", version: 2 },
});
if (!("workflow" in updateResponse)) {
throw new Error("Expected workflow update response.");
}

expect(createResponse).toEqual({
status: 201,
workflow: {
...baseWorkflow,
createdAt: "2026-05-29T01:00:00.000Z",
updatedAt: "2026-05-29T01:00:00.000Z",
},
});
expect(updateResponse.status).toBe(200);
expect(updateResponse.workflow?.name).toBe("Review Flow v2");
expect(await routes.listWorkflows()).toEqual({
status: 200,
workflows: [updateResponse.workflow],
});
expect(await routes.getWorkflow({ id: "review-flow" })).toEqual({
status: 200,
workflow: updateResponse.workflow,
});
expect(await routes.deleteWorkflow({ id: "review-flow" })).toEqual({
status: 204,
deleted: true,
});
expect(await routes.getWorkflow({ id: "review-flow" })).toEqual({
status: 404,
error: 'Workflow "review-flow" was not found.',
});
});

it("returns validation errors without persisting invalid workflows", async () => {
const service = createWorkflowRegistryService({
storePath: await createTempStorePath(),
});
const routes = createWorkflowRoutes({ service });

const response = await routes.createWorkflow({
...baseWorkflow,
nodes: [{ id: "agent-review", type: "agent", label: "Review", agentId: "reviewer" }],
edges: [],
});

expect(response).toEqual({
status: 400,
errors: [
"workflow must contain exactly one start node.",
"workflow must contain at least one end node.",
],
});
expect(await routes.listWorkflows()).toEqual({ status: 200, workflows: [] });
});
});
117 changes: 117 additions & 0 deletions apps/server/src/workflows/workflow-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { WorkflowDefinition } from "@agentdeck/workflow-core";

import {
WorkflowValidationError,
type WorkflowRegistryService,
type WorkflowUpdateInput,
} from "./workflow-service.js";

export type WorkflowRouteResponse<TBody extends object = object> = TBody & {
readonly status: number;
};

export type WorkflowMutationResponse = WorkflowRouteResponse<
| { readonly workflow: WorkflowDefinition }
| { readonly errors: readonly string[] }
| { readonly error: string }
>;

export interface WorkflowRoutes {
readonly createWorkflow: (workflow: WorkflowDefinition) => Promise<WorkflowMutationResponse>;
readonly listWorkflows: () => Promise<
WorkflowRouteResponse<{ readonly workflows: readonly WorkflowDefinition[] }>
>;
readonly getWorkflow: (input: {
readonly id: string;
}) => Promise<
WorkflowRouteResponse<{ readonly workflow: WorkflowDefinition } | { readonly error: string }>
>;
readonly updateWorkflow: (input: {
readonly id: string;
readonly workflow: WorkflowUpdateInput;
}) => Promise<WorkflowMutationResponse>;
readonly deleteWorkflow: (input: {
readonly id: string;
}) => Promise<WorkflowRouteResponse<{ readonly deleted: true } | { readonly error: string }>>;
}

export function createWorkflowRoutes(input: {
readonly service: WorkflowRegistryService;
}): WorkflowRoutes {
return {
async createWorkflow(workflow) {
try {
return {
status: 201,
workflow: await input.service.create(workflow),
};
} catch (error) {
return mapRouteError(error);
}
},
async listWorkflows() {
return {
status: 200,
workflows: await input.service.list(),
};
},
async getWorkflow({ id }) {
const workflow = await input.service.get(id);
if (!workflow) {
return {
status: 404,
error: `Workflow "${id}" was not found.`,
};
}

return {
status: 200,
workflow,
};
},
async updateWorkflow({ id, workflow }) {
try {
return {
status: 200,
workflow: await input.service.update(id, workflow),
};
} catch (error) {
return mapRouteError(error);
}
},
async deleteWorkflow({ id }) {
const deleted = await input.service.delete(id);
if (!deleted) {
return {
status: 404,
error: `Workflow "${id}" was not found.`,
};
}

return {
status: 204,
deleted: true,
};
},
};
}

function mapRouteError(
error: unknown,
): WorkflowRouteResponse<{ readonly errors: readonly string[] } | { readonly error: string }> {
if (error instanceof WorkflowValidationError) {
return {
status: 400,
errors: error.errors,
};
}

if (error instanceof Error && /Workflow ".+" was not found\./.test(error.message)) {
return {
status: 404,
error: error.message,
};
}

throw error;
}
8 changes: 4 additions & 4 deletions docs/development/task-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -620,10 +620,10 @@ Status markers:

**Tasks:**

- [ ] Add routes to create, list, get, update, and delete workflows.
- [ ] Return validation errors without persisting invalid workflows.
- [ ] Keep route handlers thin and delegate to `workflow-service`.
- [ ] Commit with message `feat: add workflow routes`.
- [x] Add routes to create, list, get, update, and delete workflows.
- [x] Return validation errors without persisting invalid workflows.
- [x] Keep route handlers thin and delegate to `workflow-service`.
- [x] Commit with message `feat: add workflow routes`.

**Acceptance Criteria:**

Expand Down
Loading