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
11 changes: 11 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
61 changes: 61 additions & 0 deletions apps/server/src/workflows/workflow-repository.ts
Original file line number Diff line number Diff line change
@@ -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<WorkflowDefinition[]>;
readonly get: (id: string) => Promise<WorkflowDefinition | undefined>;
readonly save: (workflow: WorkflowDefinition) => Promise<void>;
readonly delete: (id: string) => Promise<boolean>;
}

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<Record<string, WorkflowDefinition>> {
try {
const raw = await readFile(storePath, "utf8");
return JSON.parse(raw) as Record<string, WorkflowDefinition>;
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return {};
}

throw error;
}
}

async function writeStore(
storePath: string,
store: Readonly<Record<string, WorkflowDefinition>>,
): Promise<void> {
await mkdir(dirname(storePath), { recursive: true });
await writeFile(storePath, `${JSON.stringify(store, null, 2)}\n`, "utf8");
}
110 changes: 110 additions & 0 deletions apps/server/src/workflows/workflow-service.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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]);
});
});
93 changes: 93 additions & 0 deletions apps/server/src/workflows/workflow-service.ts
Original file line number Diff line number Diff line change
@@ -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<WorkflowDefinition, "id" | "createdAt" | "updatedAt">
> & {
readonly now?: () => string;
};

export interface WorkflowRegistryService {
readonly create: (input: WorkflowDefinition) => Promise<WorkflowDefinition>;
readonly list: () => Promise<WorkflowDefinition[]>;
readonly get: (id: string) => Promise<WorkflowDefinition | undefined>;
readonly update: (id: string, input: WorkflowUpdateInput) => Promise<WorkflowDefinition>;
readonly delete: (id: string) => Promise<boolean>;
}

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;
}
}
87 changes: 87 additions & 0 deletions docs/development/task-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading