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:
Fusion
2026-05-09 14:38:06 -07:00
committed by gsxdsm
parent f182aa30ad
commit 15e43360ef
15 changed files with 1171 additions and 2 deletions

View File

@@ -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) {

View 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");
});
});

View 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]}`;
}