fix(FN-XXXX): run auto-backup in-process to stop nested .fusion creation

The backup automation was scheduled with `npx runfusion.ai backup --create`, which spawns whatever fusion binary is on PATH. On developer machines that's usually an older globally-installed runfusion.ai (v0.13.0 at time of writing) which still carries the pluginStore-rootDir bug — every backup tick recreated `<project>/.fusion/.fusion/` with a fresh empty TaskStore.

Cron-runner and routine-runner now intercept any command matching `fn backup`, `fusion backup`, or `npx runfusion.ai backup` and call `runBackupCommand` directly via the engine's open TaskStore. The interception also handles existing schedules persisted with the old npx command, so users do not need to manually update their automation rows.

The default command for newly created backup schedules is also simplified to `fn backup --create` — both forms route through the same in-process executor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-01 22:38:37 -07:00
parent 36d623a16f
commit 85924722d4
5 changed files with 119 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Run the auto-backup automation in-process instead of shelling out to whatever fusion binary happens to be on `PATH`. The cron and routine runners now intercept commands matching `fn backup`, `fusion backup`, or `npx runfusion.ai backup` and call `runBackupCommand` directly through the engine's already-open `TaskStore`. This stops the auto-backup from launching an outdated globally-installed fusion binary that could re-introduce already-fixed bugs (most recently the `pluginStore` rootDir mistake that created a stray `.fusion/.fusion/` directory each time the schedule fired). New backup automations are also written with the simpler `fn backup --create` command — existing schedules using the old `npx runfusion.ai` form keep working because both forms hit the same in-process interception.

View File

@@ -474,7 +474,7 @@ describe("syncBackupRoutine", () => {
expect(routine).toBeDefined(); expect(routine).toBeDefined();
expect(routine?.name).toBe("Database Backup"); expect(routine?.name).toBe("Database Backup");
expect(routine?.trigger).toEqual({ type: "cron", cronExpression: "0 3 * * *" }); expect(routine?.trigger).toEqual({ type: "cron", cronExpression: "0 3 * * *" });
expect(routine?.command).toBe("npx runfusion.ai backup --create"); expect(routine?.command).toBe("fn backup --create");
expect(routine?.agentId).toBe(""); expect(routine?.agentId).toBe("");
expect(routine?.scope).toBe("project"); expect(routine?.scope).toBe("project");
}); });
@@ -495,7 +495,7 @@ describe("syncBackupRoutine", () => {
expect(routines).toHaveLength(1); expect(routines).toHaveLength(1);
expect(updated?.trigger).toEqual({ type: "cron", cronExpression: "30 4 * * *" }); expect(updated?.trigger).toEqual({ type: "cron", cronExpression: "30 4 * * *" });
expect(updated?.command).toBe("npx runfusion.ai backup --create"); expect(updated?.command).toBe("fn backup --create");
expect(updated?.enabled).toBe(true); expect(updated?.enabled).toBe(true);
}); });

View File

