diff --git a/.changeset/fix-global-settings-reset-project-central-db.md b/.changeset/fix-global-settings-reset-project-central-db.md new file mode 100644 index 0000000000..795e6a23f0 --- /dev/null +++ b/.changeset/fix-global-settings-reset-project-central-db.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix global settings (including the global concurrency cap) intermittently resetting to defaults. +category: fix +dev: Several production call sites built `new CentralCore(store.getFusionDir())`, pointing the central/global DB at the project's `.fusion/` instead of `~/.fusion/` and spawning stray per-project central DBs seeded with default global settings that shadowed real global state. Added `TaskStore.getGlobalSettingsDir()`, routed the secrets store plus the secrets/proxy/node/secrets-sync/settings-sync dashboard routes through it, and added a `resolveGlobalDir()` guard that throws on a project-local `.fusion/` dir (parent is a git repo) so the regression can't silently recur. Existing stray DBs were operator-quarantined. diff --git a/packages/core/src/__tests__/global-settings-guard.test.ts b/packages/core/src/__tests__/global-settings-guard.test.ts index 10610d3699..6f0f7f884d 100644 --- a/packages/core/src/__tests__/global-settings-guard.test.ts +++ b/packages/core/src/__tests__/global-settings-guard.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveGlobalDir } from "../global-settings.js"; @@ -68,3 +68,60 @@ describe("resolveGlobalDir() VITEST guard", () => { }); }); }); + +/* +FNXC:GlobalDirGuard 2026-06-25-22:30: +Regression for the "all my global settings reset" bug: production code that passed a project's `.fusion/` dir (e.g. store.getFusionDir()) to CentralCore/global stores spun up stray per-project central DBs seeded with default global settings that shadowed ~/.fusion. resolveGlobalDir() must refuse a project-local `.fusion/` dir (named `.fusion` inside a git repo) while still accepting the real home global dir and arbitrary non-repo custom dirs. Guard is intentionally inert under VITEST, so these tests clear VITEST to exercise it. +*/ +describe("resolveGlobalDir() project-local .fusion guard", () => { + it("throws when handed a project-local .fusion dir inside a git repo", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const projectRoot = join(homeDir, "code", "my-project"); + mkdirSync(join(projectRoot, ".git"), { recursive: true }); + const projectFusionDir = join(projectRoot, ".fusion"); + mkdirSync(projectFusionDir, { recursive: true }); + + expect(() => resolveGlobalDir(projectFusionDir)).toThrow( + /refusing project-local '\.fusion' directory/, + ); + }); + }); + }); + + it("also catches a git-worktree project (.git file, not dir)", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const worktreeRoot = join(homeDir, "worktrees", "feature"); + mkdirSync(worktreeRoot, { recursive: true }); + writeFileSync(join(worktreeRoot, ".git"), "gitdir: /somewhere/.git/worktrees/feature\n"); + const worktreeFusionDir = join(worktreeRoot, ".fusion"); + mkdirSync(worktreeFusionDir, { recursive: true }); + + expect(() => resolveGlobalDir(worktreeFusionDir)).toThrow( + /refusing project-local '\.fusion' directory/, + ); + }); + }); + }); + + it("allows the real home global dir", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const homeGlobal = join(homeDir, ".fusion"); + expect(resolveGlobalDir(homeGlobal)).toBe(homeGlobal); + }); + }); + }); + + it("allows a custom non-repo global dir (no .git parent)", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const customDir = join(homeDir, "custom-global", ".fusion"); + mkdirSync(customDir, { recursive: true }); + // Parent has no `.git`, so it is not a project worktree. + expect(resolveGlobalDir(customDir)).toBe(customDir); + }); + }); + }); +}); diff --git a/packages/core/src/global-settings.ts b/packages/core/src/global-settings.ts index 02a1474c14..877408d7d4 100644 --- a/packages/core/src/global-settings.ts +++ b/packages/core/src/global-settings.ts @@ -14,7 +14,7 @@ */ import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises"; import { existsSync, mkdirSync, renameSync } from "node:fs"; import type { GlobalSettings } from "./types.js"; @@ -90,7 +90,28 @@ export function resolveGlobalDir(dir?: string): string { ); } - if (hasExplicitDir) return dir; + if (hasExplicitDir) { + /* + FNXC:GlobalDirGuard 2026-06-25-22:10: + Production code must never point the central/global store at a project's `.fusion/` directory. Doing so silently spins up a stray per-project central DB seeded with DEFAULT global settings (globalMaxConcurrent=4, empty global secrets, default centralSettings), which then shadows the real `~/.fusion/fusion-central.db` and manifests as "all my global settings reset". Root cause was call sites passing `store.getFusionDir()` instead of the resolved global dir. + Guard heuristic: a project `.fusion` dir is named `.fusion` and lives inside a git repo (its parent has a `.git` dir or worktree file), whereas the home global dir's parent (the home dir) is not a repo. We only flag dirs that differ from the home-resolved global dir, so legitimately-threaded global dirs and test temp dirs are unaffected. Skipped under VITEST (tests pass explicit temp dirs by design). + */ + if (process.env.VITEST !== "true") { + const homeGlobalDir = resolveGlobalDirForHome(getHomeDir()); + const looksLikeProjectFusionDir = + dir !== homeGlobalDir && + basename(dir) === ".fusion" && + existsSync(join(dirname(dir), ".git")); + if (looksLikeProjectFusionDir) { + throw new Error( + `resolveGlobalDir(): refusing project-local '.fusion' directory '${dir}' for the central/global store. ` + + "This would create a stray per-project central database seeded with default global settings and silently reset them. " + + "Pass the resolved global dir (or omit the argument so it defaults to ~/.fusion); see TaskStore.getGlobalSettingsDir().", + ); + } + } + return dir; + } return resolveGlobalDirForHome(getHomeDir()); } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 9b4eeb08fd..2cc55e1299 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -16651,6 +16651,14 @@ ${stepsSection}`; return this.fusionDir; } + /* + FNXC:GlobalDirGuard 2026-06-25-22:12: + The resolved GLOBAL settings dir (undefined → ~/.fusion). Distinct from getFusionDir() which is this project's `.fusion/`. Any CentralCore/global-store construction MUST use this, never getFusionDir(); passing the project dir spins up a stray per-project central DB that shadows ~/.fusion and silently resets global settings. + */ + getGlobalSettingsDir(): string | undefined { + return this.globalSettingsDir; + } + getTasksDir(): string { return this.tasksDir; } @@ -16673,7 +16681,8 @@ ${stepsSection}`; return this.secretsStore; } - const central = new CentralCore(this.getFusionDir()); + // FNXC:GlobalDirGuard 2026-06-25-22:13: Secrets live in the GLOBAL central DB (~/.fusion), not this project's `.fusion/`. Use the resolved global dir; passing getFusionDir() created a stray per-project central DB and reset global settings. + const central = new CentralCore(this.getGlobalSettingsDir()); await central.init(); this.secretsCentralCore = central; const centralDb = (central as unknown as { db: import("./central-db.js").CentralDatabase | null }).db; diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index a6cff4824b..9f84c03189 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -4463,7 +4463,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout // Node-aware proxying: route to remote node if nodeId is provided and not local if (nodeId) { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); const localNodes = await central.listNodes(); diff --git a/packages/dashboard/src/routes/register-proxy-routes.ts b/packages/dashboard/src/routes/register-proxy-routes.ts index d14435afca..03a770ab87 100644 --- a/packages/dashboard/src/routes/register-proxy-routes.ts +++ b/packages/dashboard/src/routes/register-proxy-routes.ts @@ -34,7 +34,7 @@ async function proxyToRemoteNode( const timeoutMs = proxyOptions?.timeoutMs ?? 10_000; const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); try { await central.init(); @@ -187,7 +187,7 @@ export function registerProxyRoutes(router: Router, deps: ProxyRoutesDeps): void const nodeId = req.params.nodeId as string; const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); try { await central.init(); @@ -360,7 +360,7 @@ export function registerProxyRoutes(router: Router, deps: ProxyRoutesDeps): void const remainingPath = Array.isArray(splat) ? splat.join("/") : splat; const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); try { await central.init(); diff --git a/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts b/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts index e3716146cc..ea8c69c0e8 100644 --- a/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts +++ b/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts @@ -92,7 +92,7 @@ export const registerSecretsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.post("/secrets/sync-receive", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { // Validate auth @@ -174,7 +174,7 @@ export const registerSecretsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.get("/secrets/sync-export", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { // Validate auth diff --git a/packages/dashboard/src/routes/register-secrets-sync-routes.ts b/packages/dashboard/src/routes/register-secrets-sync-routes.ts index 9d9c9ec0cd..d2ed109f8b 100644 --- a/packages/dashboard/src/routes/register-secrets-sync-routes.ts +++ b/packages/dashboard/src/routes/register-secrets-sync-routes.ts @@ -52,7 +52,7 @@ export const registerSecretsSyncRoutes: ApiRouteRegistrar = (ctx) => { router.post("/nodes/:id/secrets/push", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { const node = await central.getNode(req.params.id); @@ -110,7 +110,7 @@ export const registerSecretsSyncRoutes: ApiRouteRegistrar = (ctx) => { router.post("/nodes/:id/secrets/pull", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { const node = await central.getNode(req.params.id); diff --git a/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts b/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts index 57a287dc1d..15a021ed4e 100644 --- a/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts +++ b/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts @@ -74,7 +74,8 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.post("/settings/sync-receive", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + // FNXC:GlobalDirGuard 2026-06-25-22:20: Inbound settings sync writes GLOBAL central state, so it must use the resolved global dir (~/.fusion). Previously this (and the secrets/proxy/node routes) passed store.getFusionDir() — the project `.fusion/` — which created a stray per-project central DB seeded with default global settings, the root cause of intermittent "all my global settings reset". Mirror this requirement on every CentralCore construction in dashboard routes. + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); // Validate auth - find local node and check apiKey @@ -181,7 +182,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.post("/settings/auth-receive", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); // Validate auth @@ -278,7 +279,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.get("/settings/auth-export", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); // Validate auth