feat(FN-3891): document memory backup settings in settings reference
Documents the memory backup feature in the settings reference and adds a changeset to prepare for publishing. Fusion-Task-Id: FN-3891
This commit is contained in:
5
.changeset/FN-3891-memory-backup.md
Normal file
5
.changeset/FN-3891-memory-backup.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add scheduled memory backup feature: project memory (.fusion/memory) and per-agent memory (.fusion/agent-memory) are now snapshotted on a configurable cron schedule with retention pruning. New `fn memory-backup` CLI command and Settings → Backups UI controls.
|
||||
@@ -239,6 +239,11 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
||||
| `autoBackupSchedule` | `string` | `"0 2 * * *"` | Backup cron schedule. |
|
||||
| `autoBackupRetention` | `number` | `7` | Number of backups to retain. |
|
||||
| `autoBackupDir` | `string` | `".fusion/backups"` | Relative backup directory path. |
|
||||
| `memoryBackupEnabled` | `boolean` | `false` | Enable scheduled memory backups. |
|
||||
| `memoryBackupSchedule` | `string` | `"0 3 * * *"` | Memory backup cron schedule. |
|
||||
| `memoryBackupRetention` | `number` | `14` | Number of memory backups to retain. |
|
||||
| `memoryBackupDir` | `string` | `".fusion/backups/memory"` | Relative memory backup directory path. |
|
||||
| `memoryBackupScope` | `"project" \| "agents" \| "all"` | `"all"` | Backup scope: project memory, agent memory, or both. |
|
||||
| `autoSummarizeTitles` | `boolean` | `false` | Auto-generate titles for long untitled descriptions across dashboard/API task creation and agent/tool-created tasks. |
|
||||
| `useAiMergeCommitSummary` | `boolean` | `false` | Use AI-generated merge commit summaries instead of raw step-commit subject lists. |
|
||||
| `titleSummarizerProvider` | `string` | `undefined` | Provider for title summarization. |
|
||||
@@ -982,6 +987,25 @@ Standard cron format: `minute hour day-of-month month day-of-week`
|
||||
| `0 */6 * * *` | Every 6 hours |
|
||||
| `0 9 * * 1` | Weekly on Monday at 9:00 AM |
|
||||
|
||||
### Memory Backups
|
||||
|
||||
Memory backups snapshot memory files into timestamped directories under `memoryBackupDir` (default: `.fusion/backups/memory`).
|
||||
|
||||
- Project memory source: `.fusion/memory/**`
|
||||
- Agent memory source: `.fusion/agent-memory/**`
|
||||
- Snapshot layout:
|
||||
- `memory-YYYY-MM-DD-HHMMSS/project/...`
|
||||
- `memory-YYYY-MM-DD-HHMMSS/agents/<agentId>/...`
|
||||
|
||||
CLI commands:
|
||||
|
||||
- `fn memory-backup --create` — Create a memory backup now.
|
||||
- `fn memory-backup --create --scope <project|agents|all>` — Override scope for this run.
|
||||
- `fn memory-backup --list` — List memory backup snapshots.
|
||||
- `fn memory-backup --restore <filename>` — Restore from a snapshot directory.
|
||||
|
||||
The default schedule is `0 3 * * *` (daily at 3:00 AM), offset from database backups (`0 2 * * *`).
|
||||
|
||||
### Scheduling Scope
|
||||
|
||||
Fusion supports scoped automations and routines:
|
||||
|
||||
@@ -124,6 +124,7 @@ async function loadCommandHandlers() {
|
||||
const { runSettingsImport } = await import("./commands/settings-import.js");
|
||||
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
|
||||
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
|
||||
const { runMemoryBackupCreate, runMemoryBackupList, runMemoryBackupRestore } = await import("./commands/memory-backup.js");
|
||||
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
|
||||
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
|
||||
const { runNodeList, runNodeConnect, runNodeDisconnect, runNodeShow, runNodeHealth, runMeshStatus } = await import("./commands/node.js");
|
||||
@@ -180,6 +181,9 @@ async function loadCommandHandlers() {
|
||||
runBackupList,
|
||||
runBackupRestore,
|
||||
runBackupCleanup,
|
||||
runMemoryBackupCreate,
|
||||
runMemoryBackupList,
|
||||
runMemoryBackupRestore,
|
||||
runMissionCreate,
|
||||
runMissionList,
|
||||
runMissionShow,
|
||||
@@ -339,6 +343,11 @@ Usage:
|
||||
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
|
||||
fn memory-backup --create [--scope <project|agents|all>]
|
||||
Create a memory backup immediately
|
||||
fn memory-backup --list List all memory backups
|
||||
fn memory-backup --restore <dir>
|
||||
Restore memory from a backup directory snapshot
|
||||
fn plugin list | ls List installed plugins
|
||||
fn plugin install <path-or-package> [--ai-scan] Install a plugin from path or package
|
||||
fn plugin add <path-or-package> Alias for plugin install
|
||||
@@ -531,6 +540,9 @@ async function main() {
|
||||
runBackupList,
|
||||
runBackupRestore,
|
||||
runBackupCleanup,
|
||||
runMemoryBackupCreate,
|
||||
runMemoryBackupList,
|
||||
runMemoryBackupRestore,
|
||||
runMissionCreate,
|
||||
runMissionList,
|
||||
runMissionShow,
|
||||
@@ -1332,6 +1344,31 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case "memory-backup": {
|
||||
const create = args.includes("--create");
|
||||
const list = args.includes("--list");
|
||||
const restoreIdx = args.indexOf("--restore");
|
||||
const restoreFile = restoreIdx !== -1 && restoreIdx + 1 < args.length ? args[restoreIdx + 1] : undefined;
|
||||
const scopeIdx = args.indexOf("--scope");
|
||||
const scope = scopeIdx !== -1 && scopeIdx + 1 < args.length ? args[scopeIdx + 1] : undefined;
|
||||
|
||||
if (create) {
|
||||
if (scope && !["project", "agents", "all"].includes(scope)) {
|
||||
console.error("Usage: fn memory-backup --create [--scope <project|agents|all>]");
|
||||
process.exit(1);
|
||||
}
|
||||
await runMemoryBackupCreate({ projectName, scope: scope as "project" | "agents" | "all" | undefined });
|
||||
} else if (list) {
|
||||
await runMemoryBackupList(projectName);
|
||||
} else if (restoreFile) {
|
||||
await runMemoryBackupRestore(restoreFile, projectName);
|
||||
} else {
|
||||
console.error("Usage: fn memory-backup --create [--scope <project|agents|all>] | --list | --restore <filename>");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "agent": {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
|
||||
90
packages/cli/src/commands/__tests__/memory-backup.test.ts
Normal file
90
packages/cli/src/commands/__tests__/memory-backup.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const {
|
||||
mockListBackups,
|
||||
mockRestoreBackup,
|
||||
mockGetSettings,
|
||||
mockRunMemoryBackupCommand,
|
||||
mockResolveProject,
|
||||
} = vi.hoisted(() => ({
|
||||
mockListBackups: vi.fn(),
|
||||
mockRestoreBackup: vi.fn(),
|
||||
mockGetSettings: vi.fn(),
|
||||
mockRunMemoryBackupCommand: vi.fn(),
|
||||
mockResolveProject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
getSettings: mockGetSettings,
|
||||
fusionDir: "/cwd/.fusion",
|
||||
})),
|
||||
createMemoryBackupManager: vi.fn(() => ({
|
||||
listBackups: mockListBackups,
|
||||
restoreBackup: mockRestoreBackup,
|
||||
})),
|
||||
runMemoryBackupCommand: mockRunMemoryBackupCommand,
|
||||
}));
|
||||
|
||||
vi.mock("../../project-context.js", () => ({
|
||||
resolveProject: mockResolveProject,
|
||||
}));
|
||||
|
||||
import { runMemoryBackupCreate, runMemoryBackupList, runMemoryBackupRestore } from "../memory-backup.js";
|
||||
|
||||
describe("memory-backup commands", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
});
|
||||
|
||||
mockGetSettings.mockResolvedValue({ memoryBackupSchedule: "0 3 * * *" });
|
||||
mockRunMemoryBackupCommand.mockResolvedValue({ success: true, output: "memory backup created" });
|
||||
mockListBackups.mockResolvedValue([]);
|
||||
mockRestoreBackup.mockResolvedValue(undefined);
|
||||
mockResolveProject.mockResolvedValue({
|
||||
store: { getSettings: mockGetSettings, fusionDir: "/projects/demo/.fusion" },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("create path succeeds", async () => {
|
||||
await expect(runMemoryBackupCreate({ projectName: "demo", scope: "agents" })).rejects.toThrow("process.exit:0");
|
||||
expect(mockRunMemoryBackupCommand).toHaveBeenCalledWith(
|
||||
"/projects/demo/.fusion",
|
||||
expect.objectContaining({ memoryBackupScope: "agents" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("list path renders entries", async () => {
|
||||
mockListBackups.mockResolvedValue([
|
||||
{ filename: "memory-2026-01-01-000000", createdAt: new Date().toISOString(), size: 100, scope: "all", entryCount: 3 },
|
||||
]);
|
||||
await runMemoryBackupList("demo");
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Found 1 memory backup"));
|
||||
});
|
||||
|
||||
it("restore path calls manager", async () => {
|
||||
await runMemoryBackupRestore("memory-2026-01-01-000000", "demo");
|
||||
expect(mockRestoreBackup).toHaveBeenCalledWith("memory-2026-01-01-000000", { overwrite: true });
|
||||
});
|
||||
|
||||
it("create path fails on invalid schedule", async () => {
|
||||
mockRunMemoryBackupCommand.mockResolvedValue({ success: false, output: "Invalid memory backup schedule: bad" });
|
||||
await expect(runMemoryBackupCreate({ projectName: "demo" })).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Invalid memory backup schedule: bad");
|
||||
});
|
||||
});
|
||||
96
packages/cli/src/commands/memory-backup.ts
Normal file
96
packages/cli/src/commands/memory-backup.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
createMemoryBackupManager,
|
||||
runMemoryBackupCommand,
|
||||
TaskStore,
|
||||
type ProjectSettings,
|
||||
} from "@fusion/core";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
|
||||
type MemoryBackupScope = "project" | "agents" | "all";
|
||||
|
||||
async function resolveBackupStore(projectName?: string): Promise<TaskStore> {
|
||||
try {
|
||||
return (await resolveProject(projectName)).store;
|
||||
} catch {
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
}
|
||||
|
||||
async function getMemoryBackupContext(projectName?: string): Promise<{
|
||||
store: TaskStore;
|
||||
fusionDir: string;
|
||||
settings: ProjectSettings;
|
||||
}> {
|
||||
const store = await resolveBackupStore(projectName);
|
||||
const fusionDir = (store as unknown as { fusionDir: string }).fusionDir;
|
||||
const settings = await store.getSettings();
|
||||
return { store, fusionDir, settings };
|
||||
}
|
||||
|
||||
export async function runMemoryBackupCreate(options?: { projectName?: string; scope?: MemoryBackupScope }): Promise<void> {
|
||||
const { fusionDir, settings } = await getMemoryBackupContext(options?.projectName);
|
||||
const effectiveSettings = options?.scope ? { ...settings, memoryBackupScope: options.scope } : settings;
|
||||
|
||||
console.log("Creating memory backup...");
|
||||
const result = await runMemoryBackupCommand(fusionDir, effectiveSettings);
|
||||
if (result.success) {
|
||||
console.log(result.output);
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(result.output);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export async function runMemoryBackupList(projectName?: string): Promise<void> {
|
||||
const { fusionDir, settings } = await getMemoryBackupContext(projectName);
|
||||
const manager = createMemoryBackupManager(fusionDir, settings);
|
||||
const backups = await manager.listBackups();
|
||||
|
||||
if (backups.length === 0) {
|
||||
console.log("No memory backups found.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${backups.length} memory backup(s):\n`);
|
||||
console.log("Date Scope Entries Size Filename");
|
||||
console.log("-".repeat(80));
|
||||
|
||||
let totalSize = 0;
|
||||
for (const backup of backups) {
|
||||
totalSize += backup.size;
|
||||
const date = new Date(backup.createdAt).toLocaleString();
|
||||
const scope = backup.scope.padEnd(7);
|
||||
const entries = String(backup.entryCount).padEnd(7);
|
||||
const size = formatBytes(backup.size).padEnd(9);
|
||||
console.log(`${date} ${scope} ${entries} ${size} ${backup.filename}`);
|
||||
}
|
||||
|
||||
console.log("-".repeat(80));
|
||||
console.log(`Total: ${formatBytes(totalSize)}`);
|
||||
}
|
||||
|
||||
export async function runMemoryBackupRestore(filename: string, projectName?: string): Promise<void> {
|
||||
const { fusionDir, settings } = await getMemoryBackupContext(projectName);
|
||||
const manager = createMemoryBackupManager(fusionDir, settings);
|
||||
|
||||
console.log(`Restoring memory backup: ${filename}`);
|
||||
console.log("This may overwrite project and/or agent memory files.\n");
|
||||
|
||||
try {
|
||||
await manager.restoreBackup(filename, { overwrite: true });
|
||||
console.log(`Successfully restored memory from ${filename}`);
|
||||
} catch (err) {
|
||||
console.error(`Memory restore failed: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
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]}`;
|
||||
}
|
||||
201
packages/core/src/__tests__/memory-backup.test.ts
Normal file
201
packages/core/src/__tests__/memory-backup.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
MemoryBackupManager,
|
||||
createMemoryBackupManager,
|
||||
runMemoryBackupCommand,
|
||||
syncMemoryBackupRoutine,
|
||||
validateMemoryBackupSchedule,
|
||||
} from "../memory-backup.js";
|
||||
import { RoutineStore } from "../routine-store.js";
|
||||
import type { ProjectSettings } from "../types.js";
|
||||
|
||||
describe("MemoryBackupManager", () => {
|
||||
let tempDir: string;
|
||||
let fusionDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-backup-test-"));
|
||||
fusionDir = join(tempDir, ".fusion");
|
||||
await mkdir(join(tempDir, ".fusion/memory"), { recursive: true });
|
||||
await mkdir(join(tempDir, ".fusion/agent-memory/agent-1"), { recursive: true });
|
||||
await writeFile(join(tempDir, ".fusion/memory/MEMORY.md"), "project memory");
|
||||
await writeFile(join(tempDir, ".fusion/agent-memory/agent-1/MEMORY.md"), "agent memory");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates and lists backups newest-first", async () => {
|
||||
const manager = new MemoryBackupManager(fusionDir);
|
||||
const b1 = await manager.createBackup();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z"));
|
||||
const b2 = await manager.createBackup();
|
||||
|
||||
const backups = await manager.listBackups();
|
||||
expect(backups).toHaveLength(2);
|
||||
expect(backups[0].filename).toBe(b2.filename);
|
||||
expect(backups[1].filename).toBe(b1.filename);
|
||||
expect(backups[0].entryCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("prunes old backups by retention", async () => {
|
||||
const manager = new MemoryBackupManager(fusionDir, { retention: 2 });
|
||||
for (let i = 0; i < 4; i++) {
|
||||
vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`));
|
||||
await manager.createBackup();
|
||||
}
|
||||
const deleted = await manager.cleanupOldBackups();
|
||||
expect(deleted).toBe(2);
|
||||
expect((await manager.listBackups()).length).toBe(2);
|
||||
});
|
||||
|
||||
it("supports scope filters", async () => {
|
||||
const projectOnly = new MemoryBackupManager(fusionDir, { scope: "project" });
|
||||
const p = await projectOnly.createBackup();
|
||||
expect(existsSync(join(p.path, "project"))).toBe(true);
|
||||
expect(existsSync(join(p.path, "agents"))).toBe(false);
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z"));
|
||||
const agentsOnly = new MemoryBackupManager(fusionDir, { scope: "agents" });
|
||||
const a = await agentsOnly.createBackup();
|
||||
expect(existsSync(join(a.path, "project"))).toBe(false);
|
||||
expect(existsSync(join(a.path, "agents"))).toBe(true);
|
||||
});
|
||||
|
||||
it("restores with overwrite and non-overwrite guards", async () => {
|
||||
const manager = new MemoryBackupManager(fusionDir);
|
||||
const backup = await manager.createBackup();
|
||||
|
||||
await writeFile(join(tempDir, ".fusion/memory/MEMORY.md"), "changed");
|
||||
await expect(manager.restoreBackup(backup.filename)).rejects.toThrow("Restore would overwrite modified memory file");
|
||||
|
||||
await manager.restoreBackup(backup.filename, { overwrite: true });
|
||||
expect(readFileSync(join(tempDir, ".fusion/memory/MEMORY.md"), "utf-8")).toBe("project memory");
|
||||
});
|
||||
|
||||
it("throws when both sources are missing", async () => {
|
||||
await rm(join(tempDir, ".fusion/memory"), { recursive: true, force: true });
|
||||
await rm(join(tempDir, ".fusion/agent-memory"), { recursive: true, force: true });
|
||||
const manager = new MemoryBackupManager(fusionDir);
|
||||
await expect(manager.createBackup()).rejects.toThrow("No memory sources found");
|
||||
});
|
||||
|
||||
it("handles filename collisions with counter suffix", async () => {
|
||||
const manager = new MemoryBackupManager(fusionDir);
|
||||
const b1 = await manager.createBackup();
|
||||
const b2 = await manager.createBackup();
|
||||
expect(b1.filename).toMatch(/^memory-\d{4}-\d{2}-\d{2}-\d{6}$/);
|
||||
expect(b2.filename).toMatch(/^memory-\d{4}-\d{2}-\d{2}-\d{6}-1$/);
|
||||
});
|
||||
|
||||
it("cleans up staging dir when create fails", async () => {
|
||||
await writeFile(join(tempDir, ".fusion/invalid-memory-backups"), "not-a-directory");
|
||||
const manager = new MemoryBackupManager(fusionDir, { backupDir: ".fusion/invalid-memory-backups" });
|
||||
|
||||
await expect(manager.createBackup()).rejects.toThrow();
|
||||
|
||||
expect(existsSync(join(tempDir, ".fusion/invalid-memory-backups/memory-2026-01-01-000000.tmp"))).toBe(false);
|
||||
});
|
||||
|
||||
it("validates schedule", () => {
|
||||
expect(validateMemoryBackupSchedule("0 3 * * *")).toBe(true);
|
||||
expect(validateMemoryBackupSchedule("not a cron")).toBe(false);
|
||||
});
|
||||
|
||||
it("runs memory backup command", async () => {
|
||||
const result = await runMemoryBackupCommand(fusionDir, {
|
||||
memoryBackupSchedule: "0 3 * * *",
|
||||
memoryBackupRetention: 14,
|
||||
memoryBackupScope: "all",
|
||||
} as ProjectSettings);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.backupPath).toContain("memory-");
|
||||
});
|
||||
|
||||
it("sanitizes canonical backup dir settings", async () => {
|
||||
const manager = createMemoryBackupManager(fusionDir, { memoryBackupDir: ".kb/backups/memory" });
|
||||
const backup = await manager.createBackup();
|
||||
expect(backup.path).toContain(".fusion/backups/memory");
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncMemoryBackupRoutine", () => {
|
||||
let tempDir: string;
|
||||
let routineStore: RoutineStore;
|
||||
|
||||
const baseSettings: ProjectSettings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-routine-test-"));
|
||||
routineStore = new RoutineStore(tempDir, { inMemoryDb: true });
|
||||
await routineStore.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates routine when enabled", async () => {
|
||||
const routine = await syncMemoryBackupRoutine(routineStore, {
|
||||
...baseSettings,
|
||||
memoryBackupEnabled: true,
|
||||
memoryBackupSchedule: "0 3 * * *",
|
||||
});
|
||||
expect(routine?.command).toBe("fn memory-backup --create");
|
||||
});
|
||||
|
||||
it("updates existing routine", async () => {
|
||||
const created = await syncMemoryBackupRoutine(routineStore, {
|
||||
...baseSettings,
|
||||
memoryBackupEnabled: true,
|
||||
memoryBackupSchedule: "0 3 * * *",
|
||||
});
|
||||
|
||||
const updated = await syncMemoryBackupRoutine(routineStore, {
|
||||
...baseSettings,
|
||||
memoryBackupEnabled: true,
|
||||
memoryBackupSchedule: "0 4 * * *",
|
||||
});
|
||||
|
||||
expect(updated?.id).toBe(created?.id);
|
||||
expect(updated?.trigger.type).toBe("cron");
|
||||
});
|
||||
|
||||
it("deletes routine when disabled", async () => {
|
||||
await syncMemoryBackupRoutine(routineStore, {
|
||||
...baseSettings,
|
||||
memoryBackupEnabled: true,
|
||||
memoryBackupSchedule: "0 3 * * *",
|
||||
});
|
||||
|
||||
const out = await syncMemoryBackupRoutine(routineStore, {
|
||||
...baseSettings,
|
||||
memoryBackupEnabled: false,
|
||||
});
|
||||
|
||||
expect(out).toBeUndefined();
|
||||
expect((await routineStore.listRoutines()).length).toBe(0);
|
||||
});
|
||||
|
||||
it("throws for invalid cron", async () => {
|
||||
await expect(syncMemoryBackupRoutine(routineStore, {
|
||||
...baseSettings,
|
||||
memoryBackupEnabled: true,
|
||||
memoryBackupSchedule: "bad cron",
|
||||
})).rejects.toThrow("Invalid backup schedule");
|
||||
});
|
||||
});
|
||||
@@ -241,6 +241,16 @@ export {
|
||||
BACKUP_SCHEDULE_NAME,
|
||||
} from "./backup.js";
|
||||
export type { BackupInfo, BackupOptions } from "./backup.js";
|
||||
export {
|
||||
MemoryBackupManager,
|
||||
createMemoryBackupManager,
|
||||
runMemoryBackupCommand,
|
||||
validateMemoryBackupSchedule,
|
||||
MEMORY_BACKUP_SCHEDULE_NAME,
|
||||
syncMemoryBackupAutomation,
|
||||
syncMemoryBackupRoutine,
|
||||
} from "./memory-backup.js";
|
||||
export type { MemoryBackupInfo, MemoryBackupOptions } from "./memory-backup.js";
|
||||
export {
|
||||
exportSettings,
|
||||
importSettings,
|
||||
|
||||
393
packages/core/src/memory-backup.ts
Normal file
393
packages/core/src/memory-backup.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve, sep } from "node:path";
|
||||
import type { ProjectSettings } from "./types.js";
|
||||
import { MEMORY_WORKSPACE_PATH } from "./memory-backend.js";
|
||||
import { validateBackupSchedule } from "./backup.js";
|
||||
|
||||
export const MEMORY_BACKUP_SCHEDULE_NAME = "Memory Backup";
|
||||
const AGENT_MEMORY_ROOT = ".fusion/agent-memory";
|
||||
|
||||
type MemoryBackupScope = "project" | "agents" | "all";
|
||||
|
||||
export interface MemoryBackupInfo {
|
||||
filename: string;
|
||||
createdAt: string;
|
||||
size: number;
|
||||
path: string;
|
||||
scope: MemoryBackupScope;
|
||||
entryCount: number;
|
||||
}
|
||||
|
||||
export interface MemoryBackupOptions {
|
||||
backupDir?: string;
|
||||
retention?: number;
|
||||
scope?: MemoryBackupScope;
|
||||
}
|
||||
|
||||
export class MemoryBackupManager {
|
||||
private fusionDir: string;
|
||||
private backupDir: string;
|
||||
private retention: number;
|
||||
private scope: MemoryBackupScope;
|
||||
|
||||
constructor(fusionDir: string, options?: MemoryBackupOptions) {
|
||||
this.fusionDir = fusionDir;
|
||||
this.backupDir = options?.backupDir ?? ".fusion/backups/memory";
|
||||
this.retention = options?.retention ?? 14;
|
||||
this.scope = options?.scope ?? "all";
|
||||
}
|
||||
|
||||
private getProjectRoot(): string {
|
||||
return resolve(this.fusionDir, "..");
|
||||
}
|
||||
|
||||
private getBackupDirPath(): string {
|
||||
return resolve(this.getProjectRoot(), this.backupDir);
|
||||
}
|
||||
|
||||
private async collectSources() {
|
||||
const root = this.getProjectRoot();
|
||||
const projectPath = join(root, MEMORY_WORKSPACE_PATH);
|
||||
const agentsPath = join(root, AGENT_MEMORY_ROOT);
|
||||
const includeProject = this.scope !== "agents";
|
||||
const includeAgents = this.scope !== "project";
|
||||
const sources: Array<{ source: string; target: string }> = [];
|
||||
|
||||
if (includeProject && existsSync(projectPath)) {
|
||||
sources.push({ source: projectPath, target: "project" });
|
||||
}
|
||||
if (includeAgents && existsSync(agentsPath)) {
|
||||
sources.push({ source: agentsPath, target: "agents" });
|
||||
}
|
||||
|
||||
if (sources.length === 0) {
|
||||
throw new Error(`No memory sources found for scope '${this.scope}' (.fusion/memory or .fusion/agent-memory)`);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
async createBackup(): Promise<MemoryBackupInfo> {
|
||||
const backupDirPath = this.getBackupDirPath();
|
||||
await mkdir(backupDirPath, { recursive: true });
|
||||
|
||||
const sources = await this.collectSources();
|
||||
const baseName = `memory-${formatTimestamp(new Date())}`;
|
||||
|
||||
let filename = baseName;
|
||||
let finalPath = join(backupDirPath, filename);
|
||||
let tmpPath = `${finalPath}.tmp`;
|
||||
let counter = 1;
|
||||
|
||||
while (existsSync(finalPath) || existsSync(tmpPath)) {
|
||||
filename = `${baseName}-${counter}`;
|
||||
finalPath = join(backupDirPath, filename);
|
||||
tmpPath = `${finalPath}.tmp`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir(tmpPath, { recursive: true });
|
||||
for (const { source, target } of sources) {
|
||||
const targetPath = join(tmpPath, target);
|
||||
await cp(source, targetPath, { recursive: true, preserveTimestamps: true });
|
||||
}
|
||||
await rename(tmpPath, finalPath);
|
||||
} catch (error) {
|
||||
await rm(tmpPath, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return this.buildInfo(filename, finalPath);
|
||||
}
|
||||
|
||||
async listBackups(): Promise<MemoryBackupInfo[]> {
|
||||
const backupDirPath = this.getBackupDirPath();
|
||||
try {
|
||||
const entries = await readdir(backupDirPath);
|
||||
const backups: MemoryBackupInfo[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.match(/^memory-\d{4}-\d{2}-\d{2}-\d{6}(?:-\d+)?$/)) continue;
|
||||
const fullPath = join(backupDirPath, entry);
|
||||
const st = await stat(fullPath);
|
||||
if (!st.isDirectory()) continue;
|
||||
backups.push(await this.buildInfo(entry, fullPath));
|
||||
}
|
||||
return backups.sort((a, b) => {
|
||||
const timeCompare = b.createdAt.localeCompare(a.createdAt);
|
||||
if (timeCompare !== 0) return timeCompare;
|
||||
return b.filename.localeCompare(a.filename);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupOldBackups(): Promise<number> {
|
||||
const backups = await this.listBackups();
|
||||
if (backups.length <= this.retention) return 0;
|
||||
const sorted = [...backups].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 deleted = 0;
|
||||
for (const backup of toDelete) {
|
||||
try {
|
||||
await rm(backup.path, { recursive: true, force: true });
|
||||
deleted++;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async restoreBackup(filename: string, opts?: { overwrite?: boolean }): Promise<void> {
|
||||
const backupRoot = join(this.getBackupDirPath(), filename);
|
||||
const sourceProject = join(backupRoot, "project");
|
||||
const sourceAgents = join(backupRoot, "agents");
|
||||
|
||||
const hasProject = existsSync(sourceProject);
|
||||
const hasAgents = existsSync(sourceAgents);
|
||||
if (!hasProject && !hasAgents) {
|
||||
throw new Error(`Memory backup not found or empty: ${filename}`);
|
||||
}
|
||||
|
||||
if (hasProject) {
|
||||
await this.restoreTree(sourceProject, join(this.getProjectRoot(), MEMORY_WORKSPACE_PATH), opts?.overwrite === true);
|
||||
}
|
||||
if (hasAgents) {
|
||||
await this.restoreTree(sourceAgents, join(this.getProjectRoot(), AGENT_MEMORY_ROOT), opts?.overwrite === true);
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreTree(sourceDir: string, destinationDir: string, overwrite: boolean): Promise<void> {
|
||||
const files = await listFilesRecursive(sourceDir);
|
||||
for (const file of files) {
|
||||
const rel = relative(sourceDir, file);
|
||||
const destFile = join(destinationDir, rel);
|
||||
await mkdir(dirname(destFile), { recursive: true });
|
||||
|
||||
if (existsSync(destFile) && !overwrite) {
|
||||
const [srcBuf, dstBuf] = await Promise.all([readFile(file), readFile(destFile)]);
|
||||
if (!srcBuf.equals(dstBuf)) {
|
||||
throw new Error(`Restore would overwrite modified memory file: ${destFile}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const tmp = `${destFile}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const content = await readFile(file);
|
||||
await writeFile(tmp, content);
|
||||
await rename(tmp, destFile);
|
||||
}
|
||||
}
|
||||
|
||||
private async buildInfo(filename: string, fullPath: string): Promise<MemoryBackupInfo> {
|
||||
const stats = await stat(fullPath);
|
||||
const sizeAndCount = await getDirectoryStats(fullPath);
|
||||
const match = filename.match(/^memory-(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})(?:-\d+)?$/);
|
||||
const createdAt = match
|
||||
? `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z`
|
||||
: stats.mtime.toISOString();
|
||||
const hasProject = existsSync(join(fullPath, "project"));
|
||||
const hasAgents = existsSync(join(fullPath, "agents"));
|
||||
const scope: MemoryBackupScope = hasProject && hasAgents ? "all" : hasProject ? "project" : "agents";
|
||||
|
||||
return {
|
||||
filename,
|
||||
createdAt,
|
||||
size: sizeAndCount.size,
|
||||
path: fullPath,
|
||||
scope,
|
||||
entryCount: sizeAndCount.entryCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createMemoryBackupManager(
|
||||
fusionDir: string,
|
||||
settings?: Partial<ProjectSettings>,
|
||||
): MemoryBackupManager {
|
||||
return new MemoryBackupManager(fusionDir, {
|
||||
backupDir: sanitizeMemoryBackupDir(fusionDir, canonicalizeMemoryBackupDir(settings?.memoryBackupDir)),
|
||||
retention: settings?.memoryBackupRetention,
|
||||
scope: settings?.memoryBackupScope,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runMemoryBackupCommand(
|
||||
fusionDir: string,
|
||||
settings: ProjectSettings,
|
||||
): Promise<{ success: boolean; output: string; backupPath?: string; deletedCount?: number }> {
|
||||
if (settings.memoryBackupSchedule && !validateMemoryBackupSchedule(settings.memoryBackupSchedule)) {
|
||||
return { success: false, output: `Invalid memory backup schedule: ${settings.memoryBackupSchedule}` };
|
||||
}
|
||||
|
||||
const manager = createMemoryBackupManager(fusionDir, settings);
|
||||
try {
|
||||
const backup = await manager.createBackup();
|
||||
const deletedCount = await manager.cleanupOldBackups();
|
||||
const output = deletedCount > 0
|
||||
? `Memory backup created: ${backup.filename} (${formatBytes(backup.size)}). Removed ${deletedCount} old backup(s).`
|
||||
: `Memory backup created: ${backup.filename} (${formatBytes(backup.size)})`;
|
||||
return { success: true, output, backupPath: backup.path, deletedCount };
|
||||
} catch (error) {
|
||||
return { success: false, output: `Memory backup failed: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export const validateMemoryBackupSchedule = validateBackupSchedule;
|
||||
|
||||
export async function syncMemoryBackupAutomation(
|
||||
automationStore: import("./automation-store.js").AutomationStore,
|
||||
settings: ProjectSettings,
|
||||
): Promise<import("./automation.js").ScheduledTask | undefined> {
|
||||
const { AutomationStore } = await import("./automation-store.js");
|
||||
const schedules = await automationStore.listSchedules();
|
||||
const existing = schedules.find((s) => s.name === MEMORY_BACKUP_SCHEDULE_NAME);
|
||||
|
||||
if (!settings.memoryBackupEnabled) {
|
||||
if (existing) await automationStore.deleteSchedule(existing.id);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const schedule = settings.memoryBackupSchedule || "0 3 * * *";
|
||||
if (!AutomationStore.isValidCron(schedule)) {
|
||||
throw new Error(`Invalid backup schedule: ${schedule}`);
|
||||
}
|
||||
|
||||
const command = "fn memory-backup --create";
|
||||
if (existing) {
|
||||
return await automationStore.updateSchedule(existing.id, {
|
||||
scheduleType: "custom",
|
||||
cronExpression: schedule,
|
||||
command,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return await automationStore.createSchedule({
|
||||
name: MEMORY_BACKUP_SCHEDULE_NAME,
|
||||
description: "Automatic memory backup based on project settings",
|
||||
scheduleType: "custom",
|
||||
cronExpression: schedule,
|
||||
command,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncMemoryBackupRoutine(
|
||||
routineStore: import("./routine-store.js").RoutineStore,
|
||||
settings: ProjectSettings,
|
||||
): Promise<import("./routine.js").Routine | undefined> {
|
||||
const { RoutineStore } = await import("./routine-store.js");
|
||||
const routines = await routineStore.listRoutines();
|
||||
const existing = routines.find((routine) => routine.name === MEMORY_BACKUP_SCHEDULE_NAME);
|
||||
|
||||
if (!settings.memoryBackupEnabled) {
|
||||
if (existing) {
|
||||
await routineStore.deleteRoutine(existing.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const schedule = settings.memoryBackupSchedule || "0 3 * * *";
|
||||
if (!RoutineStore.isValidCron(schedule)) {
|
||||
throw new Error(`Invalid backup schedule: ${schedule}`);
|
||||
}
|
||||
|
||||
const command = "fn memory-backup --create";
|
||||
if (existing) {
|
||||
return await routineStore.updateRoutine(existing.id, {
|
||||
trigger: { type: "cron" as const, cronExpression: schedule },
|
||||
command,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return await routineStore.createRoutine({
|
||||
name: MEMORY_BACKUP_SCHEDULE_NAME,
|
||||
description: "Automatic memory backup based on project settings",
|
||||
agentId: "",
|
||||
trigger: { type: "cron" as const, cronExpression: schedule },
|
||||
command,
|
||||
enabled: true,
|
||||
scope: "project" as const,
|
||||
});
|
||||
}
|
||||
|
||||
function canonicalizeMemoryBackupDir(dir: string | undefined): string | undefined {
|
||||
if (dir === ".kb/backups/memory") return ".fusion/backups/memory";
|
||||
return dir;
|
||||
}
|
||||
|
||||
function sanitizeMemoryBackupDir(fusionDir: string, dir: string | undefined): string | undefined {
|
||||
if (!dir) return undefined;
|
||||
if (dir.startsWith("/") || dir.startsWith("\\") || /^[a-zA-Z]:/.test(dir) || dir.includes("..")) {
|
||||
throw new Error(`Invalid memory backup directory: ${dir}`);
|
||||
}
|
||||
const projectRoot = resolve(fusionDir, "..");
|
||||
const resolved = resolve(projectRoot, dir);
|
||||
const rel = relative(projectRoot, resolved);
|
||||
if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
|
||||
throw new Error(`Invalid memory backup directory: ${dir}`);
|
||||
}
|
||||
return rel || ".";
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
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]}`;
|
||||
}
|
||||
|
||||
async function getDirectoryStats(dir: string): Promise<{ size: number; entryCount: number }> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
let size = 0;
|
||||
let entryCount = 0;
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const nested = await getDirectoryStats(fullPath);
|
||||
size += nested.size;
|
||||
entryCount += nested.entryCount;
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
const st = await stat(fullPath);
|
||||
size += st.size;
|
||||
entryCount++;
|
||||
}
|
||||
}
|
||||
return { size, entryCount };
|
||||
}
|
||||
|
||||
async function listFilesRecursive(root: string): Promise<string[]> {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await listFilesRecursive(fullPath));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -240,6 +240,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
autoBackupSchedule: "0 2 * * *",
|
||||
autoBackupRetention: 7,
|
||||
autoBackupDir: ".fusion/backups",
|
||||
memoryBackupEnabled: false,
|
||||
memoryBackupSchedule: "0 3 * * *",
|
||||
memoryBackupRetention: 14,
|
||||
memoryBackupDir: ".fusion/backups/memory",
|
||||
memoryBackupScope: "all" as const,
|
||||
autoSummarizeTitles: false,
|
||||
useAiMergeCommitSummary: true,
|
||||
titleSummarizerProvider: undefined,
|
||||
|
||||
@@ -2213,6 +2213,21 @@ export interface ProjectSettings {
|
||||
autoBackupRetention?: number;
|
||||
/** Directory for backup files, relative to project root. Default: ".fusion/backups". */
|
||||
autoBackupDir?: string;
|
||||
/** When true, scheduled memory backups are enabled. Default: false. */
|
||||
memoryBackupEnabled?: boolean;
|
||||
/** Cron expression for memory backup schedule. Default: "0 3 * * *" (daily at 3 AM). */
|
||||
memoryBackupSchedule?: string;
|
||||
/** Number of memory backups to retain (oldest deleted when exceeded). Default: 14. */
|
||||
memoryBackupRetention?: number;
|
||||
/** Directory for memory backup snapshots, relative to project root.
|
||||
* Default: ".fusion/backups/memory". */
|
||||
memoryBackupDir?: string;
|
||||
/** Scope of memory backup snapshots.
|
||||
* - "project": backups `.fusion/memory` only
|
||||
* - "agents": backups `.fusion/agent-memory` only
|
||||
* - "all": backups both project and per-agent memory
|
||||
* Default: "all". */
|
||||
memoryBackupScope?: "project" | "agents" | "all";
|
||||
/** When true, tasks created without titles but with descriptions longer than 200
|
||||
* characters will automatically receive an AI-generated title (max 60 chars).
|
||||
* Default: false. */
|
||||
|
||||
@@ -4498,6 +4498,82 @@ export function SettingsModal({
|
||||
<small className="field-error">Path cannot contain parent directory traversal (..)</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h4 className="settings-section-heading">Memory Backups</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryBackupEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="memoryBackupEnabled"
|
||||
type="checkbox"
|
||||
checked={form.memoryBackupEnabled || false}
|
||||
onChange={(e) => setForm((f) => ({ ...f, memoryBackupEnabled: e.target.checked }))}
|
||||
/>
|
||||
Enable automatic memory backups
|
||||
</label>
|
||||
<small>When enabled, project and agent memory files are backed up automatically on a schedule.</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryBackupSchedule">Memory Backup Schedule (Cron)</label>
|
||||
<input
|
||||
id="memoryBackupSchedule"
|
||||
type="text"
|
||||
placeholder="0 3 * * *"
|
||||
value={form.memoryBackupSchedule || "0 3 * * *"}
|
||||
onChange={(e) => setForm((f) => ({ ...f, memoryBackupSchedule: e.target.value }))}
|
||||
disabled={!form.memoryBackupEnabled}
|
||||
/>
|
||||
<small>Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM).</small>
|
||||
{form.memoryBackupSchedule && !/^[\s\d*,/-]+$/.test(form.memoryBackupSchedule) && (
|
||||
<small className="field-error">Invalid cron expression format</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryBackupRetention">Memory Retention Count</label>
|
||||
<input
|
||||
id="memoryBackupRetention"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.memoryBackupRetention ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, memoryBackupRetention: val === "" ? undefined : Number(val) }));
|
||||
}}
|
||||
disabled={!form.memoryBackupEnabled}
|
||||
/>
|
||||
<small>Number of memory backups to keep (oldest are deleted first). Range: 1-100.</small>
|
||||
{form.memoryBackupRetention !== undefined && (form.memoryBackupRetention < 1 || form.memoryBackupRetention > 100) && (
|
||||
<small className="field-error">Must be between 1 and 100</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryBackupDir">Memory Backup Directory</label>
|
||||
<input
|
||||
id="memoryBackupDir"
|
||||
type="text"
|
||||
placeholder=".fusion/backups/memory"
|
||||
value={form.memoryBackupDir || ".fusion/backups/memory"}
|
||||
onChange={(e) => setForm((f) => ({ ...f, memoryBackupDir: e.target.value }))}
|
||||
disabled={!form.memoryBackupEnabled}
|
||||
/>
|
||||
<small>Directory for memory backups, relative to project root.</small>
|
||||
{form.memoryBackupDir && form.memoryBackupDir.includes("..") && (
|
||||
<small className="field-error">Path cannot contain parent directory traversal (..)</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryBackupScope">Memory Backup Scope</label>
|
||||
<select
|
||||
id="memoryBackupScope"
|
||||
value={form.memoryBackupScope || "all"}
|
||||
onChange={(e) => setForm((f) => ({ ...f, memoryBackupScope: e.target.value as "project" | "agents" | "all" }))}
|
||||
disabled={!form.memoryBackupEnabled}
|
||||
>
|
||||
<option value="all">All (project + agents)</option>
|
||||
<option value="project">Project only (.fusion/memory)</option>
|
||||
<option value="agents">Agents only (.fusion/agent-memory)</option>
|
||||
</select>
|
||||
</div>
|
||||
{backupLoading ? (
|
||||
<div className="settings-empty-state">Loading backup info…</div>
|
||||
) : backupInfo ? (
|
||||
|
||||
@@ -2988,6 +2988,39 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory backups settings", () => {
|
||||
it("renders memory backup fields with defaults and saves changes", async () => {
|
||||
renderModal({ initialSection: "backups" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Memory Backups" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Memory Backup Schedule (Cron)")).toHaveValue("0 3 * * *");
|
||||
expect(screen.getByLabelText("Memory Retention Count")).toHaveValue(null);
|
||||
expect(screen.getByLabelText("Memory Backup Directory")).toHaveValue(".fusion/backups/memory");
|
||||
expect(screen.getByLabelText("Memory Backup Scope")).toHaveValue("all");
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Enable automatic memory backups"));
|
||||
fireEvent.change(screen.getByLabelText("Memory Backup Schedule (Cron)"), { target: { value: "0 5 * * *" } });
|
||||
fireEvent.change(screen.getByLabelText("Memory Retention Count"), { target: { value: "21" } });
|
||||
fireEvent.change(screen.getByLabelText("Memory Backup Directory"), { target: { value: ".fusion/backups/custom-memory" } });
|
||||
await userEvent.selectOptions(screen.getByLabelText("Memory Backup Scope"), "agents");
|
||||
await userEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
memoryBackupEnabled: true,
|
||||
memoryBackupSchedule: "0 5 * * *",
|
||||
memoryBackupRetention: 21,
|
||||
memoryBackupDir: ".fusion/backups/custom-memory",
|
||||
memoryBackupScope: "agents",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("research settings sections", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchSettings.mockResolvedValue({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { CronRunner, createAiPromptExecutor, isInProcessBackupCommand, isInProcessScheduledEvalCommand } from "../cron-runner.js";
|
||||
import { CronRunner, createAiPromptExecutor, isInProcessBackupCommand, isInProcessMemoryBackupCommand, isInProcessScheduledEvalCommand } from "../cron-runner.js";
|
||||
import type { AiPromptExecutor } from "../cron-runner.js";
|
||||
import type { TaskStore, AutomationStore, ScheduledTask, AutomationRunResult, AutomationStep, Settings } from "@fusion/core";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -15,6 +15,10 @@ const piModuleMocks = vi.hoisted(() => ({
|
||||
promptWithFallback: vi.fn(),
|
||||
}));
|
||||
|
||||
const coreModuleMocks = vi.hoisted(() => ({
|
||||
runMemoryBackupCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../logger.js", () => ({
|
||||
createLogger: () => ({
|
||||
log: cronLoggerSpies.log,
|
||||
@@ -28,6 +32,14 @@ vi.mock("../pi.js", () => ({
|
||||
promptWithFallback: piModuleMocks.promptWithFallback,
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@fusion/core")>();
|
||||
return {
|
||||
...actual,
|
||||
runMemoryBackupCommand: coreModuleMocks.runMemoryBackupCommand,
|
||||
};
|
||||
});
|
||||
|
||||
// Default settings inline to avoid @fusion/core build dependency during tests
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
maxConcurrent: 2,
|
||||
@@ -104,6 +116,10 @@ describe("CronRunner", () => {
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
});
|
||||
coreModuleMocks.runMemoryBackupCommand.mockResolvedValue({
|
||||
success: true,
|
||||
output: "memory backup ok",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1843,6 +1859,51 @@ describe("CronRunner", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("isInProcessMemoryBackupCommand", () => {
|
||||
const positives = [
|
||||
"fn memory-backup --create",
|
||||
"npx runfusion.ai memory-backup --create",
|
||||
" fn memory-backup --create ",
|
||||
"FN MEMORY-BACKUP --CREATE",
|
||||
];
|
||||
|
||||
const negatives: Array<string | undefined> = [
|
||||
"fn memory-backup --list",
|
||||
"fn memory-backup",
|
||||
"fn backup --create",
|
||||
"",
|
||||
undefined,
|
||||
];
|
||||
|
||||
for (const cmd of positives) {
|
||||
it(`intercepts: ${cmd}`, () => {
|
||||
expect(isInProcessMemoryBackupCommand(cmd)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const cmd of negatives) {
|
||||
it(`does NOT intercept: ${JSON.stringify(cmd)}`, () => {
|
||||
expect(isInProcessMemoryBackupCommand(cmd)).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("memory backup in-process dispatch", () => {
|
||||
it("routes fn memory-backup --create through runMemoryBackupCommand", async () => {
|
||||
const store = createMockStore() as unknown as TaskStore & { getFusionDir: () => string };
|
||||
store.getFusionDir = vi.fn().mockReturnValue("/tmp/.fusion");
|
||||
const schedule = createMockSchedule({ id: "mem-bk", command: "fn memory-backup --create" });
|
||||
runner = new CronRunner(store, createMockAutomationStore());
|
||||
|
||||
const runResult = await (runner as unknown as { executeLegacyCommand: (s: ScheduledTask, startedAt: string) => Promise<AutomationRunResult> })
|
||||
.executeLegacyCommand(schedule, new Date().toISOString());
|
||||
|
||||
expect(coreModuleMocks.runMemoryBackupCommand).toHaveBeenCalledWith("/tmp/.fusion", expect.any(Object));
|
||||
expect(runResult.success).toBe(true);
|
||||
expect(runResult.output).toContain("memory backup ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInProcessScheduledEvalCommand", () => {
|
||||
it("matches canonical scheduled eval command", () => {
|
||||
expect(isInProcessScheduledEvalCommand("fn eval --scheduled-batch")).toBe(true);
|
||||
|
||||
@@ -133,6 +133,46 @@ export function isInProcessBackupCommand(command: string | undefined): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isInProcessMemoryBackupCommand(command: string | undefined): boolean {
|
||||
if (!command) return false;
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return false;
|
||||
if (SHELL_METACHARACTERS_REGEX.test(trimmed)) return false;
|
||||
|
||||
const tokens = trimmed.split(/\s+/).map((tok) => tok.toLowerCase());
|
||||
let cursor = 0;
|
||||
|
||||
if (tokens[cursor] === "npx") {
|
||||
cursor += 1;
|
||||
while (cursor < tokens.length) {
|
||||
const tok = tokens[cursor];
|
||||
if (tok === undefined || !tok.startsWith("-")) break;
|
||||
const takesValue = (tok === "-p" || tok === "--package")
|
||||
&& cursor + 1 < tokens.length
|
||||
&& tokens[cursor + 1] !== undefined
|
||||
&& !tokens[cursor + 1]!.startsWith("-");
|
||||
cursor += takesValue ? 2 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
const binary = tokens[cursor];
|
||||
if (!binary || !FUSION_BINARY_TOKENS.has(binary)) return false;
|
||||
cursor += 1;
|
||||
|
||||
if (tokens[cursor] !== "memory-backup") return false;
|
||||
cursor += 1;
|
||||
if (tokens[cursor] !== "--create") return false;
|
||||
cursor += 1;
|
||||
|
||||
for (; cursor < tokens.length; cursor += 1) {
|
||||
const tok = tokens[cursor];
|
||||
if (!tok) continue;
|
||||
if (!tok.startsWith("-")) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isInProcessScheduledEvalCommand(command: string | undefined): boolean {
|
||||
if (!command) return false;
|
||||
const trimmed = command.trim();
|
||||
@@ -412,6 +452,10 @@ export class CronRunner {
|
||||
return this.executeBackupInProcess(schedule, startedAt);
|
||||
}
|
||||
|
||||
if (isInProcessMemoryBackupCommand(schedule.command)) {
|
||||
return this.executeMemoryBackupInProcess(schedule, startedAt);
|
||||
}
|
||||
|
||||
if (isInProcessScheduledEvalCommand(schedule.command)) {
|
||||
return this.executeScheduledEvalInProcess(schedule, startedAt);
|
||||
}
|
||||
@@ -478,6 +522,25 @@ export class CronRunner {
|
||||
};
|
||||
}
|
||||
|
||||
private async executeMemoryBackupInProcess(
|
||||
schedule: ScheduledTask,
|
||||
startedAt: string,
|
||||
): Promise<AutomationRunResult> {
|
||||
const action = await this.runMemoryBackupActionInProcess();
|
||||
if (action.success) {
|
||||
log.log(`✓ ${schedule.name} completed in-process`);
|
||||
} else {
|
||||
log.warn(`✗ ${schedule.name} in-process memory backup ${action.error ? `threw: ${action.error}` : `reported failure: ${action.output}`}`);
|
||||
}
|
||||
return {
|
||||
success: action.success,
|
||||
output: action.output,
|
||||
error: action.error,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared in-process backup execution used by both the legacy-command path
|
||||
* and the command-step path. Returns the success/output/error tuple in
|
||||
@@ -546,6 +609,27 @@ export class CronRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private async runMemoryBackupActionInProcess(): Promise<{
|
||||
success: boolean;
|
||||
output: string;
|
||||
error: string | undefined;
|
||||
}> {
|
||||
try {
|
||||
const { runMemoryBackupCommand } = await import("@fusion/core");
|
||||
const fusionDir = this.store.getFusionDir();
|
||||
const settings = await this.store.getSettings();
|
||||
const result = await runMemoryBackupCommand(fusionDir, settings);
|
||||
return {
|
||||
success: result.success,
|
||||
output: truncateOutput(result.output ?? "", ""),
|
||||
error: result.success ? undefined : result.output,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, output: "", error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple steps sequentially.
|
||||
* Aggregates per-step results into an overall AutomationRunResult.
|
||||
@@ -681,6 +765,20 @@ export class CronRunner {
|
||||
};
|
||||
}
|
||||
|
||||
if (isInProcessMemoryBackupCommand(step.command)) {
|
||||
const action = await this.runMemoryBackupActionInProcess();
|
||||
return {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex,
|
||||
success: action.success,
|
||||
output: action.output,
|
||||
error: action.error,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execCommand(step.command, {
|
||||
timeout: timeoutMs,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { CronExpressionParser } from "cron-parser";
|
||||
import { exec } from "node:child_process";
|
||||
import { isInProcessBackupCommand } from "./cron-runner.js";
|
||||
import { isInProcessBackupCommand, isInProcessMemoryBackupCommand } from "./cron-runner.js";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
RoutineStore,
|
||||
@@ -291,6 +291,31 @@ export class RoutineRunner {
|
||||
}
|
||||
}
|
||||
|
||||
if (isInProcessMemoryBackupCommand(command) && this.options.taskStore) {
|
||||
try {
|
||||
const { runMemoryBackupCommand } = await import("@fusion/core");
|
||||
const fusionDir = this.options.taskStore.getFusionDir();
|
||||
const settings = await this.options.taskStore.getSettings();
|
||||
const result = await runMemoryBackupCommand(fusionDir, settings);
|
||||
return {
|
||||
success: result.success,
|
||||
output: truncateOutput(result.output ?? "", ""),
|
||||
error: result.success ? undefined : result.output,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
success: false,
|
||||
output: "",
|
||||
error: message,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
|
||||
Reference in New Issue
Block a user