feat(KB-327): add automatic database backup system

- Add backup settings to ProjectSettings with schedule, retention, and directory config
- Create BackupManager with create, list, cleanup, and restore operations
- Add CLI backup commands: --create, --list, --restore, --cleanup
- Implement backup validation and automation sync for scheduled backups
- Add dashboard backup settings UI with stats and 'Backup Now' button
- Add backup API routes for settings management and manual operations
- Include comprehensive backup tests and changeset for patch release
- Document backup configuration and recovery in AGENTS.md
This commit is contained in:
gsxdsm
2026-03-31 15:40:25 -07:00
parent b6ceeb8ae3
commit a6f99f90bb
11 changed files with 1395 additions and 3 deletions

View File

@@ -1563,3 +1563,39 @@ export function fetchAgentHeartbeats(agentId: string, limit?: number): Promise<A
const query = limit !== undefined ? `?limit=${limit}` : "";
return api<AgentHeartbeatEvent[]>(`/agents/${encodeURIComponent(agentId)}/heartbeats${query}`);
}
// --- Backup API ---
/** Backup metadata from the API */
export interface BackupInfo {
filename: string;
createdAt: string;
size: number;
path: string;
}
/** Result of listing backups */
export interface BackupListResponse {
backups: BackupInfo[];
count: number;
totalSize: number;
}
/** Result of creating a backup */
export interface BackupCreateResponse {
success: boolean;
backupPath?: string;
output?: string;
deletedCount?: number;
error?: string;
}
/** Fetch all database backups */
export function fetchBackups(): Promise<BackupListResponse> {
return api<BackupListResponse>("/backups");
}
/** Create a new database backup immediately */
export function createBackup(): Promise<BackupCreateResponse> {
return api<BackupCreateResponse>("/backups", { method: "POST" });
}

View File

