From c6be0b158bc2274a4a9f4831218fca1a111d11c7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 16:34:09 -0700 Subject: [PATCH] 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) --- .../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 +- .../backup-settings-migration.test.ts | 50 +++++ .../__tests__/backup-settings-scope.test.ts | 27 +++ .../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 +- .../src/postgres/migrations/0000_initial.sql | 19 ++ .../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 ++- .../app/components/SettingsModal.tsx | 10 +- .../settings/__tests__/section-keys.test.ts | 1 + .../app/components/settings/save-split.ts | 2 + .../__tests__/settings-search-index.test.ts | 1 + .../app/components/settings/search/entries.ts | 2 + .../app/components/settings/section-keys.ts | 4 - .../sections/BackupsSection.search.ts | 40 ---- .../settings/sections/BackupsSection.tsx | 112 +---------- .../sections/DatabaseBackupsSection.search.ts | 51 +++++ .../sections/DatabaseBackupsSection.tsx | 142 +++++++++++++ .../settings-default-descriptions.test.tsx | 1 + packages/dashboard/src/routes.ts | 12 +- .../routes/register-settings-memory-routes.ts | 41 ++-- .../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(-) create mode 100644 .changeset/fn-8129-backup-settings-scope-split.md create mode 100644 packages/core/src/__tests__/backup-settings-migration.test.ts create mode 100644 packages/core/src/__tests__/backup-settings-scope.test.ts create mode 100644 packages/core/src/backup-settings-migration.ts create mode 100644 packages/core/src/global-routine-store.ts create mode 100644 packages/core/src/postgres/migrations/0015_global_routines.sql create mode 100644 packages/dashboard/app/components/settings/sections/DatabaseBackupsSection.search.ts create mode 100644 packages/dashboard/app/components/settings/sections/DatabaseBackupsSection.tsx diff --git a/.changeset/fn-8129-backup-settings-scope-split.md b/.changeset/fn-8129-backup-settings-scope-split.md new file mode 100644 index 0000000000..5ff3950547 --- /dev/null +++ b/.changeset/fn-8129-backup-settings-scope-split.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 6b0a90775e..b9aa1943a1 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. +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 diff --git a/docs/settings-reference.md b/docs/settings-reference.md index b8348368e9..95741741f1 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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`. diff --git a/packages/cli/src/commands/backup.ts b/packages/cli/src/commands/backup.ts index ed460ca1c0..4979dfe35c 100644 --- a/packages/cli/src/commands/backup.ts +++ b/packages/cli/src/commands/backup.ts @@ -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 }; diff --git a/packages/core/src/__tests__/backup-settings-migration.test.ts b/packages/core/src/__tests__/backup-settings-migration.test.ts new file mode 100644 index 0000000000..1c85b8e06e --- /dev/null +++ b/packages/core/src/__tests__/backup-settings-migration.test.ts @@ -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 }, + ]); + }); +}); diff --git a/packages/core/src/__tests__/backup-settings-scope.test.ts b/packages/core/src/__tests__/backup-settings-scope.test.ts new file mode 100644 index 0000000000..d7043e8b29 --- /dev/null +++ b/packages/core/src/__tests__/backup-settings-scope.test.ts @@ -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); + } + }); +}); diff --git a/packages/core/src/backup-settings-migration.ts b/packages/core/src/backup-settings-migration.ts new file mode 100644 index 0000000000..0c6afaffca --- /dev/null +++ b/packages/core/src/backup-settings-migration.ts @@ -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>; + 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 }>, + globalRaw: Record, + recordedAt = new Date().toISOString(), +): Omit { + const values: Partial> = {}; + 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 { + 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 { + 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(layer: AsyncDataLayer, action: (tx: DbTransaction) => Promise): Promise { + 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 { + 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 { + 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 | 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 & Record); + 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 { + 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 & Record); +} diff --git a/packages/core/src/backup.ts b/packages/core/src/backup.ts index bdd77b7a1e..d1bac86f2b 100644 --- a/packages/core/src/backup.ts +++ b/packages/core/src/backup.ts @@ -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, + settings?: Partial, 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 { 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 { 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); } diff --git a/packages/core/src/global-routine-store.ts b/packages/core/src/global-routine-store.ts new file mode 100644 index 0000000000..ef2b13bde1 --- /dev/null +++ b/packages/core/src/global-routine-store.ts @@ -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): 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): Promise { + 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>; + return rowToRoutine(rows[0]!); + }); + } + + async deleteByName(name: string): Promise { + await this.layer.db.execute(sql`DELETE FROM central.global_routines WHERE name = ${name}`); + } + + async listDue(now = new Date()): Promise { + 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>; + 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 { + 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>; + 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 { + 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>; + 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} + `); + }); + } +} diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 312562653e..abe33061c9 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -43,7 +43,7 @@ etc. pulled in by production modules, not test files). */ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, PLANNING_DEEPEN_CHECKPOINT_ID, PLANNING_DEEPEN_CHECKPOINT_QUESTION, PLANNING_DEEPEN_PROCEED_OPTION_ID, PLANNING_DEEPEN_PROCEED_RESPONSE_KEY, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; -export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, @@ -1326,8 +1326,12 @@ export { syncBackupRoutine, BACKUP_SCHEDULE_NAME, resolveBackendConnectionString, + resolveGlobalBackupRoot, } from "./backup.js"; export type { BackupInfo, BackupOptions, BackupFileInfo, BackupPairInfo } from "./backup.js"; +export { GlobalRoutineStore } from "./global-routine-store.js"; +export { migrateBackupSettingsToGlobalOnce, planBackupSettingsMigration, resolveBackupSettingsMigrationConflict } from "./backup-settings-migration.js"; +export type { BackupSettingKey } from "./backup-settings-migration.js"; export { registerEmbeddedRuntimeUrl, releaseEmbeddedRuntimeLease, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 481965684d..39e777f731 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, PLANNING_DEEPEN_CHECKPOINT_ID, PLANNING_DEEPEN_CHECKPOINT_QUESTION, PLANNING_DEEPEN_PROCEED_OPTION_ID, PLANNING_DEEPEN_PROCEED_RESPONSE_KEY, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; -export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, @@ -1381,8 +1381,12 @@ export { syncBackupRoutine, BACKUP_SCHEDULE_NAME, resolveBackendConnectionString, + resolveGlobalBackupRoot, } from "./backup.js"; export type { BackupInfo, BackupOptions, BackupFileInfo, BackupPairInfo } from "./backup.js"; +export { GlobalRoutineStore } from "./global-routine-store.js"; +export { migrateBackupSettingsToGlobalOnce, planBackupSettingsMigration, resolveBackupSettingsMigrationConflict } from "./backup-settings-migration.js"; +export type { BackupSettingKey } from "./backup-settings-migration.js"; export { registerEmbeddedRuntimeUrl, releaseEmbeddedRuntimeLease, diff --git a/packages/core/src/postgres/migrations/0000_initial.sql b/packages/core/src/postgres/migrations/0000_initial.sql index 730717ecad..4ecf24c125 100644 --- a/packages/core/src/postgres/migrations/0000_initial.sql +++ b/packages/core/src/postgres/migrations/0000_initial.sql @@ -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 '', diff --git a/packages/core/src/postgres/migrations/0015_global_routines.sql b/packages/core/src/postgres/migrations/0015_global_routines.sql new file mode 100644 index 0000000000..01384f737c --- /dev/null +++ b/packages/core/src/postgres/migrations/0015_global_routines.sql @@ -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); diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 416762d8c5..d705b49566 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -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 }; }); } diff --git a/packages/core/src/postgres/schema/central.ts b/packages/core/src/postgres/schema/central.ts index 3e28c7cb3c..3f04ce0d7e 100644 --- a/packages/core/src/postgres/schema/central.ts +++ b/packages/core/src/postgres/schema/central.ts @@ -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; diff --git a/packages/core/src/postgres/startup-factory.ts b/packages/core/src/postgres/startup-factory.ts index 00148b0ecd..006e02bb2e 100644 --- a/packages/core/src/postgres/startup-factory.ts +++ b/packages/core/src/postgres/startup-factory.ts @@ -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`); /* diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 4ebeddf3fd..48ddc561d0 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -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, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 25e89235f7..88a24399e6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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; /** 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). */ diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 98d4151ae9..5ff92270af 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -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 ( - ); + case "backups": + return ; case "notifications": return ( (PROJECT_SETTINGS_KEYS as readonly string const EXPECTED_KEY_OWNING_SECTIONS: Record = { // global sections (reused from GLOBAL_SECTION_KEYS in save-split.ts) appearance: "global", + "backups-global": "global", notifications: "global", experimental: "global", "global-general": "global", diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index 2237b51943..6d4e657765 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -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> = { + /* 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", diff --git a/packages/dashboard/app/components/settings/search/__tests__/settings-search-index.test.ts b/packages/dashboard/app/components/settings/search/__tests__/settings-search-index.test.ts index e02ea48f8d..c0580e6a28 100644 --- a/packages/dashboard/app/components/settings/search/__tests__/settings-search-index.test.ts +++ b/packages/dashboard/app/components/settings/search/__tests__/settings-search-index.test.ts @@ -39,6 +39,7 @@ function sectionFiles(): string[] { const SECTION_FILE_TO_ID: Record = { "AppearanceSection.tsx": "appearance", "BackupsSection.tsx": "backups", + "DatabaseBackupsSection.tsx": "backups-global", "CommandsSection.tsx": "commands", "GeneralSection.tsx": "general", "GlobalGeneralSection.tsx": "global-general", diff --git a/packages/dashboard/app/components/settings/search/entries.ts b/packages/dashboard/app/components/settings/search/entries.ts index e40fa32511..cfeee4e925 100644 --- a/packages/dashboard/app/components/settings/search/entries.ts +++ b/packages/dashboard/app/components/settings/search/entries.ts @@ -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, diff --git a/packages/dashboard/app/components/settings/section-keys.ts b/packages/dashboard/app/components/settings/section-keys.ts index 0ba1b2385a..948f125d79 100644 --- a/packages/dashboard/app/components/settings/section-keys.ts +++ b/packages/dashboard/app/components/settings/section-keys.ts @@ -164,10 +164,6 @@ const PROJECT_SECTION_KEYS: Record = { ], "agent-permissions": ["agentProvisioning", "defaultAgentPermissionPolicy"], backups: [ - "autoBackupDir", - "autoBackupEnabled", - "autoBackupRetention", - "autoBackupSchedule", "memoryBackupDir", "memoryBackupEnabled", "memoryBackupRetention", diff --git a/packages/dashboard/app/components/settings/sections/BackupsSection.search.ts b/packages/dashboard/app/components/settings/sections/BackupsSection.search.ts index 3172c5efa6..2d606a315e 100644 --- a/packages/dashboard/app/components/settings/sections/BackupsSection.search.ts +++ b/packages/dashboard/app/components/settings/sections/BackupsSection.search.ts @@ -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", diff --git a/packages/dashboard/app/components/settings/sections/BackupsSection.tsx b/packages/dashboard/app/components/settings/sections/BackupsSection.tsx index 7067769df8..65fc66335d 100644 --- a/packages/dashboard/app/components/settings/sections/BackupsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/BackupsSection.tsx @@ -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 (<> -

{t("settings.backups.databaseBackups", "Database Backups")}

- setForm((f) => ({ ...f, autoBackupEnabled: v === true }))} - /> - setForm((f) => ({ ...f, autoBackupSchedule: v ?? "" }))} - error={form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) - ? t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format") - : undefined} - /> - 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} - /> - 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 (<>

{t("settings.backups.memoryBackups", "Memory Backups")}

setForm((f) => ({ ...f, memoryBackupScope: (v ?? "all") as "project" | "agents" | "all" }))} /> - {backupLoading ? (
) : backupInfo ? (
- -
-
- {backupInfo.count} - {t("settings.backups.backups", "backups")} -
-
- - {backupInfo.totalSize > 1024 * 1024 - ? `${(backupInfo.totalSize / (1024 * 1024)).toFixed(1)} MB` - : `${(backupInfo.totalSize / 1024).toFixed(1)} KB`} - - {t("settings.backups.totalSize", "total size")} -
-
- {backupInfo.backups.length > 0 && (
- {t("settings.backups.view", "View ")}{backupInfo.backups.length}{t("settings.backups.backupS", " backup(s)")} -
    - {backupInfo.backups.slice(0, 10).map((backup) => (
  • - {backup.filename} - - {backup.size > 1024 * 1024 - ? `${(backup.size / (1024 * 1024)).toFixed(1)} MB` - : `${(backup.size / 1024).toFixed(1)} KB`} - -
  • ))} - {backupInfo.backups.length > 10 && (
  • {t("settings.backups.and", "...and ")}{backupInfo.backups.length - 10}{t("settings.backups.more", " more")}
  • )} -
-
)} -
) : null} -
- -
); } export default BackupsSection; diff --git a/packages/dashboard/app/components/settings/sections/DatabaseBackupsSection.search.ts b/packages/dashboard/app/components/settings/sections/DatabaseBackupsSection.search.ts new file mode 100644 index 0000000000..a4736031a0 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/DatabaseBackupsSection.search.ts @@ -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"], + }, +]; diff --git a/packages/dashboard/app/components/settings/sections/DatabaseBackupsSection.tsx b/packages/dashboard/app/components/settings/sections/DatabaseBackupsSection.tsx new file mode 100644 index 0000000000..8410e8057f --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/DatabaseBackupsSection.tsx @@ -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 &&
+ {t("settings.backups.migrationConflict", "Choose database backup settings")} +

{t("settings.backups.migrationConflictHelp", "Existing projects used different backup settings. Choose the value to use globally.")}

+ {conflicts.map((conflict) =>
+ {conflict.key} + {conflict.candidates.map((candidate, index) => )} +
)} +
} +

{t("settings.backups.databaseBackups", "Database Backups")}

+ setForm((f) => ({ ...f, autoBackupEnabled: v === true }))} + /> + setForm((f) => ({ ...f, autoBackupSchedule: v ?? "" }))} + error={form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) + ? t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format") + : undefined} + /> + 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} + /> + setForm((f) => ({ ...f, autoBackupDir: v ?? "" }))} + error={form.autoBackupDir && form.autoBackupDir.includes("..") + ? t("settings.backups.pathCannotContainParentDirectoryTraversal", "Path cannot contain parent directory traversal (..)") + : undefined} + /> + + {backupLoading ? (
) : backupInfo ? (
+ +
+
+ {backupInfo.count} + {t("settings.backups.backups", "backups")} +
+
+ + {backupInfo.totalSize > 1024 * 1024 + ? `${(backupInfo.totalSize / (1024 * 1024)).toFixed(1)} MB` + : `${(backupInfo.totalSize / 1024).toFixed(1)} KB`} + + {t("settings.backups.totalSize", "total size")} +
+
+ {backupInfo.backups.length > 0 && (
+ {t("settings.backups.view", "View ")}{backupInfo.backups.length}{t("settings.backups.backupS", " backup(s)")} +
    + {backupInfo.backups.slice(0, 10).map((backup) => (
  • + {backup.filename} + + {backup.size > 1024 * 1024 + ? `${(backup.size / (1024 * 1024)).toFixed(1)} MB` + : `${(backup.size / 1024).toFixed(1)} KB`} + +
  • ))} + {backupInfo.backups.length > 10 && (
  • {t("settings.backups.and", "...and ")}{backupInfo.backups.length - 10}{t("settings.backups.more", " more")}
  • )} +
+
)} +
) : null} +
+ +
+ ); +} +export default DatabaseBackupsSection; diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 1b23c858e5..ee3e643bb6 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -323,6 +323,7 @@ const NOT_SURFACED_ALLOWLIST: Record = { 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", diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 00073a6854..9d18b55a17 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -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, diff --git a/packages/dashboard/src/routes/register-settings-memory-routes.ts b/packages/dashboard/src/routes/register-settings-memory-routes.ts index 55501cd292..505e33ac9d 100644 --- a/packages/dashboard/src/routes/register-settings-memory-routes.ts +++ b/packages/dashboard/src/routes/register-settings-memory-routes.ts @@ -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; + 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; diff --git a/packages/engine/src/__tests__/routine-scheduler.test.ts b/packages/engine/src/__tests__/routine-scheduler.test.ts index bc3ab0c60d..90f9e4ec74 100644 --- a/packages/engine/src/__tests__/routine-scheduler.test.ts +++ b/packages/engine/src/__tests__/routine-scheduler.test.ts @@ -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>, + options?: Partial>, ): 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) = 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( diff --git a/packages/engine/src/cron-runner.ts b/packages/engine/src/cron-runner.ts index f2e03dd38b..256573dde1 100644 --- a/packages/engine/src/cron-runner.ts +++ b/packages/engine/src/cron-runner.ts @@ -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, diff --git a/packages/engine/src/routine-runner.ts b/packages/engine/src/routine-runner.ts index 28f1b6deb5..36984c067d 100644 --- a/packages/engine/src/routine-runner.ts +++ b/packages/engine/src/routine-runner.ts @@ -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, + ): Promise { + 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 | undefined, startedAt: string, - liveCallbacks?: RoutineLiveRunCallbacks, + liveCallbacks: RoutineLiveRunCallbacks | undefined, + persistProjectRunState: boolean, ): Promise { 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, diff --git a/packages/engine/src/routine-scheduler.ts b/packages/engine/src/routine-scheduler.ts index 6d6793bf8f..635c31d25b 100644 --- a/packages/engine/src/routine-scheduler.ts +++ b/packages/engine/src/routine-scheduler.ts @@ -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(); @@ -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 { + 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. */