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
93 changes: 92 additions & 1 deletion apps/server/src/patches/patch-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterEach, describe, expect, it } from "vitest";

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

const tempDirs: string[] = [];

async function createTempStorePath(): Promise<string> {
const tempDir = await mkdtemp(join(tmpdir(), "agentdeck-patch-service-"));
tempDirs.push(tempDir);
return join(tempDir, "workflows.json");
}

const baseWorkflow: WorkflowDefinition = {
id: "code-review",
name: "Code Review",
Expand Down Expand Up @@ -32,6 +46,10 @@ const baseWorkflow: WorkflowDefinition = {
};

describe("createPatchProposalService", () => {
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});

it("proposes and previews a valid workflow patch without mutating the target", async () => {
const service = createPatchProposalService({
now: () => "2026-05-29T01:00:00.000Z",
Expand Down Expand Up @@ -97,4 +115,77 @@ describe("createPatchProposalService", () => {
expect(applied.proposal.approvalState).toBe("applied");
expect(baseWorkflow.status).toBe("draft");
});

it("applies approved workflow patches through the workflow registry and audits success", async () => {
const workflowRegistry = createWorkflowRegistryService({
storePath: await createTempStorePath(),
now: () => "2026-05-29T01:00:00.000Z",
});
const auditLog = createAuditLogService({
now: () => "2026-05-29T02:00:00.000Z",
nextId: () => "audit-apply",
});
const service = createPatchProposalService({
nextId: () => "patch-persist",
workflowRegistry,
auditLog,
});
await workflowRegistry.create(baseWorkflow);
const proposal = await service.proposeWorkflowPatch({
targetWorkflow: baseWorkflow,
baseVersion: 2,
patch: [
{ op: "replace", path: "/name", value: "Code Review v3" },
{ op: "replace", path: "/version", value: 3 },
],
});
await service.setApprovalState(proposal.id, "approved");

const applied = await service.apply(proposal.id);
const persisted = await workflowRegistry.get("code-review");

expect(applied.workflow.name).toBe("Code Review v3");
expect(applied.workflow.version).toBe(3);
expect(persisted?.name).toBe("Code Review v3");
expect(persisted?.version).toBe(3);
expect(await auditLog.list()).toEqual([
expect.objectContaining({
action: "patch.proposal",
patchProposalId: "patch-persist",
diffSummary: "replace /name; replace /version",
approvalDecision: "approved",
}),
]);
});

it("rejects stale patch base versions and audits rejected apply attempts", async () => {
const workflowRegistry = createWorkflowRegistryService({
storePath: await createTempStorePath(),
});
const auditLog = createAuditLogService({ nextId: () => "audit-stale" });
const service = createPatchProposalService({
nextId: () => "patch-stale",
workflowRegistry,
auditLog,
});
await workflowRegistry.create({ ...baseWorkflow, version: 3 });
const proposal = await service.proposeWorkflowPatch({
targetWorkflow: baseWorkflow,
baseVersion: 2,
patch: [{ op: "replace", path: "/name", value: "Stale update" }],
});
await service.setApprovalState(proposal.id, "approved");

await expect(service.apply(proposal.id)).rejects.toThrow(
'Patch proposal "patch-stale" targets workflow version 2, but current version is 3.',
);
expect((await workflowRegistry.get("code-review"))?.name).toBe("Code Review");
expect(await auditLog.list()).toEqual([
expect.objectContaining({
action: "approval.decision",
patchProposalId: "patch-stale",
approvalDecision: "rejected",
}),
]);
});
});
67 changes: 66 additions & 1 deletion apps/server/src/patches/patch-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
ProposeWorkflowPatchInput,
WorkflowPatchProposal,
} from "./patch-schema.js";
import type { AuditLogService } from "../audit/audit-service.js";
import type { WorkflowRegistryService } from "../workflows/workflow-service.js";

export interface PatchProposalService {
readonly proposeWorkflowPatch: (
Expand All @@ -30,6 +32,8 @@ export interface CreatePatchProposalServiceOptions {
readonly now?: () => string;
readonly nextId?: () => string;
readonly workflowValidation?: WorkflowSafetyValidatorOptions;
readonly workflowRegistry?: WorkflowRegistryService;
readonly auditLog?: AuditLogService;
}

export function createPatchProposalService(
Expand Down Expand Up @@ -90,21 +94,82 @@ export function createPatchProposalService(
throw new Error(`Patch proposal "${id}" has validation errors.`);
}

const workflow = await applyWorkflowPatch(proposal, options);
const applied = {
...proposal,
approvalState: "applied" as const,
diffPreview: {
...proposal.diffPreview,
after: workflow,
},
updatedAt: now(),
};
proposals.set(id, applied);
await options.auditLog?.record({
action: "patch.proposal",
actor: { type: "system", id: "patch-service" },
patchProposalId: proposal.id,
diffSummary: proposal.diffPreview.summary.join("; "),
approvalDecision: "approved",
metadata: {
targetType: proposal.targetType,
targetId: proposal.targetId,
baseVersion: proposal.baseVersion,
},
});

return {
proposal: applied,
workflow: cloneJson(applied.diffPreview.after),
workflow: cloneJson(workflow),
};
},
};
}

async function applyWorkflowPatch(
proposal: WorkflowPatchProposal,
options: CreatePatchProposalServiceOptions,
): Promise<WorkflowDefinition> {
const workflowRegistry = options.workflowRegistry;
if (!workflowRegistry) {
return cloneJson(proposal.diffPreview.after);
}

const currentWorkflow = await workflowRegistry.get(proposal.targetId);
if (!currentWorkflow) {
throw new Error(`Workflow "${proposal.targetId}" was not found.`);
}

if (currentWorkflow.version !== proposal.baseVersion) {
await options.auditLog?.record({
action: "approval.decision",
actor: { type: "system", id: "patch-service" },
patchProposalId: proposal.id,
diffSummary: proposal.diffPreview.summary.join("; "),
approvalDecision: "rejected",
metadata: {
targetType: proposal.targetType,
targetId: proposal.targetId,
baseVersion: proposal.baseVersion,
currentVersion: currentWorkflow.version,
},
});
throw new Error(
`Patch proposal "${proposal.id}" targets workflow version ${proposal.baseVersion}, but current version is ${currentWorkflow.version}.`,
);
}

return await workflowRegistry.update(proposal.targetId, {
name: proposal.diffPreview.after.name,
version: proposal.diffPreview.after.version,
status: proposal.diffPreview.after.status,
variables: proposal.diffPreview.after.variables,
permissions: proposal.diffPreview.after.permissions,
nodes: proposal.diffPreview.after.nodes,
edges: proposal.diffPreview.after.edges,
});
}

function assertBaseVersion(workflow: WorkflowDefinition, baseVersion: number): void {
if (workflow.version !== baseVersion) {
throw new Error(
Expand Down
8 changes: 4 additions & 4 deletions docs/development/task-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,10 +641,10 @@ Status markers:

**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`.
- [x] Apply approved workflow patch proposals through the workflow registry.
- [x] Reject stale base versions before apply.
- [x] Audit approved and rejected apply attempts.
- [x] Commit with message `feat: integrate patch apply workflow persistence`.

**Acceptance Criteria:**

Expand Down
Loading