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
15 changes: 14 additions & 1 deletion apps/daemon/src/routes/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ describe("GET /events", () => {
throw error;
}
}
await new Promise((resolve) => setTimeout(resolve, 10));
await waitForSubscriberCount(eventBus, 0);

expect(eventBus.subscriberCount()).toBe(0);
});
Expand Down Expand Up @@ -147,3 +147,16 @@ async function readUntil(

return text;
}

async function waitForSubscriberCount(
eventBus: ReturnType<typeof createEventBus>,
expectedCount: number,
): Promise<void> {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (eventBus.subscriberCount() === expectedCount) {
return;
}

await new Promise((resolve) => setTimeout(resolve, 10));
}
}
3 changes: 3 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,8 @@
"lint": "echo \"@agentdeck/server lint is not scaffolded yet\"",
"typecheck": "tsc -p tsconfig.json --noEmit",
"format": "echo \"@agentdeck/server format is not scaffolded yet\""
},
"dependencies": {
"@agentdeck/workflow-core": "workspace:*"
}
}
14 changes: 14 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,17 @@ export type {
TaskStatus,
TaskValidationResult,
} from "./tasks/task-schema.js";
export { createPatchProposalService } from "./patches/patch-service.js";
export type {
CreatePatchProposalServiceOptions,
PatchProposalService,
} from "./patches/patch-service.js";
export type {
JsonPatchOperation,
PatchApprovalState,
PatchDiffPreview,
PatchProposal,
PatchTargetType,
ProposeWorkflowPatchInput,
WorkflowPatchProposal,
} from "./patches/patch-schema.js";
42 changes: 42 additions & 0 deletions apps/server/src/patches/patch-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { WorkflowDefinition, WorkflowSafetyResult } from "@agentdeck/workflow-core";

export type PatchTargetType = "workflow";
export type PatchApprovalState = "pending" | "approved" | "rejected" | "applied";

export type JsonPatchOperation =
| {
readonly op: "add" | "replace";
readonly path: string;
readonly value: unknown;
}
| {
readonly op: "remove";
readonly path: string;
};

export interface PatchDiffPreview<TTarget = unknown> {
readonly summary: readonly string[];
readonly before: TTarget;
readonly after: TTarget;
}

export interface PatchProposal<TTarget = unknown> {
readonly id: string;
readonly targetType: PatchTargetType;
readonly targetId: string;
readonly baseVersion: number;
readonly jsonPatch: readonly JsonPatchOperation[];
readonly validationResult: WorkflowSafetyResult;
readonly approvalState: PatchApprovalState;
readonly diffPreview: PatchDiffPreview<TTarget>;
readonly createdAt: string;
readonly updatedAt: string;
}

export type WorkflowPatchProposal = PatchProposal<WorkflowDefinition>;

export interface ProposeWorkflowPatchInput {
readonly targetWorkflow: WorkflowDefinition;
readonly baseVersion: number;
readonly patch: readonly JsonPatchOperation[];
}
100 changes: 100 additions & 0 deletions apps/server/src/patches/patch-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";

import { createPatchProposalService } from "./patch-service.js";
import type { JsonPatchOperation } from "./patch-schema.js";
import type { WorkflowDefinition } from "@agentdeck/workflow-core";

const baseWorkflow: WorkflowDefinition = {
id: "code-review",
name: "Code Review",
version: 2,
status: "draft",
variables: {},
permissions: {
read: true,
write: true,
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",
};

describe("createPatchProposalService", () => {
it("proposes and previews a valid workflow patch without mutating the target", async () => {
const service = createPatchProposalService({
now: () => "2026-05-29T01:00:00.000Z",
nextId: () => "patch-1",
});
const patch: JsonPatchOperation[] = [{ op: "replace", path: "/name", value: "Code Review v2" }];

const proposal = await service.proposeWorkflowPatch({
targetWorkflow: baseWorkflow,
baseVersion: 2,
patch,
});

expect(proposal).toMatchObject({
id: "patch-1",
targetType: "workflow",
targetId: "code-review",
baseVersion: 2,
approvalState: "pending",
validationResult: { success: true, errors: [] },
diffPreview: {
summary: ["replace /name"],
before: baseWorkflow,
after: { ...baseWorkflow, name: "Code Review v2" },
},
});
expect(baseWorkflow.name).toBe("Code Review");
expect(await service.get("patch-1")).toEqual(proposal);
});

it("validates workflow patches through workflow-core", async () => {
const service = createPatchProposalService({ nextId: () => "patch-invalid" });

const proposal = await service.proposeWorkflowPatch({
targetWorkflow: baseWorkflow,
baseVersion: 2,
patch: [{ op: "remove", path: "/nodes/0" }],
});

expect(proposal.validationResult.success).toBe(false);
expect(proposal.validationResult.errors).toContainEqual({
code: "schema.missingStart",
message: "workflow must contain exactly one start node.",
});
});

it("requires approval before applying a proposal", async () => {
const service = createPatchProposalService({ nextId: () => "patch-apply" });
const proposal = await service.proposeWorkflowPatch({
targetWorkflow: baseWorkflow,
baseVersion: 2,
patch: [{ op: "replace", path: "/status", value: "active" }],
});

await expect(service.apply(proposal.id)).rejects.toThrow(
'Patch proposal "patch-apply" must be approved before apply.',
);

const approved = await service.setApprovalState(proposal.id, "approved");
const applied = await service.apply(approved.id);

expect(applied.workflow.status).toBe("active");
expect(applied.proposal.approvalState).toBe("applied");
expect(baseWorkflow.status).toBe("draft");
});
});
Loading