@@ -411,7 +411,13 @@ export async function syncBackupAutomation(
// Build the backup command. // Build the backup command.
// Uses `npx runfusion.ai` so backups work even when only the zero-install // Uses `npx runfusion.ai` so backups work even when only the zero-install
// path (`npx runfusion.ai`) has been used and `fn` is not on PATH. // path (`npx runfusion.ai`) has been used and `fn` is not on PATH.
const command = "npx runfusion.ai backup --create"; // Sentinel command intercepted in-process by the engine's cron/routine
// runner (see `isInProcessBackupCommand` in @fusion/engine). Stored as a
// command rather than a step so existing UI listings still display it as
// a single-line action. Falls back to the npx shell-out only when read by
// a runner that does not implement the in-process interception (e.g.
// outdated globally-installed binaries running an older fusion engine).
const command = "fn backup --create";
if (existingSchedule) { if (existingSchedule) {
// Update existing schedule // Update existing schedule
@@ -462,7 +468,13 @@ export async function syncBackupRoutine(
throw new Error(`Invalid backup schedule: ${schedule}`); throw new Error(`Invalid backup schedule: ${schedule}`);
} }
const command = "npx runfusion.ai backup --create"; // Sentinel command intercepted in-process by the engine's cron/routine
// runner (see `isInProcessBackupCommand` in @fusion/engine). Stored as a
// command rather than a step so existing UI listings still display it as
// a single-line action. Falls back to the npx shell-out only when read by
// a runner that does not implement the in-process interception (e.g.
// outdated globally-installed binaries running an older fusion engine).
const command = "fn backup --create";
const input = { const input = {
name: BACKUP_SCHEDULE_NAME, name: BACKUP_SCHEDULE_NAME,
description: "Automatic database backup based on project settings", description: "Automatic database backup based on project settings",

View File

@@ -34,6 +34,23 @@ function execCommand(command: string, options: Parameters<typeof exec>[1]): Prom
}); });
} }
/**
* Recognize commands that the auto-backup feature schedules. These shell out
* to whatever fusion binary is on PATH — which may be older than the running
* process and still carry the pluginStore-rootDir bug that creates a stray
* `.fusion/.fusion/` directory. We intercept and run the backup in-process.
*/
export function isInProcessBackupCommand(command: string | undefined): boolean {
if (!command) return false;
const normalized = command.trim().toLowerCase();
return (
/^(?:npx\s+)?runfusion(?:\.ai)?\s+backup\b/.test(normalized) ||
/^(?:npx\s+)?@runfusion\/fusion\s+backup\b/.test(normalized) ||
/^fn\s+backup\b/.test(normalized) ||
/^fusion\s+backup\b/.test(normalized)
);
}
/** Default execution timeout: 5 minutes. */ /** Default execution timeout: 5 minutes. */
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
/** Maximum output buffer: 1 MB. */ /** Maximum output buffer: 1 MB. */
@@ -287,6 +304,16 @@ export class CronRunner {
): Promise<AutomationRunResult> { ): Promise<AutomationRunResult> {
log.log(`Executing ${schedule.name} (${schedule.id}): ${schedule.command}`); log.log(`Executing ${schedule.name} (${schedule.id}): ${schedule.command}`);
// Intercept the auto-backup command: shelling out to `npx runfusion.ai`
// (or `fn backup`) launches whichever globally-installed fusion binary is
// on PATH, which may be older than the running process and re-introduce
// bugs we have already patched here. Running the backup in-process via
// the engine's already-open TaskStore is also faster (no node startup,
// no engine initialization) and uses identical logic.
if (isInProcessBackupCommand(schedule.command)) {
return this.executeBackupInProcess(schedule, startedAt);
}
try { try {
const timeoutMs = schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS; const timeoutMs = schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const { stdout, stderr } = await execCommand(schedule.command, { const { stdout, stderr } = await execCommand(schedule.command, {
@@ -325,6 +352,47 @@ export class CronRunner {
} }
} }
/**
* Run an auto-backup schedule in-process via the engine's open TaskStore,
* bypassing the shell-out that would otherwise invoke an outdated fusion
* binary on PATH. See `isInProcessBackupCommand` for the matching contract.
*/
private async executeBackupInProcess(
schedule: ScheduledTask,
startedAt: string,
): Promise<AutomationRunResult> {
try {
const { runBackupCommand } = await import("@fusion/core");
const fusionDir = this.store.getFusionDir();
const settings = await this.store.getSettings();
const result = await runBackupCommand(fusionDir, settings);
if (result.success) {
log.log(`${schedule.name} completed in-process`);
} else {
log.warn(`${schedule.name} in-process backup reported failure: ${result.output}`);
}
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);
log.warn(`${schedule.name} in-process backup threw: ${message}`);
return {
success: false,
output: "",
error: message,
startedAt,
completedAt: new Date().toISOString(),
};
}
}
/** /**
* Execute multiple steps sequentially. * Execute multiple steps sequentially.
* Aggregates per-step results into an overall AutomationRunResult. * Aggregates per-step results into an overall AutomationRunResult.

View File

@@ -9,6 +9,7 @@
import { CronExpressionParser } from "cron-parser"; import { CronExpressionParser } from "cron-parser";
import { exec } from "node:child_process"; import { exec } from "node:child_process";
import { isInProcessBackupCommand } from "./cron-runner.js";
import { promisify } from "node:util"; import { promisify } from "node:util";
import type { import type {
RoutineStore, RoutineStore,
@@ -261,6 +262,35 @@ export class RoutineRunner {
timeoutMs: number | undefined, timeoutMs: number | undefined,
startedAt: string, startedAt: string,
): Promise<AutomationRunResult> { ): Promise<AutomationRunResult> {
// Intercept the auto-backup command so it runs in-process via the engine's
// existing TaskStore instead of shelling out to a globally-installed
// fusion binary (which may be older than the running engine and re-create
// the nested `.fusion/.fusion/` directory). Mirrors cron-runner.ts.
if (isInProcessBackupCommand(command) && this.options.taskStore) {
try {
const { runBackupCommand } = await import("@fusion/core");
const fusionDir = this.options.taskStore.getFusionDir();
const settings = await this.options.taskStore.getSettings();
const result = await runBackupCommand(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 { try {
const { stdout, stderr } = await execAsync(command, { const { stdout, stderr } = await execAsync(command, {
timeout: timeoutMs ?? DEFAULT_TIMEOUT_MS, timeout: timeoutMs ?? DEFAULT_TIMEOUT_MS,