paperclip/server/src/routes/routines.ts
Dotta d6d7a7cea6
Add routine revision history and restore flow (#5285)
## Thinking Path

> - Paperclip is the control plane for autonomous AI companies.
> - Routines are the scheduled/recurring work surface that keeps a
company operating without manual kicks.
> - Operators need routine edits to be auditable and recoverable,
especially when routines control assignments, prompts, triggers, and
webhook secrets.
> - Documents already have revision-style safety, but routines did not
have equivalent history or restore semantics.
> - This pull request adds append-only routine revisions across the
database, shared contracts, server routes, and board UI.
> - The benefit is safer routine iteration: users can inspect history,
compare changes, restore older definitions, and avoid overwriting newer
edits.

## What Changed

- Added `routine_revisions` storage, latest revision pointers on
routines, shared types, validators, and API docs for routine revision
history.
- Added server service/route support for listing routine revisions,
conflict-aware routine saves, and append-only restore operations.
- Added a History tab on routine detail with revision preview,
structured change summaries, description line diffs, dirty-edit
blocking, restore confirmation, and restored webhook secret surfacing.
- Extracted the line diff helper from `DocumentDiffModal` into
`ui/src/lib/line-diff.ts` for reuse.
- Rebased the branch onto current `public-gh/master` and renumbered the
routine revision migration to `0077_unusual_karnak` after upstream
`0076_useful_elektra`.
- Made the `0077` routine revision migration idempotent so installs that
already applied the branch-local `0076_unusual_karnak` can safely
advance.
- Updated the plugin SDK test harness routine fixture with the new
revision fields required by the shared `Routine` contract.

## Verification

- `pnpm --filter @paperclipai/db run check:migrations` passed.
- `pnpm exec vitest run --project @paperclipai/shared
packages/shared/src/validators/routine.test.ts` passed.
- `pnpm exec vitest run --project @paperclipai/ui
ui/src/lib/line-diff.test.ts
ui/src/components/RoutineHistoryTab.test.tsx
ui/src/lib/workspace-routines.test.ts ui/src/pages/Routines.test.tsx`
passed.
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/routines-service.test.ts --pool=forks
--poolOptions.forks.isolate=true` passed.
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/routines-routes.test.ts --pool=forks
--poolOptions.forks.isolate=true` passed.
- `pnpm --filter @paperclipai/plugin-sdk typecheck` passed after
updating the SDK test harness fixture.
- `pnpm --filter @paperclipai/plugin-sdk build` passed; this refreshed
local generated SDK output needed by plugin example typechecks.
- `pnpm -r typecheck` passed.

## Risks

- Medium migration risk: this adds routine revision storage and
backfills existing routines. The migration is ordered after upstream
`0076` and uses `IF NOT EXISTS` / duplicate-object guards to tolerate
earlier branch-local migration application.
- Restore behavior intentionally appends a new revision instead of
mutating history; callers expecting an in-place rollback need to follow
the new latest revision pointer.
- Restoring webhook triggers recreates webhook secret material, so users
must copy newly surfaced secrets after restore.
- Conflict-aware saves now reject stale routine edits when the client
sends an older `baseRevisionId`.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent, with shell/tool use in a local
git worktree. Exact context-window size is not exposed in this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Screenshots: not attached in this draft PR; the new UI flow is covered
by component tests listed above.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-05 11:54:52 -05:00

454 lines
17 KiB
TypeScript

import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
createRoutineSchema,
createRoutineTriggerSchema,
rotateRoutineTriggerSecretSchema,
runRoutineSchema,
updateRoutineSchema,
updateRoutineTriggerSchema,
} from "@paperclipai/shared";
import { trackRoutineCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { accessService, logActivity, routineService } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { forbidden, unauthorized } from "../errors.js";
import { getTelemetryClient } from "../telemetry.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
export function routineRoutes(
db: Db,
options: { pluginWorkerManager?: PluginWorkerManager } = {},
) {
const router = Router();
const svc = routineService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
const access = accessService(db);
async function assertBoardCanAssignTasks(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") return;
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
if (!allowed) {
throw forbidden("Missing permission: tasks:assign");
}
}
function assertCanManageCompanyRoutine(req: Request, companyId: string, assigneeAgentId?: string | null) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") return;
if (req.actor.type !== "agent" || !req.actor.agentId) throw unauthorized();
if (assigneeAgentId !== req.actor.agentId) {
throw forbidden("Agents can only manage routines assigned to themselves");
}
}
async function assertCanManageExistingRoutine(req: Request, routineId: string) {
const routine = await svc.get(routineId);
if (!routine) return null;
assertCompanyAccess(req, routine.companyId);
if (req.actor.type === "board") return routine;
if (req.actor.type !== "agent" || !req.actor.agentId) throw unauthorized();
if (routine.assigneeAgentId !== req.actor.agentId) {
throw forbidden("Agents can only manage routines assigned to themselves");
}
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);
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
const result = await svc.list(companyId, { projectId });
res.json(result);
});
router.post("/companies/:companyId/routines", validate(createRoutineSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertBoardCanAssignTasks(req, companyId);
assertCanManageCompanyRoutine(req, companyId, req.body.assigneeAgentId);
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, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.created",
entityType: "routine",
entityId: created.id,
details: { title: created.title, assigneeAgentId: created.assigneeAgentId },
});
const telemetryClient = getTelemetryClient();
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);
});
router.get("/routines/:id", async (req, res) => {
const detail = await svc.getDetail(req.params.id as string);
if (!detail) {
res.status(404).json({ error: "Routine not found" });
return;
}
assertCompanyAccess(req, detail.companyId);
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) {
res.status(404).json({ error: "Routine not found" });
return;
}
const assigneeWillChange =
req.body.assigneeAgentId !== undefined &&
req.body.assigneeAgentId !== routine.assigneeAgentId;
if (assigneeWillChange) {
await assertBoardCanAssignTasks(req, routine.companyId);
}
const statusWillActivate =
req.body.status !== undefined &&
req.body.status === "active" &&
routine.status !== "active";
if (statusWillActivate) {
await assertBoardCanAssignTasks(req, routine.companyId);
}
if (
req.actor.type === "agent" &&
req.body.assigneeAgentId !== undefined &&
req.body.assigneeAgentId !== req.actor.agentId
) {
throw forbidden("Agents can only assign routines to themselves");
}
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, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.updated",
entityType: "routine",
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) {
res.status(404).json({ error: "Routine not found" });
return;
}
assertCompanyAccess(req, routine.companyId);
const limit = Number(req.query.limit ?? 50);
const result = await svc.listRuns(routine.id, Number.isFinite(limit) ? limit : 50);
res.json(result);
});
router.post("/routines/:id/triggers", validate(createRoutineTriggerSchema), 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 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, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.trigger_created",
entityType: "routine_trigger",
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);
});
router.patch("/routine-triggers/:id", validate(updateRoutineTriggerSchema), async (req, res) => {
const trigger = await svc.getTrigger(req.params.id as string);
if (!trigger) {
res.status(404).json({ error: "Routine trigger not found" });
return;
}
const routine = await assertCanManageExistingRoutine(req, trigger.routineId);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
await assertBoardCanAssignTasks(req, routine.companyId);
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, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.trigger_updated",
entityType: "routine_trigger",
entityId: trigger.id,
details: { routineId: routine.id, kind: updated?.trigger.kind ?? trigger.kind },
});
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) => {
const trigger = await svc.getTrigger(req.params.id as string);
if (!trigger) {
res.status(404).json({ error: "Routine trigger not found" });
return;
}
const routine = await assertCanManageExistingRoutine(req, trigger.routineId);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
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,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.trigger_deleted",
entityType: "routine_trigger",
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();
});
router.post(
"/routine-triggers/:id/rotate-secret",
validate(rotateRoutineTriggerSecretSchema),
async (req, res) => {
const trigger = await svc.getTrigger(req.params.id as string);
if (!trigger) {
res.status(404).json({ error: "Routine trigger not found" });
return;
}
const routine = await assertCanManageExistingRoutine(req, trigger.routineId);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
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, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.trigger_secret_rotated",
entityType: "routine_trigger",
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);
},
);
router.post("/routines/:id/run", validate(runRoutineSchema), 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 run = await svc.runRoutine(routine.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? null : 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.run_triggered",
entityType: "routine_run",
entityId: run.id,
details: { routineId: routine.id, source: run.source, status: run.status },
});
res.status(202).json(run);
});
router.post("/routine-triggers/public/:publicId/fire", async (req, res) => {
const result = await svc.firePublicTrigger(req.params.publicId as string, {
authorizationHeader: req.header("authorization"),
signatureHeader: req.header("x-paperclip-signature"),
hubSignatureHeader: req.header("x-hub-signature-256"),
timestampHeader: req.header("x-paperclip-timestamp"),
idempotencyKey: req.header("idempotency-key"),
rawBody: (req as { rawBody?: Buffer }).rawBody ?? null,
payload: typeof req.body === "object" && req.body !== null ? req.body as Record<string, unknown> : null,
});
res.status(202).json(result);
});
return router;
}