From 400621bc7989f5aa8efcd63269b3a71601adcbc6 Mon Sep 17 00:00:00 2001 From: eleven_qy <64311644+alph-cmky@users.noreply.github.com> Date: Fri, 29 May 2026 20:11:50 +0800 Subject: [PATCH] feat: add workflow registry persistence --- apps/server/src/index.ts | 11 ++ .../src/workflows/workflow-repository.ts | 61 ++++++++++ .../src/workflows/workflow-service.test.ts | 110 ++++++++++++++++++ apps/server/src/workflows/workflow-service.ts | 93 +++++++++++++++ docs/development/task-backlog.md | 87 ++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 apps/server/src/workflows/workflow-repository.ts create mode 100644 apps/server/src/workflows/workflow-service.test.ts create mode 100644 apps/server/src/workflows/workflow-service.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 43021d7..eaf4ea7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -95,3 +95,14 @@ export type { AuditMetadataValue, CreateAuditEntryInput, } from "./audit/audit-schema.js"; +export { createFileWorkflowRepository } from "./workflows/workflow-repository.js"; +export type { WorkflowRepository } from "./workflows/workflow-repository.js"; +export { + WorkflowValidationError, + createWorkflowRegistryService, +} from "./workflows/workflow-service.js"; +export type { + CreateWorkflowRegistryServiceOptions, + WorkflowRegistryService, + WorkflowUpdateInput, +} from "./workflows/workflow-service.js"; diff --git a/apps/server/src/workflows/workflow-repository.ts b/apps/server/src/workflows/workflow-repository.ts new file mode 100644 index 0000000..76f5e52 --- /dev/null +++ b/apps/server/src/workflows/workflow-repository.ts @@ -0,0 +1,61 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import type { WorkflowDefinition } from "@agentdeck/workflow-core"; + +export interface WorkflowRepository { + readonly list: () => Promise; + readonly get: (id: string) => Promise; + readonly save: (workflow: WorkflowDefinition) => Promise; + readonly delete: (id: string) => Promise; +} + +export function createFileWorkflowRepository(storePath: string): WorkflowRepository { + return { + async list() { + return Object.values(await readStore(storePath)).sort((left, right) => + left.id.localeCompare(right.id), + ); + }, + async get(id) { + const store = await readStore(storePath); + return store[id]; + }, + async save(workflow) { + const store = await readStore(storePath); + store[workflow.id] = workflow; + await writeStore(storePath, store); + }, + async delete(id) { + const store = await readStore(storePath); + if (!store[id]) { + return false; + } + + delete store[id]; + await writeStore(storePath, store); + return true; + }, + }; +} + +async function readStore(storePath: string): Promise> { + try { + const raw = await readFile(storePath, "utf8"); + return JSON.parse(raw) as Record; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return {}; + } + + throw error; + } +} + +async function writeStore( + storePath: string, + store: Readonly>, +): Promise { + await mkdir(dirname(storePath), { recursive: true }); + await writeFile(storePath, `${JSON.stringify(store, null, 2)}\n`, "utf8"); +} diff --git a/apps/server/src/workflows/workflow-service.test.ts b/apps/server/src/workflows/workflow-service.test.ts new file mode 100644 index 0000000..da6a816 --- /dev/null +++ b/apps/server/src/workflows/workflow-service.test.ts @@ -0,0 +1,110 @@ +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 { createWorkflowRegistryService } from "./workflow-service.js"; + +const tempDirs: string[] = []; + +async function createTempStorePath(): Promise { + const tempDir = await mkdtemp(join(tmpdir(), "agentdeck-workflow-registry-")); + 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("createWorkflowRegistryService", () => { + it("creates, lists, gets, updates, and deletes workflow definitions", async () => { + const service = createWorkflowRegistryService({ + storePath: await createTempStorePath(), + now: () => "2026-05-29T01:00:00.000Z", + }); + + const created = await service.create(baseWorkflow); + + expect(created).toEqual({ + ...baseWorkflow, + createdAt: "2026-05-29T01:00:00.000Z", + updatedAt: "2026-05-29T01:00:00.000Z", + }); + expect(await service.list()).toEqual([created]); + expect(await service.get("review-flow")).toEqual(created); + + const updated = await service.update("review-flow", { + name: "Review Flow v2", + version: 2, + now: () => "2026-05-29T02:00:00.000Z", + }); + + expect(updated.name).toBe("Review Flow v2"); + expect(updated.version).toBe(2); + expect(updated.createdAt).toBe("2026-05-29T01:00:00.000Z"); + expect(updated.updatedAt).toBe("2026-05-29T02:00:00.000Z"); + + expect(await service.delete("review-flow")).toBe(true); + expect(await service.get("review-flow")).toBeUndefined(); + }); + + it("rejects invalid workflows before persistence", async () => { + const service = createWorkflowRegistryService({ + storePath: await createTempStorePath(), + }); + + await expect( + service.create({ + ...baseWorkflow, + nodes: [{ id: "agent-review", type: "agent", label: "Review", agentId: "reviewer" }], + edges: [], + }), + ).rejects.toThrow("workflow must contain exactly one start node."); + + expect(await service.list()).toEqual([]); + }); + + it("persists workflow definitions across service instances", async () => { + const storePath = await createTempStorePath(); + const firstService = createWorkflowRegistryService({ + storePath, + now: () => "2026-05-29T01:00:00.000Z", + }); + const created = await firstService.create(baseWorkflow); + + const secondService = createWorkflowRegistryService({ storePath }); + + expect(await secondService.get("review-flow")).toEqual(created); + expect(await secondService.list()).toEqual([created]); + }); +}); diff --git a/apps/server/src/workflows/workflow-service.ts b/apps/server/src/workflows/workflow-service.ts new file mode 100644 index 0000000..307daeb --- /dev/null +++ b/apps/server/src/workflows/workflow-service.ts @@ -0,0 +1,93 @@ +import { createWorkflowDefinition, type WorkflowDefinition } from "@agentdeck/workflow-core"; + +import { createFileWorkflowRepository, type WorkflowRepository } from "./workflow-repository.js"; + +export type WorkflowUpdateInput = Partial< + Omit +> & { + readonly now?: () => string; +}; + +export interface WorkflowRegistryService { + readonly create: (input: WorkflowDefinition) => Promise; + readonly list: () => Promise; + readonly get: (id: string) => Promise; + readonly update: (id: string, input: WorkflowUpdateInput) => Promise; + readonly delete: (id: string) => Promise; +} + +export interface CreateWorkflowRegistryServiceOptions { + readonly storePath?: string; + readonly repository?: WorkflowRepository; + readonly now?: () => string; +} + +export function createWorkflowRegistryService( + options: CreateWorkflowRegistryServiceOptions = {}, +): WorkflowRegistryService { + const repository = + options.repository ?? + createFileWorkflowRepository(options.storePath ?? ".agentdeck/workflows.json"); + const now = options.now ?? (() => new Date().toISOString()); + + return { + async create(input) { + const timestamp = now(); + const result = createWorkflowDefinition({ + ...input, + createdAt: timestamp, + updatedAt: timestamp, + }); + if (!result.success) { + throw new WorkflowValidationError(result.errors); + } + + await repository.save(result.value); + return result.value; + }, + async list() { + return await repository.list(); + }, + async get(id) { + return await repository.get(id); + }, + async update(id, input) { + const existing = await repository.get(id); + if (!existing) { + throw new Error(`Workflow "${id}" was not found.`); + } + + const result = createWorkflowDefinition({ + id: existing.id, + name: input.name ?? existing.name, + version: input.version ?? existing.version, + status: input.status ?? existing.status, + variables: input.variables ?? existing.variables, + permissions: input.permissions ?? existing.permissions, + nodes: input.nodes ?? existing.nodes, + edges: input.edges ?? existing.edges, + createdAt: existing.createdAt, + updatedAt: (input.now ?? now)(), + }); + if (!result.success) { + throw new WorkflowValidationError(result.errors); + } + + await repository.save(result.value); + return result.value; + }, + async delete(id) { + return await repository.delete(id); + }, + }; +} + +export class WorkflowValidationError extends Error { + readonly errors: readonly string[]; + + constructor(errors: readonly string[]) { + super(errors.join(" ")); + this.name = "WorkflowValidationError"; + this.errors = errors; + } +} diff --git a/docs/development/task-backlog.md b/docs/development/task-backlog.md index fe68047..0bde44b 100644 --- a/docs/development/task-backlog.md +++ b/docs/development/task-backlog.md @@ -583,3 +583,90 @@ Status markers: **Acceptance Criteria:** - Pull requests have the same quality gates as local development. + +## Phase 11: MVP Integration + +### AD-1101: Implement Workflow Registry Persistence + +**Goal:** Persist workflow definitions behind a server-side registry service. + +**Files:** + +- Create `apps/server/src/workflows/workflow-repository.ts` +- Create `apps/server/src/workflows/workflow-service.ts` +- Create `apps/server/src/workflows/workflow-service.test.ts` +- Modify `apps/server/src/index.ts` + +**Tasks:** + +- [x] Save, list, get, update, and delete workflow definitions. +- [x] Validate workflow definitions through `workflow-core` before persistence. +- [x] Preserve `createdAt` and update `updatedAt` on edits. +- [x] Commit with message `feat: add workflow registry persistence`. + +**Acceptance Criteria:** + +- A valid workflow can be persisted, updated, loaded by ID, listed, and deleted without bypassing `workflow-core` validation. + +### AD-1102: Add Workflow HTTP Routes + +**Goal:** Expose workflow registry operations through the server control plane. + +**Files:** + +- Create `apps/server/src/workflows/workflow-routes.ts` +- Create `apps/server/src/workflows/workflow-routes.test.ts` +- Modify `apps/server/src/index.ts` + +**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`. + +**Acceptance Criteria:** + +- The web app or MCP layer can manage workflows through server APIs instead of local mocks. + +### AD-1103: Add Patch Application Integration + +**Goal:** Connect approved patch proposals to workflow persistence. + +**Files:** + +- Modify `apps/server/src/patches/patch-service.ts` +- Modify `apps/server/src/patches/patch-service.test.ts` +- Modify `apps/server/src/workflows/workflow-service.ts` + +**Tasks:** + +- [ ] Apply approved workflow patch proposals through the workflow registry. +- [ ] Reject stale base versions before apply. +- [ ] Audit approved and rejected apply attempts. +- [ ] Commit with message `feat: integrate patch apply workflow persistence`. + +**Acceptance Criteria:** + +- Approved workflow patches update the persisted workflow and stale patches are rejected. + +### AD-1104: Add Real Web Dev Runtime + +**Goal:** Replace the placeholder web package with a runnable local app. + +**Files:** + +- Modify `apps/web/package.json` +- Create or modify Next.js app config files under `apps/web` +- Modify existing `apps/web/src/app/page.tsx` + +**Tasks:** + +- [ ] Add a real `pnpm --filter @agentdeck/web dev` server. +- [ ] Render the app shell, runtime dashboard, and workflow canvas in the browser. +- [ ] Add browser smoke verification for the first viewport. +- [ ] Commit with message `feat: add runnable web app`. + +**Acceptance Criteria:** + +- A contributor can run the web app locally and inspect the MVP UI in a browser.