FN-8129: centralize database backup settings

Move database backup policy and scheduling to shared global configuration.

- Split project memory backups from cluster-wide database backup settings.
- Migrate legacy backup values and routines safely into central global storage.
- Schedule and dispatch one shared PostgreSQL backup routine across project engines.

Files changed:
 .changeset/fn-8129-backup-settings-scope-split.md  |   7 +
 docs/dashboard-guide.md                            |   2 +
 docs/settings-reference.md                         |  10 +-
 packages/cli/src/commands/backup.ts                |   3 +-
 .../__tests__/backup-settings-migration.test.ts    |  50 ++++++
 .../src/__tests__/backup-settings-scope.test.ts    |  27 +++
 packages/core/src/backup-settings-migration.ts     | 188 +++++++++++++++++++++
 packages/core/src/backup.ts                        |  77 +++++----
 packages/core/src/global-routine-store.ts          | 104 ++++++++++++
 packages/core/src/index.gate.ts                    |   6 +-
 packages/core/src/index.ts                         |   6 +-
 .../core/src/postgres/migrations/0000_initial.sql  |  19 +++
 .../postgres/migrations/0015_global_routines.sql   |  19 +++
 packages/core/src/postgres/schema-applier.ts       |  19 ++-
 packages/core/src/postgres/schema/central.ts       |  21 ++-
 packages/core/src/postgres/startup-factory.ts      |  11 ++
 packages/core/src/settings-schema.ts               |  14 +-
 packages/core/src/types.ts                         |  31 +++-
 .../dashboard/app/components/SettingsModal.tsx     |  10 +-
 .../settings/__tests__/section-keys.test.ts        |   1 +
 .../app/components/settings/save-split.ts          |   2 +
 .../search/__tests__/settings-search-index.test.ts |   1 +
 .../settings/search/entries.ts                     |   2 +
 .../app/components/settings/section-keys.ts        |   4 -
 .../settings/sections/BackupsSection.search.ts     |  40 -----
 .../settings/sections/BackupsSection.tsx           | 112 +-----------
 .../sections/DatabaseBackupsSection.search.ts      |  51 ++++++
 .../settings/sections/DatabaseBackupsSection.tsx   | 142 ++++++++++++++++
 .../settings-default-descriptions.test.tsx         |   1 +
 packages/dashboard/src/routes.ts                   |  12 +-
 .../src/routes/register-settings-memory-routes.ts  |  41 ++---
 .../engine/src/__tests__/routine-scheduler.test.ts |  55 +++++-
 packages/engine/src/cron-runner.ts                 |   4 +-
 packages/engine/src/routine-runner.ts              |  67 +++++---
 packages/engine/src/routine-scheduler.ts           |  35 +++-
 35 files changed, 929 insertions(+), 265 deletions(-)

Fusion-Task-Id: FN-8129

Fusion-Task-Lineage: af17f39a-7f1c-40ff-8a4a-cd63895cd532

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 16:34:09 -07:00
parent d8f9b44fc3
commit c6be0b158b
35 changed files with 929 additions and 265 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Split backup settings into global Database Backups and project Memory Backups.
category: feature
dev: Moves autoBackup settings to global scope and routes in-process backup commands through the canonical global backup root.

View File

