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 { CompanySecret, EnvBinding, EnvSecretRefBinding, Routine, RoutineEnvConfig, RoutineRevision, RoutineRevisionSnapshotTriggerV1, RoutineVariable, SecretVersionSelector, } 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 SecretLookup = Map; type DirtyFieldDescriptor = { key: string; label: string; }; type Props = { routine: Routine; isEditDirty: boolean; dirtyFields: DirtyFieldDescriptor[]; onDiscardEdits: () => void; onSaveEdits: () => void; agents: AgentLookup; projects: ProjectLookup; secrets?: CompanySecret[]; onRestoreSecretMaterials: (response: RestoreRoutineRevisionResponse) => void; onRestored?: (response: RestoreRoutineRevisionResponse) => void; }; export function RoutineHistoryTab({ routine, isEditDirty, dirtyFields, onDiscardEdits, onSaveEdits, agents, projects, secrets, onRestoreSecretMaterials, onRestored, }: Props) { const secretLookup = useMemo( () => new Map((secrets ?? []).map((secret) => [secret.id, secret])), [secrets], ); 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 ? "Trigger enabled state was restored from the snapshot. New webhook secrets are available in the banner above." : "Trigger enabled state was restored from the snapshot.", tone: "success", }); onRestoreSecretMaterials(data); onRestored?.(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 envSummary = summarizeEnv(snapshot.env ?? null); const envDiffers = !!currentSnapshot && JSON.stringify(normalizeEnv(currentSnapshot.env ?? null)) !== JSON.stringify(normalizeEnv(snapshot.env ?? null)); 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: "status", label: "Status", value: snapshot.status, differs: !!currentSnapshot && currentSnapshot.status !== snapshot.status, }, { 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, }, { key: "env", label: "Env", value: envSummary, differs: envDiffers, }, ]; 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, envDiffCounts, }: { open: boolean; onOpenChange: (open: boolean) => void; target: RoutineRevision; currentRevisionNumber: number; changeSummary: string; onChangeSummaryChange: (value: string) => void; onConfirm: () => void; pending: boolean; recreatedWebhookLabels: string[]; envDiffCounts: EnvDiffCounts; }) { 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.
  • {envDiffCounts.total > 0 && (
  • Routine secrets will revert: {formatEnvDiffCounts(envDiffCounts)}.
  • )}
  • 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, secrets, onRestore, }: { open: boolean; onOpenChange: (open: boolean) => void; revisions: RoutineRevision[]; initialOldRevisionId: string; initialNewRevisionId: string; agents: AgentLookup; projects: ProjectLookup; secrets: SecretLookup; 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, secrets) : []), [left, right, agents, projects, secrets], ); 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) => ( ))}
