feat(FN-2123): merge fusion/fn-2123

This commit is contained in:
gsxdsm
2026-04-19 04:52:39 -07:00
parent b4e604688c
commit 84ed843131
24 changed files with 224 additions and 105 deletions

View File

@@ -72,7 +72,9 @@ describe("FirstRunExperience", () => {
it("should return true when central core is not initialized", async () => {
const uninitializedCore = new CentralCore(tempDir);
const uninitializedFirstRun = new FirstRunExperience(uninitializedCore);
const { GlobalSettingsStore } = await import("../global-settings.js");
const globalSettingsStore = new GlobalSettingsStore(tempDir);
const uninitializedFirstRun = new FirstRunExperience(uninitializedCore, globalSettingsStore);
const result = await uninitializedFirstRun.isFirstRun();
expect(result).toBe(true);
@@ -298,8 +300,10 @@ describe("FirstRunExperience", () => {
});
describe("createFirstRunExperience", () => {
it("should create a FirstRunExperience instance", () => {
const instance = createFirstRunExperience(centralCore);
it("should create a FirstRunExperience instance", async () => {
const { GlobalSettingsStore } = await import("../global-settings.js");
const globalSettingsStore = new GlobalSettingsStore(tempDir);
const instance = createFirstRunExperience(centralCore, globalSettingsStore);
expect(instance).toBeInstanceOf(FirstRunExperience);
});
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveGlobalDir } from "../global-settings.js";
function withTempHome<T>(fn: (homeDir: string) => T): T {
const originalHome = process.env.HOME;
const homeDir = mkdtempSync(join(tmpdir(), "kb-global-dir-guard-"));
process.env.HOME = homeDir;
try {
return fn(homeDir);
} finally {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
rmSync(homeDir, { recursive: true, force: true });
}
}
function withVitestEnv<T>(value: string | undefined, fn: () => T): T {
const originalVitest = process.env.VITEST;
if (value === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = value;
}
try {
return fn();
} finally {
if (originalVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = originalVitest;
}
}
}
describe("resolveGlobalDir() VITEST guard", () => {
it("throws without explicit dir during test execution", () => {
withVitestEnv("true", () => {
withTempHome(() => {
expect(() => resolveGlobalDir()).toThrow(
"resolveGlobalDir() called without explicit dir during test execution. Pass a temp directory to avoid writing to real ~/.fusion/",
);
});
});
});
it("allows explicit dir during test execution", () => {
withVitestEnv("true", () => {
const explicitPath = "/some/explicit/path";
expect(resolveGlobalDir(explicitPath)).toBe(explicitPath);
});
});
it("preserves production behavior when VITEST is not set", () => {
withVitestEnv(undefined, () => {
withTempHome((homeDir) => {
expect(resolveGlobalDir()).toBe(join(homeDir, ".fusion"));
});
});
});
});

View File

@@ -177,7 +177,7 @@ describe("TaskStore Backward Compatibility", () => {
mkdirSync(projectDir, { recursive: true });
// Direct construction should still work
const store = new TaskStore(projectDir);
const store = new TaskStore(projectDir, join(projectDir, ".fusion-global-settings"));
await store.init();
expect(store).toBeInstanceOf(TaskStore);
@@ -198,7 +198,7 @@ describe("TaskStore Backward Compatibility", () => {
const projectDir = join(tempDir, "event-test");
mkdirSync(projectDir, { recursive: true });
const store = new TaskStore(projectDir);
const store = new TaskStore(projectDir, join(projectDir, ".fusion-global-settings"));
await store.init();
const taskCreatedListener = vi.fn();

View File

@@ -15,7 +15,7 @@ describe("TaskStore.listTasks() sort order", () => {
beforeEach(async () => {
rootDir = makeTmpDir();
store = new TaskStore(rootDir);
store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await store.init();
});

View File

@@ -25,7 +25,7 @@ describe("TaskStore task documents", () => {
kbDir = join(rootDir, ".fusion");
db = new Database(kbDir);
db.init();
store = new TaskStore(rootDir);
store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await store.init();
});

View File

@@ -60,6 +60,9 @@ function splitSettings(settings?: Partial<Settings>): {
* This file provides per-fixture isolation on top of that safety net.
*
* Create an isolated temporary test project with a real TaskStore + SQLite DB.
*
* Note: we always pass an explicit `globalDir` into TaskStore so tests never
* hit `resolveGlobalDir()`'s VITEST guard or write to a real ~/.fusion path.
*/
export async function createTestProject(
options: CreateTestProjectOptions = {},

View File

@@ -1492,7 +1492,7 @@ describe("AgentStore", () => {
let taskId: string;
beforeEach(async () => {
taskStore = new TaskStore(rootDir);
taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore.init();
store = new AgentStore({ rootDir, taskStore });

View File

@@ -10,6 +10,25 @@ function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-global-settings-test-"));
}
/** Temporarily clear VITEST to test default GlobalSettingsStore resolution. */
async function withDefaultGlobalSettingsStore<T>(
fn: (store: GlobalSettingsStore) => Promise<T>,
): Promise<T> {
const savedVitest = process.env.VITEST;
delete process.env.VITEST;
try {
const store = new GlobalSettingsStore();
return await fn(store);
} finally {
if (savedVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = savedVitest;
}
}
}
describe("GlobalSettingsStore", () => {
let dir: string;
let store: GlobalSettingsStore;
@@ -78,17 +97,20 @@ describe("GlobalSettingsStore", () => {
JSON.stringify({ themeMode: "light" }),
);
const defaultStore = new GlobalSettingsStore();
await defaultStore.init();
try {
await withDefaultGlobalSettingsStore(async (defaultStore) => {
await defaultStore.init();
expect(defaultStore.getSettingsPath()).toBe(join(defaultGlobalDir(), "settings.json"));
expect(existsSync(join(homeDir, ".fusion", "settings.json"))).toBe(true);
expect(existsSync(join(homeDir, ".pi", "kb"))).toBe(false);
expect(defaultStore.getSettingsPath()).toBe(join(defaultGlobalDir(), "settings.json"));
expect(existsSync(join(homeDir, ".fusion", "settings.json"))).toBe(true);
expect(existsSync(join(homeDir, ".pi", "kb"))).toBe(false);
const settings = await defaultStore.getSettings();
expect(settings.themeMode).toBe("light");
await rm(homeDir, { recursive: true, force: true });
const settings = await defaultStore.getSettings();
expect(settings.themeMode).toBe("light");
});
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
it("adopts the legacy ~/.pi/fusion directory when ~/.fusion does not exist", async () => {
@@ -107,20 +129,22 @@ describe("GlobalSettingsStore", () => {
expect(existsSync(join(homeDir, ".pi", "fusion", "settings.json"))).toBe(true);
expect(existsSync(join(homeDir, ".fusion"))).toBe(false);
// Instantiate GlobalSettingsStore with no argument (uses default resolution)
const defaultStore = new GlobalSettingsStore();
await defaultStore.init();
try {
await withDefaultGlobalSettingsStore(async (defaultStore) => {
await defaultStore.init();
// Verify migration happened
expect(defaultStore.getSettingsPath()).toBe(join(defaultGlobalDir(), "settings.json"));
expect(existsSync(join(homeDir, ".fusion", "settings.json"))).toBe(true);
expect(existsSync(join(homeDir, ".pi", "fusion"))).toBe(false);
// Verify migration happened
expect(defaultStore.getSettingsPath()).toBe(join(defaultGlobalDir(), "settings.json"));
expect(existsSync(join(homeDir, ".fusion", "settings.json"))).toBe(true);
expect(existsSync(join(homeDir, ".pi", "fusion"))).toBe(false);
// Verify settings were preserved
const settings = await defaultStore.getSettings();
expect(settings.themeMode).toBe("light");
await rm(homeDir, { recursive: true, force: true });
// Verify settings were preserved
const settings = await defaultStore.getSettings();
expect(settings.themeMode).toBe("light");
});
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
});

View File

@@ -45,7 +45,15 @@ export function defaultGlobalDir(): string {
* 4. Else → return `~/.fusion` (will be created on first use)
*/
export function resolveGlobalDir(dir?: string): string {
if (dir) return dir;
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();

View File

@@ -46,7 +46,7 @@ describe("MissionFactory Parity: Core MissionStore", () => {
vi.setSystemTime(new Date("2026-04-11T00:00:00.000Z"));
rootDir = makeTmpDir();
taskStore = new TaskStore(rootDir);
taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore.init();
});
@@ -75,7 +75,7 @@ describe("MissionFactory Parity: Core MissionStore", () => {
// Simulate restart by creating new store instance
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -99,7 +99,7 @@ describe("MissionFactory Parity: Core MissionStore", () => {
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -122,7 +122,7 @@ describe("MissionFactory Parity: Core MissionStore", () => {
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -145,7 +145,7 @@ describe("MissionFactory Parity: Core MissionStore", () => {
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -239,7 +239,7 @@ describe("MissionFactory Parity: Core MissionStore", () => {
// Restart and verify link persists
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -391,7 +391,7 @@ describe("MissionFactory Parity: Core MissionStore", () => {
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -434,7 +434,7 @@ describe("MissionFactory Parity: Core MissionStore", () => {
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -456,7 +456,7 @@ describe("MissionFactory Parity: Core MissionStore", () => {
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -533,7 +533,7 @@ describe("MissionFactory Parity: Core MissionStore", () => {
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();

View File

@@ -88,7 +88,7 @@ describe("MissionStore integration with TaskStore", () => {
vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z"));
rootDir = makeTmpDir();
taskStore = new TaskStore(rootDir);
taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore.init();
});
@@ -410,7 +410,7 @@ describe("MissionStore integration with TaskStore", () => {
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -436,7 +436,7 @@ describe("MissionStore integration with TaskStore", () => {
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -461,7 +461,7 @@ describe("MissionStore integration with TaskStore", () => {
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -484,7 +484,7 @@ describe("MissionStore integration with TaskStore", () => {
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -506,7 +506,7 @@ describe("MissionStore integration with TaskStore", () => {
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -530,7 +530,7 @@ describe("MissionStore integration with TaskStore", () => {
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
@@ -556,7 +556,7 @@ describe("MissionStore integration with TaskStore", () => {
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir);
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();

View File

@@ -28,7 +28,7 @@ describe("MissionStore planning context integration", () => {
vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z"));
rootDir = makeTmpDir();
taskStore = new TaskStore(rootDir);
taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore.init();
});

View File

@@ -1663,7 +1663,7 @@ describe("MissionStore", () => {
it("throws if feature not found", async () => {
// Need a TaskStore reference for this test
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageFeature("F-NONEXISTENT")).rejects.toThrow(
@@ -1673,7 +1673,7 @@ describe("MissionStore", () => {
it("throws if feature is already triaged", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1693,7 +1693,7 @@ describe("MissionStore", () => {
it("creates a task and links it to the feature", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1725,7 +1725,7 @@ describe("MissionStore", () => {
it("uses provided title and description overrides", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1746,7 +1746,7 @@ describe("MissionStore", () => {
it("emits feature:linked event", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const linkedHandler = vi.fn();
@@ -1781,7 +1781,7 @@ describe("MissionStore", () => {
it("throws if slice not found", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageSlice("SL-NONEXISTENT")).rejects.toThrow(
@@ -1791,7 +1791,7 @@ describe("MissionStore", () => {
it("triages all defined features in a slice", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1818,7 +1818,7 @@ describe("MissionStore", () => {
it("skips already triaged features", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1840,7 +1840,7 @@ describe("MissionStore", () => {
it("returns empty array if no defined features", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1861,7 +1861,7 @@ describe("MissionStore", () => {
ms: MissionStore;
}> {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const ms = ts.getMissionStore();
return { ts, ms };
}

View File

@@ -35,7 +35,7 @@ describe("Run Audit Integration", () => {
kbDir = join(rootDir, ".fusion");
db = new Database(kbDir);
db.init();
store = new TaskStore(rootDir);
store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await store.init();
});

View File

@@ -22,7 +22,7 @@ describe("Run Audit", () => {
kbDir = join(rootDir, ".fusion");
db = new Database(kbDir);
db.init();
store = new TaskStore(rootDir);
store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await store.init();
});

View File

@@ -86,7 +86,11 @@ export interface TaskStoreEvents {
}
export class TaskStore extends EventEmitter<TaskStoreEvents> {
static async getOrCreateForProject(projectId?: string, centralCore?: CentralCore): Promise<TaskStore> {
static async getOrCreateForProject(
projectId?: string,
centralCore?: CentralCore,
globalSettingsDir?: string,
): Promise<TaskStore> {
const central = centralCore ?? new CentralCore();
let initializedHere = false;
@@ -98,7 +102,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
try {
const compat = new BackwardCompat(central);
const context = await compat.resolveProjectContext(process.cwd(), projectId);
const store = new TaskStore(context.workingDirectory);
const resolvedGlobalSettingsDir = globalSettingsDir
?? (process.env.VITEST === "true"
? join(context.workingDirectory, ".fusion-global-settings")
: undefined);
const store = new TaskStore(context.workingDirectory, resolvedGlobalSettingsDir);
await store.init();
return store;
} catch (error) {
@@ -176,7 +184,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.kbDir = join(rootDir, ".fusion");
this.tasksDir = join(this.kbDir, "tasks");
this.configPath = join(this.kbDir, "config.json");
this.globalSettingsStore = new GlobalSettingsStore(globalSettingsDir);
const resolvedGlobalSettingsDir = globalSettingsDir
?? (process.env.VITEST === "true" ? join(rootDir, ".fusion-global-settings") : undefined);
this.globalSettingsStore = new GlobalSettingsStore(resolvedGlobalSettingsDir);
}
/**