diff --git a/packages/dashboard/src/__tests__/workflow-routes.test.ts b/packages/dashboard/src/__tests__/workflow-routes.test.ts new file mode 100644 index 0000000000..e893d9b6df --- /dev/null +++ b/packages/dashboard/src/__tests__/workflow-routes.test.ts @@ -0,0 +1,150 @@ +// @vitest-environment node + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import express from "express"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore } from "@fusion/core"; +import type { WorkflowIr } from "@fusion/core"; +import { registerWorkflowRoutes } from "../routes/register-workflow-routes.js"; +import { ApiError, sendErrorResponse } from "../api-error.js"; +import { request } from "../test-request.js"; + +function linearIr(): WorkflowIr { + return { + version: "v1", + name: "wf", + nodes: [ + { id: "start", kind: "start" }, + { id: "lint", kind: "gate", config: { name: "Lint", scriptName: "lint" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "lint", condition: "success" }, + { from: "lint", to: "end", condition: "success" }, + ], + }; +} + +function branchingIr(): WorkflowIr { + return { + version: "v1", + name: "branchy", + nodes: [ + { id: "start", kind: "start" }, + { id: "a", kind: "prompt", config: { prompt: "a" } }, + { id: "b", kind: "prompt", config: { prompt: "b" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "a", condition: "success" }, + { from: "a", to: "b", condition: "success" }, + { from: "a", to: "end", condition: "success" }, + { from: "b", to: "end", condition: "success" }, + ], + }; +} + +describe("workflow routes (U4)", () => { + let store: TaskStore; + let rootDir: string; + let globalDir: string; + let app: express.Express; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "wf-routes-root-")); + globalDir = mkdtempSync(join(tmpdir(), "wf-routes-global-")); + store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + await store.init(); + + app = express(); + app.use(express.json()); + const router = express.Router(); + registerWorkflowRoutes({ + router, + getProjectContext: async () => ({ store, engine: undefined, projectId: undefined }), + rethrowAsApiError: (err: unknown) => { + throw err instanceof ApiError ? err : new ApiError(500, err instanceof Error ? err.message : String(err)); + }, + } as unknown as Parameters[0]); + app.use("/api", router); + app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + if (err instanceof ApiError) sendErrorResponse(res, err.statusCode, err.message, { details: err.details }); + else sendErrorResponse(res, 500, err instanceof Error ? err.message : String(err)); + }); + }); + + afterEach(() => { + store.close(); + rmSync(rootDir, { recursive: true, force: true }); + rmSync(globalDir, { recursive: true, force: true }); + }); + + const post = (path: string, body: unknown) => + request(app, "POST", path, JSON.stringify(body), { "content-type": "application/json" }); + const put = (path: string, body: unknown) => + request(app, "PUT", path, JSON.stringify(body), { "content-type": "application/json" }); + const get = (path: string) => request(app, "GET", path); + + it("POST /workflows creates with valid IR and rejects malformed IR", async () => { + const ok = await post("/api/workflows", { name: "QA", ir: linearIr() }); + expect(ok.status).toBe(201); + expect((ok.body as { id: string }).id).toBe("WF-001"); + + const bad = await post("/api/workflows", { name: "Bad", ir: { version: "v1", name: "x", nodes: [], edges: [] } }); + expect(bad.status).toBe(400); + }); + + it("GET /workflows lists created workflows", async () => { + await post("/api/workflows", { name: "A", ir: linearIr() }); + const res = await get("/api/workflows"); + expect(res.status).toBe(200); + expect((res.body as unknown[]).length).toBe(1); + }); + + it("POST /workflows/:id/compile returns steps for linear and 422 for branching", async () => { + const linear = await post("/api/workflows", { name: "L", ir: linearIr() }); + const linearId = (linear.body as { id: string }).id; + const okCompile = await post(`/api/workflows/${linearId}/compile`, {}); + expect(okCompile.status).toBe(200); + expect((okCompile.body as { steps: unknown[] }).steps).toHaveLength(1); + + const branchy = await post("/api/workflows", { name: "B", ir: branchingIr() }); + const branchyId = (branchy.body as { id: string }).id; + const badCompile = await post(`/api/workflows/${branchyId}/compile`, {}); + expect(badCompile.status).toBe(422); + expect((badCompile.body as { error: string }).error).toMatch(/interpreter \(deferred\)/i); + }); + + it("PUT /tasks/:taskId/workflow selects and reflects on the task", async () => { + const wf = await post("/api/workflows", { name: "QA", ir: linearIr() }); + const wfId = (wf.body as { id: string }).id; + const task = await store.createTask({ description: "T", enabledWorkflowSteps: [] }); + + const sel = await put(`/api/tasks/${task.id}/workflow`, { workflowId: wfId }); + expect(sel.status).toBe(200); + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps).toHaveLength(1); + + const read = await get(`/api/tasks/${task.id}/workflow`); + expect((read.body as { workflowId: string }).workflowId).toBe(wfId); + }); + + it("PUT /project/default-workflow then create task inherits the default", async () => { + const wf = await post("/api/workflows", { name: "Def", ir: linearIr() }); + const wfId = (wf.body as { id: string }).id; + const set = await put("/api/project/default-workflow", { workflowId: wfId }); + expect(set.status).toBe(200); + + const task = await store.createTask({ description: "inherits" }); + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps).toHaveLength(1); + }); + + it("selecting an unknown workflow returns 404", async () => { + const task = await store.createTask({ description: "T", enabledWorkflowSteps: [] }); + const res = await put(`/api/tasks/${task.id}/workflow`, { workflowId: "WF-404" }); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index e1bcde6aaa..20d250ae53 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -134,6 +134,7 @@ function resolveBundledPluginDirInDashboard(pluginId: string): string | null { import { createSessionDiagnostics } from "./ai-session-diagnostics.js"; import { createApiRoutesContext } from "./routes/context.js"; import { registerTaskWorkflowRoutes } from "./routes/register-task-workflow-routes.js"; +import { registerWorkflowRoutes } from "./routes/register-workflow-routes.js"; import { registerPlanningSubtaskRoutes } from "./routes/register-planning-subtask-routes.js"; import { registerChatRoutes } from "./routes/register-chat-routes.js"; import { registerChatRoomRoutes } from "./routes/register-chat-room-routes.js"; @@ -1053,6 +1054,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout triggerCommentWakeForAssignedAgent: (...args) => triggerCommentWakeForAssignedAgent(...args), resolveSelfHealingManager: (...args) => resolveSelfHealingManager(...args), }); + registerWorkflowRoutes(routeContext); registerPlanningSubtaskRoutes(routeContext, { store, aiSessionStore, diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts new file mode 100644 index 0000000000..7c2048b404 --- /dev/null +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -0,0 +1,196 @@ +import type { WorkflowIr } from "@fusion/core"; +import { WorkflowCompileError, WorkflowIrError, compileWorkflowToSteps } from "@fusion/core"; +import { ApiError, badRequest, notFound } from "../api-error.js"; +import type { ApiRoutesContext } from "./types.js"; + +/** + * Routes for named workflow definitions, IR compilation preview, per-task + * workflow selection, and the project default workflow. All state changes flow + * through @fusion/core's TaskStore; none touch the engine's scheduler/executor. + */ +export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { + const { router, getProjectContext, rethrowAsApiError } = ctx; + + function requireIr(body: unknown): WorkflowIr { + const ir = (body as { ir?: unknown })?.ir; + if (!ir || typeof ir !== "object") { + throw badRequest("ir is required and must be a workflow graph object"); + } + return ir as WorkflowIr; + } + + // GET /api/workflows — list all workflow definitions for the project. + router.get("/workflows", async (req, res) => { + try { + const { store } = await getProjectContext(req); + res.json(await store.listWorkflowDefinitions()); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + + // POST /api/workflows — create a workflow. Body: { name, description?, ir, layout? } + router.post("/workflows", async (req, res) => { + try { + const { store } = await getProjectContext(req); + const { name, description, layout } = req.body ?? {}; + if (!name || typeof name !== "string" || !name.trim()) { + throw badRequest("name is required"); + } + const ir = requireIr(req.body); + const created = await store.createWorkflowDefinition({ name, description, ir, layout }); + res.status(201).json(created); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + if (err instanceof WorkflowIrError) throw badRequest(err.message); + rethrowAsApiError(err); + } + }); + + // GET /api/workflows/:id + router.get("/workflows/:id", async (req, res) => { + try { + const { store } = await getProjectContext(req); + const def = await store.getWorkflowDefinition(req.params.id); + if (!def) throw notFound(`Workflow '${req.params.id}' not found`); + res.json(def); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + + // PATCH /api/workflows/:id — partial update. Body: { name?, description?, ir?, layout? } + router.patch("/workflows/:id", async (req, res) => { + try { + const { store } = await getProjectContext(req); + const { name, description, ir, layout } = req.body ?? {}; + if (name !== undefined && (typeof name !== "string" || !name.trim())) { + throw badRequest("name must be a non-empty string"); + } + if (ir !== undefined && (typeof ir !== "object" || ir === null)) { + throw badRequest("ir must be a workflow graph object"); + } + const updated = await store.updateWorkflowDefinition(req.params.id, { name, description, ir, layout }); + res.json(updated); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + if (err instanceof WorkflowIrError) throw badRequest(err.message); + if (err instanceof Error && /not found/i.test(err.message)) throw notFound(err.message); + rethrowAsApiError(err); + } + }); + + // DELETE /api/workflows/:id + router.delete("/workflows/:id", async (req, res) => { + try { + const { store } = await getProjectContext(req); + await store.deleteWorkflowDefinition(req.params.id); + res.status(204).send(); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + if (err instanceof Error && /not found/i.test(err.message)) throw notFound(err.message); + rethrowAsApiError(err); + } + }); + + // POST /api/workflows/:id/compile — preview the compiled WorkflowSteps. + // 200 with the step set, or 422 when the graph requires the deferred interpreter. + router.post("/workflows/:id/compile", async (req, res) => { + try { + const { store } = await getProjectContext(req); + const def = await store.getWorkflowDefinition(req.params.id); + if (!def) throw notFound(`Workflow '${req.params.id}' not found`); + try { + res.json({ steps: compileWorkflowToSteps(def.ir) }); + } catch (compileErr: unknown) { + if (compileErr instanceof WorkflowCompileError || compileErr instanceof WorkflowIrError) { + throw new ApiError(422, compileErr.message); + } + throw compileErr; + } + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + + // GET /api/tasks/:taskId/workflow — current selection for a task. + router.get("/tasks/:taskId/workflow", async (req, res) => { + try { + const { store } = await getProjectContext(req); + const selection = store.getTaskWorkflowSelection(req.params.taskId); + res.json({ workflowId: selection?.workflowId ?? null }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + + // PUT /api/tasks/:taskId/workflow — select (or clear) a workflow for a task. + // Body: { workflowId: string | null } + router.put("/tasks/:taskId/workflow", async (req, res) => { + try { + const { store } = await getProjectContext(req); + const workflowId = (req.body ?? {}).workflowId; + if (workflowId === null || workflowId === undefined) { + await store.clearTaskWorkflowSelection(req.params.taskId); + res.json({ workflowId: null }); + return; + } + if (typeof workflowId !== "string") { + throw badRequest("workflowId must be a string or null"); + } + try { + await store.selectTaskWorkflow(req.params.taskId, workflowId); + } catch (selectErr: unknown) { + if (selectErr instanceof WorkflowCompileError || selectErr instanceof WorkflowIrError) { + throw new ApiError(422, selectErr.message); + } + if (selectErr instanceof Error && /not found/i.test(selectErr.message)) { + throw notFound(selectErr.message); + } + throw selectErr; + } + res.json({ workflowId }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + + // GET /api/project/default-workflow + router.get("/project/default-workflow", async (req, res) => { + try { + const { store } = await getProjectContext(req); + res.json({ workflowId: (await store.getDefaultWorkflowId()) ?? null }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + + // PUT /api/project/default-workflow — Body: { workflowId: string | null } + router.put("/project/default-workflow", async (req, res) => { + try { + const { store } = await getProjectContext(req); + const workflowId = (req.body ?? {}).workflowId; + if (workflowId !== null && typeof workflowId !== "string") { + throw badRequest("workflowId must be a string or null"); + } + try { + await store.setDefaultWorkflowId(workflowId); + } catch (setErr: unknown) { + if (setErr instanceof Error && /not found/i.test(setErr.message)) { + throw notFound(setErr.message); + } + throw setErr; + } + res.json({ workflowId: workflowId ?? null }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); +}