@@ -160,6 +160,8 @@ Open **Automations** from the left sidebar (or the mobile More surfaces) to crea
When you choose **Run now**, the routine card opens a **Live output** panel while the manual run is active. The panel appends step status, AI text deltas, and tool start/finish activity as the run executes, then the card falls back to the persisted final run output and run history once the server records the result. The same `RoutineCard` surface is used by the floating modal and embedded Automations view, so live output appears in both presentations and collapses into a single-column card layout on mobile.
<!-- FNXC:DatabaseBackup 2026-07-04-00:00: FN-7537 fixed a manual/cron divergence for the built-in "Database Backup" automation/routine: a manual "Run now" now intercepts the in-process backup exactly like the scheduler, instead of shelling out to a possibly-missing global `fn`/`runfusion.ai` binary. -->
Settings splits **Database Backups** (global shared-cluster policy and `~/.fusion/backups` destination) from project-scoped **Memory Backups**.
The built-in **Database Backup** automation runs the backup in-process (via the engine's already-open task store) on both its scheduled cron trigger and a manual **Run now**, matching behavior identically between the two triggers — it never shells out to a separately-installed `fn`/`runfusion.ai` binary, which could be missing or out of date on the host.
## Deep Links

View File

@@ -669,10 +669,12 @@ GitLab enablement defaults effectively to on when `gitlabEnabled` is unset, so e
GitLab configuration examples: leave both URL fields blank for GitLab.com (`https://gitlab.com`, API `https://gitlab.com/api/v4`); set only `gitlabInstanceUrl=https://gitlab.example.com/gitlab` for a self-managed path-prefix install (API derives `https://gitlab.example.com/gitlab/api/v4`); set both URL fields when a self-managed API gateway differs from the web URL. GitLab auth uses access tokens over the GitLab REST API `PRIVATE-TOKEN` header; Fusion does not require or invoke `glab`. Supported token families are [personal access tokens](https://docs.gitlab.com/user/profile/personal_access_tokens/), [project access tokens](https://docs.gitlab.com/user/project/settings/project_access_tokens/), and [group access tokens](https://docs.gitlab.com/user/group/settings/group_access_tokens/). GitLab issue/MR import and tracking reads need `read_api` or `api`; posting notes/comments and closing/reopening issues or MRs need `api`. Project and group access tokens are constrained to their associated resource and role membership, so the configured token must cover the target project or group. Lifecycle actions use the configured API base URL for GitLab.com and self-managed instances, URL-encode project path identifiers, and skip unsupported targets such as terminal merged merge requests or group issues missing concrete project identity. Command Center signals, research/search providers, and star-prompt behavior remain deferred to later GitLab subtasks tracked from [GitLab Parity Inventory](./gitlab-parity-inventory.md).
| `autoCreatePr` | `boolean` | `false` | Auto-create PRs for completed tasks. |
| `autoBackupEnabled` | `boolean` | `false` | Enable scheduled DB backups. |
| `autoBackupSchedule` | `string` | `"0 2 * * *"` | Backup cron schedule. |
| `autoBackupRetention` | `number` | `7` | Number of backups to retain. |
| `autoBackupDir` | `string` | `".fusion/backups"` | Relative backup directory path. |
| `autoBackupEnabled` | `boolean` | `false` | Enable scheduled shared-database backups. |
| `autoBackupSchedule` | `string` | `"0 2 * * *"` | Shared database backup cron schedule. |
| `autoBackupRetention` | `number` | `7` | Number of shared database backups to retain. |
| `autoBackupDir` | `string` | `".fusion/backups"` | Relative directory beneath the global Fusion directory. |
> **Scope:** Database Backups are global because PostgreSQL is one shared cluster. Settings → Database Backups writes one global policy and stores its default dumps under `~/.fusion/backups`; Memory Backups remain project-scoped. Existing project overrides should be reviewed during upgrade before removing them.
Database backups work with both external PostgreSQL and Fusion's default embedded PostgreSQL deployment. `fn backup` and the built-in **Database Backup** cron/routine use `pg_dump` and `pg_restore`; install PostgreSQL client tools or configure their paths so both executables are available on `PATH`. They are not bundled with `embedded-postgres`.

View File

@@ -2,6 +2,7 @@ import {
BackupManager,
createBackupManager,
runBackupCommand,
resolveGlobalBackupRoot,
} from "@fusion/core";
import { resolveProject, createLocalStore, closeProjectStore, asLocalProjectContext, type ProjectContext } from "../project-context.js";
import { retryOnLock, LockRetryExhaustedError } from "../lock-retry.js";
@@ -49,7 +50,7 @@ async function getBackupManager(projectName?: string): Promise<{
try {
const { store } = context;
// Access the private fusionDir property via type assertion
const fusionDir = (store as unknown as { fusionDir: string }).fusionDir;
const fusionDir = resolveGlobalBackupRoot(store);
const settings = await retryOnLock(async () => store.getSettings(), { id: "backup-settings", action: "read settings" });
const manager = createBackupManager(fusionDir, settings);
return { manager, context, fusionDir };

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { planBackupSettingsMigration } from "../backup-settings-migration.js";
describe("backup settings project-to-global migration plan", () => {
it("leaves unset values at global defaults", () => {
expect(planBackupSettingsMigration([], {})).toEqual({ values: {}, conflicts: [] });
});
it("adopts one configured project value", () => {
const result = planBackupSettingsMigration([{ projectId: "one", settings: { autoBackupRetention: 30 } }], {});
expect(result.values.autoBackupRetention).toBe(30);
expect(result.conflicts).toEqual([]);
});
it("adopts duplicate project values", () => {
const result = planBackupSettingsMigration([
{ projectId: "one", settings: { autoBackupEnabled: true } },
{ projectId: "two", settings: { autoBackupEnabled: true } },
], {});
expect(result.values.autoBackupEnabled).toBe(true);
expect(result.conflicts).toEqual([]);
});
it("retains discriminated candidates when projects conflict", () => {
const result = planBackupSettingsMigration([
{ projectId: "one", settings: { autoBackupRetention: 7 } },
{ projectId: "two", settings: { autoBackupRetention: 30 } },
], {}, "2026-07-16T16:00:00.000Z");
expect(result.values).toEqual({});
expect(result.conflicts[0]).toMatchObject({
key: "autoBackupRetention",
candidates: [
{ source: "project", projectId: "one", value: 7 },
{ source: "project", projectId: "two", value: 30 },
],
});
});
it("never overwrites an existing global value", () => {
const result = planBackupSettingsMigration(
[{ projectId: "one", settings: { autoBackupRetention: 30 } }],
{ autoBackupRetention: 7 },
);
expect(result.values.autoBackupRetention).toBe(7);
expect(result.conflicts[0]?.candidates).toEqual([
{ source: "global", value: 7 },
{ source: "project", projectId: "one", value: 30 },
]);
});
});

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import {
GLOBAL_SETTINGS_KEYS,
PROJECT_SETTINGS_KEYS,
isGlobalSettingsKey,
isProjectSettingsKey,
} from "../settings-schema.js";
describe("database backup settings scope", () => {
const databaseKeys = ["autoBackupEnabled", "autoBackupSchedule", "autoBackupRetention", "autoBackupDir"] as const;
const memoryKeys = ["memoryBackupEnabled", "memoryBackupSchedule", "memoryBackupRetention", "memoryBackupDir", "memoryBackupScope"] as const;
it("keeps shared database backup policy global and memory snapshots project scoped", () => {
for (const key of databaseKeys) {
expect(GLOBAL_SETTINGS_KEYS).toContain(key);
expect(PROJECT_SETTINGS_KEYS).not.toContain(key);
expect(isGlobalSettingsKey(key)).toBe(true);
expect(isProjectSettingsKey(key)).toBe(false);
}
for (const key of memoryKeys) {
expect(PROJECT_SETTINGS_KEYS).toContain(key);
expect(GLOBAL_SETTINGS_KEYS).not.toContain(key);
expect(isProjectSettingsKey(key)).toBe(true);
expect(isGlobalSettingsKey(key)).toBe(false);
}
});
});

View File

@@ -0,0 +1,188 @@
import { sql } from "drizzle-orm";
import { BACKUP_SCHEDULE_NAME, validateBackupSchedule } from "./backup.js";
import { GlobalRoutineStore } from "./global-routine-store.js";
import type { BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict, GlobalSettings } from "./types.js";
import type { GlobalSettingsStore } from "./global-settings.js";
import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js";
export const BACKUP_SETTINGS_MIGRATION_KEY = "backupSettingsProjectToGlobal:v1";
export const BACKUP_SETTING_KEYS = [
"autoBackupEnabled",
"autoBackupSchedule",
"autoBackupRetention",
"autoBackupDir",
] as const;
export type BackupSettingKey = typeof BACKUP_SETTING_KEYS[number];
export type { BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js";
interface MigrationSnapshot {
phase: "snapshot" | "global-write" | "routine-sync" | "cleanup" | "completed";
values: Partial<Record<BackupSettingKey, unknown>>;
conflicts: BackupSettingsMigrationConflict[];
}
function sameValue(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
/** Derive the durable adoption/conflict decision without mutating either store. */
export function planBackupSettingsMigration(
projectSettings: Array<{ projectId: string; settings: Record<string, unknown> }>,
globalRaw: Record<string, unknown>,
recordedAt = new Date().toISOString(),
): Omit<MigrationSnapshot, "phase"> {
const values: Partial<Record<BackupSettingKey, unknown>> = {};
const conflicts: BackupSettingsMigrationConflict[] = [];
for (const key of BACKUP_SETTING_KEYS) {
const candidates: BackupSettingsMigrationCandidate[] = [];
if (Object.prototype.hasOwnProperty.call(globalRaw, key)) {
candidates.push({ source: "global", value: globalRaw[key] });
}
for (const project of projectSettings) {
if (Object.prototype.hasOwnProperty.call(project.settings, key)) {
candidates.push({ source: "project", projectId: project.projectId, value: project.settings[key] });
}
}
if (candidates.length === 0) continue;
const unique = candidates.filter((candidate, index) =>
candidates.findIndex((other) => sameValue(other.value, candidate.value)) === index,
);
if (unique.length === 1) {
values[key] = candidates[0]!.value;
} else {
const globalCandidate = candidates.find((candidate) => candidate.source === "global");
if (globalCandidate) values[key] = globalCandidate.value;
conflicts.push({ key, candidates, recordedAt });
}
}
return { values, conflicts };
}
async function readMarker(tx: DbTransaction): Promise<MigrationSnapshot | null> {
const rows = await tx.execute(sql`
SELECT value FROM central.__meta WHERE key = ${BACKUP_SETTINGS_MIGRATION_KEY}
`) as unknown as Array<{ value: string | null }>;
const value = rows[0]?.value;
if (!value) return null;
try { return JSON.parse(value) as MigrationSnapshot; } catch { return null; }
}
async function writeMarker(tx: DbTransaction, snapshot: MigrationSnapshot): Promise<void> {
await tx.execute(sql`
INSERT INTO central.__meta (key, value) VALUES (${BACKUP_SETTINGS_MIGRATION_KEY}, ${JSON.stringify(snapshot)})
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`);
}
async function withMigrationLock<T>(layer: AsyncDataLayer, action: (tx: DbTransaction) => Promise<T>): Promise<T> {
return layer.transactionImmediate(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('fusion:backup-settings-project-to-global'))`);
return action(tx);
});
}
async function syncMigratedBackupRoutine(layer: AsyncDataLayer, settings: GlobalSettings): Promise<void> {
const globalRoutines = new GlobalRoutineStore(layer);
if (!settings.autoBackupEnabled) {
await globalRoutines.deleteByName(BACKUP_SCHEDULE_NAME);
return;
}
const schedule = settings.autoBackupSchedule || "0 2 * * *";
if (!validateBackupSchedule(schedule)) throw new Error(`Invalid backup schedule: ${schedule}`);
await globalRoutines.syncBackup({
name: BACKUP_SCHEDULE_NAME,
description: "Automatic backup of the shared global PostgreSQL cluster",
agentId: "",
trigger: { type: "cron", cronExpression: schedule },
command: "fn backup --create",
enabled: true,
});
}
/**
* FNXC:SettingsBackups 2026-07-16-22:00:
* Database-backup keys moved from project config to global settings because one PostgreSQL
* cluster contains every project. Each database marker read/write uses the transaction handle
* that owns the advisory lock, which keeps a poolMax:1 startup connection from deadlocking.
* The replacement central routine is synchronized and marker-recorded before legacy project
* routines and settings are removed, so an enabled migrated backup never has a no-schedule gap.
*/
export async function migrateBackupSettingsToGlobalOnce(
layer: AsyncDataLayer | null,
globalSettingsStore: GlobalSettingsStore,
): Promise<void> {
if (!layer) return;
let snapshot = await withMigrationLock(layer, async (tx) => {
const existing = await readMarker(tx);
if (existing) return existing;
const projects = await tx.execute(sql`SELECT id FROM central.projects`) as unknown as Array<{ id: string }>;
const rows = await tx.execute(sql`SELECT project_id, settings FROM project.config`) as unknown as Array<{ project_id: string; settings: Record<string, unknown> | null }>;
const configs = projects.map((project) => ({
projectId: project.id,
settings: rows.find((row) => row.project_id === project.id)?.settings ?? {},
}));
const created: MigrationSnapshot = { phase: "snapshot", ...planBackupSettingsMigration(configs, await globalSettingsStore.readRaw()) };
await writeMarker(tx, created);
return created;
});
if (snapshot.phase === "completed") return;
if (snapshot.phase === "snapshot") {
await globalSettingsStore.updateSettings({
...snapshot.values,
backupSettingsMigrationConflicts: snapshot.conflicts.length ? snapshot.conflicts : null,
} as Partial<GlobalSettings> & Record<string, unknown>);
snapshot = await withMigrationLock(layer, async (tx) => {
const current = await readMarker(tx);
if (!current || current.phase !== "snapshot") return current ?? snapshot;
const advanced = { ...current, phase: "global-write" as const };
await writeMarker(tx, advanced);
return advanced;
});
}
if (snapshot.phase === "global-write") {
await syncMigratedBackupRoutine(layer, await globalSettingsStore.getSettings());
snapshot = await withMigrationLock(layer, async (tx) => {
const current = await readMarker(tx);
if (!current || current.phase !== "global-write") return current ?? snapshot;
const advanced = { ...current, phase: "routine-sync" as const };
await writeMarker(tx, advanced);
return advanced;
});
}
if (snapshot.phase === "routine-sync") {
snapshot = await withMigrationLock(layer, async (tx) => {
const current = await readMarker(tx);
if (!current || current.phase !== "routine-sync") return current ?? snapshot;
await tx.execute(sql.raw(`UPDATE project.config SET settings = COALESCE(settings, '{}'::jsonb) - 'autoBackupEnabled' - 'autoBackupSchedule' - 'autoBackupRetention' - 'autoBackupDir'`));
await tx.execute(sql`DELETE FROM project.routines WHERE name = ${BACKUP_SCHEDULE_NAME}`);
const advanced = { ...current, phase: "cleanup" as const };
await writeMarker(tx, advanced);
return advanced;
});
}
if (snapshot.phase === "cleanup") {
await withMigrationLock(layer, async (tx) => {
const current = await readMarker(tx);
if (current?.phase === "cleanup") await writeMarker(tx, { ...current, phase: "completed" });
});
}
}
export async function resolveBackupSettingsMigrationConflict(
globalSettingsStore: GlobalSettingsStore,
key: BackupSettingKey,
candidate: BackupSettingsMigrationCandidate,
): Promise<void> {
const settings = await globalSettingsStore.getSettings();
const conflicts = settings.backupSettingsMigrationConflicts ?? [];
const next = conflicts.filter((conflict) => conflict.key !== key);
await globalSettingsStore.updateSettings({
[key]: candidate.value,
backupSettingsMigrationConflicts: next.length ? next : null,
} as Partial<GlobalSettings> & Record<string, unknown>);
}

View File

@@ -1,10 +1,21 @@
import { join } from "node:path";
import { resolveGlobalDir } from "./global-settings.js";
import { CronExpressionParser } from "cron-parser";
import { getDefaultCentralDbPath } from "./central-db.js";
import { PgBackupManager, type PgBackupPair, type PgDumpResult } from "./postgres/pg-backup.js";
import { resolveBackend } from "./postgres/backend-resolver.js";
import { getActiveEmbeddedRuntimeUrl } from "./postgres/active-backend-registry.js";
import type { ProjectSettings } from "./types.js";
import type { Settings } from "./types.js";
/**
* FNXC:SettingsBackups 2026-07-16-14:50:
* Database dumps represent the shared PostgreSQL cluster. Resolve their root once from
* the global settings directory, falling back to the canonical global directory so no
* caller can accidentally create per-project retention sets when that directory is unset.
*/
export function resolveGlobalBackupRoot(store: { getGlobalSettingsDir(): string | undefined }): string {
return store.getGlobalSettingsDir() ?? resolveGlobalDir();
}
export interface BackupFileInfo {
filename: string;
@@ -218,7 +229,7 @@ export function validateBackupDir(dir: string): boolean {
export function createBackupManager(
fusionDir: string,
settings?: Partial<ProjectSettings>,
settings?: Partial<Settings>,
connectionString?: string,
): BackupManager {
let centralDbPath: string;
@@ -297,7 +308,7 @@ function canonicalizeBackupDir(dir: string | undefined): string | undefined {
export async function runBackupCommand(
fusionDir: string,
settings: ProjectSettings
settings: Settings
): Promise<{ success: boolean; output: string; backupPath?: string; deletedCount?: number }> {
if (settings.autoBackupSchedule && !validateBackupSchedule(settings.autoBackupSchedule)) {
return {
@@ -356,7 +367,7 @@ export const BACKUP_SCHEDULE_NAME = "Database Backup";
export async function syncBackupAutomation(
automationStore: import("./automation-store.js").AutomationStore,
settings: ProjectSettings
settings: Settings
): Promise<import("./automation.js").ScheduledTask | undefined> {
const { AutomationStore } = await import("./automation-store.js");
@@ -398,43 +409,45 @@ export async function syncBackupAutomation(
export async function syncBackupRoutine(
routineStore: import("./routine-store.js").RoutineStore,
settings: ProjectSettings,
settings: Settings,
): Promise<import("./routine.js").Routine | undefined> {
const { RoutineStore } = await import("./routine-store.js");
const routines = await routineStore.listRoutines();
const existingRoutine = routines.find((routine) => routine.name === BACKUP_SCHEDULE_NAME);
if (!settings.autoBackupEnabled) {
if (existingRoutine) {
await routineStore.deleteRoutine(existingRoutine.id);
}
return undefined;
}
const schedule = settings.autoBackupSchedule || "0 2 * * *";
if (!RoutineStore.isValidCron(schedule)) {
throw new Error(`Invalid backup schedule: ${schedule}`);
}
const command = "fn backup --create";
const input = {
name: BACKUP_SCHEDULE_NAME,
description: "Automatic database backup based on project settings",
agentId: "",
trigger: { type: "cron" as const, cronExpression: schedule },
command,
enabled: true,
scope: "project" as const,
};
if (existingRoutine) {
return await routineStore.updateRoutine(existingRoutine.id, {
trigger: input.trigger,
command,
/* FNXC:SettingsBackups 2026-07-16-16:20: backend-mode routines are central so
independently opened projects cannot each schedule a dump of the shared cluster. */
if (routineStore.asyncLayer) {
const { GlobalRoutineStore } = await import("./global-routine-store.js");
const globalRoutines = new GlobalRoutineStore(routineStore.asyncLayer);
if (!settings.autoBackupEnabled) {
await globalRoutines.deleteByName(BACKUP_SCHEDULE_NAME);
return undefined;
}
return globalRoutines.syncBackup({
name: BACKUP_SCHEDULE_NAME,
description: "Automatic backup of the shared global PostgreSQL cluster",
agentId: "",
trigger: { type: "cron", cronExpression: schedule },
command: "fn backup --create",
enabled: true,
});
}
return await routineStore.createRoutine(input);
const routines = await routineStore.listRoutines();
const existingRoutine = routines.find((routine) => routine.name === BACKUP_SCHEDULE_NAME);
if (!settings.autoBackupEnabled) {
if (existingRoutine) await routineStore.deleteRoutine(existingRoutine.id);
return undefined;
}
const input = {
name: BACKUP_SCHEDULE_NAME,
description: "Automatic backup of the shared global PostgreSQL cluster",
agentId: "", trigger: { type: "cron" as const, cronExpression: schedule },
command: "fn backup --create", enabled: true, scope: "project" as const,
};
if (existingRoutine) return routineStore.updateRoutine(existingRoutine.id, { trigger: input.trigger, command: input.command, enabled: true });
return routineStore.createRoutine(input);
}

View File

@@ -0,0 +1,104 @@
import { randomUUID } from "node:crypto";
import { CronExpressionParser } from "cron-parser";
import { sql } from "drizzle-orm";
import type { AsyncDataLayer } from "./postgres/data-layer.js";
import type { Routine, RoutineCreateInput } from "./routine.js";
function rowToRoutine(row: Record<string, unknown>): Routine {
return {
id: String(row.id), name: String(row.name), description: row.description ? String(row.description) : undefined,
agentId: String(row.agent_id ?? ""), trigger: row.trigger_config as Routine["trigger"], command: row.command ? String(row.command) : undefined,
enabled: Number(row.enabled) !== 0, scope: "global", catchUpPolicy: "run_one", executionPolicy: "queue",
lastRunAt: row.last_run_at ? String(row.last_run_at) : undefined,
nextRunAt: row.next_run_at ? String(row.next_run_at) : undefined, runCount: Number(row.run_count ?? 0),
createdAt: String(row.created_at), updatedAt: String(row.updated_at), runHistory: (row.run_history as Routine["runHistory"]) ?? [],
};
}
/**
* FNXC:SettingsBackups 2026-07-16-16:15:
* Global routines live in central.global_routines, not project.routines: the latter
* is partitioned by project_id and would create one schedule per project. The unique
* name plus transaction advisory lock make the shared-cluster database backup singular.
*/
export class GlobalRoutineStore {
constructor(private readonly layer: AsyncDataLayer) {}
async syncBackup(input: Pick<RoutineCreateInput, "name" | "description" | "agentId" | "trigger" | "command" | "enabled">): Promise<Routine> {
return this.layer.transactionImmediate(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('fusion:global-backup-routine'))`);
const now = new Date().toISOString();
const id = randomUUID();
const triggerConfig = input.trigger;
const nextRunAt = input.trigger.type === "cron"
? CronExpressionParser.parse(input.trigger.cronExpression).next().toDate().toISOString()
: null;
const rows = await tx.execute(sql`
INSERT INTO central.global_routines (id, name, description, agent_id, trigger_type, trigger_config, command, enabled, next_run_at, created_at, updated_at)
VALUES (${id}, ${input.name}, ${input.description ?? null}, ${input.agentId}, ${input.trigger.type}, ${JSON.stringify(triggerConfig)}::jsonb, ${input.command ?? null}, ${input.enabled ? 1 : 0}, ${nextRunAt}, ${now}, ${now})
ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description, agent_id = EXCLUDED.agent_id,
trigger_type = EXCLUDED.trigger_type, trigger_config = EXCLUDED.trigger_config, command = EXCLUDED.command,
enabled = EXCLUDED.enabled, next_run_at = EXCLUDED.next_run_at, updated_at = EXCLUDED.updated_at
RETURNING *
`) as unknown as Array<Record<string, unknown>>;
return rowToRoutine(rows[0]!);
});
}
async deleteByName(name: string): Promise<void> {
await this.layer.db.execute(sql`DELETE FROM central.global_routines WHERE name = ${name}`);
}
async listDue(now = new Date()): Promise<Routine[]> {
const rows = await this.layer.db.execute(sql`
SELECT * FROM central.global_routines
WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ${now.toISOString()}
`) as unknown as Array<Record<string, unknown>>;
return rows.map(rowToRoutine).filter((routine) => routine.trigger.type === "cron");
}
/**
* FNXC:SettingsBackups 2026-07-16-17:00:
* A due global backup is claimed and advanced in the central row before dispatch.
* This makes concurrent project engines observe one shared due window, rather than
* each dumping the PostgreSQL cluster after independently listing the routine.
*/
async claimDue(id: string, now = new Date()): Promise<Routine | undefined> {
return this.layer.transactionImmediate(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('fusion:global-routine:' || ${id}))`);
const rows = await tx.execute(sql`
SELECT * FROM central.global_routines
WHERE id = ${id} AND enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ${now.toISOString()}
FOR UPDATE
`) as unknown as Array<Record<string, unknown>>;
const row = rows[0];
if (!row) return undefined;
const routine = rowToRoutine(row);
if (routine.trigger.type !== "cron") return undefined;
const nextRunAt = CronExpressionParser.parse(routine.trigger.cronExpression, { currentDate: now })
.next().toDate().toISOString();
await tx.execute(sql`
UPDATE central.global_routines
SET last_run_at = ${now.toISOString()}, next_run_at = ${nextRunAt}, updated_at = ${now.toISOString()}
WHERE id = ${id}
`);
return { ...routine, lastRunAt: now.toISOString(), nextRunAt };
});
}
async completeExecution(id: string, result: import("./routine.js").RoutineExecutionResult): Promise<void> {
await this.layer.transactionImmediate(async (tx) => {
const rows = await tx.execute(sql`SELECT run_history FROM central.global_routines WHERE id = ${id} FOR UPDATE`) as unknown as Array<Record<string, unknown>>;
const existing = rows[0];
if (!existing) return;
const history = Array.isArray(existing.run_history) ? existing.run_history : [];
const runHistory = [result, ...history].slice(0, 50);
await tx.execute(sql`
UPDATE central.global_routines
SET last_run_result = ${JSON.stringify(result)}::jsonb, run_history = ${JSON.stringify(runHistory)}::jsonb,
run_count = run_count + 1, updated_at = ${new Date().toISOString()}
WHERE id = ${id}
`);
});
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1013,6 +1013,25 @@ CREATE TABLE IF NOT EXISTS project.plugins (
updated_at text NOT NULL
);
CREATE TABLE IF NOT EXISTS central.global_routines (
id text PRIMARY KEY,
name text NOT NULL UNIQUE,
description text,
agent_id text NOT NULL DEFAULT '',
trigger_type text NOT NULL,
trigger_config jsonb NOT NULL,
command text,
enabled integer NOT NULL DEFAULT 1,
last_run_at text,
last_run_result jsonb,
next_run_at text,
run_count integer NOT NULL DEFAULT 0,
run_history jsonb NOT NULL DEFAULT '[]',
created_at text NOT NULL,
updated_at text NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_global_routines_next_run_at ON central.global_routines(next_run_at);
CREATE TABLE IF NOT EXISTS project.routines (
id text PRIMARY KEY,
agent_id text NOT NULL DEFAULT '',

View File

@@ -0,0 +1,19 @@
-- FNXC:PostgresSchema 2026-07-16-15:00: global backup schedules belong to the shared cluster, not a project partition.
CREATE TABLE IF NOT EXISTS central.global_routines (
id text PRIMARY KEY,
name text NOT NULL UNIQUE,
description text,
agent_id text NOT NULL DEFAULT '',
trigger_type text NOT NULL,
trigger_config jsonb NOT NULL,
command text,
enabled integer NOT NULL DEFAULT 1,
last_run_at text,
last_run_result jsonb,
next_run_at text,
run_count integer NOT NULL DEFAULT 0,
run_history jsonb NOT NULL DEFAULT '[]',
created_at text NOT NULL,
updated_at text NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_global_routines_next_run_at ON central.global_routines(next_run_at);

View File

@@ -31,7 +31,7 @@ import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type Plugin
FNXC:MultiProjectIsolation 2026-07-15-23:40:
Advances to 0012 after the owner_project_id domain/partition split and chat pin timestamp. Per-migration identities above stay fixed; only this latest-version marker moves.
*/
export const SCHEMA_BASELINE_VERSION = "0012";
export const SCHEMA_BASELINE_VERSION = "0015";
const INITIAL_SCHEMA_VERSION = "0000";
const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002";
@@ -76,6 +76,8 @@ export const CHAT_SESSION_PINS_VERSION = "0012";
export const EXECUTOR_TOOL_FAILURE_RETRY_VERSION = "0013";
/** FNXC:ExecutorEscalation 2026-07-16-21:00: Existing clusters need the durable single-shot latch before executor reads it during post-FN-7996 escalation. */
export const EXECUTOR_ESCALATION_ATTEMPT_VERSION = "0014";
/** FNXC:PostgresSchema 2026-07-16-22:00: central global routines follow main's already-landed 0014 migration. */
export const GLOBAL_ROUTINES_SCHEMA_VERSION = "0015";
/** Bookkeeping table for the fresh Drizzle migration history. */
export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations";
@@ -152,6 +154,11 @@ const EXECUTOR_ESCALATION_ATTEMPT_MIGRATION_PATH = join(
"migrations",
"0014_executor_escalation_attempt.sql",
);
const GLOBAL_ROUTINES_MIGRATION_PATH = join(
__dirname,
"migrations",
"0015_global_routines.sql",
);
/**
* Ensure the migration bookkeeping table exists. Lives in the public schema so
@@ -235,6 +242,7 @@ export async function applySchemaBaseline(
const chatSessionPinsAlreadyApplied = applied.includes(CHAT_SESSION_PINS_VERSION);
const executorToolFailureRetryAlreadyApplied = applied.includes(EXECUTOR_TOOL_FAILURE_RETRY_VERSION);
const executorEscalationAttemptAlreadyApplied = applied.includes(EXECUTOR_ESCALATION_ATTEMPT_VERSION);
const globalRoutinesAlreadyApplied = applied.includes(GLOBAL_ROUTINES_SCHEMA_VERSION);
let schemaChanged = false;
if (!baselineAlreadyApplied) {
@@ -519,6 +527,15 @@ export async function applySchemaBaseline(
schemaChanged = true;
}
if (!globalRoutinesAlreadyApplied) {
const migrationSql = await readFile(GLOBAL_ROUTINES_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(
sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${GLOBAL_ROUTINES_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`,
);
schemaChanged = true;
}
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
});
}

