mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
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:
parent
9578dc3da7
commit
d6d7a7cea6
27 changed files with 19593 additions and 238 deletions
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue