diff --git a/apps/server/src/patches/patch-service.test.ts b/apps/server/src/patches/patch-service.test.ts index e0559d3..a2273d5 100644 --- a/apps/server/src/patches/patch-service.test.ts +++ b/apps/server/src/patches/patch-service.test.ts @@ -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 { + 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", @@ -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", @@ -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", + }), + ]); + }); }); diff --git a/apps/server/src/patches/patch-service.ts b/apps/server/src/patches/patch-service.ts index d94c702..63fe655 100644 --- a/apps/server/src/patches/patch-service.ts +++ b/apps/server/src/patches/patch-service.ts @@ -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: ( @@ -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( @@ -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 { + 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( diff --git a/docs/development/task-backlog.md b/docs/development/task-backlog.md index 1d7aa26..820d6ab 100644 --- a/docs/development/task-backlog.md +++ b/docs/development/task-backlog.md @@ -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:**