diff --git a/apps/daemon/src/routes/events.test.ts b/apps/daemon/src/routes/events.test.ts index a5076c3..2a7184b 100644 --- a/apps/daemon/src/routes/events.test.ts +++ b/apps/daemon/src/routes/events.test.ts @@ -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); }); @@ -147,3 +147,16 @@ async function readUntil( return text; } + +async function waitForSubscriberCount( + eventBus: ReturnType, + expectedCount: number, +): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (eventBus.subscriberCount() === expectedCount) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} diff --git a/apps/server/package.json b/apps/server/package.json index 12a6a17..1e8863a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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:*" } } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 724e162..c0e1647 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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"; diff --git a/apps/server/src/patches/patch-schema.ts b/apps/server/src/patches/patch-schema.ts new file mode 100644 index 0000000..6632924 --- /dev/null +++ b/apps/server/src/patches/patch-schema.ts @@ -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 { + readonly summary: readonly string[]; + readonly before: TTarget; + readonly after: TTarget; +} + +export interface PatchProposal { + 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; + readonly createdAt: string; + readonly updatedAt: string; +} + +export type WorkflowPatchProposal = PatchProposal; + +export interface ProposeWorkflowPatchInput { + readonly targetWorkflow: WorkflowDefinition; + readonly baseVersion: number; + readonly patch: readonly JsonPatchOperation[]; +} diff --git a/apps/server/src/patches/patch-service.test.ts b/apps/server/src/patches/patch-service.test.ts new file mode 100644 index 0000000..e0559d3 --- /dev/null +++ b/apps/server/src/patches/patch-service.test.ts @@ -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"); + }); +}); diff --git a/apps/server/src/patches/patch-service.ts b/apps/server/src/patches/patch-service.ts new file mode 100644 index 0000000..d94c702 --- /dev/null +++ b/apps/server/src/patches/patch-service.ts @@ -0,0 +1,244 @@ +import { + validateWorkflowSafety, + type WorkflowDefinition, + type WorkflowSafetyValidatorOptions, +} from "@agentdeck/workflow-core"; + +import type { + JsonPatchOperation, + PatchApprovalState, + ProposeWorkflowPatchInput, + WorkflowPatchProposal, +} from "./patch-schema.js"; + +export interface PatchProposalService { + readonly proposeWorkflowPatch: ( + input: ProposeWorkflowPatchInput, + ) => Promise; + readonly get: (id: string) => Promise; + readonly setApprovalState: ( + id: string, + approvalState: Exclude, + ) => Promise; + readonly apply: (id: string) => Promise<{ + readonly proposal: WorkflowPatchProposal; + readonly workflow: WorkflowDefinition; + }>; +} + +export interface CreatePatchProposalServiceOptions { + readonly now?: () => string; + readonly nextId?: () => string; + readonly workflowValidation?: WorkflowSafetyValidatorOptions; +} + +export function createPatchProposalService( + options: CreatePatchProposalServiceOptions = {}, +): PatchProposalService { + const proposals = new Map(); + const now = options.now ?? (() => new Date().toISOString()); + const nextId = options.nextId ?? (() => crypto.randomUUID()); + + return { + async proposeWorkflowPatch(input) { + assertBaseVersion(input.targetWorkflow, input.baseVersion); + + const before = cloneJson(input.targetWorkflow); + const after = applyJsonPatch(before, input.patch) as WorkflowDefinition; + const validationResult = await validateWorkflowSafety(after, options.workflowValidation); + const timestamp = now(); + const proposal: WorkflowPatchProposal = { + id: nextId(), + targetType: "workflow", + targetId: input.targetWorkflow.id, + baseVersion: input.baseVersion, + jsonPatch: cloneJson(input.patch), + validationResult, + approvalState: "pending", + diffPreview: { + summary: input.patch.map(summarizeOperation), + before, + after, + }, + createdAt: timestamp, + updatedAt: timestamp, + }; + + proposals.set(proposal.id, proposal); + return proposal; + }, + async get(id) { + return proposals.get(id); + }, + async setApprovalState(id, approvalState) { + const proposal = getRequiredProposal(proposals, id); + const updated = { + ...proposal, + approvalState, + updatedAt: now(), + }; + proposals.set(id, updated); + return updated; + }, + async apply(id) { + const proposal = getRequiredProposal(proposals, id); + if (proposal.approvalState !== "approved") { + throw new Error(`Patch proposal "${id}" must be approved before apply.`); + } + + if (!proposal.validationResult.success) { + throw new Error(`Patch proposal "${id}" has validation errors.`); + } + + const applied = { + ...proposal, + approvalState: "applied" as const, + updatedAt: now(), + }; + proposals.set(id, applied); + + return { + proposal: applied, + workflow: cloneJson(applied.diffPreview.after), + }; + }, + }; +} + +function assertBaseVersion(workflow: WorkflowDefinition, baseVersion: number): void { + if (workflow.version !== baseVersion) { + throw new Error( + `Patch base version ${baseVersion} does not match workflow version ${workflow.version}.`, + ); + } +} + +function getRequiredProposal( + proposals: ReadonlyMap, + id: string, +): WorkflowPatchProposal { + const proposal = proposals.get(id); + if (!proposal) { + throw new Error(`Patch proposal "${id}" was not found.`); + } + + return proposal; +} + +function applyJsonPatch(target: unknown, patch: readonly JsonPatchOperation[]): unknown { + const next = cloneJson(target); + for (const operation of patch) { + applyJsonPatchOperation(next, operation); + } + + return next; +} + +function applyJsonPatchOperation(target: unknown, operation: JsonPatchOperation): void { + const { parent, key } = resolveJsonPointerParent(target, operation.path); + + if (operation.op === "remove") { + removeValue(parent, key); + return; + } + + setValue(parent, key, cloneJson(operation.value), operation.op); +} + +function resolveJsonPointerParent(target: unknown, path: string): { parent: unknown; key: string } { + const parts = parseJsonPointer(path); + if (parts.length === 0) { + throw new Error("JSON patch path must reference a child value."); + } + + let parent = target; + for (const part of parts.slice(0, -1)) { + parent = readChild(parent, part); + } + + return { + parent, + key: parts[parts.length - 1] ?? "", + }; +} + +function parseJsonPointer(path: string): string[] { + if (!path.startsWith("/")) { + throw new Error(`JSON patch path "${path}" must start with "/".`); + } + + return path + .slice(1) + .split("/") + .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")); +} + +function readChild(parent: unknown, key: string): unknown { + if (Array.isArray(parent)) { + return parent[toArrayIndex(parent, key, false)]; + } + + if (isRecord(parent)) { + return parent[key]; + } + + throw new Error(`Cannot traverse JSON patch path segment "${key}".`); +} + +function setValue(parent: unknown, key: string, value: unknown, op: "add" | "replace"): void { + if (Array.isArray(parent)) { + const index = key === "-" ? parent.length : toArrayIndex(parent, key, op === "add"); + if (op === "replace") { + parent[index] = value; + return; + } + + parent.splice(index, 0, value); + return; + } + + if (!isRecord(parent)) { + throw new Error(`Cannot ${op} JSON patch path segment "${key}".`); + } + + if (op === "replace" && !(key in parent)) { + throw new Error(`Cannot replace missing JSON patch path segment "${key}".`); + } + + parent[key] = value; +} + +function removeValue(parent: unknown, key: string): void { + if (Array.isArray(parent)) { + parent.splice(toArrayIndex(parent, key, false), 1); + return; + } + + if (!isRecord(parent)) { + throw new Error(`Cannot remove JSON patch path segment "${key}".`); + } + + delete parent[key]; +} + +function toArrayIndex(values: readonly unknown[], key: string, allowEnd: boolean): number { + const index = Number.parseInt(key, 10); + const upperBound = allowEnd ? values.length : values.length - 1; + if (!Number.isInteger(index) || index < 0 || index > upperBound) { + throw new Error(`JSON patch array index "${key}" is out of bounds.`); + } + + return index; +} + +function summarizeOperation(operation: JsonPatchOperation): string { + return `${operation.op} ${operation.path}`; +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/docs/development/task-backlog.md b/docs/development/task-backlog.md index 7b9a459..d69b09f 100644 --- a/docs/development/task-backlog.md +++ b/docs/development/task-backlog.md @@ -489,10 +489,10 @@ Status markers: **Tasks:** -- [ ] Define `PatchProposal` with target type, target ID, base version, JSON patch, validation result, approval state, and diff preview. -- [ ] Validate workflow patches through `workflow-core`. -- [ ] Require approval before apply. -- [ ] Commit with message `feat: add patch proposal service`. +- [x] Define `PatchProposal` with target type, target ID, base version, JSON patch, validation result, approval state, and diff preview. +- [x] Validate workflow patches through `workflow-core`. +- [x] Require approval before apply. +- [x] Commit with message `feat: add patch proposal service`. **Acceptance Criteria:** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b271ac..1f5cc81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,7 +45,11 @@ importers: specifier: workspace:* version: link:../../packages/runtime-adapters - apps/server: {} + apps/server: + dependencies: + '@agentdeck/workflow-core': + specifier: workspace:* + version: link:../../packages/workflow-core apps/web: dependencies: