Files
fusion/packages/core/src/global-settings.ts
Fusion 4e85969d56 perf(executor): recover approved steps on engine restart
When the engine restarts mid-step, an in-progress step may have already
passed plan + code review but not yet been flipped to done by the agent's
next task_update call. Previously, the next executor pass re-entered the
step and replayed both reviews — measured at 5-20 min of pure waste per
restart (observed in FN-2215 Step 1 and FN-2207 Step 6).

recoverApprovedStepsOnResume scans the task log for any in-progress step
whose most recent "code review Step N: APPROVE" entry is newer than its
most recent "Step N → pending" transition, and marks those steps done
before execute() runs. Safely skips steps that were reset after approval
(e.g. by a workflow revision) or only received REVISE verdicts.

Called from both the engine-restart path (resumeOrphaned) and the
unpause path, matching the two places the task log shows as vulnerable
to this race.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:12:00 -07:00

269 lines
9.4 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 { 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";
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
/** Legacy directory for global settings (original name before rename to `.fusion`). */
export function legacyGlobalDir(): string {
return join(homedir(), ".pi", "fusion");
}
/** Legacy directory for global settings from the earliest fn version (`.pi/kb`). */
export function legacyGlobalDirOriginal(): string {
return join(homedir(), ".pi", "kb");
}
/** Default directory for global fusion settings: `~/.fusion/` */
export function defaultGlobalDir(): string {
return join(homedir(), ".fusion");
}
/**
* 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) return dir;
const preferredDir = defaultGlobalDir();
// Case 1: New directory already exists
if (existsSync(preferredDir)) {
return preferredDir;
}
// Case 2: Check for legacy ~/.pi/fusion directory
const legacyDir = legacyGlobalDir();
if (existsSync(legacyDir)) {
try {
mkdirSync(dirname(preferredDir), { recursive: true });
renameSync(legacyDir, preferredDir);
return preferredDir;
} catch {
return legacyDir;
}
}
// Case 3: Check for original legacy ~/.pi/kb directory
const legacyDirOriginal = legacyGlobalDirOriginal();
if (existsSync(legacyDirOriginal)) {
try {
mkdirSync(dirname(preferredDir), { recursive: true });
renameSync(legacyDirOriginal, preferredDir);
return preferredDir;
} catch {
return legacyDirOriginal;
}
}
// Case 4: Return the preferred directory (will be created on first use)
return preferredDir;
}
export class GlobalSettingsStore {
private readonly settingsPath: 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) {
this.dir = resolveGlobalDir(dir);
this.settingsPath = join(this.dir, "settings.json");
}
/**
* 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 {};
}
}
/**
* 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>): Promise<GlobalSettings> {
return this.withLock(async () => {
const raw = await this.readRaw();
// 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 {
// 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;
await mkdir(this.dir, { recursive: true });
await this.atomicWrite(withDefaults);
// Update the write-through cache
this.cachedSettings = withDefaults;
return this.cachedSettings;
});
}
/**
* 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 ─────────────────────────────────────────────
/**
* 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!();
}
});
}
}