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>
This commit is contained in:
Dotta 2026-05-05 11:54:52 -05:00 committed by GitHub
parent 9578dc3da7
commit d6d7a7cea6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 19593 additions and 238 deletions

View file

@ -283,6 +283,201 @@ 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("rejects restoring the current latest routine revision", async () => {
const { routine, svc } = await seedFixture();
await expect(
svc.restoreRevision(routine.id, routine.latestRevisionId!, {}),
).rejects.toMatchObject({
status: 409,
details: {
currentRevisionId: routine.latestRevisionId,
},
});
});
it("recreates deleted webhook trigger secrets when restoring a historical revision", async () => {
const { routine, svc } = await seedFixture();
const created = await svc.createTrigger(routine.id, {
kind: "webhook",
signingMode: "bearer",
replayWindowSec: 300,
}, {});
await svc.deleteTrigger(created.trigger.id, {});
const restored = await svc.restoreRevision(routine.id, created.revision.id, {});
expect(restored.secretMaterials).toHaveLength(1);
expect(restored.secretMaterials[0]).toMatchObject({
triggerId: created.trigger.id,
});
expect(restored.secretMaterials[0]?.webhookSecret).toBeTruthy();
expect(restored.secretMaterials[0]?.webhookUrl).toContain("/api/routine-triggers/public/");
const restoredTrigger = await svc.getTrigger(created.trigger.id);
expect(restoredTrigger?.secretId).toBeTruthy();
expect(restoredTrigger?.publicId).toBeTruthy();
expect(restoredTrigger?.publicId).not.toBe(created.trigger.publicId);
});
it("blocks agents from restoring routine revisions assigned to another agent", async () => {
const { companyId, routine, svc } = await seedFixture();
const otherAgentId = randomUUID();
await db.insert(agents).values({
id: otherAgentId,
companyId,
name: "OtherCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const revision1Id = routine.latestRevisionId!;
await svc.update(routine.id, { assigneeAgentId: otherAgentId }, {});
await expect(
svc.restoreRevision(routine.id, revision1Id, { agentId: otherAgentId }),
).rejects.toMatchObject({
status: 403,
message: "Agents can only restore routine revisions assigned to themselves",
});
await expect(svc.get(routine.id)).resolves.toMatchObject({
assigneeAgentId: otherAgentId,
latestRevisionNumber: 2,
});
});
it("blocks restoring routine revisions assigned to agents that are no longer assignable", async () => {
const { agentId, routine, svc } = await seedFixture();
const revision1Id = routine.latestRevisionId!;
await svc.update(routine.id, { description: "revision 2" }, {});
await db
.update(agents)
.set({ status: "terminated" })
.where(eq(agents.id, agentId));
await expect(
svc.restoreRevision(routine.id, revision1Id, { userId: "board-user" }),
).rejects.toMatchObject({
status: 409,
message: "Cannot assign routines to terminated agents",
});
await expect(svc.get(routine.id)).resolves.toMatchObject({
description: "revision 2",
latestRevisionNumber: 2,
});
});
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();