Instance settings
diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts
index 9bfaee76a7f..65686a9cefc 100644
--- a/ui/src/lib/queryKeys.ts
+++ b/ui/src/lib/queryKeys.ts
@@ -379,6 +379,11 @@ export const queryKeys = {
cloud: {
stacks: ["cloud", "stacks"] as const,
},
+ stateRepo: {
+ health: (companyId: string) => ["state-repo", "health", companyId] as const,
+ log: (companyId: string) => ["state-repo", "log", companyId] as const,
+ remote: (companyId: string) => ["state-repo", "remote", companyId] as const,
+ },
secrets: {
list: (companyId: string) => ["secrets", companyId] as const,
providers: (companyId: string) => ["secret-providers", companyId] as const,
diff --git a/ui/src/pages/BackupsVersionHistory.tsx b/ui/src/pages/BackupsVersionHistory.tsx
new file mode 100644
index 00000000000..c308672981d
--- /dev/null
+++ b/ui/src/pages/BackupsVersionHistory.tsx
@@ -0,0 +1,507 @@
+import { useEffect, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ CheckCircle2,
+ CircleSlash,
+ Cloud,
+ Download,
+ GitCommitHorizontal,
+ History,
+ Link2,
+ Link2Off,
+ Loader2,
+ RefreshCcw,
+ ShieldCheck,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { SecretBindingPicker, type SecretBindingValue } from "@/components/SecretBindingPicker";
+import { stateRepoApi } from "@/api/stateRepo";
+import { healthApi } from "@/api/health";
+import { queryKeys } from "@/lib/queryKeys";
+import { useBreadcrumbs } from "@/context/BreadcrumbContext";
+import { useCompany } from "@/context/CompanyContext";
+import { useToastActions } from "@/context/ToastContext";
+import { timeAgo } from "@/lib/timeAgo";
+import { ApiError } from "@/api/client";
+import { cn } from "@/lib/utils";
+
+type Tone = "ok" | "warn" | "danger" | "neutral";
+
+const TONE_STYLES: Record
= {
+ ok: {
+ badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
+ dot: "bg-emerald-500",
+ icon: "text-emerald-600 dark:text-emerald-400",
+ },
+ warn: {
+ badge: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400",
+ dot: "bg-amber-500",
+ icon: "text-amber-600 dark:text-amber-400",
+ },
+ danger: {
+ badge: "border-destructive/30 bg-destructive/10 text-destructive",
+ dot: "bg-destructive",
+ icon: "text-destructive",
+ },
+ neutral: {
+ badge: "border-border bg-muted/40 text-muted-foreground",
+ dot: "bg-muted-foreground/50",
+ icon: "text-muted-foreground",
+ },
+};
+
+function StatusPill({ tone, label }: { tone: Tone; label: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+function errorMessage(error: unknown, fallback: string): string {
+ if (error instanceof ApiError) return error.message;
+ if (error instanceof Error) return error.message;
+ return fallback;
+}
+
+export function BackupsVersionHistory() {
+ const { selectedCompany, selectedCompanyId } = useCompany();
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const queryClient = useQueryClient();
+ const { pushToast } = useToastActions();
+
+ const [remoteUrl, setRemoteUrl] = useState("");
+ const [tokenBinding, setTokenBinding] = useState(null);
+ const [editingRemote, setEditingRemote] = useState(false);
+
+ useEffect(() => {
+ setBreadcrumbs([
+ { label: selectedCompany?.name ?? "Company", href: "/dashboard" },
+ { label: "Settings", href: "/company/settings" },
+ { label: "Backups & version history" },
+ ]);
+ }, [selectedCompany?.name, setBreadcrumbs]);
+
+ const enabled = !!selectedCompanyId;
+
+ const healthQuery = useQuery({
+ queryKey: queryKeys.health,
+ queryFn: () => healthApi.get(),
+ });
+ const logQuery = useQuery({
+ queryKey: selectedCompanyId ? queryKeys.stateRepo.log(selectedCompanyId) : ["state-repo", "log", "__none__"],
+ queryFn: () => stateRepoApi.log(selectedCompanyId!),
+ enabled,
+ });
+ const mirrorHealthQuery = useQuery({
+ queryKey: selectedCompanyId ? queryKeys.stateRepo.health(selectedCompanyId) : ["state-repo", "health", "__none__"],
+ queryFn: () => stateRepoApi.health(selectedCompanyId!),
+ enabled,
+ });
+ const remoteQuery = useQuery({
+ queryKey: selectedCompanyId ? queryKeys.stateRepo.remote(selectedCompanyId) : ["state-repo", "remote", "__none__"],
+ queryFn: () => stateRepoApi.getRemote(selectedCompanyId!),
+ enabled,
+ });
+
+ const remote = remoteQuery.data?.remote ?? null;
+ const mirrorHealth = mirrorHealthQuery.data ?? null;
+ const commits = logQuery.data?.commits ?? [];
+
+ useEffect(() => {
+ if (remote && !editingRemote) {
+ setRemoteUrl(remote.remoteUrl);
+ setTokenBinding(
+ remote.secretId
+ ? { secretId: remote.secretId, version: remote.secretVersion === null ? "latest" : (remote.secretVersion as never) }
+ : null,
+ );
+ }
+ }, [remote, editingRemote]);
+
+ const invalidateMirror = async () => {
+ if (!selectedCompanyId) return;
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: queryKeys.stateRepo.health(selectedCompanyId) }),
+ queryClient.invalidateQueries({ queryKey: queryKeys.stateRepo.remote(selectedCompanyId) }),
+ ]);
+ };
+
+ const saveRemoteMutation = useMutation({
+ mutationFn: async () => {
+ if (!selectedCompanyId) throw new Error("No company selected");
+ await stateRepoApi.setRemote(selectedCompanyId, {
+ remoteUrl: remoteUrl.trim(),
+ secretId: tokenBinding?.secretId ?? null,
+ secretVersion:
+ tokenBinding?.version === undefined || tokenBinding?.version === "latest"
+ ? "latest"
+ : String(tokenBinding.version),
+ });
+ return stateRepoApi.testMirror(selectedCompanyId);
+ },
+ onSuccess: async () => {
+ setEditingRemote(false);
+ await invalidateMirror();
+ pushToast({ title: "Repository connected", body: "First mirror push succeeded.", tone: "success" });
+ },
+ onError: (error) => {
+ void invalidateMirror();
+ pushToast({ title: "Connection test failed", body: errorMessage(error, "Push failed."), tone: "error" });
+ },
+ });
+
+ const testMutation = useMutation({
+ mutationFn: () => stateRepoApi.testMirror(selectedCompanyId!),
+ onSuccess: async () => {
+ await invalidateMirror();
+ pushToast({ title: "Push succeeded", body: "The mirror is up to date.", tone: "success" });
+ },
+ onError: (error) => {
+ void invalidateMirror();
+ pushToast({ title: "Push failed", body: errorMessage(error, "Push failed."), tone: "error" });
+ },
+ });
+
+ const disconnectMutation = useMutation({
+ mutationFn: () => stateRepoApi.disconnectRemote(selectedCompanyId!),
+ onSuccess: async () => {
+ setEditingRemote(false);
+ setRemoteUrl("");
+ setTokenBinding(null);
+ await invalidateMirror();
+ pushToast({ title: "Repository disconnected", tone: "info" });
+ },
+ onError: (error) => pushToast({ title: "Failed to disconnect", body: errorMessage(error, ""), tone: "error" }),
+ });
+
+ const snapshot = healthQuery.data?.stateSnapshot;
+ const dbBackup = healthQuery.data?.databaseBackup;
+
+ const snapshotTone: Tone = !snapshot?.enabled
+ ? "neutral"
+ : snapshot.status === "ok"
+ ? "ok"
+ : "warn";
+ const dbBackupTone: Tone = !dbBackup?.enabled ? "neutral" : dbBackup.status === "ok" ? "ok" : "warn";
+
+ const mirrorTone: Tone = !mirrorHealth?.configured
+ ? "neutral"
+ : mirrorHealth.failure
+ ? "danger"
+ : mirrorHealth.healthy
+ ? "ok"
+ : "warn";
+
+ if (!selectedCompanyId) {
+ return (
+
+ Select a company to view backups and version history.
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
Backups & version history
+
+ Durability and change history for this company's agent instructions, skills, and memory.
+ Snapshots protect the whole instance; the state repository keeps an attributed, restorable
+ history you can mirror to your own git host.
+
+
+
+
+ Restore runbook
+
+
+
+ {/* Snapshot health */}
+
+
+ Snapshot health
+
+
+
+
+
+
+
+ {/* Version history */}
+
+
+
+ {logQuery.isLoading ? (
+
+ Loading history…
+
+ ) : commits.length === 0 ? (
+
+
+
No history yet
+
+ The state repository records a commit whenever an agent's instructions, skills, or memory
+ change. Edits will appear here with who changed what, and when.
+
+
+ ) : (
+
+ {commits.map((commit) => (
+
+
+
+
{commit.subject}
+
+ {commit.author}
+ {" · "}
+ {commit.date ? timeAgo(commit.date) : "unknown time"}
+
+
+ {commit.shortHash}
+
+ ))}
+
+ )}
+
+
+
+ {/* Connect your repo */}
+
+
+ Connect your repository
+
+
+
+
+
+
+
+ Mirror to your own git host
+
+
+ Paperclip pushes a one-way mirror of the state repository to your remote after every
+ change. Your remote is a copy — Paperclip never force-pushes and never rewrites your
+ history.
+
+
+ {mirrorHealth?.configured && (
+
+ )}
+
+
+
+ {remote && !editingRemote ? (
+ <>
+
+
+ Remote URL
+ {remote.remoteUrl}
+
+
+ Push token
+
+ {remote.secretId ? "Company secret bound" : "None (public/anonymous remote)"}
+
+
+
+ {mirrorHealth?.failure ? (
+
+
+
+
Last push failed
+
{mirrorHealth.failure}
+
+
+ ) : mirrorHealth?.success ? (
+
+
+ Last pushed {timeAgo(mirrorHealth.success.pushedAt)} — mirror is up to date.
+
+ ) : null}
+
+ testMutation.mutate()} disabled={testMutation.isPending}>
+ {testMutation.isPending ? : }
+ Test connection
+
+ setEditingRemote(true)}>
+ Edit
+
+ disconnectMutation.mutate()}
+ disabled={disconnectMutation.isPending}
+ >
+
+ Disconnect
+
+
+ >
+ ) : (
+ <>
+
+
+ Remote URL
+
+
setRemoteUrl(event.target.value)}
+ />
+
+ Must be an https:// URL. Create an empty repository first;
+ Paperclip pushes into it.
+
+
+
+
+ saveRemoteMutation.mutate()}
+ disabled={!remoteUrl.trim() || saveRemoteMutation.isPending}
+ >
+ {saveRemoteMutation.isPending ? : }
+ Connect & test
+
+ {remote && (
+ setEditingRemote(false)}>
+ Cancel
+
+ )}
+
+ >
+ )}
+
+
+
+
+ );
+}
+
+function HealthCard({
+ icon: Icon,
+ title,
+ tone,
+ statusLabel,
+ detail,
+ warnings,
+ loading,
+}: {
+ icon: typeof ShieldCheck;
+ title: string;
+ tone: Tone;
+ statusLabel: string;
+ detail: string;
+ warnings: Array<{ code: string; message: string }>;
+ loading?: boolean;
+}) {
+ return (
+
+
+
+
+
+ {title}
+
+ {loading ? (
+
+ ) : tone === "neutral" ? (
+
+
+ {statusLabel}
+
+ ) : (
+
+ )}
+
+
+
+ {detail}
+ {warnings.length > 0 && (
+
+ {warnings.map((warning) => (
+
+
+ {warning.message}
+
+ ))}
+
+ )}
+
+
+ );
+}