Field Old value New 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, secrets: SecretLookup, ): 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), }); } compareEnv(oldRoutine.env ?? null, newRoutine.env ?? null, secrets, changes); compareTriggers(left.snapshot.triggers, right.snapshot.triggers, changes); return changes; } function normalizeEnv(env: RoutineEnvConfig | null): Record { if (!env) return {}; return env; } function envBindingKind(binding: EnvBinding): "plain" | "secret_ref" { if (typeof binding === "string") return "plain"; if (binding && typeof binding === "object" && "type" in binding && binding.type === "secret_ref") { return "secret_ref"; } return "plain"; } function asSecretRef(binding: EnvBinding): EnvSecretRefBinding | null { if (typeof binding === "string") return null; if (binding && typeof binding === "object" && "type" in binding && binding.type === "secret_ref") { return binding; } return null; } function formatVersionSelector(version: SecretVersionSelector | undefined): string { if (version == null || version === "latest") return "latest"; return `v${version}`; } function describeSecretRef(ref: EnvSecretRefBinding, secrets: SecretLookup): string { const secret = secrets.get(ref.secretId); const name = secret?.name ?? ""; return `${name} ${formatVersionSelector(ref.version)}`; } function describeEnvBinding(binding: EnvBinding | undefined, secrets: SecretLookup): string { if (binding === undefined) return "—"; const ref = asSecretRef(binding); if (ref) return `secret_ref → ${describeSecretRef(ref, secrets)}`; return "plain (set)"; } function summarizeEnv(env: RoutineEnvConfig | null): string { const entries = Object.entries(normalizeEnv(env)); if (entries.length === 0) return ""; const secretCount = entries.filter(([, binding]) => envBindingKind(binding) === "secret_ref").length; const keyLabel = entries.length === 1 ? "key" : "keys"; if (secretCount === 0) return `${entries.length} ${keyLabel}`; return `${entries.length} ${keyLabel} (${secretCount} secret ${secretCount === 1 ? "ref" : "refs"})`; } type EnvDiffCounts = { added: number; removed: number; changed: number; total: number; }; function summarizeEnvDiffCounts( current: RoutineEnvConfig | null, target: RoutineEnvConfig | null, ): EnvDiffCounts { const currentRec = normalizeEnv(current); const targetRec = normalizeEnv(target); let added = 0; let removed = 0; let changed = 0; const keys = new Set([...Object.keys(currentRec), ...Object.keys(targetRec)]); for (const key of keys) { const inCurrent = key in currentRec; const inTarget = key in targetRec; if (inTarget && !inCurrent) { added += 1; continue; } if (!inTarget && inCurrent) { removed += 1; continue; } if (JSON.stringify(currentRec[key]) !== JSON.stringify(targetRec[key])) { changed += 1; } } return { added, removed, changed, total: added + removed + changed }; } function formatEnvDiffCounts(counts: EnvDiffCounts): string { const parts: string[] = []; if (counts.added > 0) parts.push(`${counts.added} ${counts.added === 1 ? "key" : "keys"} added`); if (counts.removed > 0) parts.push(`${counts.removed} ${counts.removed === 1 ? "key" : "keys"} removed`); if (counts.changed > 0) parts.push(`${counts.changed} ${counts.changed === 1 ? "key" : "keys"} changed`); return parts.join(", "); } function compareEnv( oldEnv: RoutineEnvConfig | null, newEnv: RoutineEnvConfig | null, secrets: SecretLookup, changes: Array<{ field: string; oldValue: string | null; newValue: string | null }>, ) { const oldRec = normalizeEnv(oldEnv); const newRec = normalizeEnv(newEnv); const keys = new Set([...Object.keys(oldRec), ...Object.keys(newRec)]); const sortedKeys = [...keys].sort(); for (const key of sortedKeys) { const oldBinding = oldRec[key]; const newBinding = newRec[key]; const inOld = key in oldRec; const inNew = key in newRec; if (inNew && !inOld) { changes.push({ field: `Env added (${key})`, oldValue: "—", newValue: describeEnvBinding(newBinding, secrets), }); continue; } if (!inNew && inOld) { changes.push({ field: `Env removed (${key})`, oldValue: describeEnvBinding(oldBinding, secrets), newValue: "—", }); continue; } if (JSON.stringify(oldBinding) === JSON.stringify(newBinding)) continue; const oldKind = envBindingKind(oldBinding); const newKind = envBindingKind(newBinding); if (oldKind !== newKind) { changes.push({ field: `Env ${key} binding kind`, oldValue: describeEnvBinding(oldBinding, secrets), newValue: describeEnvBinding(newBinding, secrets), }); continue; } if (newKind === "secret_ref") { const oldRef = asSecretRef(oldBinding)!; const newRef = asSecretRef(newBinding)!; if (oldRef.secretId !== newRef.secretId) { changes.push({ field: `Env ${key} secret`, oldValue: describeEnvBinding(oldBinding, secrets), newValue: describeEnvBinding(newBinding, secrets), }); continue; } changes.push({ field: `Env ${key} version`, oldValue: describeSecretRef(oldRef, secrets), newValue: describeSecretRef(newRef, secrets), }); continue; } changes.push({ field: `Env ${key} value`, oldValue: "plain (set)", newValue: "plain (changed)", }); } } 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;