mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +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
|
|
@ -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());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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