From fb2f836a1c0c54ed13455243c15f2380ff5df3c4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 4 May 2026 17:52:35 -0500 Subject: [PATCH] Build routine revision history tab and conflict-aware save flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the History tab on routine detail (PAP-3584) on top of the revision API shipped in PAP-3583. Board users can now inspect every routine revision, preview historical content, compare against current with structured field-changes plus a description line diff, and restore an older revision — which always appends a new latest revision so history stays append-only. Behavior: - New API client methods listRevisions / restoreRevision plus a routines revisions queryKey. - Routine save now sends the current latestRevisionId as baseRevisionId; a 409 from the server flips an "Out of date" banner on the editor and invalidates the detail query so the user can reload before retrying. - Historical-preview banner spells out the append-only semantics and shows the next revision number. - Restore confirmation dialog enumerates side effects, including a per- trigger callout for any webhook trigger that will be re-created with a fresh secret. Recreated webhook secret material is mounted in the existing routine secret banner so the user can copy URL + secret once. - Conflict path: when the routine editor has unsaved local edits, the History tab blocks historical previews and surfaces an itemized "Unsaved routine edits" banner with Discard / Save and continue. - Loading, empty (only revision 1), and error states are covered. - Restored success state highlights the new revision row, auto-selects it, and surfaces a success toast naming both rev numbers. Tests: - ui/src/lib/line-diff.test.ts covers the lifted diff helper. - ui/src/components/RoutineHistoryTab.test.tsx covers empty state, list rendering, historical-preview banner copy, dirty-edit conflict blocking, and the restore mutation success path. Co-Authored-By: Paperclip --- ui/src/api/routines.ts | 23 + ui/src/components/RoutineHistoryTab.test.tsx | 320 +++++ ui/src/components/RoutineHistoryTab.tsx | 1091 ++++++++++++++++++ ui/src/lib/queryKeys.ts | 1 + ui/src/pages/RoutineDetail.tsx | 130 ++- 5 files changed, 1550 insertions(+), 15 deletions(-) create mode 100644 ui/src/components/RoutineHistoryTab.test.tsx create mode 100644 ui/src/components/RoutineHistoryTab.tsx diff --git a/ui/src/api/routines.ts b/ui/src/api/routines.ts index 0c6c0681c44..9bc1a25adae 100644 --- a/ui/src/api/routines.ts +++ b/ui/src/api/routines.ts @@ -3,6 +3,7 @@ import type { Routine, RoutineDetail, RoutineListItem, + RoutineRevision, RoutineRun, RoutineRunSummary, RoutineTrigger, @@ -21,6 +22,18 @@ export interface RotateRoutineTriggerResponse { secretMaterial: RoutineTriggerSecretMaterial; } +export interface RestoreRoutineRevisionSecretMaterial extends RoutineTriggerSecretMaterial { + triggerId: string; +} + +export interface RestoreRoutineRevisionResponse { + routine: Routine; + revision: RoutineRevision; + restoredFromRevisionId: string; + restoredFromRevisionNumber: number; + secretMaterials: RestoreRoutineRevisionSecretMaterial[]; +} + export const routinesApi = { list: (companyId: string, filters?: { projectId?: string | null }) => { const params = new URLSearchParams(); @@ -32,6 +45,16 @@ export const routinesApi = { api.post(`/companies/${companyId}/routines`, data), get: (id: string) => api.get(`/routines/${id}`), update: (id: string, data: Record) => api.patch(`/routines/${id}`, data), + listRevisions: (id: string) => api.get(`/routines/${id}/revisions`), + restoreRevision: ( + id: string, + revisionId: string, + body: { changeSummary?: string | null } = {}, + ) => + api.post( + `/routines/${id}/revisions/${revisionId}/restore`, + body, + ), listRuns: (id: string, limit: number = 50) => api.get(`/routines/${id}/runs?limit=${limit}`), createTrigger: (id: string, data: Record) => api.post(`/routines/${id}/triggers`, data), diff --git a/ui/src/components/RoutineHistoryTab.test.tsx b/ui/src/components/RoutineHistoryTab.test.tsx new file mode 100644 index 00000000000..66d5264e787 --- /dev/null +++ b/ui/src/components/RoutineHistoryTab.test.tsx @@ -0,0 +1,320 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ComponentProps } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { + Routine, + RoutineRevision, + RoutineRevisionSnapshotV1, +} from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RoutineHistoryTab } from "./RoutineHistoryTab"; + +const mockRoutinesApi = vi.hoisted(() => ({ + listRevisions: vi.fn(), + restoreRevision: vi.fn(), +})); + +vi.mock("../api/routines", async () => { + const actual = await vi.importActual>("../api/routines"); + return { + ...actual, + routinesApi: { + ...((actual as { routinesApi?: Record }).routinesApi ?? {}), + ...mockRoutinesApi, + }, + }; +}); + +vi.mock("./MarkdownBody", () => ({ + MarkdownBody: ({ children }: { children: string }) =>
{children}
, +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, + DialogDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, onClick, type = "button", disabled, ...props }: ComponentProps<"button">) => ( + + ), +})); + +vi.mock("@/components/ui/input", () => ({ + Input: (props: ComponentProps<"input">) => , +})); + +vi.mock("@/components/ui/label", () => ({ + Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) => ( + + ), +})); + +vi.mock("@/components/ui/skeleton", () => ({ + Skeleton: (props: ComponentProps<"div">) =>
, +})); + +const toastSpy = vi.fn(); +vi.mock("../context/ToastContext", () => ({ + useToastActions: () => ({ pushToast: toastSpy }), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function snapshotV1(overrides?: Partial): RoutineRevisionSnapshotV1 { + return { + version: 1, + routine: { + id: "routine-1", + companyId: "company-1", + projectId: null, + goalId: null, + parentIssueId: null, + title: "Daily standup digest", + description: "Summarize standup notes", + assigneeAgentId: null, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [], + ...overrides, + }, + triggers: [], + }; +} + +function createRevision(overrides: Partial = {}): RoutineRevision { + return { + id: overrides.id ?? "revision-1", + companyId: "company-1", + routineId: "routine-1", + revisionNumber: overrides.revisionNumber ?? 1, + title: "Daily standup digest", + description: "Summarize standup notes", + snapshot: overrides.snapshot ?? snapshotV1(), + changeSummary: null, + restoredFromRevisionId: null, + createdByAgentId: null, + createdByUserId: "user-1", + createdByRunId: null, + createdAt: new Date("2026-05-01T12:00:00.000Z"), + ...overrides, + }; +} + +function createRoutine(overrides: Partial = {}): Routine { + return { + id: "routine-1", + companyId: "company-1", + projectId: null, + goalId: null, + parentIssueId: null, + title: "Daily standup digest", + description: "Summarize standup notes", + assigneeAgentId: null, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [], + latestRevisionId: "revision-2", + latestRevisionNumber: 2, + createdByAgentId: null, + createdByUserId: "user-1", + updatedByAgentId: null, + updatedByUserId: "user-1", + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: new Date("2026-05-01T11:00:00.000Z"), + updatedAt: new Date("2026-05-04T12:00:00.000Z"), + ...overrides, + }; +} + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); +} + +describe("RoutineHistoryTab", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + vi.clearAllMocks(); + toastSpy.mockReset(); + }); + + afterEach(() => { + container.remove(); + }); + + async function render(props: Partial[0]> = {}) { + const root = createRoot(container); + const queryClient = makeQueryClient(); + const routine = props.routine ?? createRoutine(); + await act(async () => { + root.render( + + {}} + onSaveEdits={() => {}} + agents={new Map()} + projects={new Map()} + onRestoreSecretMaterials={() => {}} + {...props} + /> + , + ); + }); + await flush(); + return root; + } + + it("shows the empty state when only the bootstrap revision exists", async () => { + mockRoutinesApi.listRevisions.mockResolvedValue([ + createRevision({ id: "revision-1", revisionNumber: 1 }), + ]); + await render({ + routine: createRoutine({ latestRevisionId: "revision-1", latestRevisionNumber: 1 }), + }); + expect(container.textContent).toContain("No edits yet"); + expect(container.textContent).toContain("Revision 1 is the only history"); + }); + + it("renders the revision list with current and historical pills", async () => { + const current = createRevision({ + id: "revision-2", + revisionNumber: 2, + changeSummary: "Updated routine", + }); + const old = createRevision({ + id: "revision-1", + revisionNumber: 1, + changeSummary: "Created routine", + }); + mockRoutinesApi.listRevisions.mockResolvedValue([current, old]); + await render(); + expect(container.textContent).toContain("rev 2"); + expect(container.textContent).toContain("rev 1"); + expect(container.textContent).toContain("Current"); + }); + + it("shows the historical-preview banner with append-only copy when previewing an old revision", async () => { + const current = createRevision({ + id: "revision-2", + revisionNumber: 2, + changeSummary: "Updated routine", + }); + const old = createRevision({ + id: "revision-1", + revisionNumber: 1, + changeSummary: "Created routine", + }); + mockRoutinesApi.listRevisions.mockResolvedValue([current, old]); + await render(); + const oldRow = container.querySelector( + "[data-testid='revision-row-1']", + ) as HTMLButtonElement | null; + expect(oldRow).not.toBeNull(); + await act(async () => { + oldRow?.click(); + }); + await flush(); + expect(container.textContent).toContain("Viewing revision 1 (read-only)"); + expect(container.textContent).toContain( + "Restoring this revision creates a new revision 3 with the same content. History stays append-only.", + ); + expect(container.textContent).toContain("Restore as new revision"); + }); + + it("blocks historical preview and surfaces the conflict banner when local edits are dirty", async () => { + const current = createRevision({ id: "revision-2", revisionNumber: 2 }); + const old = createRevision({ id: "revision-1", revisionNumber: 1 }); + mockRoutinesApi.listRevisions.mockResolvedValue([current, old]); + await render({ + isEditDirty: true, + dirtyFields: [{ key: "description", label: "the description" }], + }); + expect(container.textContent).toContain("Unsaved routine edits"); + const oldRow = container.querySelector( + "[data-testid='revision-row-1']", + ) as HTMLButtonElement | null; + expect(oldRow?.disabled).toBe(true); + }); + + it("calls restoreRevision and surfaces a success toast after confirming restore", async () => { + const current = createRevision({ id: "revision-2", revisionNumber: 2 }); + const old = createRevision({ id: "revision-1", revisionNumber: 1 }); + mockRoutinesApi.listRevisions.mockResolvedValue([current, old]); + mockRoutinesApi.restoreRevision.mockResolvedValue({ + routine: createRoutine({ latestRevisionId: "revision-3", latestRevisionNumber: 3 }), + revision: createRevision({ + id: "revision-3", + revisionNumber: 3, + restoredFromRevisionId: "revision-1", + }), + restoredFromRevisionId: "revision-1", + restoredFromRevisionNumber: 1, + secretMaterials: [], + }); + await render(); + const oldRow = container.querySelector( + "[data-testid='revision-row-1']", + ) as HTMLButtonElement | null; + await act(async () => { + oldRow?.click(); + }); + await flush(); + const restoreButtons = Array.from(container.querySelectorAll("button")).filter( + (button) => button.textContent === "Restore as new revision", + ); + expect(restoreButtons.length).toBeGreaterThan(0); + await act(async () => { + restoreButtons[0].click(); + }); + await flush(); + expect(container.querySelector("[data-testid='dialog']")).not.toBeNull(); + const confirmButtons = Array.from(container.querySelectorAll("button")).filter((b) => + (b.textContent ?? "").includes("Restore as revision 3"), + ); + expect(confirmButtons.length).toBeGreaterThan(0); + await act(async () => { + confirmButtons[0].click(); + }); + await flush(); + expect(mockRoutinesApi.restoreRevision).toHaveBeenCalledWith( + "routine-1", + "revision-1", + { changeSummary: null }, + ); + expect(toastSpy).toHaveBeenCalled(); + const successCall = toastSpy.mock.calls.find( + (call) => call[0]?.title === "Restored revision 1 as revision 3", + ); + expect(successCall).toBeTruthy(); + }); +}); diff --git a/ui/src/components/RoutineHistoryTab.tsx b/ui/src/components/RoutineHistoryTab.tsx new file mode 100644 index 00000000000..1e6ec6ec732 --- /dev/null +++ b/ui/src/components/RoutineHistoryTab.tsx @@ -0,0 +1,1091 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { History as HistoryIcon, RotateCcw, Search } from "lucide-react"; +import type { + Routine, + RoutineRevision, + RoutineRevisionSnapshotTriggerV1, + RoutineVariable, +} from "@paperclipai/shared"; +import { + routinesApi, + type RestoreRoutineRevisionResponse, +} from "../api/routines"; +import { ApiError } from "../api/client"; +import { queryKeys } from "../lib/queryKeys"; +import { buildLineDiff, type DiffRow } from "../lib/line-diff"; +import { relativeTime } from "../lib/utils"; +import { useToastActions } from "../context/ToastContext"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmptyState } from "./EmptyState"; +import { MarkdownBody } from "./MarkdownBody"; + +type AgentLookup = Map; +type ProjectLookup = Map; + +type DirtyFieldDescriptor = { + key: string; + label: string; +}; + +type Props = { + routine: Routine; + isEditDirty: boolean; + dirtyFields: DirtyFieldDescriptor[]; + onDiscardEdits: () => void; + onSaveEdits: () => void; + agents: AgentLookup; + projects: ProjectLookup; + onRestoreSecretMaterials: (response: RestoreRoutineRevisionResponse) => void; +}; + +export function RoutineHistoryTab({ + routine, + isEditDirty, + dirtyFields, + onDiscardEdits, + onSaveEdits, + agents, + projects, + onRestoreSecretMaterials, +}: Props) { + const queryClient = useQueryClient(); + const { pushToast } = useToastActions(); + const [selectedRevisionId, setSelectedRevisionId] = useState(null); + const [highlightedRevisionId, setHighlightedRevisionId] = useState(null); + const [showOlder, setShowOlder] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [diffOpen, setDiffOpen] = useState(false); + const [restoreSummary, setRestoreSummary] = useState(""); + + const revisionsQuery = useQuery({ + queryKey: queryKeys.routines.revisions(routine.id), + queryFn: () => routinesApi.listRevisions(routine.id), + }); + + const revisions = useMemo(() => revisionsQuery.data ?? [], [revisionsQuery.data]); + const sortedRevisions = useMemo( + () => [...revisions].sort((a, b) => b.revisionNumber - a.revisionNumber), + [revisions], + ); + const currentRevision = useMemo( + () => sortedRevisions.find((r) => r.id === routine.latestRevisionId) ?? sortedRevisions[0] ?? null, + [sortedRevisions, routine.latestRevisionId], + ); + + useEffect(() => { + if (selectedRevisionId === null && currentRevision) { + setSelectedRevisionId(currentRevision.id); + } + }, [currentRevision, selectedRevisionId]); + + const selectedRevision = useMemo( + () => sortedRevisions.find((r) => r.id === selectedRevisionId) ?? null, + [sortedRevisions, selectedRevisionId], + ); + const isHistoricalSelected = !!selectedRevision && selectedRevision.id !== routine.latestRevisionId; + const visibleRevisions = useMemo(() => { + if (showOlder || sortedRevisions.length <= 8) return sortedRevisions; + return sortedRevisions.slice(0, 8); + }, [sortedRevisions, showOlder]); + + const restoreMutation = useMutation({ + mutationFn: (input: { revisionId: string; changeSummary: string }) => + routinesApi.restoreRevision(routine.id, input.revisionId, { + changeSummary: input.changeSummary.trim() || null, + }), + onSuccess: async (data) => { + const restoredFromNumber = data.restoredFromRevisionNumber; + const newNumber = data.revision.revisionNumber; + pushToast({ + title: `Restored revision ${restoredFromNumber} as revision ${newNumber}`, + body: data.secretMaterials.length > 0 + ? "Paused triggers stay paused. New webhook secret available in the banner above." + : "Paused triggers stay paused.", + tone: "success", + }); + onRestoreSecretMaterials(data); + setConfirmOpen(false); + setRestoreSummary(""); + setSelectedRevisionId(data.revision.id); + setHighlightedRevisionId(data.revision.id); + window.setTimeout(() => { + setHighlightedRevisionId((current) => + current === data.revision.id ? null : current, + ); + }, 3000); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routine.id) }), + queryClient.invalidateQueries({ queryKey: queryKeys.routines.runs(routine.id) }), + queryClient.invalidateQueries({ + queryKey: queryKeys.routines.activity(routine.companyId, routine.id), + }), + queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(routine.companyId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.routines.revisions(routine.id) }), + ]); + }, + onError: (error) => { + pushToast({ + title: "Failed to restore revision", + body: error instanceof Error ? error.message : "Paperclip could not restore the revision.", + tone: "error", + }); + }, + }); + + const handleSelectRevision = (revisionId: string) => { + if (isEditDirty) return; + setSelectedRevisionId(revisionId); + }; + + const handleReturnToCurrent = () => { + if (currentRevision) setSelectedRevisionId(currentRevision.id); + }; + + const openRestoreConfirm = () => { + if (!selectedRevision || !isHistoricalSelected) return; + setRestoreSummary(""); + setConfirmOpen(true); + }; + + const confirmRestore = () => { + if (!selectedRevision) return; + restoreMutation.mutate({ + revisionId: selectedRevision.id, + changeSummary: restoreSummary, + }); + }; + + if (revisionsQuery.isLoading) { + return ( +
+
+ {Array.from({ length: 5 }).map((_, idx) => ( + + ))} +
+ +
+ ); + } + + if (revisionsQuery.error) { + return ( +
+
+

Could not load revisions

+

+ {revisionsQuery.error instanceof Error + ? revisionsQuery.error.message + : "Unknown error loading revisions."} +

+
+ +
+ ); + } + + const onlyBootstrapRevision = revisions.length <= 1; + + return ( +
+ setShowOlder(true)} + showOlder={showOlder} + /> +
+ {isEditDirty && ( + + )} + {!isEditDirty && onlyBootstrapRevision ? ( +
+ +

+ Revision 1 is the only history this routine has. Saving an edit creates the first + additional revision. +

+
+ ) : ( + selectedRevision && ( + <> + {isHistoricalSelected && currentRevision && ( + + )} + setDiffOpen(true)} + onRestore={openRestoreConfirm} + restorePending={restoreMutation.isPending} + highlighted={highlightedRevisionId === selectedRevision.id} + /> + + ) + )} +
+ + {selectedRevision && currentRevision && ( + + )} + + {currentRevision && selectedRevision && ( + { + setSelectedRevisionId(rev.id); + setDiffOpen(false); + setRestoreSummary(""); + setConfirmOpen(true); + }} + /> + )} +
+ ); +} + +function HistoricalPreviewBanner({ + revisionNumber, + nextRevisionNumber, + onReturn, + onRestore, + pending, +}: { + revisionNumber: number; + nextRevisionNumber: number; + onReturn: () => void; + onRestore: () => void; + pending: boolean; +}) { + return ( +
+
+
+

+ Viewing revision {revisionNumber} (read-only) +

+

+ Restoring this revision creates a new revision {nextRevisionNumber} with the same content. + History stays append-only. +

+
+
+ + +
+
+
+ ); +} + +function ConflictBanner({ + dirtyFields, + onDiscard, + onSave, +}: { + dirtyFields: DirtyFieldDescriptor[]; + onDiscard: () => void; + onSave: () => void; +}) { + const labels = dirtyFields.length > 0 + ? dirtyFields.map((field) => field.label) + : ["the routine"]; + const fieldsText = formatDirtyFieldList(labels); + return ( +
+
+
+

Unsaved routine edits

+

+ You changed {fieldsText} but haven't saved yet. Save or discard before previewing or + restoring an older revision. +

+
+
+ + +
+
+ {dirtyFields.length > 0 && ( +
    + {dirtyFields.map((field) => ( +
  • + + {field.label} +
  • + ))} +
+ )} +
+ ); +} + +function RevisionList({ + revisions, + latestRevisionId, + selectedRevisionId, + highlightedRevisionId, + isEditDirty, + totalRevisions, + onSelect, + onShowOlder, + showOlder, +}: { + revisions: RoutineRevision[]; + latestRevisionId: string | null; + selectedRevisionId: string | null; + highlightedRevisionId: string | null; + isEditDirty: boolean; + totalRevisions: number; + onSelect: (revisionId: string) => void; + onShowOlder: () => void; + showOlder: boolean; +}) { + return ( + + ); +} + +function RevisionPreview({ + revision, + currentRevision, + isHistorical, + agents, + projects, + onCompare, + onRestore, + restorePending, + highlighted, +}: { + revision: RoutineRevision; + currentRevision: RoutineRevision | null; + isHistorical: boolean; + agents: AgentLookup; + projects: ProjectLookup; + onCompare: () => void; + onRestore: () => void; + restorePending: boolean; + highlighted: boolean; +}) { + const snapshot = revision.snapshot.routine; + const triggers = revision.snapshot.triggers; + const currentSnapshot = currentRevision?.snapshot.routine ?? null; + const restoreLabel = isHistorical ? "Restore this revision" : "Restore this revision"; + const cardWrapper = `rounded-md border transition-colors duration-1000 ${ + highlighted ? "border-emerald-500/40 bg-emerald-500/10" : "border-border" + }`; + + const fieldRows: Array<{ key: string; label: string; value: string; differs: boolean }> = [ + { + key: "title", + label: "Title", + value: snapshot.title, + differs: !!currentSnapshot && currentSnapshot.title !== snapshot.title, + }, + { + key: "priority", + label: "Priority", + value: snapshot.priority, + differs: !!currentSnapshot && currentSnapshot.priority !== snapshot.priority, + }, + { + key: "assigneeAgentId", + label: "Default agent", + value: resolveAgentName(snapshot.assigneeAgentId, agents), + differs: !!currentSnapshot && currentSnapshot.assigneeAgentId !== snapshot.assigneeAgentId, + }, + { + key: "projectId", + label: "Project", + value: resolveProjectName(snapshot.projectId, projects), + differs: !!currentSnapshot && currentSnapshot.projectId !== snapshot.projectId, + }, + { + key: "concurrencyPolicy", + label: "Concurrency", + value: snapshot.concurrencyPolicy.replaceAll("_", " "), + differs: !!currentSnapshot && currentSnapshot.concurrencyPolicy !== snapshot.concurrencyPolicy, + }, + { + key: "catchUpPolicy", + label: "Catch-up", + value: snapshot.catchUpPolicy.replaceAll("_", " "), + differs: !!currentSnapshot && currentSnapshot.catchUpPolicy !== snapshot.catchUpPolicy, + }, + ]; + + return ( +
+
+
+
+

rev {revision.revisionNumber}

+

+ Saved {relativeTime(revision.createdAt)} by {getActorLabel(revision)} + {revision.changeSummary ? ` · ${revision.changeSummary}` : ""} +

+
+
+ + +
+
+
+ +
+

+ Structured fields +

+
+ {fieldRows.map((row) => ( +
+

{row.label}

+

+ {row.value || —} + {row.differs && ( + + differs from current + + )} +

+
+ ))} +
+
+ +
+

+ Description +

+
+ {snapshot.description ? ( + {snapshot.description} + ) : ( + No description + )} +
+
+ +
+

+ Triggers ({triggers.length}) +

+ {triggers.length === 0 ? ( +

No triggers in this revision.

+ ) : ( +
    + {triggers.map((trigger) => ( +
  • + + {trigger.kind} + + {trigger.label ?? trigger.kind} + + {summarizeTriggerSnapshot(trigger)} + + + {trigger.enabled ? "enabled" : "disabled"} + +
  • + ))} +
+ )} +

+ Webhook secrets are not stored in revisions. If a restored webhook trigger needs re-creation, + Paperclip mints fresh secret material at restore time. +

+
+ + {snapshot.variables.length > 0 && ( +
+

+ Variables ({snapshot.variables.length}) +

+
    + {snapshot.variables.map((variable) => ( +
  • + {variable.name} + + default: {formatVariableDefault(variable)} + +
  • + ))} +
+
+ )} +
+ ); +} + +function RestoreConfirmDialog({ + open, + onOpenChange, + target, + currentRevisionNumber, + changeSummary, + onChangeSummaryChange, + onConfirm, + pending, + recreatedWebhookLabels, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + target: RoutineRevision; + currentRevisionNumber: number; + changeSummary: string; + onChangeSummaryChange: (value: string) => void; + onConfirm: () => void; + pending: boolean; + recreatedWebhookLabels: string[]; +}) { + const newRevisionNumber = currentRevisionNumber + 1; + return ( + + + + Restore revision {target.revisionNumber}? + + This creates a new revision {newRevisionNumber} with the same content as revision{" "} + {target.revisionNumber}. Revisions {target.revisionNumber}–{currentRevisionNumber} stay + in history and are not modified. + + +
    +
  • + + Routine field values, variables, and schedule cron will revert. +
  • +
  • + + Previous run history is preserved. +
  • + {recreatedWebhookLabels.map((label) => ( +
  • + + The webhook trigger {label} will be recreated with a new URL and secret. Paperclip will + show the secret once after restore — copy it before closing. +
  • + ))} +
+
+ + onChangeSummaryChange(event.target.value)} + /> +
+ + + + +
+
+ ); +} + +function RoutineRevisionDiffModal({ + open, + onOpenChange, + revisions, + initialOldRevisionId, + initialNewRevisionId, + agents, + projects, + onRestore, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + revisions: RoutineRevision[]; + initialOldRevisionId: string; + initialNewRevisionId: string; + agents: AgentLookup; + projects: ProjectLookup; + onRestore: (revision: RoutineRevision) => void; +}) { + const [leftId, setLeftId] = useState(initialOldRevisionId); + const [rightId, setRightId] = useState(initialNewRevisionId); + + useEffect(() => { + if (open) { + setLeftId(initialOldRevisionId); + setRightId(initialNewRevisionId); + } + }, [open, initialOldRevisionId, initialNewRevisionId]); + + const left = revisions.find((r) => r.id === leftId) ?? null; + const right = revisions.find((r) => r.id === rightId) ?? null; + const fieldChanges = useMemo( + () => (left && right ? computeFieldChanges(left, right, agents, projects) : []), + [left, right, agents, projects], + ); + const descriptionDiff = useMemo( + () => (left && right + ? buildLineDiff(left.snapshot.routine.description ?? "", right.snapshot.routine.description ?? "") + : []), + [left, right], + ); + const newest = revisions[0] ?? null; + const leftIsHistorical = !!left && !!newest && left.id !== newest.id; + + return ( + + + + Compare routine revisions + +
+ + +
+
+
+

+ Field changes +

+ {fieldChanges.length === 0 ? ( +

No structural field changes.

+ ) : ( + + + + + + + + + + {fieldChanges.map((change) => ( + + + + + + ))} + +
FieldOld valueNew value
{change.field} + {change.oldValue ?? "—"} + + {change.newValue ?? "—"} +
+ )} +
+
+

+ Description diff +

+ +
+
+ + + {leftIsHistorical && left && ( + + )} + +
+
+ ); +} + +function RevisionPicker({ + label, + value, + onChange, + revisions, + tone, +}: { + label: string; + value: string; + onChange: (id: string) => void; + revisions: RoutineRevision[]; + tone: "red" | "green"; +}) { + const toneClass = tone === "red" + ? "border-red-500/30 bg-red-500/10 text-red-300" + : "border-green-500/30 bg-green-500/10 text-green-300"; + return ( +
+ + {label} + + +
+ ); +} + +function DiffTable({ rows }: { rows: DiffRow[] }) { + if (rows.length === 0) { + return

No description on either revision.

; + } + if (rows.every((row) => row.kind === "context")) { + return

Descriptions are identical.

; + } + const lineClassesByKind: Record = { + context: "bg-transparent", + removed: "bg-red-500/10 text-red-100", + added: "bg-green-500/10 text-green-100", + }; + const markerByKind: Record = { + context: " ", + removed: "-", + added: "+", + }; + return ( +
+
+ Old + New + + Content +
+ {rows.map((row, index) => ( +
+ + {row.oldLineNumber ?? ""} + + + {row.newLineNumber ?? ""} + + + {markerByKind[row.kind]} + +
+            {row.text.length > 0 ? row.text : " "}
+          
+
+ ))} +
+ ); +} + +function getActorLabel(revision: RoutineRevision): string { + if (revision.createdByUserId) return "board"; + if (revision.createdByAgentId) return "agent"; + return "system"; +} + +function resolveAgentName(agentId: string | null, lookup: AgentLookup) { + if (!agentId) return "Unassigned"; + return lookup.get(agentId)?.name ?? agentId; +} + +function resolveProjectName(projectId: string | null, lookup: ProjectLookup) { + if (!projectId) return "No project"; + return lookup.get(projectId)?.name ?? projectId; +} + +function summarizeTriggerSnapshot(trigger: RoutineRevisionSnapshotTriggerV1): string { + if (trigger.kind === "schedule") { + return [trigger.cronExpression, trigger.timezone].filter(Boolean).join(" · "); + } + if (trigger.kind === "webhook") { + const replay = trigger.replayWindowSec != null ? `replay ${trigger.replayWindowSec}s` : ""; + return [trigger.signingMode, replay].filter(Boolean).join(" · "); + } + return "API"; +} + +function formatVariableDefault(variable: RoutineVariable): string { + if (variable.defaultValue == null) return "—"; + return String(variable.defaultValue); +} + +function formatDirtyFieldList(labels: string[]): string { + if (labels.length === 0) return "the routine"; + if (labels.length === 1) return labels[0]; + if (labels.length === 2) return `${labels[0]} and ${labels[1]}`; + return `${labels.slice(0, -1).join(", ")}, and ${labels[labels.length - 1]}`; +} + +function collectWebhookTriggerDifferences( + target: RoutineRevision, + current: RoutineRevision, +): string[] { + const currentIds = new Set(current.snapshot.triggers.map((t) => t.id)); + return target.snapshot.triggers + .filter((trigger) => trigger.kind === "webhook" && !currentIds.has(trigger.id)) + .map((trigger) => trigger.label ?? "webhook"); +} + +function describeSnapshotField(value: unknown): string { + if (value == null) return "—"; + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +function computeFieldChanges( + left: RoutineRevision, + right: RoutineRevision, + agents: AgentLookup, + projects: ProjectLookup, +): Array<{ field: string; oldValue: string | null; newValue: string | null }> { + const oldRoutine = left.snapshot.routine; + const newRoutine = right.snapshot.routine; + const changes: Array<{ field: string; oldValue: string | null; newValue: string | null }> = []; + const compareScalar = ( + _field: string, + label: string, + oldVal: unknown, + newVal: unknown, + transform: (value: unknown) => string = describeSnapshotField, + ) => { + if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { + changes.push({ field: label, oldValue: transform(oldVal), newValue: transform(newVal) }); + } + }; + compareScalar("title", "Title", oldRoutine.title, newRoutine.title); + compareScalar("priority", "Priority", oldRoutine.priority, newRoutine.priority); + compareScalar( + "assigneeAgentId", + "Default agent", + resolveAgentName(oldRoutine.assigneeAgentId, agents), + resolveAgentName(newRoutine.assigneeAgentId, agents), + ); + compareScalar( + "projectId", + "Project", + resolveProjectName(oldRoutine.projectId, projects), + resolveProjectName(newRoutine.projectId, projects), + ); + compareScalar("concurrencyPolicy", "Concurrency", oldRoutine.concurrencyPolicy, newRoutine.concurrencyPolicy); + compareScalar("catchUpPolicy", "Catch-up", oldRoutine.catchUpPolicy, newRoutine.catchUpPolicy); + compareScalar("status", "Status", oldRoutine.status, newRoutine.status); + if (JSON.stringify(oldRoutine.variables) !== JSON.stringify(newRoutine.variables)) { + changes.push({ + field: "Variables", + oldValue: summarizeVariables(oldRoutine.variables), + newValue: summarizeVariables(newRoutine.variables), + }); + } + compareTriggers(left.snapshot.triggers, right.snapshot.triggers, changes); + return changes; +} + +function summarizeVariables(variables: RoutineVariable[]): string { + if (variables.length === 0) return "(none)"; + return variables + .map((variable) => `${variable.name}=${formatVariableDefault(variable)}`) + .join(", "); +} + +function compareTriggers( + oldTriggers: RoutineRevisionSnapshotTriggerV1[], + newTriggers: RoutineRevisionSnapshotTriggerV1[], + changes: Array<{ field: string; oldValue: string | null; newValue: string | null }>, +) { + const byId = new Map(); + for (const trigger of oldTriggers) byId.set(trigger.id, { old: trigger }); + for (const trigger of newTriggers) { + const existing = byId.get(trigger.id) ?? {}; + byId.set(trigger.id, { ...existing, next: trigger }); + } + for (const [, pair] of byId) { + if (pair.old && !pair.next) { + changes.push({ + field: `Trigger removed (${pair.old.label ?? pair.old.kind})`, + oldValue: summarizeTriggerSnapshot(pair.old), + newValue: null, + }); + } else if (!pair.old && pair.next) { + changes.push({ + field: `Trigger added (${pair.next.label ?? pair.next.kind})`, + oldValue: null, + newValue: summarizeTriggerSnapshot(pair.next), + }); + } else if (pair.old && pair.next) { + const oldSummary = summarizeTriggerSnapshot(pair.old); + const newSummary = summarizeTriggerSnapshot(pair.next); + if (oldSummary !== newSummary || pair.old.enabled !== pair.next.enabled) { + changes.push({ + field: `Trigger ${pair.next.label ?? pair.next.kind}`, + oldValue: `${oldSummary} (${pair.old.enabled ? "enabled" : "disabled"})`, + newValue: `${newSummary} (${pair.next.enabled ? "enabled" : "disabled"})`, + }); + } + } + } +} + +export function isUpdateConflictError(error: unknown): error is ApiError { + return error instanceof ApiError && error.status === 409; +} + +export type RoutineHistoryDirtyFieldDescriptor = DirtyFieldDescriptor; +export type RoutineHistoryAgentLookup = AgentLookup; +export type RoutineHistoryProjectLookup = ProjectLookup; diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index ac76438cffa..c5064a348d1 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -70,6 +70,7 @@ export const queryKeys = { ["routines", companyId, filters?.projectId ?? "__all-projects__"] as const, detail: (id: string) => ["routines", "detail", id] as const, runs: (id: string) => ["routines", "runs", id] as const, + revisions: (id: string) => ["routines", "revisions", id] as const, activity: (companyId: string, id: string) => ["routines", "activity", companyId, id] as const, }, executionWorkspaces: { diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index a2af906ff29..ceedff8a141 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -7,6 +7,7 @@ import { ChevronRight, Clock3, Copy, + History as HistoryIcon, Play, RefreshCw, Repeat, @@ -15,7 +16,12 @@ import { Webhook, Zap, } from "lucide-react"; -import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines"; +import { ApiError } from "../api/client"; +import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse, type RestoreRoutineRevisionResponse } from "../api/routines"; +import { + RoutineHistoryTab, + type RoutineHistoryDirtyFieldDescriptor, +} from "../components/RoutineHistoryTab"; import { heartbeatsApi } from "../api/heartbeats"; import { LiveRunWidget } from "../components/LiveRunWidget"; import { agentsApi } from "../api/agents"; @@ -63,7 +69,7 @@ const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_ac const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"]; const triggerKinds = ["schedule", "webhook"]; const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"]; -const routineTabs = ["triggers", "runs", "activity"] as const; +const routineTabs = ["triggers", "runs", "activity", "history"] as const; const concurrencyPolicyDescriptions: Record = { coalesce_if_active: "Keep one follow-up run queued while an active run is still working.", always_enqueue: "Queue every trigger occurrence, even if several runs stack up.", @@ -279,6 +285,7 @@ export function RoutineDetail() { const projectSelectorRef = useRef(null); const [secretMessage, setSecretMessage] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); + const [saveConflict, setSaveConflict] = useState(false); const [runVariablesOpen, setRunVariablesOpen] = useState(false); const [newTrigger, setNewTrigger] = useState({ kind: "schedule", @@ -374,19 +381,34 @@ export function RoutineDetail() { : null, [routine], ); - const isEditDirty = useMemo(() => { - if (!routineDefaults) return false; - return ( - editDraft.title !== routineDefaults.title || - editDraft.description !== routineDefaults.description || - editDraft.projectId !== routineDefaults.projectId || - editDraft.assigneeAgentId !== routineDefaults.assigneeAgentId || - editDraft.priority !== routineDefaults.priority || - editDraft.concurrencyPolicy !== routineDefaults.concurrencyPolicy || - editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy || - JSON.stringify(editDraft.variables) !== JSON.stringify(routineDefaults.variables) - ); + const dirtyFields = useMemo(() => { + if (!routineDefaults) return []; + const result: RoutineHistoryDirtyFieldDescriptor[] = []; + if (editDraft.title !== routineDefaults.title) result.push({ key: "title", label: "the title" }); + if (editDraft.description !== routineDefaults.description) { + result.push({ key: "description", label: "the description" }); + } + if (editDraft.projectId !== routineDefaults.projectId) { + result.push({ key: "projectId", label: "the project" }); + } + if (editDraft.assigneeAgentId !== routineDefaults.assigneeAgentId) { + result.push({ key: "assigneeAgentId", label: "the default agent" }); + } + if (editDraft.priority !== routineDefaults.priority) { + result.push({ key: "priority", label: "the priority" }); + } + if (editDraft.concurrencyPolicy !== routineDefaults.concurrencyPolicy) { + result.push({ key: "concurrencyPolicy", label: "the concurrency policy" }); + } + if (editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy) { + result.push({ key: "catchUpPolicy", label: "the catch-up policy" }); + } + if (JSON.stringify(editDraft.variables) !== JSON.stringify(routineDefaults.variables)) { + result.push({ key: "variables", label: "the variables" }); + } + return result; }, [editDraft, routineDefaults]); + const isEditDirty = dirtyFields.length > 0; useEffect(() => { if (!routine) return; @@ -437,16 +459,32 @@ export function RoutineDetail() { const saveRoutine = useMutation({ mutationFn: () => { - return routinesApi.update(routineId!, buildRoutineMutationPayload(editDraft)); + const payload = buildRoutineMutationPayload(editDraft); + const baseRevisionId = routine?.latestRevisionId ?? null; + return routinesApi.update(routineId!, { + ...payload, + ...(baseRevisionId ? { baseRevisionId } : {}), + }); }, onSuccess: async () => { + setSaveConflict(false); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }), + queryClient.invalidateQueries({ queryKey: queryKeys.routines.revisions(routineId!) }), ]); }, onError: (error) => { + if (error instanceof ApiError && error.status === 409) { + setSaveConflict(true); + pushToast({ + title: "Routine changed", + body: "Someone else updated this routine. Reload to see the latest revision.", + tone: "warn", + }); + return; + } pushToast({ title: "Failed to save routine", body: error instanceof Error ? error.message : "Paperclip could not save the routine.", @@ -796,6 +834,36 @@ export function RoutineDetail() {
)} + {/* Save conflict banner */} + {saveConflict && ( +
+
+
+

Out of date

+

+ This routine changed while you were editing. Reload to merge the latest revision before + saving again. +

+
+
+ +
+
+
+ )} + {!routine.assigneeAgentId ? (
Default agent required. This routine can stay as a draft and still run manually, but automation stays paused until you assign a default agent. @@ -999,6 +1067,10 @@ export function RoutineDetail() { Activity + + + History + @@ -1138,6 +1210,34 @@ export function RoutineDetail() {
)} + + + { + if (routineDefaults) setEditDraft(routineDefaults); + }} + onSaveEdits={() => { + if (!saveRoutine.isPending && editDraft.title.trim()) { + saveRoutine.mutate(); + } + }} + agents={agentById} + projects={projectById} + onRestoreSecretMaterials={(response: RestoreRoutineRevisionResponse) => { + const recreated = response.secretMaterials[0]; + if (recreated) { + setSecretMessage({ + title: "Webhook trigger restored", + webhookUrl: recreated.webhookUrl, + webhookSecret: recreated.webhookSecret, + }); + } + }} + /> +