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

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

View File

@@ -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,

View File

@@ -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,