@@ -1,8 +1,8 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { THINKING_LEVELS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../api";
import type { AuthProvider, ModelInfo } from "../api";
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification, fetchBackups, createBackup } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse } from "../api";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
import { CustomModelDropdown } from "./CustomModelDropdown";
@@ -42,6 +42,7 @@ const SETTINGS_SECTIONS = [
{ id: "worktrees", label: "Worktrees", scope: "project" as const },
{ id: "commands", label: "Commands", scope: "project" as const },
{ id: "merge", label: "Merge", scope: "project" as const },
{ id: "backups", label: "Backups", scope: "project" as const },
{ id: "notifications", label: "Notifications", scope: "global" as const },
{ id: "authentication", label: "Authentication", scope: undefined },
] as const;
@@ -93,6 +94,10 @@ export function SettingsModal({
const [presetDraft, setPresetDraft] = useState<ModelPreset | null>(null);
const [presetIdTouched, setPresetIdTouched] = useState(false);
// Backup state
const [backupInfo, setBackupInfo] = useState<BackupListResponse | null>(null);
const [backupLoading, setBackupLoading] = useState(false);
useEffect(() => {
fetchSettings()
.then((s) => {
@@ -125,6 +130,16 @@ export function SettingsModal({
}
}, [activeSection]);
useEffect(() => {
if (activeSection === "backups") {
setBackupLoading(true);
fetchBackups()
.then((info) => setBackupInfo(info))
.catch(() => setBackupInfo(null))
.finally(() => setBackupLoading(false));
}
}, [activeSection]);
useEffect(() => {
if (activeSection === "authentication") {
setAuthLoading(true);
@@ -206,6 +221,25 @@ export function SettingsModal({
}
}, [addToast, form.ntfyEnabled, form.ntfyTopic]);
const handleBackupNow = useCallback(async () => {
setBackupLoading(true);
try {
const result = await createBackup();
if (result.success) {
addToast("Backup created successfully", "success");
// Refresh backup list
const info = await fetchBackups();
setBackupInfo(info);
} else {
addToast(result.error || "Failed to create backup", "error");
}
} catch (err: any) {
addToast(err.message || "Failed to create backup", "error");
} finally {
setBackupLoading(false);
}
}, [addToast]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -970,6 +1004,133 @@ export function SettingsModal({
</div>
</>
);
case "backups":
return (
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Database Backups</h4>
<div className="form-group">
<label htmlFor="autoBackupEnabled" className="checkbox-label">
<input
id="autoBackupEnabled"
type="checkbox"
checked={form.autoBackupEnabled || false}
onChange={(e) =>
setForm((f) => ({ ...f, autoBackupEnabled: e.target.checked }))
}
/>
Enable automatic database backups
</label>
<small>When enabled, the database is backed up automatically on a schedule</small>
</div>
<div className="form-group">
<label htmlFor="autoBackupSchedule">Backup Schedule (Cron)</label>
<input
id="autoBackupSchedule"
type="text"
placeholder="0 2 * * *"
value={form.autoBackupSchedule || "0 2 * * *"}
onChange={(e) =>
setForm((f) => ({ ...f, autoBackupSchedule: e.target.value }))
}
disabled={!form.autoBackupEnabled}
/>
<small>
Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM).
Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min)
</small>
{form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && (
<small className="field-error">Invalid cron expression format</small>
)}
</div>
<div className="form-group">
<label htmlFor="autoBackupRetention">Retention Count</label>
<input
id="autoBackupRetention"
type="number"
min={1}
max={100}
value={form.autoBackupRetention || 7}
onChange={(e) =>
setForm((f) => ({ ...f, autoBackupRetention: Number(e.target.value) }))
}
disabled={!form.autoBackupEnabled}
/>
<small>Number of backup files to keep (oldest are deleted first). Range: 1-100.</small>
{form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100) && (
<small className="field-error">Must be between 1 and 100</small>
)}
</div>
<div className="form-group">
<label htmlFor="autoBackupDir">Backup Directory</label>
<input
id="autoBackupDir"
type="text"
placeholder=".kb/backups"
value={form.autoBackupDir || ".kb/backups"}
onChange={(e) =>
setForm((f) => ({ ...f, autoBackupDir: e.target.value }))
}
disabled={!form.autoBackupEnabled}
/>
<small>Directory for backup files, relative to project root</small>
{form.autoBackupDir && form.autoBackupDir.includes("..") && (
<small className="field-error">Path cannot contain parent directory traversal (..)</small>
)}
</div>
{backupLoading ? (
<div className="settings-empty-state">Loading backup info</div>
) : backupInfo ? (
<div className="form-group">
<label>Current Backups</label>
<div className="backup-stats">
<div className="backup-stat">
<span className="backup-stat-value">{backupInfo.count}</span>
<span className="backup-stat-label">backups</span>
</div>
<div className="backup-stat">
<span className="backup-stat-value">
{backupInfo.totalSize > 1024 * 1024
? `${(backupInfo.totalSize / (1024 * 1024)).toFixed(1)} MB`
: `${(backupInfo.totalSize / 1024).toFixed(1)} KB`}
</span>
<span className="backup-stat-label">total size</span>
</div>
</div>
{backupInfo.backups.length > 0 && (
<details className="backup-list">
<summary>View {backupInfo.backups.length} backup(s)</summary>
<ul>
{backupInfo.backups.slice(0, 10).map((backup) => (
<li key={backup.filename}>
<code>{backup.filename}</code>
<span className="backup-size">
{backup.size > 1024 * 1024
? `${(backup.size / (1024 * 1024)).toFixed(1)} MB`
: `${(backup.size / 1024).toFixed(1)} KB`}
</span>
</li>
))}
{backupInfo.backups.length > 10 && (
<li><em>...and {backupInfo.backups.length - 10} more</em></li>
)}
</ul>
</details>
)}
</div>
) : null}
<div className="form-group">
<button
type="button"
className="btn btn-sm"
onClick={handleBackupNow}
disabled={backupLoading}
>
{backupLoading ? "Creating…" : "Backup Now"}
</button>
</div>
</>
);
case "notifications":
return (
<>

View File

@@ -3,7 +3,7 @@ import multer from "multer";
import { createReadStream, existsSync } from "node:fs";
import { execSync } from "node:child_process";
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -1114,7 +1114,32 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets);
}
// Validate backup settings if provided
if (clientSettings.autoBackupSchedule !== undefined && !validateBackupSchedule(clientSettings.autoBackupSchedule)) {
res.status(400).json({ error: "Invalid cron expression for autoBackupSchedule" });
return;
}
if (clientSettings.autoBackupRetention !== undefined && !validateBackupRetention(clientSettings.autoBackupRetention)) {
res.status(400).json({ error: "autoBackupRetention must be between 1 and 100" });
return;
}
if (clientSettings.autoBackupDir !== undefined && !validateBackupDir(clientSettings.autoBackupDir)) {
res.status(400).json({ error: "autoBackupDir must be a relative path without '..' traversal" });
return;
}
const settings = await store.updateSettings(clientSettings);
// Sync backup automation schedule when backup settings change
if (options?.automationStore) {
try {
await syncBackupAutomation(options.automationStore, settings);
} catch (err) {
// Log but don't fail the settings update if automation sync fails
console.error("Failed to sync backup automation:", err);
}
}
res.json(settings);
} catch (err: any) {
const status = typeof err?.message === "string" && (
@@ -1216,6 +1241,60 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── Backup Routes ─────────────────────────────────────────────────
/**
* GET /api/backups
* List all database backups with metadata.
*/
router.get("/backups", async (_req, res) => {
try {
const { createBackupManager } = await import("@fusion/core");
const settings = await store.getSettings();
const manager = createBackupManager(store["kbDir"], settings);
const backups = await manager.listBackups();
// Calculate total size
const totalSize = backups.reduce((sum, b) => sum + b.size, 0);
res.json({
backups,
count: backups.length,
totalSize,
});
} catch (err: any) {
res.status(500).json({ error: err.message ?? "Failed to list backups" });
}
});
/**
* POST /api/backups
* Create a new database backup immediately.
*/
router.post("/backups", async (_req, res) => {
try {
const { runBackupCommand } = await import("@fusion/core");
const settings = await store.getSettings();
const result = await runBackupCommand(store["kbDir"], settings);
if (result.success) {
res.json({
success: true,
backupPath: result.backupPath,
output: result.output,
deletedCount: result.deletedCount,
});
} else {
res.status(500).json({
success: false,
error: result.output,
});
}
} catch (err: any) {
res.status(500).json({ error: err.message ?? "Failed to create backup" });
}
});
// Models
registerModelsRoute(router, options?.modelRegistry);