From 74104940f51e378220fafdb4e11bd552d69981b1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 16 May 2026 20:18:47 -0500 Subject: [PATCH] Wire routine Secrets tab and history env diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Secrets tab to RoutineDetail that reuses the existing EnvVarEditor for plain values and secret refs, alongside agent and project env. Env joins routine draft state, dirty-field detection, mutation payload, conflict reset, and the restore-response handler so it flows through every routine save path consistently. Extend RoutineHistoryTab with env-aware diffs that never echo secret material: - Add an "Env" row to the revision preview summary with `N keys (M secret refs)` and the existing `differs from current` chip. - Add a "Routine secrets will revert: …" bullet to the restore confirm dialog when target/current env counts differ. - Add an env diff vocabulary to the compare modal: `Env added (KEY)`, `Env removed (KEY)`, `Env KEY binding kind`, `Env KEY version`, and `Env KEY value`, with `(set)`/`(changed)` redaction tokens and secret name + version for refs. Add a routine-secrets Storybook story covering empty, configured, disabled-or-missing, and history-diff states; export EnvPlainBinding and EnvSecretRefBinding from shared so the history helpers can type the binding kind locally. Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 2 + ui/src/components/RoutineHistoryTab.test.tsx | 68 +++++ ui/src/components/RoutineHistoryTab.tsx | 202 +++++++++++++- ui/src/pages/RoutineDetail.tsx | 60 +++- .../stories/routine-secrets.stories.tsx | 257 ++++++++++++++++++ 5 files changed, 585 insertions(+), 4 deletions(-) create mode 100644 ui/storybook/stories/routine-secrets.stories.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 88b12dccfc8..945dcd1c191 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -550,6 +550,8 @@ export type { CompanyPortabilityImportResult, CompanyPortabilityExportRequest, EnvBinding, + EnvPlainBinding, + EnvSecretRefBinding, AgentEnvConfig, CompanySecret, CompanySecretProviderConfig, diff --git a/ui/src/components/RoutineHistoryTab.test.tsx b/ui/src/components/RoutineHistoryTab.test.tsx index 56dd9c80171..bceb9d6fcf8 100644 --- a/ui/src/components/RoutineHistoryTab.test.tsx +++ b/ui/src/components/RoutineHistoryTab.test.tsx @@ -5,7 +5,9 @@ import type { ComponentProps } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { + CompanySecret, Routine, + RoutineEnvConfig, RoutineRevision, RoutineRevisionSnapshotV1, } from "@paperclipai/shared"; @@ -95,6 +97,7 @@ function snapshotV1(overrides?: Partial): concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", variables: [], + env: null, ...overrides, }, triggers: [], @@ -321,6 +324,71 @@ describe("RoutineHistoryTab", () => { expect(successCall).toBeTruthy(); }); + it("shows env summary on the revision preview and routes counts into restore dialog", async () => { + const env: RoutineEnvConfig = { + GH_TOKEN: { type: "secret_ref", secretId: "secret-1", version: "latest" }, + LOG_LEVEL: { type: "plain", value: "debug" }, + }; + const current = createRevision({ + id: "revision-2", + revisionNumber: 2, + snapshot: snapshotV1({ env }), + }); + const old = createRevision({ + id: "revision-1", + revisionNumber: 1, + snapshot: snapshotV1({ + env: { GH_TOKEN: { type: "secret_ref", secretId: "secret-1", version: 3 } }, + }), + }); + mockRoutinesApi.listRevisions.mockResolvedValue([current, old]); + const secrets: CompanySecret[] = [ + { + id: "secret-1", + companyId: "company-1", + key: "gh_token", + name: "github-bot", + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 4, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + }, + ]; + await render({ secrets }); + expect(container.textContent).toContain("Env"); + expect(container.textContent).toContain("2 keys (1 secret ref)"); + + 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.textContent).toContain("Routine secrets will revert"); + expect(container.textContent).toContain("1 key removed"); + expect(container.textContent).toContain("1 key changed"); + }); + it("invokes onRestored with the restore response so the editor can rehydrate (PAP-3588)", async () => { const current = createRevision({ id: "revision-2", revisionNumber: 2 }); const old = createRevision({ diff --git a/ui/src/components/RoutineHistoryTab.tsx b/ui/src/components/RoutineHistoryTab.tsx index 0bae27142b7..30782ae03ba 100644 --- a/ui/src/components/RoutineHistoryTab.tsx +++ b/ui/src/components/RoutineHistoryTab.tsx @@ -2,10 +2,15 @@ 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, @@ -33,6 +38,7 @@ import { MarkdownBody } from "./MarkdownBody"; type AgentLookup = Map; type ProjectLookup = Map; +type SecretLookup = Map; type DirtyFieldDescriptor = { key: string; @@ -47,6 +53,7 @@ type Props = { onSaveEdits: () => void; agents: AgentLookup; projects: ProjectLookup; + secrets?: CompanySecret[]; onRestoreSecretMaterials: (response: RestoreRoutineRevisionResponse) => void; onRestored?: (response: RestoreRoutineRevisionResponse) => void; }; @@ -59,9 +66,14 @@ export function RoutineHistoryTab({ 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); @@ -277,6 +289,10 @@ export function RoutineHistoryTab({ selectedRevision, currentRevision, )} + envDiffCounts={summarizeEnvDiffCounts( + currentRevision.snapshot.routine.env ?? null, + selectedRevision.snapshot.routine.env ?? null, + )} /> )} @@ -289,6 +305,7 @@ export function RoutineHistoryTab({ initialNewRevisionId={currentRevision.id} agents={agents} projects={projects} + secrets={secretLookup} onRestore={(rev) => { setSelectedRevisionId(rev.id); setDiffOpen(false); @@ -498,6 +515,10 @@ function RevisionPreview({ 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", @@ -541,6 +562,12 @@ function RevisionPreview({ value: snapshot.catchUpPolicy.replaceAll("_", " "), differs: !!currentSnapshot && currentSnapshot.catchUpPolicy !== snapshot.catchUpPolicy, }, + { + key: "env", + label: "Env", + value: envSummary, + differs: envDiffers, + }, ]; return ( @@ -670,6 +697,7 @@ function RestoreConfirmDialog({ onConfirm, pending, recreatedWebhookLabels, + envDiffCounts, }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -680,6 +708,7 @@ function RestoreConfirmDialog({ onConfirm: () => void; pending: boolean; recreatedWebhookLabels: string[]; + envDiffCounts: EnvDiffCounts; }) { const newRevisionNumber = currentRevisionNumber + 1; return ( @@ -698,6 +727,12 @@ function RestoreConfirmDialog({ Routine field values, variables, and schedule cron will revert. + {envDiffCounts.total > 0 && ( +
  • + + Routine secrets will revert: {formatEnvDiffCounts(envDiffCounts)}. +
  • + )}
  • Previous run history is preserved. @@ -743,6 +778,7 @@ function RoutineRevisionDiffModal({ initialNewRevisionId, agents, projects, + secrets, onRestore, }: { open: boolean; @@ -752,6 +788,7 @@ function RoutineRevisionDiffModal({ initialNewRevisionId: string; agents: AgentLookup; projects: ProjectLookup; + secrets: SecretLookup; onRestore: (revision: RoutineRevision) => void; }) { const [leftId, setLeftId] = useState(initialOldRevisionId); @@ -767,8 +804,8 @@ function RoutineRevisionDiffModal({ 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], + () => (left && right ? computeFieldChanges(left, right, agents, projects, secrets) : []), + [left, right, agents, projects, secrets], ); const descriptionDiff = useMemo( () => (left && right @@ -1003,6 +1040,7 @@ function computeFieldChanges( 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; @@ -1042,10 +1080,170 @@ function computeFieldChanges( 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} binding kind`, + 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 diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index a8a0e9d0e51..a502feef441 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -8,6 +8,7 @@ import { Clock3, Copy, History as HistoryIcon, + KeyRound, Play, RefreshCw, Repeat, @@ -18,6 +19,8 @@ import { } from "lucide-react"; import { ApiError } from "../api/client"; import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse, type RestoreRoutineRevisionResponse } from "../api/routines"; +import { secretsApi } from "../api/secrets"; +import { EnvVarEditor } from "../components/EnvVarEditor"; import { RoutineHistoryTab, type RoutineHistoryDirtyFieldDescriptor, @@ -63,13 +66,19 @@ import { } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; -import type { RoutineDetail as RoutineDetailType, RoutineTrigger, RoutineVariable } from "@paperclipai/shared"; +import type { + EnvBinding, + RoutineDetail as RoutineDetailType, + RoutineEnvConfig, + RoutineTrigger, + RoutineVariable, +} from "@paperclipai/shared"; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; 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", "history"] as const; +const routineTabs = ["triggers", "runs", "activity", "secrets", "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.", @@ -141,12 +150,14 @@ function buildRoutineMutationPayload(input: { concurrencyPolicy: string; catchUpPolicy: string; variables: RoutineVariable[]; + env: RoutineEnvConfig | null; }) { return { ...input, description: input.description.trim() || null, projectId: input.projectId || null, assigneeAgentId: input.assigneeAgentId || null, + env: input.env && Object.keys(input.env).length > 0 ? input.env : null, }; } @@ -304,6 +315,7 @@ export function RoutineDetail() { concurrencyPolicy: string; catchUpPolicy: string; variables: RoutineVariable[]; + env: RoutineEnvConfig | null; }>({ title: "", description: "", @@ -313,6 +325,7 @@ export function RoutineDetail() { concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", variables: [], + env: null, }); const activeTab = useMemo(() => getRoutineTabFromSearch(location.search), [location.search]); @@ -366,6 +379,21 @@ export function RoutineDetail() { queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const { data: availableSecrets = [] } = useQuery({ + queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"], + queryFn: () => secretsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + const createSecret = useMutation({ + mutationFn: (input: { name: string; value: string }) => { + if (!selectedCompanyId) throw new Error("Select a company to create secrets"); + return secretsApi.create(selectedCompanyId, input); + }, + onSuccess: () => { + if (!selectedCompanyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) }); + }, + }); const routineDefaults = useMemo( () => @@ -379,6 +407,7 @@ export function RoutineDetail() { concurrencyPolicy: routine.concurrencyPolicy, catchUpPolicy: routine.catchUpPolicy, variables: routine.variables, + env: routine.env ?? null, } : null, [routine], @@ -408,6 +437,9 @@ export function RoutineDetail() { if (JSON.stringify(editDraft.variables) !== JSON.stringify(routineDefaults.variables)) { result.push({ key: "variables", label: "the variables" }); } + if (JSON.stringify(editDraft.env ?? null) !== JSON.stringify(routineDefaults.env ?? null)) { + result.push({ key: "env", label: "the secrets" }); + } return result; }, [editDraft, routineDefaults]); const isEditDirty = dirtyFields.length > 0; @@ -1082,6 +1114,10 @@ export function RoutineDetail() { Activity + + + Secrets + History @@ -1226,6 +1262,24 @@ export function RoutineDetail() { )} + +

    + Routine secrets apply to every issue this routine creates. They override matching keys in + project and agent env. PAPERCLIP_* variables are reserved. +

    + } + secrets={availableSecrets} + onCreateSecret={async (name, value) => { + const created = await createSecret.mutateAsync({ name, value }); + return created; + }} + onChange={(env) => + setEditDraft((current) => ({ ...current, env: env ?? null })) + } + /> +
    + { if (response.secretMaterials.length > 0) { setSecretMessage({ @@ -1277,6 +1332,7 @@ export function RoutineDetail() { concurrencyPolicy: response.routine.concurrencyPolicy, catchUpPolicy: response.routine.catchUpPolicy, variables: response.routine.variables, + env: response.routine.env ?? null, }); hydratedRoutineIdRef.current = response.routine.id; }} diff --git a/ui/storybook/stories/routine-secrets.stories.tsx b/ui/storybook/stories/routine-secrets.stories.tsx new file mode 100644 index 00000000000..612145cbad0 --- /dev/null +++ b/ui/storybook/stories/routine-secrets.stories.tsx @@ -0,0 +1,257 @@ +import { useEffect, useState, type ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useQueryClient } from "@tanstack/react-query"; +import { KeyRound } from "lucide-react"; +import type { + CompanySecret, + EnvBinding, + Routine, + RoutineEnvConfig, + RoutineRevision, + RoutineRevisionSnapshotV1, +} from "@paperclipai/shared"; +import { EnvVarEditor } from "@/components/EnvVarEditor"; +import { RoutineHistoryTab } from "@/components/RoutineHistoryTab"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { useCompany } from "@/context/CompanyContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { storybookCompanies, storybookSecrets } from "../fixtures/paperclipData"; + +const COMPANY_ID = "company-storybook"; + +if (typeof window !== "undefined") { + window.localStorage.setItem("paperclip.selectedCompanyId", COMPANY_ID); +} + +function StorybookRoutineFixtures({ + revisions, + children, +}: { + revisions: RoutineRevision[]; + children: ReactNode; +}) { + const queryClient = useQueryClient(); + queryClient.setQueryData(queryKeys.companies.all, { companies: storybookCompanies, unauthorized: false }); + queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets); + queryClient.setQueryData(queryKeys.routines.revisions("routine-storybook"), revisions); + + const { selectedCompanyId, setSelectedCompanyId } = useCompany(); + useEffect(() => { + if (selectedCompanyId !== COMPANY_ID) { + setSelectedCompanyId(COMPANY_ID); + } + }, [selectedCompanyId, setSelectedCompanyId]); + + if (selectedCompanyId !== COMPANY_ID) return null; + return <>{children}; +} + +const meta: Meta = { + title: "Product/Routines · Secrets tab", + parameters: { + layout: "fullscreen", + a11y: { test: "off" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +function SecretsTabSurface({ + initial, + title, +}: { + initial: RoutineEnvConfig | null; + title: string; +}) { + const [env, setEnv] = useState>(() => (initial ?? {}) as Record); + return ( + + + + + {title} + + + The Secrets tab on a routine reuses the env-var editor and adds a one-line precedence helper. + + + +

    + Routine secrets apply to every issue this routine creates. They override matching keys in + project and agent env. PAPERCLIP_* variables are reserved. +

    + ({ + ...storybookSecrets[0]!, + id: `secret-${Math.random().toString(36).slice(2, 8)}`, + name, + key: name.toLowerCase(), + description: `New routine secret ${name}`, + })} + onChange={(next) => setEnv((next ?? {}) as Record)} + /> +
    +
    + ); +} + +export const SecretsTabEmpty: Story = { + render: () => ( +
    + +
    + ), +}; + +export const SecretsTabConfigured: Story = { + render: () => ( +
    + +
    + ), +}; + +export const SecretsTabDisabledOrMissing: Story = { + render: () => ( +
    + +
    + ), +}; + +function makeSnapshot(env: RoutineEnvConfig | null): RoutineRevisionSnapshotV1 { + return { + version: 1, + routine: { + id: "routine-storybook", + companyId: COMPANY_ID, + projectId: null, + goalId: null, + parentIssueId: null, + title: "Nightly digest", + description: "Summarize agent activity each night.", + assigneeAgentId: null, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [], + env, + }, + triggers: [], + }; +} + +function makeRoutine(latestRevisionId: string, latestRevisionNumber: number): Routine { + return { + id: "routine-storybook", + companyId: COMPANY_ID, + projectId: null, + goalId: null, + parentIssueId: null, + title: "Nightly digest", + description: "Summarize agent activity each night.", + assigneeAgentId: null, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [], + env: makeSnapshot({ + OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: "latest" }, + STAGE: { type: "plain", value: "production" }, + }).routine.env, + latestRevisionId, + latestRevisionNumber, + createdByAgentId: null, + createdByUserId: "user-board", + updatedByAgentId: null, + updatedByUserId: "user-board", + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: new Date("2026-05-01T11:00:00.000Z"), + updatedAt: new Date("2026-05-04T12:00:00.000Z"), + }; +} + +export const HistoryDiffWithEnv: Story = { + name: "History diff — env keys added/removed/changed", + render: () => { + const revisions: RoutineRevision[] = [ + { + id: "rev-2", + companyId: COMPANY_ID, + routineId: "routine-storybook", + revisionNumber: 2, + title: "Nightly digest", + description: "Summarize agent activity each night.", + snapshot: makeSnapshot({ + OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: "latest" }, + STAGE: { type: "plain", value: "production" }, + }), + changeSummary: "Added STAGE plain value", + restoredFromRevisionId: null, + createdByAgentId: null, + createdByUserId: "user-board", + createdByRunId: null, + createdAt: new Date("2026-05-04T12:00:00.000Z"), + }, + { + id: "rev-1", + companyId: COMPANY_ID, + routineId: "routine-storybook", + revisionNumber: 1, + title: "Nightly digest", + description: "Summarize agent activity each night.", + snapshot: makeSnapshot({ + OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: 2 }, + GH_TOKEN: { type: "plain", value: "legacy" }, + }), + changeSummary: "Created routine", + restoredFromRevisionId: null, + createdByAgentId: null, + createdByUserId: "user-board", + createdByRunId: null, + createdAt: new Date("2026-05-01T11:00:00.000Z"), + }, + ]; + return ( + +
    + {}} + onSaveEdits={() => {}} + agents={new Map()} + projects={new Map()} + secrets={storybookSecrets as CompanySecret[]} + onRestoreSecretMaterials={() => {}} + /> +
    +
    + ); + }, +};