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 a4f80a743d
commit 1608efa7a2
11 changed files with 1395 additions and 3 deletions

View File

@@ -42,6 +42,7 @@ const { runDashboard } = await import("./commands/dashboard.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskPrCreate } = await import("./commands/task.js");
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
const HELP = `
fn — AI-orchestrated task board
@@ -83,6 +84,10 @@ Usage:
fn git push Push current branch
fn git pull Pull current branch
fn git fetch [remote] Fetch from remote (default: origin)
fn backup --create Create a database backup immediately
fn backup --list List all database backups
fn backup --restore <file> Restore database from a backup file
fn backup --cleanup Remove old backups exceeding retention limit
Options:
--port, -p <port> Dashboard port (default: 4040)
@@ -463,6 +468,28 @@ async function main() {
break;
}
case "backup": {
const create = args.includes("--create");
const list = args.includes("--list");
const cleanup = args.includes("--cleanup");
const restoreIdx = args.indexOf("--restore");
const restoreFile = restoreIdx !== -1 && restoreIdx + 1 < args.length ? args[restoreIdx + 1] : undefined;
if (create) {
await runBackupCreate();
} else if (list) {
await runBackupList();
} else if (cleanup) {
await runBackupCleanup();
} else if (restoreFile) {
await runBackupRestore(restoreFile);
} else {
console.error("Usage: fn backup --create | --list | --cleanup | --restore <filename>");
process.exit(1);
}
break;
}
default:
console.error(`Unknown command: ${command}`);
console.log(HELP);

View File

@@ -0,0 +1,126 @@
import {
BackupManager,
createBackupManager,
runBackupCommand,
TaskStore,
} from "@fusion/core";
/**
* Find the project root and create a backup manager.
*/
async function getBackupManager(): Promise<{
manager: BackupManager;
store: TaskStore;
kbDir: string;
}> {
const store = new TaskStore(process.cwd());
await store.init();
// Access the private kbDir property via type assertion
const kbDir = (store as unknown as { kbDir: string }).kbDir;
const settings = await store.getSettings();
const manager = createBackupManager(kbDir, settings);
return { manager, store, kbDir };
}
/**
* Create a database backup immediately.
* Usage: kb backup --create
*/
export async function runBackupCreate(): Promise<void> {
const { manager, kbDir, store } = await getBackupManager();
const settings = await store.getSettings();
console.log("Creating database backup...");
const result = await runBackupCommand(kbDir, settings);
if (result.success) {
console.log(result.output);
process.exit(0);
} else {
console.error(result.output);
process.exit(1);
}
}
/**
* List all database backups.
* Usage: kb backup --list
*/
export async function runBackupList(): Promise<void> {
const { manager } = await getBackupManager();
const backups = await manager.listBackups();
if (backups.length === 0) {
console.log("No backups found.");
return;
}
console.log(`Found ${backups.length} backup(s):\n`);
// Calculate total size
const totalSize = backups.reduce((sum, b) => sum + b.size, 0);
const formattedTotal = formatBytes(totalSize);
console.log("Date Size Filename");
console.log("-".repeat(60));
for (const backup of backups) {
const date = new Date(backup.createdAt).toLocaleString();
const size = formatBytes(backup.size).padEnd(8);
console.log(`${date} ${size} ${backup.filename}`);
}
console.log("-".repeat(60));
console.log(`Total: ${formattedTotal}`);
}
/**
* Restore database from a backup file.
* Usage: kb backup --restore <filename>
*/
export async function runBackupRestore(filename: string): Promise<void> {
const { manager } = await getBackupManager();
console.log(`Restoring backup: ${filename}`);
console.log("A pre-restore backup will be created first.\n");
try {
await manager.restoreBackup(filename, { createPreRestoreBackup: true });
console.log(`Successfully restored from ${filename}`);
console.log("Note: The pre-restore backup was saved in case you need to undo this operation.");
} catch (err) {
console.error(`Restore failed: ${(err as Error).message}`);
process.exit(1);
}
}
/**
* Remove old backups exceeding retention limit.
* Usage: kb backup --cleanup
*/
export async function runBackupCleanup(): Promise<void> {
const { manager } = await getBackupManager();
console.log("Cleaning up old backups...");
const deletedCount = await manager.cleanupOldBackups();
if (deletedCount > 0) {
console.log(`Removed ${deletedCount} old backup(s).`);
} else {
console.log("No backups to clean up (within retention limit).");
}
}
/**
* Format bytes as human-readable string.
*/
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}

View File

@@ -0,0 +1,433 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { rm, mkdir, writeFile, readdir } from "node:fs/promises";
import {
BackupManager,
createBackupManager,
generateBackupFilename,
validateBackupSchedule,
validateBackupRetention,
validateBackupDir,
runBackupCommand,
} from "./backup.js";
import type { ProjectSettings } from "./types.js";
// Helper to wait with a delay that ensures different timestamps
async function waitForNextSecond(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 1100));
}
describe("BackupManager", () => {
let tempDir: string;
let kbDir: string;
let backupManager: BackupManager;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
kbDir = join(tempDir, ".kb");
await mkdir(kbDir, { recursive: true });
// Create a dummy database file
writeFileSync(join(kbDir, "kb.db"), "dummy database content");
backupManager = new BackupManager(kbDir);
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("createBackup", () => {
it("should create a backup file with correct name pattern", async () => {
const backup = await backupManager.createBackup();
expect(backup.filename).toMatch(/^kb-\d{4}-\d{2}-\d{2}-\d{6}\.db$/);
expect(existsSync(backup.path)).toBe(true);
});
it("should copy database content correctly", async () => {
const backup = await backupManager.createBackup();
const originalContent = readFileSync(join(kbDir, "kb.db"), "utf-8");
const backupContent = readFileSync(backup.path, "utf-8");
expect(backupContent).toBe(originalContent);
});
it("should return correct backup info", async () => {
const backup = await backupManager.createBackup();
expect(backup.filename).toBeDefined();
expect(backup.createdAt).toBeDefined();
expect(backup.size).toBeGreaterThan(0);
expect(backup.path).toContain(backup.filename);
});
it("should create backup directory if it does not exist", async () => {
const customBackupDir = "custom-backups";
const manager = new BackupManager(kbDir, { backupDir: customBackupDir });
const customBackupPath = join(tempDir, customBackupDir);
expect(existsSync(customBackupPath)).toBe(false);
await manager.createBackup();
expect(existsSync(customBackupPath)).toBe(true);
});
});
describe("listBackups", () => {
it("should return empty array when no backups exist", async () => {
const backups = await backupManager.listBackups();
expect(backups).toEqual([]);
});
it("should return sorted array newest-first", async () => {
// Create multiple backups with delays to ensure different timestamps
await backupManager.createBackup();
await waitForNextSecond();
await backupManager.createBackup();
await waitForNextSecond();
await backupManager.createBackup();
const backups = await backupManager.listBackups();
expect(backups).toHaveLength(3);
// Verify sorted by createdAt descending
for (let i = 0; i < backups.length - 1; i++) {
expect(backups[i].createdAt >= backups[i + 1].createdAt).toBe(true);
}
});
it("should only list files matching backup pattern", async () => {
await backupManager.createBackup();
// Create some non-backup files
const backupDir = join(tempDir, ".kb/backups");
await writeFile(join(backupDir, "not-a-backup.txt"), "content");
await writeFile(join(backupDir, "random.db"), "content");
const backups = await backupManager.listBackups();
expect(backups).toHaveLength(1);
expect(backups[0].filename).toMatch(/^kb-\d{4}-\d{2}-\d{2}-\d{6}\.db$/);
});
it("should return correct file sizes", async () => {
const backup = await backupManager.createBackup();
const backups = await backupManager.listBackups();
expect(backups[0].size).toBe(backup.size);
});
});
describe("cleanupOldBackups", () => {
it("should not delete when backup count is within retention", async () => {
// Create 3 backups with retention of 7
for (let i = 0; i < 3; i++) {
await backupManager.createBackup();
await waitForNextSecond();
}
const deleted = await backupManager.cleanupOldBackups();
expect(deleted).toBe(0);
const backups = await backupManager.listBackups();
expect(backups).toHaveLength(3);
});
it("should delete oldest backups exceeding retention", async () => {
const manager = new BackupManager(kbDir, { retention: 2 });
// Create 4 backups with 1-second delays to ensure different timestamps
for (let i = 0; i < 4; i++) {
await manager.createBackup();
await waitForNextSecond();
}
const deleted = await manager.cleanupOldBackups();
expect(deleted).toBe(2); // 4 - 2 = 2 deleted
const backups = await manager.listBackups();
expect(backups).toHaveLength(2);
}, 10000);
it("should keep the newest backups after cleanup", async () => {
const manager = new BackupManager(kbDir, { retention: 2 });
// Create 4 backups and record their names
const backupNames: string[] = [];
for (let i = 0; i < 4; i++) {
const backup = await manager.createBackup();
backupNames.push(backup.filename);
await waitForNextSecond();
}
await manager.cleanupOldBackups();
const backups = await manager.listBackups();
const remainingNames = backups.map((b) => b.filename);
// Should keep the 2 newest (last 2 in the array)
expect(remainingNames).toContain(backupNames[2]);
expect(remainingNames).toContain(backupNames[3]);
expect(remainingNames).not.toContain(backupNames[0]);
expect(remainingNames).not.toContain(backupNames[1]);
});
});
describe("restoreBackup", () => {
it("should restore backup to main database location", async () => {
const backup = await backupManager.createBackup();
// Modify the original database
await writeFile(join(kbDir, "kb.db"), "modified content");
// Restore the backup
await backupManager.restoreBackup(backup.filename, { createPreRestoreBackup: false });
// Verify the restore
const restoredContent = readFileSync(join(kbDir, "kb.db"), "utf-8");
expect(restoredContent).toBe("dummy database content");
});
it("should throw when backup file does not exist", async () => {
await expect(
backupManager.restoreBackup("nonexistent-backup.db", { createPreRestoreBackup: false })
).rejects.toThrow("Backup file not found");
});
it("should create pre-restore backup by default", async () => {
const backup = await backupManager.createBackup();
// Wait to ensure different timestamp
await waitForNextSecond();
// Restore with default options (should create pre-restore backup)
await backupManager.restoreBackup(backup.filename);
// Check for pre-restore backup
const backups = await backupManager.listBackups();
const preRestoreBackup = backups.find((b) => b.filename.includes("pre-restore"));
expect(preRestoreBackup).toBeDefined();
});
});
});
describe("generateBackupFilename", () => {
it("should generate filename with correct pattern", () => {
const filename = generateBackupFilename();
expect(filename).toMatch(/^kb-\d{4}-\d{2}-\d{2}-\d{6}\.db$/);
});
it("should generate unique filenames for different timestamps", async () => {
const filename1 = generateBackupFilename();
await waitForNextSecond();
const filename2 = generateBackupFilename();
expect(filename1).not.toBe(filename2);
});
});
describe("validateBackupSchedule", () => {
it("should return true for valid cron expressions", () => {
expect(validateBackupSchedule("0 2 * * *")).toBe(true); // Daily at 2 AM
expect(validateBackupSchedule("0 * * * *")).toBe(true); // Hourly
expect(validateBackupSchedule("*/15 * * * *")).toBe(true); // Every 15 minutes
expect(validateBackupSchedule("0 0 * * 0")).toBe(true); // Weekly on Sunday
});
it("should return false for invalid cron expressions", () => {
expect(validateBackupSchedule("invalid")).toBe(false);
expect(validateBackupSchedule("")).toBe(false);
expect(validateBackupSchedule(" ")).toBe(false);
expect(validateBackupSchedule("* *")).toBe(false); // Too few fields
expect(validateBackupSchedule("99 99 99 99 99")).toBe(false); // Out of range
});
});
describe("validateBackupRetention", () => {
it("should return true for valid retention values", () => {
expect(validateBackupRetention(1)).toBe(true);
expect(validateBackupRetention(7)).toBe(true);
expect(validateBackupRetention(100)).toBe(true);
});
it("should return false for invalid retention values", () => {
expect(validateBackupRetention(0)).toBe(false);
expect(validateBackupRetention(-1)).toBe(false);
expect(validateBackupRetention(101)).toBe(false);
expect(validateBackupRetention(1.5)).toBe(false); // Not an integer
expect(validateBackupRetention(NaN)).toBe(false);
});
});
describe("validateBackupDir", () => {
it("should return true for valid relative paths", () => {
expect(validateBackupDir(".kb/backups")).toBe(true);
expect(validateBackupDir("backups")).toBe(true);
expect(validateBackupDir("data/backups/kb")).toBe(true);
});
it("should return false for absolute paths", () => {
expect(validateBackupDir("/absolute/path")).toBe(false);
expect(validateBackupDir("/home/user/backups")).toBe(false);
});
it("should return false for paths with parent traversal", () => {
expect(validateBackupDir("../backups")).toBe(false);
expect(validateBackupDir(".kb/../backups")).toBe(false);
expect(validateBackupDir("data/../../backups")).toBe(false);
});
it("should return false for Windows absolute paths", () => {
expect(validateBackupDir("C:\\backups")).toBe(false);
expect(validateBackupDir("D:\\data\\backups")).toBe(false);
});
});
describe("createBackupManager", () => {
it("should create manager with default options when no settings provided", () => {
const manager = createBackupManager("/tmp/.kb");
expect(manager).toBeInstanceOf(BackupManager);
});
it("should use settings when provided", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
const kbDir = join(tempDir, ".kb");
await mkdir(kbDir, { recursive: true });
writeFileSync(join(kbDir, "kb.db"), "test");
const settings: Partial<ProjectSettings> = {
autoBackupDir: "custom/backups",
autoBackupRetention: 2,
};
const manager = createBackupManager(kbDir, settings);
// Create 4 backups with 1-second delays
for (let i = 0; i < 4; i++) {
await manager.createBackup();
await waitForNextSecond();
}
// Cleanup should leave only 2
const deleted = await manager.cleanupOldBackups();
expect(deleted).toBe(2);
await rm(tempDir, { recursive: true, force: true });
}, 10000);
});
describe("runBackupCommand", () => {
let tempDir: string;
let kbDir: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
kbDir = join(tempDir, ".kb");
await mkdir(kbDir, { recursive: true });
writeFileSync(join(kbDir, "kb.db"), "dummy database content");
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it("should create backup regardless of autoBackupEnabled setting", async () => {
const settings: ProjectSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
autoBackupEnabled: false, // Disabled, but should still work when called manually
};
const result = await runBackupCommand(kbDir, settings);
// Should succeed even when autoBackupEnabled is false
expect(result.success).toBe(true);
expect(result.backupPath).toBeDefined();
});
it("should create backup when enabled", async () => {
const settings: ProjectSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
autoBackupEnabled: true,
autoBackupRetention: 7,
};
const result = await runBackupCommand(kbDir, settings);
expect(result.success).toBe(true);
expect(result.backupPath).toBeDefined();
expect(result.output).toContain("Backup created");
});
it("should return failure for invalid schedule", async () => {
const settings: ProjectSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
autoBackupEnabled: true,
autoBackupSchedule: "invalid-cron",
};
const result = await runBackupCommand(kbDir, settings);
expect(result.success).toBe(false);
expect(result.output).toContain("Invalid backup schedule");
});
it("should cleanup old backups after creation", async () => {
const settings: ProjectSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
autoBackupEnabled: true,
autoBackupRetention: 2,
};
// Create 3 backups first (manually to test cleanup) with delays
const manager = createBackupManager(kbDir, settings);
for (let i = 0; i < 3; i++) {
await manager.createBackup();
await waitForNextSecond();
}
// Now run backup command
const result = await runBackupCommand(kbDir, settings);
expect(result.success).toBe(true);
expect(result.deletedCount).toBeGreaterThanOrEqual(1);
});
it("should return failure when database file is missing", async () => {
// Remove the database
await rm(join(kbDir, "kb.db"));
const settings: ProjectSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
autoBackupEnabled: true,
};
const result = await runBackupCommand(kbDir, settings);
expect(result.success).toBe(false);
expect(result.output).toContain("failed");
});
});

423
packages/core/src/backup.ts Normal file
View File

@@ -0,0 +1,423 @@
import { cp, mkdir, readdir, stat, unlink } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { CronExpressionParser } from "cron-parser";
import type { ProjectSettings } from "./types.js";
/**
* Metadata for a database backup file.
*/
export interface BackupInfo {
/** Filename of the backup (e.g., "kb-2026-03-31-020000.db") */
filename: string;
/** ISO-8601 timestamp when the backup was created */
createdAt: string;
/** Size in bytes */
size: number;
/** Full absolute path to the backup file */
path: string;
}
/**
* Options for configuring the backup manager.
*/
export interface BackupOptions {
/** Directory for backup files, relative to the project root. Default: ".kb/backups" */
backupDir?: string;
/** Number of backups to retain. Default: 7 */
retention?: number;
}
/**
* Manages database backup operations including creation, listing,
* cleanup of old backups, and restoration.
*/
export class BackupManager {
private kbDir: string;
private backupDir: string;
private retention: number;
/**
* Creates a new BackupManager instance.
* @param kbDir - Absolute path to the .kb directory
* @param options - Backup configuration options
*/
constructor(kbDir: string, options?: BackupOptions) {
this.kbDir = kbDir;
this.backupDir = options?.backupDir ?? ".kb/backups";
this.retention = options?.retention ?? 7;
}
/**
* Gets the absolute path to the backup directory.
*/
private getBackupDirPath(): string {
// The backupDir is relative to project root, which is parent of kbDir
return join(this.kbDir, "..", this.backupDir);
}
/**
* Creates a timestamped backup of the database.
* @returns BackupInfo for the newly created backup
*/
async createBackup(): Promise<BackupInfo> {
const sourcePath = join(this.kbDir, "kb.db");
const backupDirPath = this.getBackupDirPath();
// Ensure backup directory exists
await mkdir(backupDirPath, { recursive: true });
// Generate unique filename (handle collisions with counter suffix)
let filename = generateBackupFilename();
let targetPath = join(backupDirPath, filename);
let counter = 1;
while (existsSync(targetPath)) {
const baseName = filename.replace(/\.db$/, "");
filename = `${baseName}-${counter}.db`;
targetPath = join(backupDirPath, filename);
counter++;
}
// Copy the database file
await cp(sourcePath, targetPath, { preserveTimestamps: true });
// Get file stats
const stats = await stat(targetPath);
return {
filename,
createdAt: new Date().toISOString(),
size: stats.size,
path: targetPath,
};
}
/**
* Lists all backup files sorted by creation time (newest first).
* @returns Array of BackupInfo objects
*/
async listBackups(): Promise<BackupInfo[]> {
const backupDirPath = this.getBackupDirPath();
try {
const files = await readdir(backupDirPath);
const backups: BackupInfo[] = [];
for (const filename of files) {
// Match both regular backups (kb-YYYY-MM-DD-HHmmss.db or kb-YYYY-MM-DD-HHmmss-N.db) and pre-restore backups
if (!filename.match(/^kb(-pre-restore)?-\d{4}-\d{2}-\d{2}-\d{6}(-\d+)?\.db$/)) {
continue;
}
const filePath = join(backupDirPath, filename);
const stats = await stat(filePath);
// Parse timestamp from filename: kb-YYYY-MM-DD-HHmmss.db or kb-pre-restore-YYYY-MM-DD-HHmmss.db
// Also handles counter suffix: kb-YYYY-MM-DD-HHmmss-N.db
const match = filename.match(/^(kb(?:-pre-restore)?)-(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})(?:-\d+)?\.db$/);
const createdAt = match
? `${match[2]}-${match[3]}-${match[4]}T${match[5]}:${match[6]}:${match[7]}Z`
: stats.mtime.toISOString();
backups.push({
filename,
createdAt,
size: stats.size,
path: filePath,
});
}
// Sort by createdAt descending (newest first), then by filename for deterministic ordering
return backups.sort((a, b) => {
const timeCompare = b.createdAt.localeCompare(a.createdAt);
if (timeCompare !== 0) return timeCompare;
return b.filename.localeCompare(a.filename);
});
} catch {
// Directory doesn't exist or can't be read - return empty array
return [];
}
}
/**
* Removes old backups to maintain the retention limit.
* Only removes regular backups, not pre-restore backups.
* @returns Number of backups deleted
*/
async cleanupOldBackups(): Promise<number> {
const backups = await this.listBackups();
// Filter to only regular backups (not pre-restore)
const regularBackups = backups.filter(b => !b.filename.includes("pre-restore"));
if (regularBackups.length <= this.retention) {
return 0;
}
// Sort ascending (oldest first) for deletion, using filename as secondary sort for determinism
const sorted = [...regularBackups].sort((a, b) => {
const timeCompare = a.createdAt.localeCompare(b.createdAt);
if (timeCompare !== 0) return timeCompare;
return a.filename.localeCompare(b.filename);
});
const toDelete = sorted.slice(0, sorted.length - this.retention);
let deletedCount = 0;
for (const backup of toDelete) {
try {
await unlink(backup.path);
deletedCount++;
} catch {
// Ignore deletion errors
}
}
return deletedCount;
}
/**
* Restores a backup to become the main database.
* Optionally creates a pre-restore backup of the current database.
* @param filename - Name of the backup file to restore
* @param options - Restore options
*/
async restoreBackup(
filename: string,
options?: { createPreRestoreBackup?: boolean }
): Promise<void> {
const backupDirPath = this.getBackupDirPath();
const sourcePath = join(backupDirPath, filename);
const targetPath = join(this.kbDir, "kb.db");
// Verify source exists
try {
await stat(sourcePath);
} catch {
throw new Error(`Backup file not found: ${filename}`);
}
// Optionally create pre-restore backup
if (options?.createPreRestoreBackup ?? true) {
const preRestoreFilename = `kb-pre-restore-${formatTimestamp(new Date())}.db`;
const preRestorePath = join(backupDirPath, preRestoreFilename);
await mkdir(backupDirPath, { recursive: true });
await cp(targetPath, preRestorePath, { preserveTimestamps: true });
}
// Restore the backup
await cp(sourcePath, targetPath, { preserveTimestamps: true });
}
}
/**
* Generates a backup filename with timestamp.
* Format: kb-YYYY-MM-DD-HHmmss.db
*/
export function generateBackupFilename(): string {
return `kb-${formatTimestamp(new Date())}.db`;
}
/**
* Formats a date as YYYY-MM-DD-HHmmss in UTC.
*/
function formatTimestamp(date: Date): string {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
const hours = String(date.getUTCHours()).padStart(2, "0");
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
const seconds = String(date.getUTCSeconds()).padStart(2, "0");
return `${year}-${month}-${day}-${hours}${minutes}${seconds}`;
}
/**
* Validates a cron expression for backup scheduling.
* @param schedule - Cron expression to validate
* @returns True if valid, false otherwise
*/
export function validateBackupSchedule(schedule: string): boolean {
if (!schedule || schedule.trim() === "") {
return false;
}
try {
CronExpressionParser.parse(schedule);
return true;
} catch {
return false;
}
}
/**
* Validates the backup retention count.
* @param retention - Number of backups to retain
* @returns True if valid (1-100), false otherwise
*/
export function validateBackupRetention(retention: number): boolean {
return Number.isInteger(retention) && retention >= 1 && retention <= 100;
}
/**
* Validates the backup directory path.
* Must be relative and not contain parent directory traversal.
* @param dir - Directory path to validate
* @returns True if valid, false otherwise
*/
export function validateBackupDir(dir: string): boolean {
// Must be relative (not start with / or \)
if (dir.startsWith("/") || dir.startsWith("\\")) {
return false;
}
// Must not contain parent directory traversal
if (dir.includes("..")) {
return false;
}
// Must not be absolute path with drive letter (Windows)
if (/^[a-zA-Z]:/.test(dir)) {
return false;
}
return true;
}
/**
* Factory function to create a BackupManager with project settings.
* @param kbDir - Absolute path to the .kb directory
* @param settings - Project settings containing backup configuration
* @returns Configured BackupManager instance
*/
export function createBackupManager(
kbDir: string,
settings?: Partial<ProjectSettings>
): BackupManager {
return new BackupManager(kbDir, {
backupDir: settings?.autoBackupDir,
retention: settings?.autoBackupRetention,
});
}
/**
* Runs the backup command with settings from the project.
* This is the main entry point for scheduled backup automation.
*
* NOTE: This function does NOT check autoBackupEnabled - that check should happen
* at the automation/scheduler level. This allows manual backups via CLI even when
* auto-backup is disabled.
*
* @param kbDir - Absolute path to the .kb directory
* @param settings - Project settings
* @returns Result of the backup operation
*/
export async function runBackupCommand(
kbDir: string,
settings: ProjectSettings
): Promise<{ success: boolean; output: string; backupPath?: string; deletedCount?: number }> {
// Validate schedule if provided (for logging purposes)
if (settings.autoBackupSchedule && !validateBackupSchedule(settings.autoBackupSchedule)) {
return {
success: false,
output: `Invalid backup schedule: ${settings.autoBackupSchedule}`,
};
}
// Create backup manager with settings
const manager = createBackupManager(kbDir, settings);
try {
// Create the backup
const backup = await manager.createBackup();
// Cleanup old backups
const deletedCount = await manager.cleanupOldBackups();
const output = deletedCount > 0
? `Backup created: ${backup.filename} (${formatBytes(backup.size)}). Removed ${deletedCount} old backup(s).`
: `Backup created: ${backup.filename} (${formatBytes(backup.size)})`;
return {
success: true,
output,
backupPath: backup.path,
deletedCount,
};
} catch (err) {
return {
success: false,
output: `Backup failed: ${(err as Error).message}`,
};
}
}
/**
* Formats bytes as human-readable string.
*/
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
/**
* Constant name for the backup automation schedule.
* Used to identify and manage the backup schedule in the automation store.
*/
export const BACKUP_SCHEDULE_NAME = "Database Backup";
/**
* Synchronizes the backup automation schedule with project settings.
* Creates, updates, or deletes the backup schedule based on settings.
*
* @param automationStore - The AutomationStore instance
* @param settings - Current project settings
* @returns The created/updated schedule, or undefined if deleted/disabled
*/
export async function syncBackupAutomation(
automationStore: import("./automation-store.js").AutomationStore,
settings: ProjectSettings
): Promise<import("./automation.js").ScheduledTask | undefined> {
const { AutomationStore } = await import("./automation-store.js");
// Find existing backup schedule by name
const schedules = await automationStore.listSchedules();
const existingSchedule = schedules.find(s => s.name === BACKUP_SCHEDULE_NAME);
// If backups are disabled, delete existing schedule if present
if (!settings.autoBackupEnabled) {
if (existingSchedule) {
await automationStore.deleteSchedule(existingSchedule.id);
}
return undefined;
}
// Validate the cron schedule
const schedule = settings.autoBackupSchedule || "0 2 * * *";
if (!AutomationStore.isValidCron(schedule)) {
throw new Error(`Invalid backup schedule: ${schedule}`);
}
// Build the backup command
// The CLI command will be: kb backup --auto
// The --auto flag indicates this is an automated run (can add special handling if needed)
const command = "kb backup --create";
if (existingSchedule) {
// Update existing schedule
return await automationStore.updateSchedule(existingSchedule.id, {
scheduleType: "custom",
cronExpression: schedule,
command,
enabled: true,
});
} else {
// Create new schedule
return await automationStore.createSchedule({
name: BACKUP_SCHEDULE_NAME,
description: "Automatic database backup based on project settings",
scheduleType: "custom",
cronExpression: schedule,
command,
enabled: true,
});
}
}

View File

@@ -25,3 +25,15 @@ export { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult } from "./automation.js";
export { AutomationStore } from "./automation-store.js";
export type { AutomationStoreEvents } from "./automation-store.js";
export {
BackupManager,
createBackupManager,
generateBackupFilename,
validateBackupSchedule,
validateBackupRetention,
validateBackupDir,
runBackupCommand,
syncBackupAutomation,
BACKUP_SCHEDULE_NAME,
} from "./backup.js";
export type { BackupInfo, BackupOptions } from "./backup.js";

View File

@@ -633,6 +633,14 @@ export interface ProjectSettings {
/** When true, automatically create GitHub PRs for completed tasks.
* Default: false. */
autoCreatePr?: boolean;
/** When true, automatic database backups are enabled. Default: false. */
autoBackupEnabled?: boolean;
/** Cron expression for backup schedule. Default: "0 2 * * *" (daily at 2 AM). */
autoBackupSchedule?: string;
/** Number of backup files to retain (oldest deleted when exceeded). Default: 7. */
autoBackupRetention?: number;
/** Directory for backup files, relative to project root. Default: ".kb/backups". */
autoBackupDir?: string;
}
/**
@@ -689,6 +697,10 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
taskStuckTimeoutMs: undefined,
autoUpdatePrStatus: false,
autoCreatePr: false,
autoBackupEnabled: false,
autoBackupSchedule: "0 2 * * *",
autoBackupRetention: 7,
autoBackupDir: ".kb/backups",
};
/**
@@ -742,6 +754,10 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
"taskStuckTimeoutMs",
"autoUpdatePrStatus",
"autoCreatePr",
"autoBackupEnabled",
"autoBackupSchedule",
"autoBackupRetention",
"autoBackupDir",
] as const;
export interface BoardConfig {

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);