Files
fusion/packages/core/src/global-settings.ts
gsxdsm bcb1256d6b FN-8282: add configuration revision history and rollback
Record configuration revisions so prior settings can be restored safely.

- Add revision storage interfaces, PostgreSQL schema, migration, and pruning.
- Capture changes to global settings, routines, automations, and async task settings.
- Expose revision listing and restore operations with coverage and storage documentation.

Files changed:
 .changeset/fn-8282-config-versioning.md            |   7 +
 docs/storage.md                                    |   6 +
 .../__tests__/configuration-revision-store.test.ts |  26 +++
 .../src/__tests__/postgres/schema-applier.test.ts  |  15 +-
 .../core/src/async-configuration-revision-store.ts | 192 +++++++++++++++++++++
 packages/core/src/automation-store.ts              |  88 +++++++++-
 packages/core/src/configuration-revision-store.ts  |  33 ++++
 packages/core/src/global-settings.ts               | 172 +++++++++++++++++-
 packages/core/src/index.gate.ts                    |   3 +
 packages/core/src/index.ts                         |   3 +
 .../migrations/0021_configuration_revisions.sql    |  40 +++++
 packages/core/src/postgres/schema-applier.ts       |  19 +-
 packages/core/src/postgres/schema/project.ts       |  27 +++
 packages/core/src/routine-store.ts                 | 103 ++++++++++-
 packages/core/src/store.ts                         |  21 ++-
 packages/core/src/task-store/async-settings.ts     |  14 +-
 packages/core/src/task-store/remaining-ops-2.ts    |  86 ++++++++-
 packages/core/src/task-store/settings-ops.ts       | 104 +++++++----
 packages/core/src/types.ts                         |  36 ++++
 packages/engine/src/agent-tools.ts                 |   7 +-
 20 files changed, 928 insertions(+), 74 deletions(-)

Fusion-Task-Id: FN-8282

Fusion-Task-Lineage: fd681798-10b0-47ce-b96b-6f33eecdf70a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-18 11:34:42 -07:00

492 lines
22 KiB
TypeScript

