From fafc26f9bc3a518276addfb439e6c0a9a404d273 Mon Sep 17 00:00:00 2001 From: eleven_qy <64311644+alph-cmky@users.noreply.github.com> Date: Fri, 29 May 2026 22:20:20 +0800 Subject: [PATCH] feat: add workflow routes --- apps/server/src/index.ts | 6 + .../src/workflows/workflow-routes.test.ts | 116 +++++++++++++++++ apps/server/src/workflows/workflow-routes.ts | 117 ++++++++++++++++++ docs/development/task-backlog.md | 8 +- 4 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/workflows/workflow-routes.test.ts create mode 100644 apps/server/src/workflows/workflow-routes.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index eaf4ea7..7110e78 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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"; diff --git a/apps/server/src/workflows/workflow-routes.test.ts b/apps/server/src/workflows/workflow-routes.test.ts new file mode 100644 index 0000000..667f349 --- /dev/null +++ b/apps/server/src/workflows/workflow-routes.test.ts @@ -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 { + 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: [] }); + }); +}); diff --git a/apps/server/src/workflows/workflow-routes.ts b/apps/server/src/workflows/workflow-routes.ts new file mode 100644 index 0000000..735cb92 --- /dev/null +++ b/apps/server/src/workflows/workflow-routes.ts @@ -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 & { + 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; + 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; + readonly deleteWorkflow: (input: { + readonly id: string; + }) => Promise>; +} + +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; +} diff --git a/docs/development/task-backlog.md b/docs/development/task-backlog.md index 0bde44b..1d7aa26 100644 --- a/docs/development/task-backlog.md +++ b/docs/development/task-backlog.md @@ -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:**