View File

@@ -306,6 +306,25 @@ export const taskClaims = centralSchema.table("task_claims", {
index("idxTaskClaimsOwner").on(t.ownerNodeId),
]);
/** FNXC:SettingsBackups 2026-07-16-15:00: a unique central name makes a shared-cluster routine impossible to duplicate per project. */
export const globalRoutines = centralSchema.table("global_routines", {
id: text("id").primaryKey(),
name: text("name").notNull().unique(),
description: text("description"),
agentId: text("agent_id").notNull().default(""),
triggerType: text("trigger_type").notNull(),
triggerConfig: jsonb("trigger_config").notNull(),
command: text("command"),
enabled: integer("enabled").notNull().default(1),
lastRunAt: text("last_run_at"),
lastRunResult: jsonb("last_run_result"),
nextRunAt: text("next_run_at"),
runCount: integer("run_count").notNull().default(0),
runHistory: jsonb("run_history").notNull().default([]),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
});
// ── Schema version meta ──────────────────────────────────────────────
export const centralMeta = centralSchema.table("__meta", {
key: text("key").primaryKey(),
@@ -321,5 +340,5 @@ export const centralTableNames = [
"central_activity_log", "global_concurrency", "central_settings",
"peer_nodes", "settings_sync_state", "managed_docker_nodes",
"plugin_installs", "project_plugin_states", "mesh_shared_snapshots",
"mesh_write_queue", "secrets_global", "task_claims", "__meta",
"mesh_write_queue", "secrets_global", "task_claims", "global_routines", "__meta",
] as const;

View File

@@ -847,6 +847,17 @@ export async function createTaskStoreForBackend(
}`,
);
}
/*
FNXC:SettingsBackups 2026-07-16-16:00:
This is deliberately the sole store-open entry point for the coordinated backup
scope migration. The TaskStore now exposes the JSON GlobalSettingsStore while
the bound AsyncDataLayer provides central marker/lock access; schema bootstrap
has neither capability and must not attempt this cross-storage migration.
*/
await (await import("../backup-settings-migration.js")).migrateBackupSettingsToGlobalOnce(
taskStore.getAsyncLayer(),
taskStore.getGlobalSettingsStore(),
);
log.log(`startup phase backend.factory.total: ${Date.now() - factoryT0}ms`);
/*

View File

@@ -84,6 +84,16 @@ export const DEFAULT_GLOBAL_SETTINGS = {
shadcnCustomColors: undefined,
dashboardFontScalePct: 100,
/*
FNXC:SettingsBackups 2026-07-16-14:20:
PostgreSQL holds every project in one shared cluster, so database backup policy is global.
Memory snapshots remain project-scoped because their files live under each project’s .fusion directory.
*/
autoBackupEnabled: false,
autoBackupSchedule: "0 2 * * *",
autoBackupRetention: 7,
autoBackupDir: ".fusion/backups",
backupSettingsMigrationConflicts: undefined,
/*
FNXC:DashboardShortcuts 2026-07-04-00:00:
Global dashboard shortcuts must hydrate with documented safe defaults even when old settings files are missing the object. Space opens Quick Chat; Ctrl+` opens Terminal without colliding with common browser find/search accelerators. FN-7553 adds openFiles (Ctrl+E), openSettings (Ctrl+,), openCommandCenter (Ctrl+K), and newTask (Ctrl+Shift+N) — chosen to avoid colliding with the base two or each other. Empty strings are preserved so operators can disable an action.
*/
@@ -622,10 +632,6 @@ export const DEFAULT_PROJECT_SETTINGS = {
githubTrackingDedupEnabled: true,
githubAuthMode: "gh-cli",
githubAuthToken: undefined,
autoBackupEnabled: false,
autoBackupSchedule: "0 2 * * *",
autoBackupRetention: 7,
autoBackupDir: ".fusion/backups",
memoryBackupEnabled: false,
memoryBackupSchedule: "0 3 * * *",
memoryBackupRetention: 14,

View File

@@ -2362,6 +2362,19 @@ export interface DashboardKeyboardShortcuts {
newTask?: string;
}
export interface BackupSettingsMigrationCandidate {
source: "global" | "project";
projectId?: string;
value: unknown;
}
/** A preserved operator-choice record when legacy project backup values diverge. */
export interface BackupSettingsMigrationConflict {
key: "autoBackupEnabled" | "autoBackupSchedule" | "autoBackupRetention" | "autoBackupDir";
candidates: BackupSettingsMigrationCandidate[];
recordedAt: string;
}
export interface GlobalSettings {
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
themeMode?: ThemeMode;
@@ -2371,6 +2384,16 @@ export interface GlobalSettings {
shadcnCustomColors?: Record<string, string>;
/** Dashboard font size scale percentage. Bounded to 85-125. Default: 100. */
dashboardFontScalePct?: number;
/** When true, automatic database backups for the shared PostgreSQL cluster are enabled. Default: false. */
autoBackupEnabled?: boolean;
/** Cron expression for the shared database backup schedule. Default: "0 2 * * *". */
autoBackupSchedule?: string;
/** Number of shared database backup files to retain. Default: 7. */
autoBackupRetention?: number;
/** Directory for shared database backup files, relative to the global Fusion directory. Default: ".fusion/backups". */
autoBackupDir?: string;
/** Durable candidates requiring an operator choice after project-to-global backup migration. */
backupSettingsMigrationConflicts?: BackupSettingsMigrationConflict[];
/**
* FNXC:DashboardShortcuts 2026-07-04-00:00:
* Dashboard keyboard shortcuts are global operator preferences because they control browser UI affordances, not project execution policy. Defaults keep Space for Quick Chat and Ctrl+` for Terminal; blank values intentionally disable an action.
@@ -3923,14 +3946,6 @@ export interface ProjectSettings {
/** Personal access token used when githubAuthMode is "token" (FN-3868).
* Stored as a plain settings string in this phase. */
githubAuthToken?: string;
/** 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: ".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). */

View File

@@ -60,6 +60,7 @@ import { MemorySection } from "./settings/sections/MemorySection";
import { ResearchProjectSection } from "./settings/sections/ResearchProjectSection";
import { ProjectMcpSection } from "./settings/sections/ProjectMcpSection";
import { BackupsSection } from "./settings/sections/BackupsSection";
import { DatabaseBackupsSection } from "./settings/sections/DatabaseBackupsSection";
import { LoadingSpinner } from "./LoadingSpinner";
import { PluginsSection } from "./settings/sections/PluginsSection";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
@@ -647,7 +648,8 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
FN-7062 requires the remote settings nav entry to read "Remote Access" only. The stale "& Node Sync" suffix belongs to the separate Node Sync settings section, while this section body already uses the Remote Access heading.
*/
{ id: "remote", label: "Remote Access", labelKey: "settings.nav.remote", scope: "global", searchableText: ["cloudflared", "tunnel", "QR", "persistent token", "remote URL"] },
{ id: "backups", label: "Backups", labelKey: "settings.nav.backups", scope: "project", searchableText: ["backup", "restore", "settings export", "settings import"] },
{ id: "backups-global", label: "Database Backups", labelKey: "settings.backups.databaseBackups", scope: "global", searchableText: ["database backup", "restore", "shared cluster"] },
{ id: "backups", label: "Memory Backups", labelKey: "settings.backups.memoryBackups", scope: "project", searchableText: ["memory backup", "memory snapshot"] },
{ id: "__advanced_header", label: "Advanced", labelKey: "settings.nav.advancedHeader", scope: undefined, isGroupHeader: true },
{ id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global", searchableText: ["feature flags", "experiments", "research view", "evals view", "sandbox", "subtask breakdown"] },
@@ -4103,9 +4105,9 @@ export function SettingsModal({
hiddenFeatureKeys={HIDDEN_EXPERIMENTAL_FEATURE_KEYS}
/>
);
case "backups":
case "backups-global":
return (
<BackupsSection
<DatabaseBackupsSection
form={form}
setForm={setForm}
backupInfo={backupInfo}
@@ -4113,6 +4115,8 @@ export function SettingsModal({
onBackupNow={handleBackupNow}
/>
);
case "backups":
return <BackupsSection form={form} setForm={setForm} />;
case "notifications":
return (
<NotificationsSection

View File

@@ -16,6 +16,7 @@ const PROJECT_KEY_SET = new Set<string>(PROJECT_SETTINGS_KEYS as readonly string
const EXPECTED_KEY_OWNING_SECTIONS: Record<string, "global" | "project"> = {
// global sections (reused from GLOBAL_SECTION_KEYS in save-split.ts)
appearance: "global",
"backups-global": "global",
notifications: "global",
experimental: "global",
"global-general": "global",

View File

@@ -100,6 +100,8 @@ which GLOBAL keys belong to which settings section, instead of duplicating
the list for the "Reset this menu" feature.
*/
export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
/* FNXC:SettingsBackups 2026-07-16-14:35: shared PostgreSQL backup policy may only write through its global Database Backups section. */
"backups-global": new Set(["autoBackupEnabled", "autoBackupSchedule", "autoBackupRetention", "autoBackupDir"]),
appearance: new Set([
"themeMode",
"colorTheme",

View File

@@ -39,6 +39,7 @@ function sectionFiles(): string[] {
const SECTION_FILE_TO_ID: Record<string, string> = {
"AppearanceSection.tsx": "appearance",
"BackupsSection.tsx": "backups",
"DatabaseBackupsSection.tsx": "backups-global",
"CommandsSection.tsx": "commands",
"GeneralSection.tsx": "general",
"GlobalGeneralSection.tsx": "global-general",

View File

@@ -9,6 +9,7 @@
import type { SettingsSearchEntry } from "./types";
import { appearanceSearchEntries } from "../sections/AppearanceSection.search";
import { backupsSearchEntries } from "../sections/BackupsSection.search";
import { databaseBackupsSearchEntries } from "../sections/DatabaseBackupsSection.search";
import { commandsSearchEntries } from "../sections/CommandsSection.search";
import { generalSearchEntries } from "../sections/GeneralSection.search";
import { globalGeneralSearchEntries } from "../sections/GlobalGeneralSection.search";
@@ -36,6 +37,7 @@ import { worktreesSearchEntries } from "../sections/WorktreesSection.search";
export const SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = [
...appearanceSearchEntries,
...backupsSearchEntries,
...databaseBackupsSearchEntries,
...commandsSearchEntries,
...generalSearchEntries,
...globalGeneralSearchEntries,

View File

@@ -164,10 +164,6 @@ const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
],
"agent-permissions": ["agentProvisioning", "defaultAgentPermissionPolicy"],
backups: [
"autoBackupDir",
"autoBackupEnabled",
"autoBackupRetention",
"autoBackupSchedule",
"memoryBackupDir",
"memoryBackupEnabled",
"memoryBackupRetention",

View File

@@ -8,46 +8,6 @@
import type { SettingsSearchEntry } from "../search/types";
export const backupsSearchEntries: SettingsSearchEntry[] = [
{
sectionId: "backups",
key: "autoBackupEnabled",
labelKey: "settings.backups.enableAutomaticDatabaseBackups",
labelFallback: " Enable automatic database backups ",
helpKey: "settings.backups.whenEnabledTheDatabaseIsBackedUpAutomatically",
helpFallback:
"When enabled, the database is backed up automatically on a schedule. Default: disabled.",
keywords: ["sqlite", "snapshot", "restore"],
},
{
sectionId: "backups",
key: "autoBackupSchedule",
labelKey: "settings.backups.backupScheduleCron",
labelFallback: "Backup Schedule (Cron)",
helpKey: "settings.backups.cronExpressionForBackupTimingDefault02",
helpFallback:
" Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) ",
keywords: ["timing", "frequency"],
},
{
sectionId: "backups",
key: "autoBackupRetention",
labelKey: "settings.backups.retentionCount",
labelFallback: "Retention Count",
helpKey: "settings.backups.numberOfBackupFilesToKeepOldestAre",
helpFallback:
"Number of backup files to keep (oldest are deleted first). Range: 1-100. Default: 7.",
keywords: ["how many", "prune", "rotation"],
},
{
sectionId: "backups",
key: "autoBackupDir",
labelKey: "settings.backups.backupDirectory",
labelFallback: "Backup Directory",
helpKey: "settings.backups.directoryForBackupFilesRelativeToProjectRoot",
helpFallback:
"Directory for backup files, relative to project root. Default: .fusion/backups.",
keywords: ["location", "folder", "destination"],
},
{
sectionId: "backups",
key: "memoryBackupEnabled",

View File

@@ -1,82 +1,16 @@
import { useTranslation } from "react-i18next";
import type { BackupListResponse } from "../../../api";
import { SettingsToggleRow } from "../SettingsToggleRow";
import { SettingsSelectRow } from "../SettingsSelectRow";
import { SettingsNumberRow } from "../SettingsNumberRow";
import { SettingsTextRow } from "../SettingsTextRow";
import type { SectionBaseProps } from "./context";
import { LoadingSpinner } from "../../LoadingSpinner";
export interface BackupsSectionProps extends SectionBaseProps {
backupInfo: BackupListResponse | null;
backupLoading: boolean;
onBackupNow: () => void;
}
/*
FNXC:SettingsBackups 2026-07-15-17:35:
Every schedule/retention/directory row is gated on its own `*Enabled` toggle: the cron, retention count, and target directory only describe an automatic backup that is actually scheduled, so they are disabled rather than hidden — an operator turning backups on needs to see the values that will take effect.
Per-row validation (cron shape, 1-100 retention range, `..` traversal) rides the primitive's `error` band instead of a trailing `field-error` small, so an invalid value reports against the control that owns it.
FNXC:SettingsBackups 2026-07-16-14:35:
Memory backup files are project-owned, so only memory settings remain in this project-scoped section after database backup policy moved to the global cluster section.
*/
export function BackupsSection({ form, setForm, backupInfo, backupLoading, onBackupNow }: BackupsSectionProps) {
const { t } = useTranslation("app");
return (<>
<h4 className="settings-section-heading">{t("settings.backups.databaseBackups", "Database Backups")}</h4>
<SettingsToggleRow
descriptor={{
key: "autoBackupEnabled",
label: t("settings.backups.enableAutomaticDatabaseBackups", " Enable automatic database backups "),
help: t("settings.backups.whenEnabledTheDatabaseIsBackedUpAutomatically", "When enabled, the database is backed up automatically on a schedule. Default: disabled."),
scope: "project",
}}
value={form.autoBackupEnabled || false}
onChange={(v) => setForm((f) => ({ ...f, autoBackupEnabled: v === true }))}
/>
<SettingsTextRow
descriptor={{
key: "autoBackupSchedule",
label: t("settings.backups.backupScheduleCron", "Backup Schedule (Cron)"),
help: t("settings.backups.cronExpressionForBackupTimingDefault02", " Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) "),
scope: "project",
placeholder: t("settings.backups.02", "0 2 * * *"),
disabled: !form.autoBackupEnabled,
}}
value={form.autoBackupSchedule || "0 2 * * *"}
onChange={(v) => setForm((f) => ({ ...f, autoBackupSchedule: v ?? "" }))}
error={form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule)
? t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format")
: undefined}
/>
<SettingsNumberRow
descriptor={{
key: "autoBackupRetention",
label: t("settings.backups.retentionCount", "Retention Count"),
help: t("settings.backups.numberOfBackupFilesToKeepOldestAre", "Number of backup files to keep (oldest are deleted first). Range: 1-100. Default: 7."),
scope: "project",
min: 1,
max: 100,
disabled: !form.autoBackupEnabled,
}}
value={form.autoBackupRetention ?? null}
onChange={(v) => setForm((f) => ({ ...f, autoBackupRetention: v ?? undefined }))}
error={form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100)
? t("settings.backups.mustBeBetween1And100", "Must be between 1 and 100")
: undefined}
/>
<SettingsTextRow
descriptor={{
key: "autoBackupDir",
label: t("settings.backups.backupDirectory", "Backup Directory"),
help: t("settings.backups.directoryForBackupFilesRelativeToProjectRoot", "Directory for backup files, relative to project root. Default: .fusion/backups."),
scope: "project",
placeholder: t("settings.backups.fusionBackups", ".fusion/backups"),
disabled: !form.autoBackupEnabled,
}}
value={form.autoBackupDir || ".fusion/backups"}
onChange={(v) => setForm((f) => ({ ...f, autoBackupDir: v ?? "" }))}
error={form.autoBackupDir && form.autoBackupDir.includes("..")
? t("settings.backups.pathCannotContainParentDirectoryTraversal", "Path cannot contain parent directory traversal (..)")
: undefined}
/>
export function BackupsSection({ form, setForm }: SectionBaseProps) {
const { t } = useTranslation("app");
return (<>
<h4 className="settings-section-heading">{t("settings.backups.memoryBackups", "Memory Backups")}</h4>
<SettingsToggleRow
descriptor={{
@@ -150,42 +84,6 @@ export function BackupsSection({ form, setForm, backupInfo, backupLoading, onBac
value={form.memoryBackupScope || "all"}
onChange={(v) => setForm((f) => ({ ...f, memoryBackupScope: (v ?? "all") as "project" | "agents" | "all" }))}
/>
{backupLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.backups.loadingBackupInfo", "Loading backup info\u2026")} /></div>) : backupInfo ? (<div className="form-group">
<label>{t("settings.backups.currentBackups", "Current Backups")}</label>
<div className="backup-stats">
<div className="backup-stat">
<span className="backup-stat-value">{backupInfo.count}</span>
<span className="backup-stat-label">{t("settings.backups.backups", "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">{t("settings.backups.totalSize", "total size")}</span>
</div>
</div>
{backupInfo.backups.length > 0 && (<details className="backup-list">
<summary>{t("settings.backups.view", "View ")}{backupInfo.backups.length}{t("settings.backups.backupS", " 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>{t("settings.backups.and", "...and ")}{backupInfo.backups.length - 10}{t("settings.backups.more", " more")}</em></li>)}
</ul>
</details>)}
</div>) : null}
<div className="form-group">
<button type="button" className="btn btn-sm" onClick={onBackupNow} disabled={backupLoading}>
{backupLoading ? t("settings.backups.creating", "Creating…") : t("settings.backups.backupNow", "Backup Now")}
</button>
</div>
</>);
}
export default BackupsSection;

View File

@@ -0,0 +1,51 @@
/**
* Search entries for the global Database Backups section.
*
* FNXC:SettingsSearch 2026-07-15-17:35:
* One entry per settings control the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
* The section's backup-stats panel and "Backup Now" button are deliberately absent — they report and trigger, they do not configure.
*/
import type { SettingsSearchEntry } from "../search/types";
export const databaseBackupsSearchEntries: SettingsSearchEntry[] = [
{
sectionId: "backups-global",
key: "autoBackupEnabled",
labelKey: "settings.backups.enableAutomaticDatabaseBackups",
labelFallback: " Enable automatic database backups ",
helpKey: "settings.backups.whenEnabledTheDatabaseIsBackedUpAutomatically",
helpFallback:
"When enabled, the database is backed up automatically on a schedule. Default: disabled.",
keywords: ["sqlite", "snapshot", "restore"],
},
{
sectionId: "backups-global",
key: "autoBackupSchedule",
labelKey: "settings.backups.backupScheduleCron",
labelFallback: "Backup Schedule (Cron)",
helpKey: "settings.backups.cronExpressionForBackupTimingDefault02",
helpFallback:
" Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) ",
keywords: ["timing", "frequency"],
},
{
sectionId: "backups-global",
key: "autoBackupRetention",
labelKey: "settings.backups.retentionCount",
labelFallback: "Retention Count",
helpKey: "settings.backups.numberOfBackupFilesToKeepOldestAre",
helpFallback:
"Number of backup files to keep (oldest are deleted first). Range: 1-100. Default: 7.",
keywords: ["how many", "prune", "rotation"],
},
{
sectionId: "backups-global",
key: "autoBackupDir",
labelKey: "settings.backups.backupDirectory",
labelFallback: "Backup Directory",
helpKey: "settings.backups.directoryForBackupFilesRelativeToProjectRoot",
helpFallback:
"Directory for backup files, relative to project root. Default: .fusion/backups.",
keywords: ["location", "folder", "destination"],
},
];

View File

@@ -0,0 +1,142 @@
import { useTranslation } from "react-i18next";
import type { BackupListResponse } from "../../../api";
import { SettingsToggleRow } from "../SettingsToggleRow";
import { SettingsNumberRow } from "../SettingsNumberRow";
import { SettingsTextRow } from "../SettingsTextRow";
import type { BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "@fusion/core";
import type { SectionBaseProps } from "./context";
import { LoadingSpinner } from "../../LoadingSpinner";
export interface DatabaseBackupsSectionProps extends SectionBaseProps {
backupInfo: BackupListResponse | null;
backupLoading: boolean;
onBackupNow: () => void;
}
/*
FNXC:SettingsBackups 2026-07-16-14:35:
The global PostgreSQL cluster contains every project, so database controls and the one shared backup destination live in this global section. Memory snapshots remain in the project section.
*/
export function DatabaseBackupsSection({ form, setForm, backupInfo, backupLoading, onBackupNow }: DatabaseBackupsSectionProps) {
const { t } = useTranslation("app");
const conflicts = form.backupSettingsMigrationConflicts ?? [];
const chooseCandidate = (conflict: BackupSettingsMigrationConflict, candidate: BackupSettingsMigrationCandidate) => {
setForm((current) => ({
...current,
[conflict.key]: candidate.value,
backupSettingsMigrationConflicts: conflicts.filter((entry) => entry.key !== conflict.key),
}));
};
return (<>
{conflicts.length > 0 && <div className="settings-notice settings-notice-warning" role="status">
<strong>{t("settings.backups.migrationConflict", "Choose database backup settings")}</strong>
<p>{t("settings.backups.migrationConflictHelp", "Existing projects used different backup settings. Choose the value to use globally.")}</p>
{conflicts.map((conflict) => <div key={conflict.key}>
<span>{conflict.key}</span>
{conflict.candidates.map((candidate, index) => <button
type="button"
className="btn btn-sm"
key={`${candidate.source}-${candidate.projectId ?? "global"}-${index}`}
onClick={() => chooseCandidate(conflict, candidate)}
>
{candidate.source === "global"
? t("settings.backups.existingGlobalValue", "Existing global value")
: `${t("settings.backups.projectValue", "Project value")}: ${candidate.projectId}`}
</button>)}
</div>)}
</div>}
<h4 className="settings-section-heading">{t("settings.backups.databaseBackups", "Database Backups")}</h4>
<SettingsToggleRow
descriptor={{
key: "autoBackupEnabled",
label: t("settings.backups.enableAutomaticDatabaseBackups", " Enable automatic database backups "),
help: t("settings.backups.whenEnabledTheDatabaseIsBackedUpAutomatically", "When enabled, the database is backed up automatically on a schedule. Default: disabled."),
scope: "global",
}}
value={form.autoBackupEnabled || false}
onChange={(v) => setForm((f) => ({ ...f, autoBackupEnabled: v === true }))}
/>
<SettingsTextRow
descriptor={{
key: "autoBackupSchedule",
label: t("settings.backups.backupScheduleCron", "Backup Schedule (Cron)"),
help: t("settings.backups.cronExpressionForBackupTimingDefault02", " Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) "),
scope: "global",
placeholder: t("settings.backups.02", "0 2 * * *"),
disabled: !form.autoBackupEnabled,
}}
value={form.autoBackupSchedule || "0 2 * * *"}
onChange={(v) => setForm((f) => ({ ...f, autoBackupSchedule: v ?? "" }))}
error={form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule)
? t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format")
: undefined}
/>
<SettingsNumberRow
descriptor={{
key: "autoBackupRetention",
label: t("settings.backups.retentionCount", "Retention Count"),
help: t("settings.backups.numberOfBackupFilesToKeepOldestAre", "Number of backup files to keep (oldest are deleted first). Range: 1-100. Default: 7."),
scope: "global",
min: 1,
max: 100,
disabled: !form.autoBackupEnabled,
}}
value={form.autoBackupRetention ?? null}
onChange={(v) => setForm((f) => ({ ...f, autoBackupRetention: v ?? undefined }))}
error={form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100)
? t("settings.backups.mustBeBetween1And100", "Must be between 1 and 100")
: undefined}
/>
<SettingsTextRow
descriptor={{
key: "autoBackupDir",
label: t("settings.backups.backupDirectory", "Backup Directory"),
help: t("settings.backups.directoryForBackupFilesRelativeToProjectRoot", "Directory for backup files, relative to the global Fusion directory. Default: .fusion/backups."),
scope: "global",
placeholder: t("settings.backups.fusionBackups", ".fusion/backups"),
disabled: !form.autoBackupEnabled,
}}
value={form.autoBackupDir || ".fusion/backups"}
onChange={(v) => setForm((f) => ({ ...f, autoBackupDir: v ?? "" }))}
error={form.autoBackupDir && form.autoBackupDir.includes("..")
? t("settings.backups.pathCannotContainParentDirectoryTraversal", "Path cannot contain parent directory traversal (..)")
: undefined}
/>
{backupLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.backups.loadingBackupInfo", "Loading backup info\u2026")} /></div>) : backupInfo ? (<div className="form-group">
<label>{t("settings.backups.currentBackups", "Current Backups")}</label>
<div className="backup-stats">
<div className="backup-stat">
<span className="backup-stat-value">{backupInfo.count}</span>
<span className="backup-stat-label">{t("settings.backups.backups", "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">{t("settings.backups.totalSize", "total size")}</span>
</div>
</div>
{backupInfo.backups.length > 0 && (<details className="backup-list">
<summary>{t("settings.backups.view", "View ")}{backupInfo.backups.length}{t("settings.backups.backupS", " 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>{t("settings.backups.and", "...and ")}{backupInfo.backups.length - 10}{t("settings.backups.more", " more")}</em></li>)}
</ul>
</details>)}
</div>) : null}
<div className="form-group">
<button type="button" className="btn btn-sm" onClick={onBackupNow} disabled={backupLoading}>
{backupLoading ? t("settings.backups.creating", "Creating…") : t("settings.backups.backupNow", "Backup Now")}
</button>
</div>
</>);
}
export default DatabaseBackupsSection;

View File

@@ -323,6 +323,7 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
migration bookkeeping as engine-managed records, not user-editable Settings descriptions.
*/
sqliteMigrationNotice: "startup-factory-managed PostgreSQL migration banner record, not a plain description field",
backupSettingsMigrationConflicts: "startup migration conflict record is rendered conditionally, not a plain settings row",
postgresMigrationInboxMessageSentAt: "engine-written PostgreSQL migration inbox completion-message marker, not a plain description field",
dashboardCurrentNodeId: "dashboard session/PWA restore state, not a setting field",
dashboardCurrentProjectIdByNode: "dashboard session/PWA restore state, not a setting field",

View File

@@ -1857,9 +1857,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.get("/backups", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { createBackupManager } = await import("@fusion/core");
const { createBackupManager, resolveGlobalBackupRoot } = await import("@fusion/core");
const settings = await scopedStore.getSettings();
const manager = createBackupManager(scopedStore.getFusionDir(), settings);
const manager = createBackupManager(resolveGlobalBackupRoot(scopedStore), settings);
const backups = await manager.listBackups();
// Calculate total size
@@ -1885,9 +1885,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.post("/backups", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { runBackupCommand } = await import("@fusion/core");
const { runBackupCommand, resolveGlobalBackupRoot } = await import("@fusion/core");
const settings = await scopedStore.getSettings();
const result = await runBackupCommand(scopedStore.getFusionDir(), settings);
const result = await runBackupCommand(resolveGlobalBackupRoot(scopedStore), settings);
if (result.success) {
res.json({
@@ -5271,9 +5271,9 @@ async function executeSingleCommand(
if (taskStore && isInProcessBackupCommand(command)) {
const fusionDir = taskStore.getFusionDir();
try {
const { runBackupCommand } = await import("@fusion/core");
const { runBackupCommand, resolveGlobalBackupRoot } = await import("@fusion/core");
const settings = await taskStore.getSettings();
const result = await runBackupCommand(fusionDir, settings);
const result = await runBackupCommand(resolveGlobalBackupRoot(taskStore), settings);
const output = truncateAutomationOutput(result.output ?? "", "");
return {
success: result.success,

View File

@@ -583,7 +583,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
router.put("/settings", async (req, res) => {
try {
const { store: scopedStore, engine } = await getProjectContext(req);
const { store: scopedStore } = await getProjectContext(req);
// Strip server-owned fields that should never be persisted to config.json.
// These are computed server-side and injected only on GET /settings.
@@ -613,16 +613,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
clientSettings.overlapIgnorePaths = sanitizeOverlapIgnorePaths(clientSettings.overlapIgnorePaths);
}
// Validate backup settings if provided
if (clientSettings.autoBackupSchedule !== undefined && !validateBackupSchedule(clientSettings.autoBackupSchedule)) {
throw badRequest("Invalid cron expression for autoBackupSchedule");
}
if (clientSettings.autoBackupRetention !== undefined && !validateBackupRetention(clientSettings.autoBackupRetention)) {
throw badRequest("autoBackupRetention must be between 1 and 100");
}
if (clientSettings.autoBackupDir !== undefined && !validateBackupDir(clientSettings.autoBackupDir)) {
throw badRequest("autoBackupDir must be a relative path without '..' traversal");
}
if (clientSettings.autoArchiveDoneAfterMs !== undefined) {
const ageMs = clientSettings.autoArchiveDoneAfterMs;
if (!Number.isInteger(ageMs) || ageMs < 60_000 || ageMs > 10 * 365 * 24 * 60 * 60 * 1000) {
@@ -747,19 +737,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
const settings = await scopedStore.updateSettings(clientSettings);
// Sync backup routine when backup settings change.
const routineStoreForProject = engine?.getRoutineStore() ?? options?.routineStore;
if (routineStoreForProject) {
try {
await syncBackupRoutine(routineStoreForProject, settings);
} catch (err) {
// Log but don't fail the settings update if routine sync fails
runtimeLogger.error("Failed to sync backup routine", {
error: err instanceof Error ? err.message : String(err),
});
}
}
res.json(settings);
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -1945,6 +1922,16 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
// Best-effort: on read failure assume false so a flip-on still fires.
}
const globalPatch = req.body as Record<string, unknown>;
if (globalPatch.autoBackupSchedule !== undefined && (typeof globalPatch.autoBackupSchedule !== "string" || !validateBackupSchedule(globalPatch.autoBackupSchedule))) {
throw badRequest("Invalid cron expression for autoBackupSchedule");
}
if (globalPatch.autoBackupRetention !== undefined && (typeof globalPatch.autoBackupRetention !== "number" || !validateBackupRetention(globalPatch.autoBackupRetention))) {
throw badRequest("autoBackupRetention must be between 1 and 100");
}
if (globalPatch.autoBackupDir !== undefined && (typeof globalPatch.autoBackupDir !== "string" || !validateBackupDir(globalPatch.autoBackupDir))) {
throw badRequest("autoBackupDir must be a relative path without '..' traversal");
}
const settings = await store.updateGlobalSettings(req.body);
// Invalidate global settings caches in all project-scoped stores so the
// next GET /settings?projectId=xxx reads fresh values from disk rather
@@ -1959,6 +1946,12 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
}
}
/* FNXC:SettingsBackups 2026-07-16-14:45: global writes own the single shared-cluster backup routine; project writes must not create competing schedules. */
const backupRoutineStore = options?.engineManager?.getAllEngines().values().next().value?.getRoutineStore?.() ?? options?.routineStore;
if (backupRoutineStore) {
await syncBackupRoutine(backupRoutineStore, await store.getSettings());
}
// Fire the toggle hook only on an actual transition — avoids redundant
// skill-install sweeps when the user saves unrelated settings.
const nextUseClaudeCli = settings.useClaudeCli === true;

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { RoutineScheduler, type RoutineSchedulerOptions } from "../routine-scheduler.js";
import type { RoutineStore, Routine, TaskStore, RoutineExecutionResult, Settings } from "@fusion/core";
import type { GlobalRoutineStore, RoutineStore, Routine, TaskStore, RoutineExecutionResult, Settings } from "@fusion/core";
import type { RoutineRunner } from "../routine-runner.js";
// Default settings inline to avoid @fusion/core build dependency during tests
@@ -91,6 +91,13 @@ function createMockRoutineStore(routines: Routine[] = []): RoutineStore {
function createMockRoutineRunner(): RoutineRunner {
return {
executeGlobalRoutine: vi.fn().mockResolvedValue({
routineId: "test-routine",
success: true,
output: "Success",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
} as RoutineExecutionResult),
executeRoutine: vi.fn().mockResolvedValue({
routineId: "test-routine",
success: true,
@@ -108,7 +115,7 @@ function createRoutineScheduler(
taskStore?: TaskStore,
routineStore?: RoutineStore,
routineRunner?: RoutineRunner,
options?: Partial<Pick<RoutineSchedulerOptions, "pollIntervalMs" | "scope">>,
options?: Partial<Pick<RoutineSchedulerOptions, "pollIntervalMs" | "scope" | "globalRoutineStore">>,
): RoutineScheduler {
return new RoutineScheduler({
taskStore: taskStore ?? createMockTaskStore(),
@@ -116,6 +123,7 @@ function createRoutineScheduler(
routineRunner: routineRunner ?? createMockRoutineRunner(),
pollIntervalMs: options?.pollIntervalMs,
scope: options?.scope,
globalRoutineStore: options?.globalRoutineStore,
});
}
@@ -129,6 +137,49 @@ describe("RoutineScheduler", () => {
vi.clearAllMocks();
});
describe("central global routines", () => {
it("claims and completes a central backup routine once while leaving project routine persistence untouched", async () => {
const globalRoutine = createMockRoutine({ id: "central-backup", scope: "global", command: "fn backup --create" });
const centralStore = {
listDue: vi.fn().mockResolvedValue([globalRoutine]),
claimDue: vi.fn().mockResolvedValue(globalRoutine),
completeExecution: vi.fn().mockResolvedValue(undefined),
} as unknown as GlobalRoutineStore;
const runner = createMockRoutineRunner();
(runner.executeGlobalRoutine as ReturnType<typeof vi.fn>) = vi.fn().mockResolvedValue({
routineId: globalRoutine.id, success: true, output: "backup complete",
startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), triggerType: "cron",
});
const projectStore = createMockRoutineStore([]);
scheduler = createRoutineScheduler(undefined, projectStore, runner, { globalRoutineStore: centralStore });
await scheduler.tick();
expect(centralStore.listDue).toHaveBeenCalledOnce();
expect(centralStore.claimDue).toHaveBeenCalledWith("central-backup");
expect(runner.executeGlobalRoutine).toHaveBeenCalledWith(globalRoutine, "cron");
expect(centralStore.completeExecution).toHaveBeenCalledWith("central-backup", expect.objectContaining({ success: true }));
expect(projectStore.startRoutineExecution).not.toHaveBeenCalled();
expect(projectStore.completeRoutineExecution).not.toHaveBeenCalled();
});
it("does not dispatch when another project engine already claimed the central due window", async () => {
const globalRoutine = createMockRoutine({ id: "central-backup", scope: "global" });
const centralStore = {
listDue: vi.fn().mockResolvedValue([globalRoutine]),
claimDue: vi.fn().mockResolvedValue(undefined),
completeExecution: vi.fn(),
} as unknown as GlobalRoutineStore;
const runner = createMockRoutineRunner();
scheduler = createRoutineScheduler(undefined, undefined, runner, { globalRoutineStore: centralStore });
await scheduler.tick();
expect(runner.executeGlobalRoutine).not.toHaveBeenCalled();
expect(centralStore.completeExecution).not.toHaveBeenCalled();
});
});
describe("constructor", () => {
it("clamps poll interval to minimum 10000ms", () => {
scheduler = createRoutineScheduler(

View File

@@ -626,10 +626,10 @@ export class CronRunner {
error: string | undefined;
}> {
try {
const { runBackupCommand } = await import("@fusion/core");
const { runBackupCommand, resolveGlobalBackupRoot } = await import("@fusion/core");
const fusionDir = this.store.getFusionDir();
const settings = await this.store.getSettings();
const result = await runBackupCommand(fusionDir, settings);
const result = await runBackupCommand(resolveGlobalBackupRoot(this.store), settings);
const output = truncateOutput(result.output ?? "", "");
return {
success: result.success,

View File

@@ -140,7 +140,7 @@ export class RoutineRunner {
const startedAt = new Date().toISOString();
// Set in-flight BEFORE starting execution to prevent race conditions
const executionPromise = this.runExecution(routine, triggerType, context, startedAt, liveCallbacks);
const executionPromise = this.runExecution(routine, triggerType, context, startedAt, liveCallbacks, true);
this.inFlightExecutions.set(routineId, executionPromise);
try {
@@ -156,6 +156,26 @@ export class RoutineRunner {
}
}
/**
* Execute an already-claimed central global routine.
* The scheduler owns central run-state persistence because project RoutineStore
* cannot address central.global_routines.
*/
async executeGlobalRoutine(
routine: Routine,
triggerType: "cron" | "webhook" | "api",
context?: Record<string, unknown>,
): Promise<RoutineExecutionResult> {
const startedAt = new Date().toISOString();
const executionPromise = this.runExecution(routine, triggerType, context, startedAt, undefined, false);
this.inFlightExecutions.set(routine.id, executionPromise);
try {
return await executionPromise;
} finally {
this.inFlightExecutions.delete(routine.id);
}
}
/**
* Internal execution logic for a routine.
*/
@@ -164,7 +184,8 @@ export class RoutineRunner {
triggerType: string,
context: Record<string, unknown> | undefined,
startedAt: string,
liveCallbacks?: RoutineLiveRunCallbacks,
liveCallbacks: RoutineLiveRunCallbacks | undefined,
persistProjectRunState: boolean,
): Promise<RoutineExecutionResult> {
const routineId = routine.id;
@@ -173,15 +194,17 @@ export class RoutineRunner {
? await this.executeRoutineAction(routine, startedAt, liveCallbacks)
: await this.executeAgentRoutine(routine, triggerType, context);
await this.options.routineStore.completeRoutineExecution(routineId, {
completedAt: actionResult.completedAt,
success: actionResult.success,
resultJson: actionResult.success ? { output: actionResult.output } : undefined,
output: actionResult.output,
error: actionResult.error,
triggerType: triggerType as RoutineExecutionResult["triggerType"],
stepResults: actionResult.stepResults,
});
if (persistProjectRunState) {
await this.options.routineStore.completeRoutineExecution(routineId, {
completedAt: actionResult.completedAt,
success: actionResult.success,
resultJson: actionResult.success ? { output: actionResult.output } : undefined,
output: actionResult.output,
error: actionResult.error,
triggerType: triggerType as RoutineExecutionResult["triggerType"],
stepResults: actionResult.stepResults,
});
}
return {
routineId,
@@ -198,14 +221,16 @@ export class RoutineRunner {
log.error(`Routine ${routineId} execution failed: ${errorMessage}`);
// Record failure
try {
await this.options.routineStore.completeRoutineExecution(routineId, {
completedAt: new Date().toISOString(),
success: false,
error: errorMessage,
});
} catch (persistError) {
log.error(`[${routineId}] Failed to persist error state: ${persistError}`);
if (persistProjectRunState) {
try {
await this.options.routineStore.completeRoutineExecution(routineId, {
completedAt: new Date().toISOString(),
success: false,
error: errorMessage,
});
} catch (persistError) {
log.error(`[${routineId}] Failed to persist error state: ${persistError}`);
}
}
return {
@@ -302,10 +327,10 @@ export class RoutineRunner {
// the nested `.fusion/.fusion/` directory). Mirrors cron-runner.ts.
if (isInProcessBackupCommand(command) && this.options.taskStore) {
try {
const { runBackupCommand } = await import("@fusion/core");
const { runBackupCommand, resolveGlobalBackupRoot } = await import("@fusion/core");
const fusionDir = this.options.taskStore.getFusionDir();
const settings = await this.options.taskStore.getSettings();
const result = await runBackupCommand(fusionDir, settings);
const result = await runBackupCommand(resolveGlobalBackupRoot(this.options.taskStore), settings);
const output = truncateOutput(result.output ?? "", "");
return {
success: result.success,

View File

@@ -21,7 +21,7 @@
*/
import { CronExpressionParser } from "cron-parser";
import type { Routine, RoutineStore, TaskStore } from "@fusion/core";
import { GlobalRoutineStore, type Routine, type RoutineStore, type TaskStore } from "@fusion/core";
import { RoutineRunner } from "./routine-runner.js";
import { createLogger } from "./logger.js";
@@ -37,6 +37,8 @@ export interface RoutineSchedulerOptions {
routineStore: RoutineStore;
/** RoutineRunner for executing routines */
routineRunner: RoutineRunner;
/** Central owner for globally shared PostgreSQL backup routines. */
globalRoutineStore?: GlobalRoutineStore;
/** Polling interval in milliseconds. Default: 60000 (60s). Minimum: 10000 (10s). */
pollIntervalMs?: number;
/**
@@ -55,6 +57,7 @@ export class RoutineScheduler {
private taskStore: TaskStore;
private routineStore: RoutineStore;
private routineRunner: RoutineRunner;
private globalRoutineStore?: GlobalRoutineStore;
private pollIntervalMs: number;
/** Scope to poll: "global", "project", or "all". */
private scope: "global" | "project" | "all";
@@ -67,6 +70,8 @@ export class RoutineScheduler {
this.taskStore = options.taskStore;
this.routineStore = options.routineStore;
this.routineRunner = options.routineRunner;
this.globalRoutineStore = options.globalRoutineStore
?? (this.routineStore.asyncLayer ? new GlobalRoutineStore(this.routineStore.asyncLayer) : undefined);
this.pollIntervalMs = Math.max(10000, options.pollIntervalMs ?? 60000);
this.scope = options.scope ?? "project";
}
@@ -149,11 +154,23 @@ export class RoutineScheduler {
dueRoutines = await this.routineStore.getDueRoutines(this.scope);
}
if (dueRoutines.length === 0) {
// FNXC:SettingsBackups 2026-07-16-17:00:
// Every project engine polls the central backup row, but claimDue serializes
// dispatch and advances its central next-run state so only one engine runs it.
const dueGlobalRoutines = this.globalRoutineStore ? await this.globalRoutineStore.listDue() : [];
if (dueRoutines.length === 0 && dueGlobalRoutines.length === 0) {
return;
}
logger.log(`Found ${dueRoutines.length} due routines (scope: ${this.scope})`);
logger.log(`Found ${dueRoutines.length} project and ${dueGlobalRoutines.length} central global due routines (scope: ${this.scope})`);
for (const candidate of dueGlobalRoutines) {
try {
await this.processGlobalRoutine(candidate);
} catch (err) {
logger.error(`[${candidate.id}] Failed to process central global routine: ${err}`);
}
}
// Track executed routine IDs to prevent double-execution when polling all scopes
const executedIds = new Set<string>();
@@ -195,6 +212,18 @@ export class RoutineScheduler {
}
}
/**
* Claim, execute, and persist a central routine independently of the
* project-partitioned RoutineStore.
*/
private async processGlobalRoutine(candidate: Routine): Promise<void> {
if (!this.globalRoutineStore) return;
const routine = await this.globalRoutineStore.claimDue(candidate.id);
if (!routine) return;
const result = await this.routineRunner.executeGlobalRoutine(routine, "cron");
await this.globalRoutineStore.completeExecution(routine.id, result);
}
/**
* Process a single routine.
*/