/**
* Global settings store — manages user-level settings in `~/.fusion/settings.json`.
*
* Global settings persist across all fn projects for the current user.
* They include UI theme preferences, default AI model selection, and
* notification configuration.
*
* **Schema protection**: The store preserves any keys found in the settings
* file that are not part of the current `GlobalSettings` schema. This prevents
* data loss when schema changes remove fields — the values remain on disk and
* can be restored if the field is re-added later. See `readRaw()`.
*
* @see {@link GlobalSettings} for the full list of global fields.
*/
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { mkdir, readFile, writeFile, rename, chmod, unlink } from "node:fs/promises";
import { existsSync, mkdirSync, realpathSync, renameSync } from "node:fs";
import type { ConfigChangedBy, ConfigKind, ConfigurationRevision, ConfigurationTarget, GlobalSettings } from "./types.js";
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
import { sanitizeCliAgentsSettings } from "./settings-schema.js";
import type { AsyncDataLayer } from "./postgres/data-layer.js";
import { GLOBAL_CONFIGURATION_OWNER_ID, appendGlobalConfigurationRevision, createConfigurationRevision, getGlobalConfigurationRevision, listGlobalConfigurationRevisions } from "./async-configuration-revision-store.js";
/*
FNXC:ConfigVersioning 2026-07-18-10:30:
Direct GlobalSettingsStore callers (CLI bootstrap and maintenance commands) do
not have a TaskStore to inject a project-bound layer. Resolve one central layer
per settings directory before a write so that path cannot silently bypass the
immutable global revision partition. Unit-only filesystem tests deliberately
remain layerless; production always initializes the central PostgreSQL layer.
*/
const directGlobalRevisionLayers = new Map<string, Promise<AsyncDataLayer>>();
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir();
}
/** Legacy directory for global settings (original name before rename to `.fusion`). */
export function legacyGlobalDir(): string {
return join(getHomeDir(), ".pi", "fusion");
}
/** Legacy directory for global settings from the earliest fn version (`.pi/kb`). */
export function legacyGlobalDirOriginal(): string {
return join(getHomeDir(), ".pi", "kb");
}
/** Default directory for global fusion settings: `~/.fusion/` */
export function defaultGlobalDir(): string {
return join(getHomeDir(), ".fusion");
}
/** Resolve the active global directory for an explicit home directory. */
export function resolveGlobalDirForHome(homeDir: string): string {
const preferredDir = join(homeDir, ".fusion");
if (existsSync(preferredDir)) {
return preferredDir;
}
const legacyDir = join(homeDir, ".pi", "fusion");
if (existsSync(legacyDir)) {
try {
mkdirSync(dirname(preferredDir), { recursive: true });
renameSync(legacyDir, preferredDir);
return preferredDir;
} catch {
return legacyDir;
}
}
const legacyDirOriginal = join(homeDir, ".pi", "kb");
if (existsSync(legacyDirOriginal)) {
try {
mkdirSync(dirname(preferredDir), { recursive: true });
renameSync(legacyDirOriginal, preferredDir);
return preferredDir;
} catch {
return legacyDirOriginal;
}
}
return preferredDir;
}
/**
* Resolve the active global directory.
*
* Migration chain:
* 1. If `~/.fusion` exists → use it
* 2. Else if `~/.pi/fusion` exists → rename to `~/.fusion` and use it
* 3. Else if `~/.pi/kb` exists → rename to `~/.fusion` and use it
* 4. Else → return `~/.fusion` (will be created on first use)
*/
export function resolveGlobalDir(dir?: string): string {
const hasExplicitDir = typeof dir === "string" && dir.length > 0;
if (!hasExplicitDir && process.env.VITEST === "true") {
throw new Error(
"resolveGlobalDir() called without explicit dir during test execution. Pass a temp directory to avoid writing to real ~/.fusion/",
);
}
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).
FNXC:GlobalDirGuard 2026-06-25-22:55:
The heuristic is intentionally conservative but can't perfectly distinguish a project `.fusion` from a legitimately version-controlled custom global dir (e.g. a dotfiles repo with `~/dotfiles/.fusion` + `.git`). To avoid hard-crashing that rare setup, honor an explicit opt-out env var `FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR=true`. This is not reachable via normal production call sites (they resolve to ~/.fusion); it only matters for operators who deliberately configure a custom global dir inside a repo.
FNXC:GlobalDirGuard 2026-06-26-06:25:
Order matters and the home-dir comparison must be normalized:
- Run the CHEAP, read-only checks first (basename is `.fusion` AND its parent contains a `.git`). Only if both hold do we call `resolveGlobalDirForHome()` — which can perform a one-time legacy-dir rename — so we never trigger that filesystem side effect on the hot path (every explicit-dir call, e.g. getGlobalSettingsDir() in dashboard routes, previously hit it).
- Compare against the home global dir using normalized real paths (realpathSync when the path exists, else resolve()), so a trailing slash, doubled separator, or symlinked home dir doesn't make the legitimate home global dir look like a foreign project dir and trip the guard.
*/
if (process.env.VITEST !== "true" && process.env.FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR !== "true") {
const isFusionDirInsideRepo =
basename(dir) === ".fusion" && existsSync(join(dirname(dir), ".git"));
if (isFusionDirInsideRepo) {
const homeGlobalDir = resolveGlobalDirForHome(getHomeDir());
const normalize = (p: string): string => {
try {
return realpathSync.native(p);
} catch {
return resolve(p);
}
};
if (normalize(dir) !== normalize(homeGlobalDir)) {
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(). " +
"If this really is your intended global dir (e.g. a version-controlled dotfiles repo), set FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR=true to override.",
);
}
}
}
return dir;
}
return resolveGlobalDirForHome(getHomeDir());
}
export class GlobalSettingsStore {
private readonly settingsPath: string;
private readonly revisionIntentPath: string;
private readonly dir: string;
/** Write-through cache for settings. Invalidated on every updateSettings() call. */
private cachedSettings: GlobalSettings | null = null;
/** Promise chain for serializing read-modify-write cycles */
private lock: Promise<void> = Promise.resolve();
/**
* Create a GlobalSettingsStore.
* @param dir — Directory to store settings.json. Defaults to `~/.fusion/`.
* Accepts a custom path for testing.
*/
constructor(dir?: string, private readonly asyncLayer?: AsyncDataLayer) {
this.dir = resolveGlobalDir(dir);
this.settingsPath = join(this.dir, "settings.json");
this.revisionIntentPath = join(this.dir, "settings.json.configuration-revision-intent.json");
}
/** Resolve the central history partition for direct, layerless production callers. */
private async getRevisionLayer(): Promise<AsyncDataLayer | undefined> {
if (this.asyncLayer) return this.asyncLayer;
// Filesystem-focused unit tests construct isolated stores without starting
// embedded PostgreSQL. Production must never take this branch.
if (process.env.VITEST === "true") return undefined;
let layer = directGlobalRevisionLayers.get(this.dir);
if (!layer) {
layer = (async () => {
const { CentralCore } = await import("./central-core.js");
const central = new CentralCore(this.dir);
await central.init();
if (!central.asyncLayer) {
throw new Error("Global configuration history requires the central PostgreSQL layer");
}
return central.asyncLayer;
})();
directGlobalRevisionLayers.set(this.dir, layer);
}
return layer;
}
/**
* Ensure the settings directory exists. Creates it recursively if needed.
* If the settings file doesn't exist, creates it with defaults.
* Returns true if the file was created for the first time.
*/
async init(): Promise<boolean> {
await mkdir(this.dir, { recursive: true });
if (!existsSync(this.settingsPath)) {
await this.atomicWrite(DEFAULT_GLOBAL_SETTINGS);
return true;
}
return false;
}
/**
* Read the raw JSON object from disk without applying defaults.
* Returns all keys present in the file, including any that are no longer
* part of the current GlobalSettings schema. Returns an empty object if
* the file is missing or invalid.
*
* This is the foundation of schema protection — unknown keys survive
* read-modify-write cycles because they flow through this method.
*/
async readRaw(): Promise<Record<string, unknown>> {
try {
const raw = await readFile(this.settingsPath, "utf-8");
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return {};
}
}
private async readRawForUpdate(): Promise<Record<string, unknown>> {
if (!existsSync(this.settingsPath)) {
return {};
}
try {
const raw = await readFile(this.settingsPath, "utf-8");
return JSON.parse(raw) as Record<string, unknown>;
} catch (error) {
/*
FNXC:SettingsPersistence 2026-06-23-00:37:
Existing global settings must never be overwritten with defaults because a read failed. Fail closed on update so a corrupt, partially-written, or temporarily unreadable ~/.fusion/settings.json can be inspected or recovered instead of being replaced by DEFAULT_GLOBAL_SETTINGS plus the new patch.
*/
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Refusing to update global settings because ${this.settingsPath} could not be read as valid JSON: ${message}`);
}
}
/**
* Read global settings. Returns cached value if available, otherwise reads
* from disk and caches the result. This avoids repeated filesystem reads for
* settings that are accessed frequently.
*
* If the file doesn't exist or is invalid, returns defaults without throwing.
*/
async getSettings(): Promise<GlobalSettings> {
if (this.cachedSettings !== null) {
return this.cachedSettings;
}
const parsed = await this.readRaw();
this.cachedSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...parsed } as GlobalSettings;
return this.cachedSettings;
}
/**
* Update global settings by merging a partial patch into the existing values.
* Only fields present in the patch are overwritten; other fields are preserved.
* Uses atomic write (write-to-temp-then-rename) and serialized locking.
*
* **Schema protection**: reads the raw file (including unknown keys) before
* merging, so fields that were removed from the TypeScript schema are not
* silently dropped during save cycles.
*
* **Null-as-delete semantics**: Fields set to `null` in the patch are
* explicitly deleted from the settings. This allows the frontend to clear
* a setting by sending `null` instead of `undefined` (since JSON.stringify
* drops `undefined` values, `null` serves as the explicit clear sentinel).
*
* @returns The full updated settings after merge.
*/
async updateSettings(
patch: Partial<GlobalSettings> & Record<string, unknown>,
changedBy: ConfigChangedBy = { kind: "human", id: "local-user" },
): Promise<GlobalSettings> {
return this.withLock(async () => {
// Obtain history before changing the file: a failed central bootstrap is
// a failed configuration mutation, never an unversioned successful one.
const revisionLayer = await this.getRevisionLayer();
await this.reconcileRevisionIntent(revisionLayer);
const raw = await this.readRawForUpdate();
// Apply null-as-delete semantics: null means "remove this field"
// Merge order: defaults → raw (disk) → patch
// This means: patch values win, then raw, then defaults
// But null in patch means "delete" - so we delete from raw first
const merged: Record<string, unknown> = { ...raw };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
// null → delete this key from the merged object
// This effectively makes it fall through to the default
delete merged[key];
} else if (key === "cliAgents") {
// Validation at the write boundary (U15, Global Settings convention):
// unknown adapter ids and invalid fields are dropped before persist so
// a malformed `cliAgents` payload can never reach launch resolution.
merged[key] = sanitizeCliAgentsSettings(value);
} else {
// normal value → set it
merged[key] = value;
}
}
// After merging, fill in defaults for any missing keys
// This ensures fields that were deleted (by null) get their default value
const withDefaults = { ...DEFAULT_GLOBAL_SETTINGS, ...merged } as GlobalSettings;
const revision = revisionLayer ? createConfigurationRevision({
projectId: GLOBAL_CONFIGURATION_OWNER_ID,
ownerScope: "global",
configKind: "global-settings",
configTarget: { scope: "user-global" },
before: raw,
after: withDefaults,
changedBy,
}) : null;
if (!revision && revisionLayer) {
this.cachedSettings = withDefaults;
return this.cachedSettings;
}
if (revision) {
await this.writeVersionedSnapshot(revisionLayer!, revision, raw, withDefaults as unknown as Record<string, unknown>);
} else {
// Isolated filesystem tests do not initialize PostgreSQL; production
// always has a layer and therefore never takes this compatibility path.
await mkdir(this.dir, { recursive: true });
await this.atomicWrite(withDefaults);
}
this.cachedSettings = withDefaults;
return this.cachedSettings;
});
}
/** List central/global revisions newest-first for one structured target. */
async listConfigurationRevisions(
configKind: ConfigKind = "global-settings",
configTarget: ConfigurationTarget = { scope: "user-global" },
limit?: number,
): Promise<ConfigurationRevision[]> {
const layer = await this.getRevisionLayer();
if (!layer) throw new Error("Configuration history requires the PostgreSQL revision store");
return listGlobalConfigurationRevisions(layer, configKind, configTarget, limit);
}
/**
* Exactly restore a recorded user-global snapshot and record one forward
* rollback revision. The filesystem compensation mirrors updateSettings().
*/
async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<ConfigurationRevision> {
const layer = await this.getRevisionLayer();
if (!layer) throw new Error("Configuration rollback requires the PostgreSQL revision store");
return this.withLock(async () => {
const target = await getGlobalConfigurationRevision(layer, revisionId);
if (!target || target.configKind !== "global-settings") {
throw new Error(`Global configuration revision ${revisionId} was not found`);
}
const current = await this.readRawForUpdate();
const restored = target.before as Record<string, unknown>;
const rollback = createConfigurationRevision({
projectId: GLOBAL_CONFIGURATION_OWNER_ID,
ownerScope: "global",
configKind: "global-settings",
configTarget: target.configTarget,
before: current,
after: restored,
changedBy,
source: "rollback",
rollbackToRevisionId: target.id,
});
if (!rollback) throw new Error(`Configuration revision ${revisionId} is already restored`);
await this.writeVersionedSnapshot(layer, rollback, current, restored);
this.cachedSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...restored } as GlobalSettings;
return rollback;
});
}
/**
* Get the path to the settings file (useful for diagnostics/logging).
*/
getSettingsPath(): string {
return this.settingsPath;
}
/**
* Invalidate the in-memory cache. Forces the next getSettings() call to
* re-read from disk. Useful for testing and edge cases where external
* processes modify the settings file.
*/
invalidateCache(): void {
this.cachedSettings = null;
}
// ── Private helpers ─────────────────────────────────────────────
/*
FNXC:ConfigVersioning 2026-07-18-19:00:
The filesystem and PostgreSQL cannot share one transaction. Persist a durable
intent before replacing settings.json so startup can finish the journal write
after a crash, rather than leaving an unversioned successful configuration.
*/
private async writeVersionedSnapshot(
layer: AsyncDataLayer,
revision: ConfigurationRevision,
before: Record<string, unknown>,
after: Record<string, unknown>,
): Promise<void> {
await mkdir(this.dir, { recursive: true });
await this.writeRevisionIntent(revision);
try {
await this.atomicWrite(after as GlobalSettings);
await appendGlobalConfigurationRevision(layer, revision);
await unlink(this.revisionIntentPath);
} catch (error) {
// A caught failure is compensated immediately. A process crash retains
// the intent and is reconciled before the next mutation.
await this.atomicWrite(before as GlobalSettings).catch(() => undefined);
await unlink(this.revisionIntentPath).catch(() => undefined);
throw error;
}
}
private async writeRevisionIntent(revision: ConfigurationRevision): Promise<void> {
const temporary = `${this.revisionIntentPath}.tmp`;
await writeFile(temporary, JSON.stringify({ revision }), { mode: 0o600 });
await rename(temporary, this.revisionIntentPath);
}
private async reconcileRevisionIntent(layer: AsyncDataLayer | undefined): Promise<void> {
if (!existsSync(this.revisionIntentPath)) return;
if (!layer) throw new Error("Global configuration recovery requires the PostgreSQL revision store");
const parsed = JSON.parse(await readFile(this.revisionIntentPath, "utf-8")) as { revision?: ConfigurationRevision };
const revision = parsed.revision;
if (!revision || revision.projectId !== GLOBAL_CONFIGURATION_OWNER_ID || revision.ownerScope !== "global") {
throw new Error("Global configuration revision intent is invalid");
}
const recorded = await getGlobalConfigurationRevision(layer, revision.id);
if (!recorded) {
const current = await this.readRawForUpdate();
if (JSON.stringify(current) !== JSON.stringify(revision.after)) {
await this.atomicWrite(revision.before as GlobalSettings);
} else {
await appendGlobalConfigurationRevision(layer, revision);
}
}
await unlink(this.revisionIntentPath);
}
/**
* Atomically write settings to disk. Writes to a temp file first,
* then renames into place (atomic on POSIX).
*
* The file is written with mode 0600 (owner-only read/write) because the
* settings object can contain secrets — specifically `daemonToken`, which
* is a bearer credential for the HTTP API. POSIX-only; no-op on Windows.
*/
private async atomicWrite(settings: GlobalSettings): Promise<void> {
const tmpPath = this.settingsPath + ".tmp";
await writeFile(tmpPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
await rename(tmpPath, this.settingsPath);
// `writeFile` with `mode` honors umask on some platforms, so re-chmod the
// final path to guarantee 0600. Ignore failures (Windows has no POSIX
// permission bits; some filesystems may reject chmod).
try {
await chmod(this.settingsPath, 0o600);
} catch {
// Best effort — on Windows or filesystems without POSIX perms, the file
// is already protected by the user's home directory ACL.
}
}
/**
* Serialize operations via promise chain to prevent lost-update races.
*/
private withLock<T>(fn: () => Promise<T>): Promise<T> {
let resolve: () => void;
const next = new Promise<void>((r) => { resolve = r; });
const prev = this.lock;
this.lock = next;
return prev.then(async () => {
try {
return await fn();
} finally {
resolve!();
}
});
}
}