diff --git a/docs/api/routines.md b/docs/api/routines.md index eb6b9adc382..2ad279a74fd 100644 --- a/docs/api/routines.md +++ b/docs/api/routines.md @@ -75,11 +75,28 @@ Fields: ``` PATCH /api/routines/{routineId} { - "status": "paused" + "status": "paused", + "baseRevisionId": "{latestRevisionId}" } ``` -All fields from create are updatable. **Agents can only update routines assigned to themselves and cannot reassign a routine to another agent.** +All fields from create are updatable. `baseRevisionId` is optional for backward compatibility; when provided, stale values return `409 Conflict` with the current revision id. **Agents can only update routines assigned to themselves and cannot reassign a routine to another agent.** + +## List Revisions + +``` +GET /api/routines/{routineId}/revisions +``` + +Returns append-only routine definition revisions newest first. Snapshots include routine fields and safe trigger metadata only; webhook secret values and `secretId` are never returned. + +## Restore Revision + +``` +POST /api/routines/{routineId}/revisions/{revisionId}/restore +``` + +Restores a historical routine definition by creating a new latest revision copied from the selected revision. Historical revision rows, routine run history, and activity history are preserved. If restoring a deleted webhook trigger requires recreating it, the response can include one-time replacement secret material for that trigger. ## Add Trigger diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index 35b6b355f0a..d541f0cf909 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -7,6 +7,7 @@ const agentId = "11111111-1111-4111-8111-111111111111"; const routineId = "33333333-3333-4333-8333-333333333333"; const projectId = "44444444-4444-4444-8444-444444444444"; const otherAgentId = "55555555-5555-4555-8555-555555555555"; +const revisionId = "77777777-7777-4777-8777-777777777777"; const routine = { id: routineId, @@ -21,6 +22,9 @@ const routine = { status: "active", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", + variables: [], + latestRevisionId: revisionId, + latestRevisionNumber: 1, createdByAgentId: null, createdByUserId: null, updatedByAgentId: null, @@ -30,6 +34,40 @@ const routine = { createdAt: new Date("2026-03-20T00:00:00.000Z"), updatedAt: new Date("2026-03-20T00:00:00.000Z"), }; + +const revision = { + id: revisionId, + companyId, + routineId, + revisionNumber: 1, + title: "Daily routine", + description: null, + snapshot: { + version: 1, + routine: { + id: routineId, + companyId, + projectId, + goalId: null, + parentIssueId: null, + title: "Daily routine", + description: null, + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [], + }, + triggers: [], + }, + changeSummary: "Created routine", + restoredFromRevisionId: null, + createdByAgentId: null, + createdByUserId: "board-user", + createdByRunId: null, + createdAt: new Date("2026-03-20T00:00:00.000Z"), +}; const pausedRoutine = { ...routine, status: "paused", @@ -65,6 +103,8 @@ const mockRoutineService = vi.hoisted(() => ({ getDetail: vi.fn(), update: vi.fn(), create: vi.fn(), + listRevisions: vi.fn(), + restoreRevision: vi.fn(), listRuns: vi.fn(), createTrigger: vi.fn(), getTrigger: vi.fn(), @@ -150,6 +190,14 @@ describe("routine routes", () => { mockRoutineService.get.mockResolvedValue(routine); mockRoutineService.getTrigger.mockResolvedValue(trigger); mockRoutineService.update.mockResolvedValue({ ...routine, assigneeAgentId: otherAgentId }); + mockRoutineService.listRevisions.mockResolvedValue([revision]); + mockRoutineService.restoreRevision.mockResolvedValue({ + routine, + revision: { ...revision, revisionNumber: 2, restoredFromRevisionId: revision.id }, + restoredFromRevisionId: revision.id, + restoredFromRevisionNumber: revision.revisionNumber, + secretMaterials: [], + }); mockRoutineService.runRoutine.mockResolvedValue({ id: "run-1", source: "manual", @@ -176,6 +224,73 @@ describe("routine routes", () => { expect(mockRoutineService.list).toHaveBeenCalledWith(companyId, { projectId }); }); + it("lists routine revisions for a board member in newest-first service order", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app).get(`/api/routines/${routineId}/revisions`); + + expect(res.status).toBe(200); + expect(mockRoutineService.listRevisions).toHaveBeenCalledWith(routineId); + expect(res.body[0]).toMatchObject({ id: revisionId, revisionNumber: 1 }); + }); + + it("blocks routine revision reads across company scope", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: false, + companyIds: ["99999999-9999-4999-8999-999999999999"], + }); + + const res = await request(app).get(`/api/routines/${routineId}/revisions`); + + expect(res.status).toBe(403); + expect(mockRoutineService.listRevisions).not.toHaveBeenCalled(); + }); + + it("requires an assigned agent for routine revision history access", async () => { + const app = await createApp({ + type: "agent", + agentId: otherAgentId, + companyId, + }); + + const res = await request(app).get(`/api/routines/${routineId}/revisions`); + + expect(res.status).toBe(403); + expect(mockRoutineService.listRevisions).not.toHaveBeenCalled(); + }); + + it("restores routine revisions with existing routine-management permissions", async () => { + const app = await createApp({ + type: "agent", + agentId, + companyId, + runId: "88888888-8888-4888-8888-888888888888", + }); + + const res = await request(app).post(`/api/routines/${routineId}/revisions/${revisionId}/restore`).send({}); + + expect(res.status).toBe(200); + expect(mockRoutineService.restoreRevision).toHaveBeenCalledWith(routineId, revisionId, { + agentId, + userId: null, + runId: "88888888-8888-4888-8888-888888888888", + }); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "routine.revision_restored", + entityId: routineId, + runId: "88888888-8888-4888-8888-888888888888", + })); + }); + it("requires tasks:assign permission for non-admin board routine creation", async () => { const app = await createApp({ type: "board", @@ -348,6 +463,7 @@ describe("routine routes", () => { }), { agentId: null, userId: "board-user", + runId: null, }); expect(mockTrackRoutineCreated).toHaveBeenCalledWith(expect.anything()); }); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 45769877c09..7d02c8d04e0 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -283,6 +283,113 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(routine.status).toBe("paused"); }); + it("creates revision 1 on routine create and appends revisions for real updates only", async () => { + const { routine, svc } = await seedFixture(); + + const initialRevisions = await svc.listRevisions(routine.id); + expect(initialRevisions).toHaveLength(1); + expect(initialRevisions[0]).toMatchObject({ + id: routine.latestRevisionId, + revisionNumber: 1, + title: "ascii frog", + changeSummary: "Created routine", + }); + expect(initialRevisions[0]?.snapshot.routine.description).toBe("Run the frog routine"); + + const updated = await svc.update( + routine.id, + { + description: "Run the frog routine with logs", + baseRevisionId: routine.latestRevisionId, + }, + {}, + ); + expect(updated?.latestRevisionNumber).toBe(2); + expect(updated?.latestRevisionId).not.toBe(routine.latestRevisionId); + + const noOp = await svc.update( + routine.id, + { + description: "Run the frog routine with logs", + baseRevisionId: updated?.latestRevisionId, + }, + {}, + ); + expect(noOp?.latestRevisionId).toBe(updated?.latestRevisionId); + expect(noOp?.latestRevisionNumber).toBe(2); + + const revisions = await svc.listRevisions(routine.id); + expect(revisions.map((revision) => revision.revisionNumber)).toEqual([2, 1]); + expect(revisions[0]?.snapshot.routine.description).toBe("Run the frog routine with logs"); + expect(revisions[1]?.snapshot.routine.description).toBe("Run the frog routine"); + }); + + it("rejects stale routine baseRevisionId updates", async () => { + const { routine, svc } = await seedFixture(); + const updated = await svc.update(routine.id, { description: "new description" }, {}); + await expect( + svc.update(routine.id, { + title: "stale update", + baseRevisionId: routine.latestRevisionId, + }, {}), + ).rejects.toMatchObject({ + status: 409, + details: { + currentRevisionId: updated?.latestRevisionId, + }, + }); + }); + + it("restores an older routine revision append-only and preserves run history", async () => { + const { routine, svc } = await seedFixture(); + const revision1Id = routine.latestRevisionId!; + const run = await svc.runRoutine(routine.id, { source: "manual" }); + const revision2Routine = await svc.update(routine.id, { description: "revision 2" }, {}); + + const restored = await svc.restoreRevision(routine.id, revision1Id, {}); + + expect(restored.restoredFromRevisionId).toBe(revision1Id); + expect(restored.restoredFromRevisionNumber).toBe(1); + expect(restored.routine.latestRevisionNumber).toBe(3); + expect(restored.routine.latestRevisionId).not.toBe(revision2Routine?.latestRevisionId); + expect(restored.routine.description).toBe("Run the frog routine"); + expect(restored.revision.restoredFromRevisionId).toBe(revision1Id); + expect(restored.revision.snapshot.routine.description).toBe("Run the frog routine"); + + const revisions = await svc.listRevisions(routine.id); + expect(revisions.map((revision) => revision.revisionNumber)).toEqual([3, 2, 1]); + await expect(db.select().from(routineRuns).where(eq(routineRuns.id, run.id))).resolves.toHaveLength(1); + }); + + it("appends safe trigger metadata revisions without leaking webhook secrets", async () => { + const { routine, svc } = await seedFixture(); + const created = await svc.createTrigger(routine.id, { + kind: "webhook", + signingMode: "bearer", + replayWindowSec: 300, + }, {}); + expect(created.revision.revisionNumber).toBe(2); + expect(created.secretMaterial?.webhookSecret).toBeTruthy(); + + const updated = await svc.updateTrigger(created.trigger.id, { label: "deploy hook" }, {}); + expect(updated?.revision.revisionNumber).toBe(3); + + const rotated = await svc.rotateTriggerSecret(created.trigger.id, {}); + expect(rotated.revision.revisionNumber).toBe(4); + expect(rotated.secretMaterial.webhookSecret).toBeTruthy(); + + const deleted = await svc.deleteTrigger(created.trigger.id, {}); + expect(deleted.revision?.revisionNumber).toBe(5); + + const revisions = await svc.listRevisions(routine.id); + const serialized = JSON.stringify(revisions.map((revision) => revision.snapshot)); + expect(serialized).toContain(created.trigger.publicId!); + expect(serialized).not.toContain(created.secretMaterial!.webhookSecret); + expect(serialized).not.toContain(rotated.secretMaterial.webhookSecret); + expect(serialized).not.toContain(created.trigger.secretId!); + expect(revisions[0]?.snapshot.triggers).toHaveLength(0); + }); + it("wakes the assignee when a routine creates a fresh execution issue", async () => { const { agentId, routine, svc, wakeups } = await seedFixture(); diff --git a/server/src/routes/routines.ts b/server/src/routes/routines.ts index 5bae6bdb45c..d5d9d35f082 100644 --- a/server/src/routes/routines.ts +++ b/server/src/routes/routines.ts @@ -57,6 +57,34 @@ export function routineRoutes( return routine; } + async function logRoutineRevisionCreated(req: Request, input: { + companyId: string; + routineId: string; + revisionId: string | null; + revisionNumber: number; + changeSummary?: string | null; + triggerCount?: number | null; + }) { + if (!input.revisionId) return; + const actor = getActorInfo(req); + await logActivity(db, { + companyId: input.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "routine.revision_created", + entityType: "routine", + entityId: input.routineId, + details: { + revisionId: input.revisionId, + revisionNumber: input.revisionNumber, + changeSummary: input.changeSummary ?? null, + triggerCount: input.triggerCount ?? null, + }, + }); + } + router.get("/companies/:companyId/routines", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -72,6 +100,7 @@ export function routineRoutes( const created = await svc.create(companyId, req.body, { agentId: req.actor.type === "agent" ? req.actor.agentId : null, userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null, + runId: req.actor.runId ?? null, }); const actor = getActorInfo(req); await logActivity(db, { @@ -89,6 +118,14 @@ export function routineRoutes( if (telemetryClient) { trackRoutineCreated(telemetryClient); } + await logRoutineRevisionCreated(req, { + companyId, + routineId: created.id, + revisionId: created.latestRevisionId, + revisionNumber: created.latestRevisionNumber, + changeSummary: "Created routine", + triggerCount: 0, + }); res.status(201).json(created); }); @@ -102,6 +139,16 @@ export function routineRoutes( res.json(detail); }); + router.get("/routines/:id/revisions", async (req, res) => { + const routine = await assertCanManageExistingRoutine(req, req.params.id as string); + if (!routine) { + res.status(404).json({ error: "Routine not found" }); + return; + } + const revisions = await svc.listRevisions(routine.id); + res.json(revisions); + }); + router.patch("/routines/:id", validate(updateRoutineSchema), async (req, res) => { const routine = await assertCanManageExistingRoutine(req, req.params.id as string); if (!routine) { @@ -131,6 +178,7 @@ export function routineRoutes( const updated = await svc.update(routine.id, req.body, { agentId: req.actor.type === "agent" ? req.actor.agentId : null, userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null, + runId: req.actor.runId ?? null, }); const actor = getActorInfo(req); await logActivity(db, { @@ -144,9 +192,52 @@ export function routineRoutes( entityId: routine.id, details: { title: updated?.title ?? routine.title }, }); + if (updated && updated.latestRevisionId !== routine.latestRevisionId) { + await logRoutineRevisionCreated(req, { + companyId: routine.companyId, + routineId: routine.id, + revisionId: updated.latestRevisionId, + revisionNumber: updated.latestRevisionNumber, + changeSummary: "Updated routine", + triggerCount: null, + }); + } res.json(updated); }); + router.post("/routines/:id/revisions/:revisionId/restore", async (req, res) => { + const routine = await assertCanManageExistingRoutine(req, req.params.id as string); + if (!routine) { + res.status(404).json({ error: "Routine not found" }); + return; + } + await assertBoardCanAssignTasks(req, routine.companyId); + const result = await svc.restoreRevision(routine.id, req.params.revisionId as string, { + agentId: req.actor.type === "agent" ? req.actor.agentId : null, + userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null, + runId: req.actor.runId ?? null, + }); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: routine.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "routine.revision_restored", + entityType: "routine", + entityId: routine.id, + details: { + revisionId: result.revision.id, + revisionNumber: result.revision.revisionNumber, + restoredFromRevisionId: result.restoredFromRevisionId, + restoredFromRevisionNumber: result.restoredFromRevisionNumber, + triggerCount: result.revision.snapshot.triggers.length, + }, + }); + res.json(result); + }); + router.get("/routines/:id/runs", async (req, res) => { const routine = await svc.get(req.params.id as string); if (!routine) { @@ -169,6 +260,7 @@ export function routineRoutes( const created = await svc.createTrigger(routine.id, req.body, { agentId: req.actor.type === "agent" ? req.actor.agentId : null, userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null, + runId: req.actor.runId ?? null, }); const actor = getActorInfo(req); await logActivity(db, { @@ -182,6 +274,14 @@ export function routineRoutes( entityId: created.trigger.id, details: { routineId: routine.id, kind: created.trigger.kind }, }); + await logRoutineRevisionCreated(req, { + companyId: routine.companyId, + routineId: routine.id, + revisionId: created.revision.id, + revisionNumber: created.revision.revisionNumber, + changeSummary: created.revision.changeSummary, + triggerCount: created.revision.snapshot.triggers.length, + }); res.status(201).json(created); }); @@ -200,6 +300,7 @@ export function routineRoutes( const updated = await svc.updateTrigger(trigger.id, req.body, { agentId: req.actor.type === "agent" ? req.actor.agentId : null, userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null, + runId: req.actor.runId ?? null, }); const actor = getActorInfo(req); await logActivity(db, { @@ -211,9 +312,19 @@ export function routineRoutes( action: "routine.trigger_updated", entityType: "routine_trigger", entityId: trigger.id, - details: { routineId: routine.id, kind: updated?.kind ?? trigger.kind }, + details: { routineId: routine.id, kind: updated?.trigger.kind ?? trigger.kind }, }); - res.json(updated); + if (updated) { + await logRoutineRevisionCreated(req, { + companyId: routine.companyId, + routineId: routine.id, + revisionId: updated.revision.id, + revisionNumber: updated.revision.revisionNumber, + changeSummary: updated.revision.changeSummary, + triggerCount: updated.revision.snapshot.triggers.length, + }); + } + res.json(updated?.trigger ?? null); }); router.delete("/routine-triggers/:id", async (req, res) => { @@ -227,7 +338,11 @@ export function routineRoutes( res.status(404).json({ error: "Routine not found" }); return; } - await svc.deleteTrigger(trigger.id); + const deleted = await svc.deleteTrigger(trigger.id, { + agentId: req.actor.type === "agent" ? req.actor.agentId : null, + userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null, + runId: req.actor.runId ?? null, + }); const actor = getActorInfo(req); await logActivity(db, { companyId: routine.companyId, @@ -240,6 +355,16 @@ export function routineRoutes( entityId: trigger.id, details: { routineId: routine.id, kind: trigger.kind }, }); + if (deleted.revision) { + await logRoutineRevisionCreated(req, { + companyId: routine.companyId, + routineId: routine.id, + revisionId: deleted.revision.id, + revisionNumber: deleted.revision.revisionNumber, + changeSummary: deleted.revision.changeSummary, + triggerCount: deleted.revision.snapshot.triggers.length, + }); + } res.status(204).end(); }); @@ -260,6 +385,7 @@ export function routineRoutes( const rotated = await svc.rotateTriggerSecret(trigger.id, { agentId: req.actor.type === "agent" ? req.actor.agentId : null, userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null, + runId: req.actor.runId ?? null, }); const actor = getActorInfo(req); await logActivity(db, { @@ -273,6 +399,14 @@ export function routineRoutes( entityId: trigger.id, details: { routineId: routine.id }, }); + await logRoutineRevisionCreated(req, { + companyId: routine.companyId, + routineId: routine.id, + revisionId: rotated.revision.id, + revisionNumber: rotated.revision.revisionNumber, + changeSummary: rotated.revision.changeSummary, + triggerCount: rotated.revision.snapshot.triggers.length, + }); res.json(rotated); }, ); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index b1242624f9f..67bbccdf725 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { and, asc, desc, eq, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNotNull, isNull, lte, ne, not, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -13,6 +13,7 @@ import { pluginManagedResources, plugins, projects, + routineRevisions, routineRuns, routines, routineTriggers, @@ -24,6 +25,8 @@ import type { RoutineDetail, RoutineListItem, RoutineManagedByPlugin, + RoutineRevision, + RoutineRevisionSnapshotV1, RoutineRunSummary, RoutineTrigger, RoutineTriggerSecretMaterial, @@ -67,7 +70,13 @@ const WEEKDAY_INDEX: Record = { Sat: 6, }; -type Actor = { agentId?: string | null; userId?: string | null }; +type Actor = { agentId?: string | null; userId?: string | null; runId?: string | null }; +type RoutineRow = typeof routines.$inferSelect; +type RoutineTriggerRow = typeof routineTriggers.$inferSelect; + +interface RoutineTriggerSecretRestoreMaterial extends RoutineTriggerSecretMaterial { + triggerId: string; +} function assertTimeZone(timeZone: string) { try { @@ -373,6 +382,77 @@ function routineUsesWorkspaceBranch(routine: typeof routines.$inferSelect) { || extractRoutineVariableNames([routine.title, routine.description]).includes(WORKSPACE_BRANCH_ROUTINE_VARIABLE); } +function routineRevisionSnapshotRoutine(routine: RoutineRow): RoutineRevisionSnapshotV1["routine"] { + return { + id: routine.id, + companyId: routine.companyId, + projectId: routine.projectId, + goalId: routine.goalId, + parentIssueId: routine.parentIssueId, + title: routine.title, + description: routine.description, + assigneeAgentId: routine.assigneeAgentId, + priority: routine.priority as RoutineRevisionSnapshotV1["routine"]["priority"], + status: routine.status as RoutineRevisionSnapshotV1["routine"]["status"], + concurrencyPolicy: routine.concurrencyPolicy as RoutineRevisionSnapshotV1["routine"]["concurrencyPolicy"], + catchUpPolicy: routine.catchUpPolicy as RoutineRevisionSnapshotV1["routine"]["catchUpPolicy"], + variables: routine.variables ?? [], + }; +} + +function routineRevisionSnapshotTrigger(trigger: RoutineTriggerRow): RoutineRevisionSnapshotV1["triggers"][number] { + return { + id: trigger.id, + kind: trigger.kind as RoutineRevisionSnapshotV1["triggers"][number]["kind"], + label: trigger.label, + enabled: trigger.enabled, + cronExpression: trigger.cronExpression, + timezone: trigger.timezone, + publicId: trigger.publicId, + signingMode: trigger.signingMode as RoutineRevisionSnapshotV1["triggers"][number]["signingMode"], + replayWindowSec: trigger.replayWindowSec, + }; +} + +async function buildRoutineRevisionSnapshot( + executor: Db, + routine: RoutineRow, +): Promise { + const triggers = await executor + .select() + .from(routineTriggers) + .where(and(eq(routineTriggers.companyId, routine.companyId), eq(routineTriggers.routineId, routine.id))) + .orderBy(asc(routineTriggers.createdAt), asc(routineTriggers.id)); + + return { + version: 1, + routine: routineRevisionSnapshotRoutine(routine), + triggers: triggers.map(routineRevisionSnapshotTrigger), + }; +} + +function canonicalSnapshot(value: RoutineRevisionSnapshotV1) { + return JSON.stringify(value); +} + +function snapshotsMatch(left: RoutineRevisionSnapshotV1, right: RoutineRevisionSnapshotV1) { + return canonicalSnapshot(left) === canonicalSnapshot(right); +} + +function routineCurrentFieldsMatch(left: RoutineRow, right: RoutineRow) { + return snapshotsMatch( + { version: 1, routine: routineRevisionSnapshotRoutine(left), triggers: [] }, + { version: 1, routine: routineRevisionSnapshotRoutine(right), triggers: [] }, + ); +} + +function mapRoutineRevision(row: typeof routineRevisions.$inferSelect): RoutineRevision { + return { + ...row, + snapshot: row.snapshot as RoutineRevisionSnapshotV1, + }; +} + export function routineService( db: Db, deps: { @@ -459,6 +539,52 @@ export function routineService( .then((rows) => rows[0] ?? null); } + async function appendRoutineRevision( + executor: Db, + routine: RoutineRow, + actor: Actor, + options: { + changeSummary?: string | null; + restoredFromRevisionId?: string | null; + } = {}, + ) { + const snapshot = await buildRoutineRevisionSnapshot(executor, routine); + const nextRevisionNumber = routine.latestRevisionId ? routine.latestRevisionNumber + 1 : 1; + const now = new Date(); + const [revision] = await executor + .insert(routineRevisions) + .values({ + companyId: routine.companyId, + routineId: routine.id, + revisionNumber: nextRevisionNumber, + title: snapshot.routine.title, + description: snapshot.routine.description, + snapshot, + changeSummary: options.changeSummary ?? null, + restoredFromRevisionId: options.restoredFromRevisionId ?? null, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + createdAt: now, + }) + .returning(); + + const [updatedRoutine] = await executor + .update(routines) + .set({ + latestRevisionId: revision.id, + latestRevisionNumber: nextRevisionNumber, + updatedAt: now, + }) + .where(eq(routines.id, routine.id)) + .returning(); + + return { + routine: updatedRoutine ?? { ...routine, latestRevisionId: revision.id, latestRevisionNumber: nextRevisionNumber, updatedAt: now }, + revision: mapRoutineRevision(revision), + }; + } + async function assertRoutineAccess(companyId: string, routineId: string) { const routine = await getRoutineById(routineId); if (!routine) throw notFound("Routine not found"); @@ -1305,28 +1431,34 @@ export function routineService( ); assertRoutineVariableDefinitions(variables); const status = normalizeDraftRoutineStatus(input.status, input.assigneeAgentId); - const [created] = await db - .insert(routines) - .values({ - companyId, - projectId: input.projectId ?? null, - goalId: input.goalId ?? null, - parentIssueId: input.parentIssueId ?? null, - title: input.title, - description: input.description ?? null, - assigneeAgentId: input.assigneeAgentId ?? null, - priority: input.priority, - status, - concurrencyPolicy: input.concurrencyPolicy, - catchUpPolicy: input.catchUpPolicy, - variables, - createdByAgentId: actor.agentId ?? null, - createdByUserId: actor.userId ?? null, - updatedByAgentId: actor.agentId ?? null, - updatedByUserId: actor.userId ?? null, - }) - .returning(); - return created; + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const [created] = await txDb + .insert(routines) + .values({ + companyId, + projectId: input.projectId ?? null, + goalId: input.goalId ?? null, + parentIssueId: input.parentIssueId ?? null, + title: input.title, + description: input.description ?? null, + assigneeAgentId: input.assigneeAgentId ?? null, + priority: input.priority, + status, + concurrencyPolicy: input.concurrencyPolicy, + catchUpPolicy: input.catchUpPolicy, + variables, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + updatedByAgentId: actor.agentId ?? null, + updatedByUserId: actor.userId ?? null, + }) + .returning(); + const { routine } = await appendRoutineRevision(txDb, created, actor, { + changeSummary: "Created routine", + }); + return routine; + }); }, update: async (id: string, patch: UpdateRoutine, actor: Actor): Promise => { @@ -1367,34 +1499,94 @@ export function routineService( if (enabledScheduleTriggers) { assertScheduleCompatibleVariables(nextVariables); } - const [updated] = await db - .update(routines) - .set({ + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + await tx.execute(sql`select id from ${routines} where ${routines.id} = ${id} for update`); + const locked = await txDb + .select() + .from(routines) + .where(eq(routines.id, id)) + .then((rows) => rows[0] ?? null); + if (!locked) return null; + + if (patch.baseRevisionId && patch.baseRevisionId !== locked.latestRevisionId) { + throw conflict("Routine was updated by someone else", { + currentRevisionId: locked.latestRevisionId, + }); + } + + const candidate: RoutineRow = { + ...locked, projectId: nextProjectId, - goalId: patch.goalId === undefined ? existing.goalId : patch.goalId, - parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId, + goalId: patch.goalId === undefined ? locked.goalId : patch.goalId, + parentIssueId: patch.parentIssueId === undefined ? locked.parentIssueId : patch.parentIssueId, title: nextTitle, description: nextDescription, assigneeAgentId: nextAssigneeAgentId, - priority: patch.priority ?? existing.priority, + priority: patch.priority ?? locked.priority, status: nextStatus, - concurrencyPolicy: patch.concurrencyPolicy ?? existing.concurrencyPolicy, - catchUpPolicy: patch.catchUpPolicy ?? existing.catchUpPolicy, + concurrencyPolicy: patch.concurrencyPolicy ?? locked.concurrencyPolicy, + catchUpPolicy: patch.catchUpPolicy ?? locked.catchUpPolicy, variables: nextVariables, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, - updatedAt: new Date(), - }) - .where(eq(routines.id, id)) - .returning(); - return updated ?? null; + }; + + if (locked.latestRevisionId && routineCurrentFieldsMatch(locked, candidate)) { + return locked; + } + + const nextSnapshot = await buildRoutineRevisionSnapshot(txDb, candidate); + if (locked.latestRevisionId) { + const latestRevision = await txDb + .select({ snapshot: routineRevisions.snapshot }) + .from(routineRevisions) + .where( + and( + eq(routineRevisions.companyId, locked.companyId), + eq(routineRevisions.routineId, locked.id), + eq(routineRevisions.id, locked.latestRevisionId), + ), + ) + .then((rows) => rows[0] ?? null); + if (latestRevision && snapshotsMatch(nextSnapshot, latestRevision.snapshot as RoutineRevisionSnapshotV1)) { + return locked; + } + } + + const [updated] = await txDb + .update(routines) + .set({ + projectId: candidate.projectId, + goalId: candidate.goalId, + parentIssueId: candidate.parentIssueId, + title: candidate.title, + description: candidate.description, + assigneeAgentId: candidate.assigneeAgentId, + priority: candidate.priority, + status: candidate.status, + concurrencyPolicy: candidate.concurrencyPolicy, + catchUpPolicy: candidate.catchUpPolicy, + variables: candidate.variables, + updatedByAgentId: actor.agentId ?? null, + updatedByUserId: actor.userId ?? null, + updatedAt: new Date(), + }) + .where(eq(routines.id, id)) + .returning(); + if (!updated) return null; + const { routine } = await appendRoutineRevision(txDb, updated, actor, { + changeSummary: "Updated routine", + }); + return routine; + }); }, createTrigger: async ( routineId: string, input: CreateRoutineTrigger, actor: Actor, - ): Promise<{ trigger: RoutineTrigger; secretMaterial: RoutineTriggerSecretMaterial | null }> => { + ): Promise<{ trigger: RoutineTrigger; secretMaterial: RoutineTriggerSecretMaterial | null; revision: RoutineRevision }> => { const routine = await getRoutineById(routineId); if (!routine) throw notFound("Routine not found"); @@ -1422,36 +1614,50 @@ export function routineService( }; } - const [trigger] = await db - .insert(routineTriggers) - .values({ - companyId: routine.companyId, - routineId: routine.id, - kind: input.kind, - label: input.label ?? null, - enabled: input.enabled ?? true, - cronExpression: input.kind === "schedule" ? input.cronExpression : null, - timezone: input.kind === "schedule" ? (input.timezone || "UTC") : null, - nextRunAt, - publicId, - secretId, - signingMode: input.kind === "webhook" ? input.signingMode : null, - replayWindowSec: input.kind === "webhook" ? input.replayWindowSec : null, - lastRotatedAt: input.kind === "webhook" ? new Date() : null, - createdByAgentId: actor.agentId ?? null, - createdByUserId: actor.userId ?? null, - updatedByAgentId: actor.agentId ?? null, - updatedByUserId: actor.userId ?? null, - }) - .returning(); + const { trigger, revision } = await db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + await tx.execute(sql`select id from ${routines} where ${routines.id} = ${routine.id} for update`); + const [createdTrigger] = await txDb + .insert(routineTriggers) + .values({ + companyId: routine.companyId, + routineId: routine.id, + kind: input.kind, + label: input.label ?? null, + enabled: input.enabled ?? true, + cronExpression: input.kind === "schedule" ? input.cronExpression : null, + timezone: input.kind === "schedule" ? (input.timezone || "UTC") : null, + nextRunAt, + publicId, + secretId, + signingMode: input.kind === "webhook" ? input.signingMode : null, + replayWindowSec: input.kind === "webhook" ? input.replayWindowSec : null, + lastRotatedAt: input.kind === "webhook" ? new Date() : null, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + updatedByAgentId: actor.agentId ?? null, + updatedByUserId: actor.userId ?? null, + }) + .returning(); + const latestRoutine = await txDb.select().from(routines).where(eq(routines.id, routine.id)).then((rows) => rows[0] ?? routine); + const appended = await appendRoutineRevision(txDb, latestRoutine, actor, { + changeSummary: `Created ${input.kind} trigger`, + }); + return { trigger: createdTrigger, revision: appended.revision }; + }); return { trigger: trigger as RoutineTrigger, secretMaterial, + revision, }; }, - updateTrigger: async (id: string, patch: UpdateRoutineTrigger, actor: Actor): Promise => { + updateTrigger: async ( + id: string, + patch: UpdateRoutineTrigger, + actor: Actor, + ): Promise<{ trigger: RoutineTrigger; revision: RoutineRevision } | null> => { const existing = await getTriggerById(id); if (!existing) return null; @@ -1481,37 +1687,63 @@ export function routineService( } } - const [updated] = await db - .update(routineTriggers) - .set({ - label: patch.label === undefined ? existing.label : patch.label, - enabled: patch.enabled ?? existing.enabled, - cronExpression, - timezone, - nextRunAt, - signingMode: patch.signingMode === undefined ? existing.signingMode : patch.signingMode, - replayWindowSec: patch.replayWindowSec === undefined ? existing.replayWindowSec : patch.replayWindowSec, - updatedByAgentId: actor.agentId ?? null, - updatedByUserId: actor.userId ?? null, - updatedAt: new Date(), - }) - .where(eq(routineTriggers.id, id)) - .returning(); - - return (updated as RoutineTrigger | undefined) ?? null; + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`); + const [updated] = await txDb + .update(routineTriggers) + .set({ + label: patch.label === undefined ? existing.label : patch.label, + enabled: patch.enabled ?? existing.enabled, + cronExpression, + timezone, + nextRunAt, + signingMode: patch.signingMode === undefined ? existing.signingMode : patch.signingMode, + replayWindowSec: patch.replayWindowSec === undefined ? existing.replayWindowSec : patch.replayWindowSec, + updatedByAgentId: actor.agentId ?? null, + updatedByUserId: actor.userId ?? null, + updatedAt: new Date(), + }) + .where(eq(routineTriggers.id, id)) + .returning(); + if (!updated) return null; + const routine = await txDb + .select() + .from(routines) + .where(eq(routines.id, existing.routineId)) + .then((rows) => rows[0] ?? null); + if (!routine) throw notFound("Routine not found"); + const appended = await appendRoutineRevision(txDb, routine, actor, { + changeSummary: `Updated ${existing.kind} trigger`, + }); + return { trigger: updated as RoutineTrigger, revision: appended.revision }; + }); }, - deleteTrigger: async (id: string): Promise => { + deleteTrigger: async (id: string, actor: Actor = {}): Promise<{ deleted: boolean; revision: RoutineRevision | null }> => { const existing = await getTriggerById(id); - if (!existing) return false; - await db.delete(routineTriggers).where(eq(routineTriggers.id, id)); - return true; + if (!existing) return { deleted: false, revision: null }; + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`); + await txDb.delete(routineTriggers).where(eq(routineTriggers.id, id)); + const routine = await txDb + .select() + .from(routines) + .where(eq(routines.id, existing.routineId)) + .then((rows) => rows[0] ?? null); + if (!routine) throw notFound("Routine not found"); + const appended = await appendRoutineRevision(txDb, routine, actor, { + changeSummary: `Deleted ${existing.kind} trigger`, + }); + return { deleted: true, revision: appended.revision }; + }); }, rotateTriggerSecret: async ( id: string, actor: Actor, - ): Promise<{ trigger: RoutineTrigger; secretMaterial: RoutineTriggerSecretMaterial }> => { + ): Promise<{ trigger: RoutineTrigger; secretMaterial: RoutineTriggerSecretMaterial; revision: RoutineRevision }> => { const existing = await getTriggerById(id); if (!existing) throw notFound("Routine trigger not found"); if (existing.kind !== "webhook" || !existing.publicId || !existing.secretId) { @@ -1520,26 +1752,211 @@ export function routineService( const secretValue = crypto.randomBytes(24).toString("hex"); await secretsSvc.rotate(existing.secretId, { value: secretValue }, actor); - const [updated] = await db - .update(routineTriggers) - .set({ - lastRotatedAt: new Date(), - updatedByAgentId: actor.agentId ?? null, - updatedByUserId: actor.userId ?? null, - updatedAt: new Date(), - }) - .where(eq(routineTriggers.id, id)) - .returning(); + const { trigger, revision } = await db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`); + const [updated] = await txDb + .update(routineTriggers) + .set({ + lastRotatedAt: new Date(), + updatedByAgentId: actor.agentId ?? null, + updatedByUserId: actor.userId ?? null, + updatedAt: new Date(), + }) + .where(eq(routineTriggers.id, id)) + .returning(); + const routine = await txDb + .select() + .from(routines) + .where(eq(routines.id, existing.routineId)) + .then((rows) => rows[0] ?? null); + if (!routine) throw notFound("Routine not found"); + const appended = await appendRoutineRevision(txDb, routine, actor, { + changeSummary: "Rotated webhook trigger secret", + }); + return { trigger: updated, revision: appended.revision }; + }); return { - trigger: updated as RoutineTrigger, + trigger: trigger as RoutineTrigger, secretMaterial: { webhookUrl: `${process.env.PAPERCLIP_API_URL}/api/routine-triggers/public/${existing.publicId}/fire`, webhookSecret: secretValue, }, + revision, }; }, + listRevisions: async (routineId: string): Promise => { + const routine = await getRoutineById(routineId); + if (!routine) throw notFound("Routine not found"); + const rows = await db + .select() + .from(routineRevisions) + .where(and(eq(routineRevisions.companyId, routine.companyId), eq(routineRevisions.routineId, routine.id))) + .orderBy(desc(routineRevisions.revisionNumber), desc(routineRevisions.createdAt)); + return rows.map(mapRoutineRevision); + }, + + restoreRevision: async ( + routineId: string, + revisionId: string, + actor: Actor, + ): Promise<{ + routine: Routine; + revision: RoutineRevision; + restoredFromRevisionId: string; + restoredFromRevisionNumber: number; + secretMaterials: RoutineTriggerSecretRestoreMaterial[]; + }> => { + const existingRoutine = await getRoutineById(routineId); + if (!existingRoutine) throw notFound("Routine not found"); + const targetRevision = await db + .select() + .from(routineRevisions) + .where( + and( + eq(routineRevisions.companyId, existingRoutine.companyId), + eq(routineRevisions.routineId, existingRoutine.id), + eq(routineRevisions.id, revisionId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!targetRevision) throw notFound("Routine revision not found"); + if (existingRoutine.latestRevisionId === targetRevision.id) { + throw conflict("Selected revision is already the latest revision", { + currentRevisionId: existingRoutine.latestRevisionId, + }); + } + + const snapshot = targetRevision.snapshot as RoutineRevisionSnapshotV1; + const currentTriggers = await db + .select({ id: routineTriggers.id }) + .from(routineTriggers) + .where(and(eq(routineTriggers.companyId, existingRoutine.companyId), eq(routineTriggers.routineId, existingRoutine.id))); + const currentTriggerIds = new Set(currentTriggers.map((trigger) => trigger.id)); + const missingWebhookTriggers = snapshot.triggers + .filter((trigger) => trigger.kind === "webhook" && !currentTriggerIds.has(trigger.id)); + const recreatedWebhookSecrets = new Map(); + for (const trigger of missingWebhookTriggers) { + const publicId = crypto.randomBytes(12).toString("hex"); + const created = await createWebhookSecret(existingRoutine.companyId, existingRoutine.id, actor); + recreatedWebhookSecrets.set(trigger.id, { + publicId, + secretId: created.secret.id, + secretMaterial: { + triggerId: trigger.id, + webhookUrl: `${process.env.PAPERCLIP_API_URL}/api/routine-triggers/public/${publicId}/fire`, + webhookSecret: created.secretValue, + }, + }); + } + + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existingRoutine.id} for update`); + const locked = await txDb + .select() + .from(routines) + .where(eq(routines.id, existingRoutine.id)) + .then((rows) => rows[0] ?? null); + if (!locked) throw notFound("Routine not found"); + + const routineSnapshot = snapshot.routine; + const now = new Date(); + const [restoredRoutine] = await txDb + .update(routines) + .set({ + projectId: routineSnapshot.projectId, + goalId: routineSnapshot.goalId, + parentIssueId: routineSnapshot.parentIssueId, + title: routineSnapshot.title, + description: routineSnapshot.description, + assigneeAgentId: routineSnapshot.assigneeAgentId, + priority: routineSnapshot.priority, + status: routineSnapshot.status, + concurrencyPolicy: routineSnapshot.concurrencyPolicy, + catchUpPolicy: routineSnapshot.catchUpPolicy, + variables: routineSnapshot.variables, + updatedByAgentId: actor.agentId ?? null, + updatedByUserId: actor.userId ?? null, + updatedAt: now, + }) + .where(eq(routines.id, locked.id)) + .returning(); + + const snapshotTriggerIds = new Set(snapshot.triggers.map((trigger) => trigger.id)); + if (snapshotTriggerIds.size === 0) { + await txDb + .delete(routineTriggers) + .where(and(eq(routineTriggers.companyId, locked.companyId), eq(routineTriggers.routineId, locked.id))); + } else { + await txDb + .delete(routineTriggers) + .where( + and( + eq(routineTriggers.companyId, locked.companyId), + eq(routineTriggers.routineId, locked.id), + not(inArray(routineTriggers.id, snapshot.triggers.map((trigger) => trigger.id))), + ), + ); + } + + for (const triggerSnapshot of snapshot.triggers) { + const current = await txDb + .select() + .from(routineTriggers) + .where(and(eq(routineTriggers.companyId, locked.companyId), eq(routineTriggers.id, triggerSnapshot.id))) + .then((rows) => rows[0] ?? null); + const webhookSecret = recreatedWebhookSecrets.get(triggerSnapshot.id); + const restoredNextRunAt = triggerSnapshot.kind === "schedule" && triggerSnapshot.enabled + && triggerSnapshot.cronExpression && triggerSnapshot.timezone + ? nextCronTickInTimeZone(triggerSnapshot.cronExpression, triggerSnapshot.timezone, now) + : null; + const baseValues = { + companyId: locked.companyId, + routineId: locked.id, + kind: triggerSnapshot.kind, + label: triggerSnapshot.label, + enabled: triggerSnapshot.enabled, + cronExpression: triggerSnapshot.kind === "schedule" ? triggerSnapshot.cronExpression : null, + timezone: triggerSnapshot.kind === "schedule" ? triggerSnapshot.timezone : null, + publicId: triggerSnapshot.kind === "webhook" ? (current?.publicId ?? webhookSecret?.publicId ?? triggerSnapshot.publicId) : null, + secretId: triggerSnapshot.kind === "webhook" ? (current?.secretId ?? webhookSecret?.secretId ?? null) : null, + signingMode: triggerSnapshot.kind === "webhook" ? triggerSnapshot.signingMode : null, + replayWindowSec: triggerSnapshot.kind === "webhook" ? triggerSnapshot.replayWindowSec : null, + nextRunAt: restoredNextRunAt, + updatedByAgentId: actor.agentId ?? null, + updatedByUserId: actor.userId ?? null, + updatedAt: now, + }; + if (current) { + await txDb.update(routineTriggers).set(baseValues).where(eq(routineTriggers.id, triggerSnapshot.id)); + } else { + await txDb.insert(routineTriggers).values({ + id: triggerSnapshot.id, + ...baseValues, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + createdAt: now, + }); + } + } + + const appended = await appendRoutineRevision(txDb, restoredRoutine ?? locked, actor, { + changeSummary: `Restored from revision ${targetRevision.revisionNumber}`, + restoredFromRevisionId: targetRevision.id, + }); + return { + routine: appended.routine, + revision: appended.revision, + restoredFromRevisionId: targetRevision.id, + restoredFromRevisionNumber: targetRevision.revisionNumber, + secretMaterials: [...recreatedWebhookSecrets.values()].map((entry) => entry.secretMaterial), + }; + }); + }, + runRoutine: async (id: string, input: RunRoutine, actor?: Actor) => { const routine = await getRoutineById(id); if (!routine) throw notFound("Routine not found");