mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00: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
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue