diff --git a/.changeset/fix-sqlite-startup-validation.md b/.changeset/fix-sqlite-startup-validation.md new file mode 100644 index 000000000..1707a5455 --- /dev/null +++ b/.changeset/fix-sqlite-startup-validation.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix SQLite project and central database validation so dashboard and desktop startup handle corrupt database files more predictably. diff --git a/packages/cli/src/__tests__/project-context.test.ts b/packages/cli/src/__tests__/project-context.test.ts index cce3dd89b..f82120003 100644 --- a/packages/cli/src/__tests__/project-context.test.ts +++ b/packages/cli/src/__tests__/project-context.test.ts @@ -117,6 +117,16 @@ describe("project-context", () => { expect(found?.path).toBe(resolve(projectPath)); expect(found?.name).toBe("legacy-project"); }); + + it("should ignore invalid fusion.db files in the cwd", async () => { + const projectPath = join(tempDir, "invalid-project"); + mkdirSync(join(projectPath, ".fusion"), { recursive: true }); + writeFileSync(join(projectPath, ".fusion", "fusion.db"), "SQLite format 3\x00"); + + const found = await detectProjectFromCwd(projectPath, central); + + expect(found).toBeUndefined(); + }); }); describe("formatProjectLine", () => { diff --git a/packages/cli/src/__tests__/project-resolver.test.ts b/packages/cli/src/__tests__/project-resolver.test.ts index 84b68c194..5ccd05884 100644 --- a/packages/cli/src/__tests__/project-resolver.test.ts +++ b/packages/cli/src/__tests__/project-resolver.test.ts @@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { existsSync, statSync } from "node:fs"; import { TaskStore } from "@fusion/core"; +const { mockIsValidSqliteDatabaseFile } = vi.hoisted(() => ({ + mockIsValidSqliteDatabaseFile: vi.fn(), +})); + // Mock fs module vi.mock("node:fs", () => ({ existsSync: vi.fn(), @@ -23,6 +27,8 @@ vi.mock("@fusion/core", async () => { getProjectHealth = vi.fn().mockResolvedValue(undefined); isInitialized = vi.fn().mockReturnValue(true); }, + isValidSqliteDatabaseFile: (...args: Parameters) => + mockIsValidSqliteDatabaseFile(...args), TaskStore: vi.fn().mockImplementation(() => ({ init: vi.fn().mockResolvedValue(undefined), listTasks: vi.fn().mockResolvedValue([]), @@ -69,6 +75,7 @@ describe("Project Resolver", () => { beforeEach(() => { vi.clearAllMocks(); resetProjectResolution(); + mockIsValidSqliteDatabaseFile.mockReturnValue(false); vi.mocked(TaskStore).mockImplementation(() => ({ init: vi.fn().mockResolvedValue(undefined), listTasks: vi.fn().mockResolvedValue([]), @@ -81,49 +88,38 @@ describe("Project Resolver", () => { describe("findKbDir", () => { it("should find .fusion directory in current path", () => { - vi.mocked(existsSync) - .mockReturnValueOnce(true) - .mockReturnValue(false); - vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any); + mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/project/.fusion/fusion.db"); const result = findKbDir("/project"); expect(result).toBe("/project"); }); it("should walk up parent directories to find .fusion", () => { - vi.mocked(existsSync) - .mockReturnValueOnce(false) - .mockReturnValueOnce(true) - .mockReturnValue(false); - vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any); + mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/a/b/.fusion/fusion.db"); const result = findKbDir("/a/b/c"); expect(result).toBe("/a/b"); }); it("should return null if no .fusion found", () => { - vi.mocked(existsSync).mockReturnValue(false); const result = findKbDir("/some/path"); expect(result).toBeNull(); }); - it("should return null if .fusion is not a directory", () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(statSync).mockReturnValue({ isDirectory: () => false } as any); + it("should return null if fusion.db is not a valid SQLite database", () => { + mockIsValidSqliteDatabaseFile.mockReturnValue(false); const result = findKbDir("/project"); expect(result).toBeNull(); }); }); describe("isKbProject", () => { - it("should return true if .fusion directory exists", () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any); + it("should return true if fusion.db is a valid SQLite database", () => { + mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/project/.fusion/fusion.db"); expect(isKbProject("/project")).toBe(true); }); - it("should return false if .fusion directory does not exist", () => { - vi.mocked(existsSync).mockReturnValue(false); + it("should return false if fusion.db is invalid or missing", () => { expect(isKbProject("/project")).toBe(false); }); }); @@ -200,8 +196,7 @@ describe("Project Resolver", () => { }); it("should throw NOT_REGISTERED if .fusion exists but project not registered", async () => { - vi.mocked(existsSync).mockReturnValueOnce(true).mockReturnValue(true); - vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any); + mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/unregistered/.fusion/fusion.db"); const core = await getCentralCore(); core.listProjects.mockResolvedValue([]); @@ -213,8 +208,6 @@ describe("Project Resolver", () => { }); it("should throw NO_PROJECTS when no projects registered and no .fusion found", async () => { - vi.mocked(existsSync).mockReturnValue(false); - const core = await getCentralCore(); core.listProjects.mockResolvedValue([]); @@ -283,11 +276,8 @@ describe("Project Resolver", () => { updatedAt: "", }; - vi.mocked(existsSync).mockImplementation((path) => { - const p = String(path); - return p === "/workspace/cwd-match/.fusion" || p === "/workspace/cwd-match"; - }); - vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any); + mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/workspace/cwd-match/.fusion/fusion.db"); + vi.mocked(existsSync).mockImplementation((path) => String(path) === "/workspace/cwd-match"); const core = await getCentralCore(); core.listProjects.mockResolvedValue([mockProject]); @@ -329,8 +319,8 @@ describe("Project Resolver", () => { updatedAt: "", }; - vi.mocked(existsSync).mockImplementation((path) => String(path) === "/workspace/missing-cwd/.fusion"); - vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any); + mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/workspace/missing-cwd/.fusion/fusion.db"); + vi.mocked(existsSync).mockReturnValue(false); const core = await getCentralCore(); core.listProjects.mockResolvedValue([match]); diff --git a/packages/cli/src/commands/__tests__/init.test.ts b/packages/cli/src/commands/__tests__/init.test.ts index 6ce0424e7..63201943c 100644 --- a/packages/cli/src/commands/__tests__/init.test.ts +++ b/packages/cli/src/commands/__tests__/init.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "node:fs"; +import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runInit } from "../init.js"; @@ -17,20 +17,29 @@ const mockCentralClose = vi.fn(); const mockGetProjectByPath = vi.fn(); const mockRegisterProject = vi.fn(); const mockUpdateProject = vi.fn().mockResolvedValue({}); - -vi.mock("@fusion/core", () => ({ - CentralCore: vi.fn().mockImplementation(() => ({ - init: mockCentralInit, - close: mockCentralClose, - getProjectByPath: mockGetProjectByPath, - registerProject: mockRegisterProject, - updateProject: mockUpdateProject, - })), - isQmdAvailable: vi.fn(() => Promise.resolve(true)), - QMD_INSTALL_COMMAND: "bun install -g @tobilu/qmd", - resolveGlobalDir: vi.fn(), +const { mockIsValidSqliteDatabaseFile } = vi.hoisted(() => ({ + mockIsValidSqliteDatabaseFile: vi.fn(), })); +vi.mock("@fusion/core", async () => { + const actual = await vi.importActual("@fusion/core"); + return { + ...actual, + CentralCore: vi.fn().mockImplementation(() => ({ + init: mockCentralInit, + close: mockCentralClose, + getProjectByPath: mockGetProjectByPath, + registerProject: mockRegisterProject, + updateProject: mockUpdateProject, + })), + isQmdAvailable: vi.fn(() => Promise.resolve(true)), + QMD_INSTALL_COMMAND: "bun install -g @tobilu/qmd", + resolveGlobalDir: vi.fn(), + isValidSqliteDatabaseFile: (...args: Parameters) => + mockIsValidSqliteDatabaseFile(...args), + }; +}); + function tempDir(prefix: string): string { return mkdtempSync(join(tmpdir(), prefix)); } @@ -62,6 +71,13 @@ describe("init command", () => { path: tempProjectDir, isolationMode: "in-process", }); + mockIsValidSqliteDatabaseFile.mockImplementation((dbPath: string) => { + if (!existsSync(dbPath)) { + return false; + } + + return readFileSync(dbPath).length === 0; + }); }); afterEach(() => { @@ -100,6 +116,18 @@ describe("init command", () => { await runInit({ path: tempProjectDir }); expect(existsSync(dbPath)).toBe(true); + expect(statSync(dbPath).size).toBe(0); + }); + + it("should reject existing invalid fusion.db files", async () => { + const fusionDir = join(tempProjectDir, ".fusion"); + const dbPath = join(fusionDir, "fusion.db"); + mkdirSync(fusionDir, { recursive: true }); + writeFileSync(dbPath, "not a sqlite database"); + + await expect(runInit({ path: tempProjectDir })).rejects.toThrow( + `Existing database at ${dbPath} is not a valid SQLite database.`, + ); }); it("should be idempotent - report already initialized", async () => { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 616d9aef0..c3b9eca1a 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -13,7 +13,7 @@ import { join, resolve, basename } from "node:path"; import { exec } from "node:child_process"; import { promisify } from "node:util"; const execAsync = promisify(exec); -import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable } from "@fusion/core"; +import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable, isValidSqliteDatabaseFile } from "@fusion/core"; import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js"; import { isGitRepo } from "./git.js"; import { @@ -41,9 +41,11 @@ export async function runInit(options: InitOptions = {}): Promise { const cwd = options.path ? resolve(options.path) : process.cwd(); const fusionDir = join(cwd, ".fusion"); const dbPath = join(fusionDir, "fusion.db"); + const hasDbPath = existsSync(dbPath); + const hasValidDb = hasDbPath && isValidSqliteDatabaseFile(dbPath); // Check if already initialized - if (existsSync(fusionDir) && existsSync(dbPath)) { + if (existsSync(fusionDir) && hasDbPath && hasValidDb) { // Check if registered in central DB const central = new CentralCore(); await central.init(); @@ -69,6 +71,13 @@ export async function runInit(options: InitOptions = {}): Promise { return; } + if (existsSync(fusionDir) && hasDbPath && !hasValidDb) { + throw new Error( + `Existing database at ${dbPath} is not a valid SQLite database. ` + + "Restore it from .fusion/backups or move it aside before re-running fn init.", + ); + } + // Get or generate project name const projectName = options.name ?? await detectProjectName(cwd); @@ -95,12 +104,8 @@ export async function runInit(options: InitOptions = {}): Promise { // Create fusion.db (empty SQLite file) if (!existsSync(dbPath)) { - // SQLite database header for an empty database - const sqliteHeader = Buffer.from([ - 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, - 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00 - ]); - writeFileSync(dbPath, sqliteHeader); + // A zero-byte bootstrap file is a valid SQLite starting point. + writeFileSync(dbPath, ""); console.log(` ✓ Created fusion.db`); } diff --git a/packages/cli/src/project-context.ts b/packages/cli/src/project-context.ts index a096863f6..c2991aac6 100644 --- a/packages/cli/src/project-context.ts +++ b/packages/cli/src/project-context.ts @@ -5,9 +5,8 @@ * for operating on tasks across multiple registered projects. */ -import { TaskStore, type RegisteredProject, CentralCore, GlobalSettingsStore } from "@fusion/core"; +import { TaskStore, type RegisteredProject, CentralCore, GlobalSettingsStore, isValidSqliteDatabaseFile } from "@fusion/core"; import { resolve, dirname, basename } from "node:path"; -import { existsSync } from "node:fs"; /** Project context for CLI operations */ export interface ProjectContext { @@ -187,7 +186,7 @@ export async function detectProjectFromCwd( while (true) { // Check for fn database const kbPath = resolve(currentDir, ".fusion", "fusion.db"); - if (existsSync(kbPath)) { + if (isValidSqliteDatabaseFile(kbPath)) { // Found a fn project - check if it's registered const project = await central.getProjectByPath(currentDir); if (project) { diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts index 92222d8bb..2d18e68a7 100644 --- a/packages/cli/src/project-resolver.ts +++ b/packages/cli/src/project-resolver.ts @@ -11,7 +11,7 @@ import { existsSync, statSync } from "node:fs"; import { basename, dirname, resolve, normalize } from "node:path"; import { createInterface } from "node:readline/promises"; -import { CentralCore, type RegisteredProject, type TaskStore } from "@fusion/core"; +import { CentralCore, isValidSqliteDatabaseFile, type RegisteredProject, type TaskStore } from "@fusion/core"; import { ProjectManager } from "@fusion/engine"; // Singleton instances for reuse across commands @@ -106,8 +106,8 @@ export function findKbDir(startPath: string): string | null { // Safety limit to prevent infinite loops for (let i = 0; i < 100; i++) { - const kbPath = resolve(current, ".fusion"); - if (existsSync(kbPath) && statSync(kbPath).isDirectory()) { + const dbPath = resolve(current, ".fusion", "fusion.db"); + if (isValidSqliteDatabaseFile(dbPath)) { return current; } @@ -454,8 +454,7 @@ export async function isProjectNameTaken( * Validate that a path contains an initialized fn project (.fusion/ directory exists). */ export function isKbProject(path: string): boolean { - const kbPath = resolve(path, ".fusion"); - return existsSync(kbPath) && statSync(kbPath).isDirectory(); + return isValidSqliteDatabaseFile(resolve(path, ".fusion", "fusion.db")); } /** diff --git a/packages/core/src/__tests__/migration-orchestrator.test.ts b/packages/core/src/__tests__/migration-orchestrator.test.ts index 58d257e42..63e041e6c 100644 --- a/packages/core/src/__tests__/migration-orchestrator.test.ts +++ b/packages/core/src/__tests__/migration-orchestrator.test.ts @@ -170,6 +170,17 @@ describe("MigrationOrchestrator", () => { expect(detected).toHaveLength(0); }); + + it("should ignore header-only fusion.db files that are not real SQLite databases", async () => { + const projectDir = join(tempDir, "invalid-project"); + mkdirSync(projectDir, { recursive: true }); + mkdirSync(join(projectDir, ".fusion"), { recursive: true }); + writeFileSync(join(projectDir, ".fusion", "fusion.db"), "SQLite format 3\x00"); + + const detected = await orchestrator.detectExistingProjects(projectDir); + + expect(detected).toHaveLength(0); + }); }); describe("autoRegisterProjects", () => { diff --git a/packages/core/src/__tests__/migration.test.ts b/packages/core/src/__tests__/migration.test.ts index 67411d501..5fc2be3eb 100644 --- a/packages/core/src/__tests__/migration.test.ts +++ b/packages/core/src/__tests__/migration.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { tempWorkspace, useIsolatedCwd } from "@fusion/test-utils"; @@ -20,10 +20,48 @@ import { CentralCore } from "../central-core.js"; function createFakeKbProject(dir: string): void { const kbDir = join(dir, ".fusion"); mkdirSync(kbDir, { recursive: true }); - // Create empty fusion.db file (SQLite needs actual format, but for detection an empty file works) + // A zero-byte file is a valid SQLite bootstrap database. + writeFileSync(join(kbDir, "fusion.db"), ""); +} + +function createInvalidKbProject(dir: string): void { + const kbDir = join(dir, ".fusion"); + mkdirSync(kbDir, { recursive: true }); writeFileSync(join(kbDir, "fusion.db"), "SQLite format 3\x00"); } +async function withDefaultFirstRunDetector( + homeDir: string, + fn: (detector: FirstRunDetector) => Promise | T, +): Promise { + const savedHome = process.env.HOME; + const savedUserProfile = process.env.USERPROFILE; + const savedVitest = process.env.VITEST; + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + delete process.env.VITEST; + + try { + return await fn(new FirstRunDetector()); + } finally { + if (savedHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = savedHome; + } + if (savedUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = savedUserProfile; + } + if (savedVitest === undefined) { + delete process.env.VITEST; + } else { + process.env.VITEST = savedVitest; + } + } +} + // Helper to create a fake git remote async function initGitRepo(dir: string, remoteUrl?: string): Promise { const { execFile } = await import("node:child_process"); @@ -222,6 +260,16 @@ describe("FirstRunDetector", () => { expect(projects).toHaveLength(0); }); + + it("should ignore invalid fusion.db files when scanning for local projects", async () => { + const tempProjectDir = tempWorkspace("kb-invalid-detect-"); + createInvalidKbProject(tempProjectDir); + + const detector = new FirstRunDetector(tempGlobalDir); + const projects = await detector.detectExistingProjects(tempProjectDir); + + expect(projects).toHaveLength(0); + }); }); describe("generateProjectName", () => { @@ -260,6 +308,18 @@ describe("FirstRunDetector", () => { const detector = new FirstRunDetector(tempGlobalDir); expect(detector.getCentralDbPath()).toBe(join(tempGlobalDir, "fusion-central.db")); }); + + it("should default to ~/.fusion when no explicit global dir is provided", async () => { + const homeDir = tempWorkspace("kb-default-global-dir-"); + + try { + await withDefaultFirstRunDetector(homeDir, (detector) => { + expect(detector.getCentralDbPath()).toBe(join(homeDir, ".fusion", "fusion-central.db")); + }); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } + }); }); }); diff --git a/packages/core/src/__tests__/sqlite-validation.test.ts b/packages/core/src/__tests__/sqlite-validation.test.ts new file mode 100644 index 000000000..9c5db1107 --- /dev/null +++ b/packages/core/src/__tests__/sqlite-validation.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isValidSqliteDatabaseFile } from "../sqlite-validation.js"; + +function makeTempDir(): string { + return mkdtempSync(join(tmpdir(), "kb-sqlite-validation-")); +} + +describe("isValidSqliteDatabaseFile", () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns false when the file does not exist", () => { + const dir = makeTempDir(); + dirs.push(dir); + + expect(isValidSqliteDatabaseFile(join(dir, "missing.db"))).toBe(false); + }); + + it("returns true for a zero-byte bootstrap database", () => { + const dir = makeTempDir(); + dirs.push(dir); + const dbPath = join(dir, "fusion.db"); + writeFileSync(dbPath, ""); + + expect(isValidSqliteDatabaseFile(dbPath)).toBe(true); + }); + + it("returns false for plain text files", () => { + const dir = makeTempDir(); + dirs.push(dir); + const dbPath = join(dir, "fusion.db"); + writeFileSync(dbPath, "not a sqlite database"); + + expect(isValidSqliteDatabaseFile(dbPath)).toBe(false); + }); + + it("returns false for a header-only file", () => { + const dir = makeTempDir(); + dirs.push(dir); + const dbPath = join(dir, "fusion.db"); + writeFileSync(dbPath, "SQLite format 3\x00"); + + expect(isValidSqliteDatabaseFile(dbPath)).toBe(false); + }); +}); diff --git a/packages/core/src/central-db.ts b/packages/core/src/central-db.ts index 321b73f2f..3e8c50cbc 100644 --- a/packages/core/src/central-db.ts +++ b/packages/core/src/central-db.ts @@ -220,7 +220,12 @@ export class CentralDatabase { mkdirSync(this.globalDir, { recursive: true }); } - this.db = new DatabaseSync(this.dbPath); + try { + this.db = new DatabaseSync(this.dbPath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to open Fusion central database at ${this.dbPath}: ${message}`); + } // Enable WAL mode for concurrent reader/writer access this.db.exec("PRAGMA journal_mode = WAL"); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 36c0b60c7..53f1d1260 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -677,7 +677,12 @@ export class Database { mkdirSync(fusionDir, { recursive: true }); } - this.db = new DatabaseSync(this.dbPath); + try { + this.db = new DatabaseSync(this.dbPath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to open Fusion database at ${this.dbPath}: ${message}`); + } // WAL is meaningless for `:memory:` connections — SQLite ignores it // and there's no other writer to coordinate with — so we skip it. The diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6922731f0..34aca9f95 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,6 +50,7 @@ export type { Statement } from "./db.js"; export { ArchiveDatabase } from "./archive-db.js"; export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js"; export { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js"; +export { isValidSqliteDatabaseFile } from "./sqlite-validation.js"; export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js"; export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, reconcileClaudeCliPaths, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js"; export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js"; diff --git a/packages/core/src/migration-orchestrator.ts b/packages/core/src/migration-orchestrator.ts index 6f7eaceb5..fbe2941c0 100644 --- a/packages/core/src/migration-orchestrator.ts +++ b/packages/core/src/migration-orchestrator.ts @@ -31,6 +31,7 @@ import type { RegisteredProject, } from "./types.js"; import type { CentralCore } from "./central-core.js"; +import { isValidSqliteDatabaseFile } from "./sqlite-validation.js"; // ── Constants ────────────────────────────────────────────────────────────── @@ -200,38 +201,10 @@ export class MigrationOrchestrator { /** * Check if a directory contains a valid kb project. - * Validates that .fusion/fusion.db (or legacy .fusion/fusion.db) exists and is a file. + * Validates that .fusion/fusion.db is an openable SQLite database. */ private isKbProject(dir: string): boolean { - // Check current layout: .fusion/fusion.db - const fusionPath = join(dir, ".fusion"); - if (existsSync(fusionPath)) { - const fusionDb = join(fusionPath, "fusion.db"); - if (existsSync(fusionDb)) { - try { - const stats = statSync(fusionDb); - if (stats.isFile()) return true; - } catch { /* fall through */ } - } - } - - // Fall back to legacy layout: .fusion/fusion.db - const kbPath = join(dir, ".fusion"); - if (!existsSync(kbPath)) { - return false; - } - - const dbPath = join(kbPath, "fusion.db"); - if (!existsSync(dbPath)) { - return false; - } - - try { - const stats = statSync(dbPath); - return stats.isFile(); - } catch { - return false; - } + return isValidSqliteDatabaseFile(join(dir, ".fusion", "fusion.db")); } /** @@ -481,4 +454,4 @@ export class MigrationOrchestrator { */ export function createMigrationOrchestrator(centralCore: CentralCore): MigrationOrchestrator { return new MigrationOrchestrator(centralCore); -} \ No newline at end of file +} diff --git a/packages/core/src/migration.ts b/packages/core/src/migration.ts index d7c3ac94e..6ffe50e9d 100644 --- a/packages/core/src/migration.ts +++ b/packages/core/src/migration.ts @@ -10,18 +10,20 @@ * @module migration */ -import { existsSync, statSync } from "node:fs"; +import { existsSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { isAbsolute, join, resolve, basename, dirname } from "node:path"; import type { CentralCore } from "./central-core.js"; import { CentralCore as CentralCoreClass } from "./central-core.js"; +import { resolveGlobalDir } from "./global-settings.js"; +import { isValidSqliteDatabaseFile } from "./sqlite-validation.js"; function getHomeDir(): string { return process.env.HOME || process.env.USERPROFILE || homedir(); } /** - * Check whether `//` exists as a non-empty regular file. + * Check whether `//` exists as an openable SQLite file. * Used to decide if a directory contains a current (.fusion/fusion.db) project database. */ function hasProjectDbFile(dir: string, folderName: string, dbName: string): boolean { @@ -29,14 +31,7 @@ function hasProjectDbFile(dir: string, folderName: string, dbName: string): bool const dbPath = join(projectDir, dbName); if (!existsSync(projectDir)) return false; - if (!existsSync(dbPath)) return false; - - try { - const stat = statSync(dbPath); - return stat.isFile() && stat.size > 0; - } catch { - return false; - } + return isValidSqliteDatabaseFile(dbPath); } // ── Types ──────────────────────────────────────────────────────────── @@ -303,7 +298,7 @@ export class FirstRunDetector { } private getDefaultGlobalDir(): string { - return join(getHomeDir(), ".pi", "kb"); + return resolveGlobalDir(); } } diff --git a/packages/core/src/sqlite-validation.ts b/packages/core/src/sqlite-validation.ts new file mode 100644 index 000000000..e062ae5df --- /dev/null +++ b/packages/core/src/sqlite-validation.ts @@ -0,0 +1,38 @@ +import { existsSync, statSync } from "node:fs"; +import { DatabaseSync } from "./sqlite-adapter.js"; + +/** + * Validate that a path points to a SQLite database file that can be opened. + * + * Zero-byte files are treated as valid bootstrap databases because SQLite + * upgrades them in place on first open. Non-existent paths and unreadable or + * malformed files return false. + */ +export function isValidSqliteDatabaseFile(dbPath: string): boolean { + if (!existsSync(dbPath)) { + return false; + } + + try { + if (!statSync(dbPath).isFile()) { + return false; + } + } catch { + return false; + } + + let db: DatabaseSync | null = null; + try { + db = new DatabaseSync(dbPath); + db.prepare("PRAGMA schema_version").get(); + return true; + } catch { + return false; + } finally { + try { + db?.close(); + } catch { + // Best-effort close only. + } + } +} diff --git a/packages/dashboard/src/__tests__/setup-routes.test.ts b/packages/dashboard/src/__tests__/setup-routes.test.ts new file mode 100644 index 000000000..7244534b4 --- /dev/null +++ b/packages/dashboard/src/__tests__/setup-routes.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment node + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import { request } from "../test-request.js"; +import type { TaskStore } from "@fusion/core"; + +const mockCentralInit = vi.fn().mockResolvedValue(undefined); +const mockCentralClose = vi.fn().mockResolvedValue(undefined); +const mockCentralListProjects = vi.fn().mockResolvedValue([]); +const mockCentralGetGlobalDir = vi.fn().mockReturnValue("/tmp/fusion-global"); + +const mockDetectorDetectExistingProjects = vi.fn().mockResolvedValue([]); +const mockDetectorDetectFirstRunState = vi.fn().mockResolvedValue("fresh-install"); +const mockDetectorHasCentralDb = vi.fn().mockReturnValue(false); + +vi.mock("@fusion/core", async () => { + const actual = await vi.importActual("@fusion/core"); + return { + ...actual, + isGhAuthenticated: vi.fn(), + CentralCore: vi.fn().mockImplementation(() => ({ + init: mockCentralInit, + close: mockCentralClose, + listProjects: mockCentralListProjects, + getGlobalDir: mockCentralGetGlobalDir, + })), + FirstRunDetector: vi.fn().mockImplementation(() => ({ + detectExistingProjects: mockDetectorDetectExistingProjects, + detectFirstRunState: mockDetectorDetectFirstRunState, + hasCentralDb: mockDetectorHasCentralDb, + })), + }; +}); + +vi.mock("@fusion/engine", () => ({ + createFnAgent: vi.fn(async () => ({ + session: { + state: { messages: [] as Array<{ role: string; content: string }> }, + prompt: vi.fn(async function (this: { state?: { messages?: Array<{ role: string; content: string }> } }, message: string) { + const messages = this.state?.messages ?? []; + messages.push({ role: "user", content: message }); + messages.push({ role: "assistant", content: JSON.stringify({ subtasks: [] }) }); + }), + dispose: vi.fn(), + }, + })), + AgentReflectionService: class { + async generateReflection(): Promise { throw new Error("Reflection service unavailable"); } + async buildReflectionContext(): Promise { throw new Error("Reflection service unavailable"); } + }, +})); + +function createMockStore(overrides: Partial = {}): TaskStore { + return { + getTask: vi.fn(), + listTasks: vi.fn().mockResolvedValue([]), + searchTasks: vi.fn().mockResolvedValue([]), + createTask: vi.fn(), + moveTask: vi.fn(), + updateTask: vi.fn(), + deleteTask: vi.fn(), + mergeTask: vi.fn(), + getTaskByBranch: vi.fn(), + getTaskByWorktree: vi.fn(), + checkoutTask: vi.fn(), + releaseTask: vi.fn(), + listAgents: vi.fn().mockResolvedValue([]), + createAgent: vi.fn(), + updateAgent: vi.fn(), + deleteAgent: vi.fn(), + getAgent: vi.fn(), + logAgentEvent: vi.fn(), + logEntry: vi.fn(), + addComment: vi.fn(), + getComments: vi.fn().mockResolvedValue([]), + updateSettings: vi.fn(), + getSettings: vi.fn().mockResolvedValue({}), + getRootDir: vi.fn().mockReturnValue("/tmp/test"), + getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"), + getPluginStore: vi.fn().mockReturnValue({ + listPlugins: vi.fn().mockResolvedValue([]), + getPlugin: vi.fn(), + registerPlugin: vi.fn(), + updatePlugin: vi.fn(), + unregisterPlugin: vi.fn(), + }), + getMissionStore: vi.fn().mockReturnValue({ + listMissions: vi.fn().mockResolvedValue([]), + }), + getRoutineStore: vi.fn().mockReturnValue({ + listRoutines: vi.fn().mockResolvedValue([]), + }), + getAutomationStore: vi.fn().mockReturnValue({ + listScheduledTasks: vi.fn().mockResolvedValue([]), + }), + ...overrides, + } as unknown as TaskStore; +} + +describe("setup routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCentralInit.mockResolvedValue(undefined); + mockCentralClose.mockResolvedValue(undefined); + mockCentralListProjects.mockResolvedValue([]); + mockCentralGetGlobalDir.mockReturnValue("/tmp/fusion-global"); + mockDetectorDetectExistingProjects.mockResolvedValue([]); + mockDetectorDetectFirstRunState.mockResolvedValue("fresh-install"); + mockDetectorHasCentralDb.mockReturnValue(false); + }); + + async function buildApp() { + const { createApiRoutes } = await import("../routes.js"); + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(createMockStore())); + return app; + } + + it("falls back to detected local projects when /api/first-run-status cannot open the central DB", async () => { + mockCentralInit.mockRejectedValueOnce(new Error("file is not a database")); + mockDetectorDetectExistingProjects.mockResolvedValueOnce([ + { path: "/workspace/f1", name: "f1", hasDb: true }, + ]); + + const res = await request(await buildApp(), "GET", "/api/first-run-status"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + hasProjects: true, + singleProjectPath: "/workspace/f1", + }); + expect(mockDetectorDetectExistingProjects).toHaveBeenCalledWith(process.cwd()); + expect(mockCentralClose).toHaveBeenCalledTimes(1); + }); + + it("returns setup state with empty registered projects when the central DB is unreadable", async () => { + const detectedProjects = [{ path: "/workspace/f1", name: "f1", hasDb: true }]; + mockCentralInit.mockRejectedValueOnce(new Error("file is not a database")); + mockDetectorDetectExistingProjects.mockResolvedValueOnce(detectedProjects); + mockDetectorDetectFirstRunState.mockResolvedValueOnce("fresh-install"); + mockDetectorHasCentralDb.mockReturnValueOnce(true); + + const res = await request(await buildApp(), "GET", "/api/setup-state"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + state: "fresh-install", + detectedProjects, + hasCentralDb: true, + registeredProjects: [], + }); + expect(mockCentralListProjects).not.toHaveBeenCalled(); + expect(mockCentralClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 5167403e9..e710b24de 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -3765,7 +3765,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout try { const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore(); const shouldClose = !options?.centralCore; - if (shouldClose) await central.init(); + if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) await central.init(); const state = await central.getGlobalConcurrencyState(); if (shouldClose) await central.close(); @@ -3794,7 +3794,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout try { const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore(); const shouldClose = !options?.centralCore; - if (shouldClose) await central.init(); + if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) await central.init(); const state = await central.updateGlobalConcurrency({ globalMaxConcurrent }); if (shouldClose) await central.close(); @@ -3815,17 +3815,39 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout */ router.get("/first-run-status", async (_req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); + const { CentralCore, FirstRunDetector } = await import("@fusion/core"); + const central = options?.centralCore ?? new CentralCore(); + const shouldClose = !options?.centralCore; + const detector = new FirstRunDetector(central.getGlobalDir()); + + try { + if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) { + await central.init(); + } + + const projects = await central.listProjects(); + const hasProjects = projects.length > 0; + const singleProjectPath = projects.length === 1 ? projects[0].path : null; + + res.json({ hasProjects, singleProjectPath }); + } catch (error) { + const detectedProjects = await detector.detectExistingProjects(process.cwd()); + const hasProjects = detectedProjects.length > 0; + const singleProjectPath = detectedProjects.length === 1 ? detectedProjects[0].path : null; + + console.warn( + `[routes:first-run-status] Falling back to detected projects after central DB error: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + + res.json({ hasProjects, singleProjectPath }); + } finally { + if (shouldClose) { + await central.close(); + } + } - const projects = await central.listProjects(); - await central.close(); - - const hasProjects = projects.length > 0; - const singleProjectPath = projects.length === 1 ? projects[0].path : null; - - res.json({ hasProjects, singleProjectPath }); } catch (err: unknown) { if (err instanceof ApiError) { throw err; @@ -3841,18 +3863,29 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout */ router.get("/setup-state", async (_req, res) => { try { - const { FirstRunDetector } = await import("@fusion/core"); - const { CentralCore } = await import("@fusion/core"); - - const detector = new FirstRunDetector(); - const state = await detector.detectFirstRunState(); + const { CentralCore, FirstRunDetector } = await import("@fusion/core"); + const central = options?.centralCore ?? new CentralCore(); + const shouldClose = !options?.centralCore; + const detector = new FirstRunDetector(central.getGlobalDir()); const detectedProjects = await detector.detectExistingProjects(process.cwd()); + let state = await detector.detectFirstRunState(); + let projects: Array<{ id: string; name: string; path: string }> = []; - // Get central DB info - const central = new CentralCore(); - await central.init(); - const projects = await central.listProjects(); - await central.close(); + try { + if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) { + await central.init(); + } + state = await detector.detectFirstRunState(central); + projects = await central.listProjects(); + } catch (error) { + console.warn( + `[routes:setup-state] Unable to read central DB state: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + if (shouldClose) { + await central.close(); + } + } res.json({ state, @@ -3890,8 +3923,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout throw badRequest("projects must be an array"); } - const central = new CentralCore(); - await central.init(); + const central = options?.centralCore ?? new CentralCore(); + const shouldClose = !options?.centralCore; + + if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) { + await central.init(); + } try { const coordinator = new MigrationCoordinator(central); @@ -3903,7 +3940,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout errors: result.errors, }); } finally { - await central.close(); + if (shouldClose) { + await central.close(); + } } } catch (err: unknown) { if (err instanceof ApiError) { diff --git a/packages/dashboard/src/routes/register-project-routes.ts b/packages/dashboard/src/routes/register-project-routes.ts index f418292ee..53942640c 100644 --- a/packages/dashboard/src/routes/register-project-routes.ts +++ b/packages/dashboard/src/routes/register-project-routes.ts @@ -1,6 +1,7 @@ import * as fsPromises from "node:fs/promises"; import { dirname, isAbsolute, join } from "node:path"; -import { ensureMemoryFileWithBackend } from "@fusion/core"; +import { ensureMemoryFileWithBackend, isValidSqliteDatabaseFile } from "@fusion/core"; +import type { CentralCore as CentralCoreApi } from "@fusion/core"; import { ApiError, badRequest, notFound } from "../api-error.js"; import { execFileAsync } from "../exec-file.js"; import { getOrCreateProjectStore } from "../project-store-resolver.js"; @@ -17,9 +18,33 @@ const { export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { const { router, options, runtimeLogger, prioritizeProjectsForCurrentDirectory, rethrowAsApiError } = ctx; + async function withCentralCore( + run: (central: CentralCoreApi) => Promise, + onError?: (error: unknown) => Promise | T, + ): Promise { + const sharedCentral = options?.centralCore; + const shouldClose = !sharedCentral; + const central = sharedCentral ?? new (await import("@fusion/core")).CentralCore(); + + try { + if (!sharedCentral || (typeof central.isInitialized === "function" && !central.isInitialized())) { + await central.init(); + } + return await run(central); + } catch (error) { + if (onError) { + return await onError(error); + } + throw error; + } finally { + if (shouldClose) { + await central.close(); + } + } + } + // ── Project Management Routes (Multi-Project Support) ─────────────────────── - // These routes require CentralCore which is imported dynamically to avoid - // circular dependencies and ensure the central database is initialized. + // These routes require CentralCore for the shared project registry. /** * GET /api/projects @@ -28,16 +53,20 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { */ router.get("/projects", async (_req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - - // Reconcile stale "initializing" projects before listing so the - // dashboard never shows permanent loading spinners for legacy records. - await central.reconcileProjectStatuses(); - - const projects = prioritizeProjectsForCurrentDirectory(await central.listProjects()); - await central.close(); + const projects = await withCentralCore( + async (central) => { + // Reconcile stale "initializing" projects before listing so the + // dashboard never shows permanent loading spinners for legacy records. + await central.reconcileProjectStatuses(); + return prioritizeProjectsForCurrentDirectory(await central.listProjects()); + }, + (error) => { + runtimeLogger.child("projects").warn( + `Failed to list registered projects: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + }, + ); res.json(projects); } catch (err: unknown) { @@ -56,18 +85,26 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { */ router.get("/projects/across-nodes", async (_req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); + const { localProjects, allNodes } = await withCentralCore( + async (central) => { + // Reconcile stale "initializing" projects before listing + await central.reconcileProjectStatuses(); - // Reconcile stale "initializing" projects before listing - await central.reconcileProjectStatuses(); + // Get local projects and registered nodes in parallel + const [projects, nodes] = await Promise.all([ + central.listProjects(), + central.listNodes(), + ]); - // Get local projects and registered nodes in parallel - const [localProjects, allNodes] = await Promise.all([ - central.listProjects(), - central.listNodes(), - ]); + return { localProjects: projects, allNodes: nodes }; + }, + (error) => { + runtimeLogger.child("projects:across-nodes").warn( + `Failed to load local project registry: ${error instanceof Error ? error.message : String(error)}`, + ); + return { localProjects: [], allNodes: [] }; + }, + ); // Filter to online remote nodes with URLs const remoteNodes = allNodes.filter( @@ -79,7 +116,6 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { // no cross-node aggregation overhead. if (remoteNodes.length === 0) { const prioritizedProjects = prioritizeProjectsForCurrentDirectory(localProjects); - await central.close(); res.json(prioritizedProjects); return; } @@ -159,8 +195,6 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { // Apply directory prioritization const prioritizedProjects = prioritizeProjectsForCurrentDirectory(mergedProjects); - await central.close(); - res.json(prioritizedProjects); } catch (err: unknown) { if (err instanceof ApiError) { @@ -304,20 +338,18 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { hasFusionDir = false; } - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); + const activeProject = await withCentralCore(async (central) => { + const project = await central.registerProject({ + name: normalizedName, + path: normalizedPath, + isolationMode, + nodeId, + }); - const project = await central.registerProject({ - name: normalizedName, - path: normalizedPath, - isolationMode, - nodeId, + // Activate the project (registration sets it to 'initializing') + return await central.updateProject(project.id, { status: "active" }); }); - // Activate the project (registration sets it to 'initializing') - const activeProject = await central.updateProject(project.id, { status: "active" }); - // Bootstrap memory files (non-blocking, non-fatal) ensureMemoryFileWithBackend(normalizedPath).catch(() => { // Memory bootstrap failure is non-fatal - project registration succeeded @@ -341,9 +373,6 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { ); } } - - await central.close(); - res.status(201).json({ ...activeProject, _meta: { hasFusionDir: hasFusionDir ? undefined : false } }); } catch (err: unknown) { if (err instanceof ApiError) { @@ -379,15 +408,19 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { } // Get list of existing projects to check for duplicates - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - const existingProjects = await central.listProjects(); - await central.close(); + const existingProjects = await withCentralCore( + async (central) => await central.listProjects(), + (error) => { + runtimeLogger.child("projects:detect").warn( + `Failed to load existing projects during detection: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + }, + ); const existingPaths = new Set(existingProjects.map((p: { path: string }) => p.path)); - // Scan for .fusion/fusion.db or .fusion/fusion.db files (indicating fn projects) + // Scan for openable .fusion/fusion.db files (indicating fn projects) const detected: Array<{ path: string; suggestedName: string; existing: boolean }> = []; try { @@ -397,25 +430,7 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { if (!entry.isDirectory()) continue; const dirPath = join(searchPath, entry.name); - // Check for .fusion/fusion.db or .fusion directory (async to avoid blocking event loop) - let hasKbDb = false; - let hasFusionDir = false; - try { - await access(join(dirPath, ".fusion", "fusion.db")); - hasKbDb = true; - } catch { - hasKbDb = false; - } - if (!hasKbDb) { - try { - await access(join(dirPath, ".fusion")); - hasFusionDir = true; - } catch { - hasFusionDir = false; - } - } - - if (hasKbDb || hasFusionDir) { + if (isValidSqliteDatabaseFile(join(dirPath, ".fusion", "fusion.db"))) { detected.push({ path: dirPath, suggestedName: entry.name, @@ -442,12 +457,7 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { */ router.get("/projects/:id", async (req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - - const project = await central.getProject(req.params.id); - await central.close(); + const project = await withCentralCore(async (central) => await central.getProject(req.params.id)); if (!project) { throw notFound("Project not found"); @@ -475,29 +485,24 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { if (status !== undefined) updates.status = status as import("@fusion/core").ProjectStatus; if (isolationMode !== undefined) updates.isolationMode = isolationMode as "in-process" | "child-process"; - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - - const project = await central.updateProject(req.params.id, updates); - if (!project) { - await central.close(); - throw notFound("Project not found"); - } - - let resultProject = project; - if (nodeId !== undefined) { - if (nodeId === null) { - resultProject = await central.unassignProjectFromNode(req.params.id); - } else if (typeof nodeId === "string" && nodeId.trim()) { - resultProject = await central.assignProjectToNode(req.params.id, nodeId.trim()); - } else { - await central.close(); - throw badRequest("nodeId must be a non-empty string or null"); + const resultProject = await withCentralCore(async (central) => { + const project = await central.updateProject(req.params.id, updates); + if (!project) { + throw notFound("Project not found"); } - } - await central.close(); + if (nodeId === undefined) { + return project; + } + if (nodeId === null) { + return await central.unassignProjectFromNode(req.params.id); + } + if (typeof nodeId === "string" && nodeId.trim()) { + return await central.assignProjectToNode(req.params.id, nodeId.trim()); + } + + throw badRequest("nodeId must be a non-empty string or null"); + }); res.json(resultProject); } catch (err: unknown) { @@ -515,12 +520,9 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { */ router.delete("/projects/:id", async (req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - - await central.unregisterProject(req.params.id); - await central.close(); + await withCentralCore(async (central) => { + await central.unregisterProject(req.params.id); + }); res.json({ success: true }); } catch (err: unknown) { @@ -541,49 +543,47 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { */ router.get("/projects/:id/health", async (req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); + const health = await withCentralCore(async (central) => { + const project = await central.getProject(req.params.id); + if (!project) { + throw notFound("Project not found"); + } - const project = await central.getProject(req.params.id); - if (!project) { - await central.close(); - throw notFound("Project not found"); - } + // Use the project-scoped store resolver to get the correct store for + // this project. This ensures we compute counts from the right project, + // regardless of which project is the dashboard's default. + const projectStore = await getOrCreateProjectStore(req.params.id); - // Use the project-scoped store resolver to get the correct store for - // this project. This ensures we compute counts from the right project, - // regardless of which project is the dashboard's default. - const projectStore = await getOrCreateProjectStore(req.params.id); + // Compute live task counts from the project-specific store + const tasks = await projectStore.listTasks({ slim: true }); + const activeCols = new Set(["triage", "todo", "in-progress", "in-review"]); + const activeTaskCount = tasks.filter((t) => activeCols.has(t.column)).length; + const inFlightAgentCount = tasks.filter((t) => t.column === "in-progress").length; + const totalTasksCompleted = tasks.filter((t) => t.column === "done" || t.column === "archived").length; - // Compute live task counts from the project-specific store - const tasks = await projectStore.listTasks({ slim: true }); - const activeCols = new Set(["triage", "todo", "in-progress", "in-review"]); - const activeTaskCount = tasks.filter((t) => activeCols.has(t.column)).length; - const inFlightAgentCount = tasks.filter((t) => t.column === "in-progress").length; - const totalTasksCompleted = tasks.filter((t) => t.column === "done" || t.column === "archived").length; + // Get central health metadata (if available) to preserve non-count fields + const centralHealth = await central.getProjectHealth(req.params.id); - // Get central health metadata (if available) to preserve non-count fields - const centralHealth = await central.getProjectHealth(req.params.id); - await central.close(); + // Build response: use central health as base if available, otherwise synthesize + const healthBase = centralHealth ?? { + projectId: req.params.id, + status: project.status ?? "active", + activeTaskCount: 0, + inFlightAgentCount: 0, + totalTasksCompleted: 0, + totalTasksFailed: 0, + updatedAt: new Date().toISOString(), + }; - // Build response: use central health as base if available, otherwise synthesize - const healthBase = centralHealth ?? { - projectId: req.params.id, - status: project.status ?? "active", - activeTaskCount: 0, - inFlightAgentCount: 0, - totalTasksCompleted: 0, - totalTasksFailed: 0, - updatedAt: new Date().toISOString(), - }; - - res.json({ - ...healthBase, - activeTaskCount, - inFlightAgentCount, - totalTasksCompleted, + return { + ...healthBase, + activeTaskCount, + inFlightAgentCount, + totalTasksCompleted, + }; }); + + res.json(health); } catch (err: unknown) { if (err instanceof ApiError) { throw err; @@ -599,12 +599,7 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { */ router.get("/projects/:id/config", async (req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - - const project = await central.getProject(req.params.id); - await central.close(); + const project = await withCentralCore(async (central) => await central.getProject(req.params.id)); if (!project) { throw notFound("Project not found"); @@ -635,20 +630,14 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { await options.engineManager.pauseProject(projectId); } else { // Fallback: update CentralCore directly (dev mode) - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - await central.updateProject(projectId, { status: "paused" }); - await central.updateProjectHealth(projectId, { status: "paused" }); - await central.close(); + await withCentralCore(async (central) => { + await central.updateProject(projectId, { status: "paused" }); + await central.updateProjectHealth(projectId, { status: "paused" }); + }); } // Fetch and return the updated project - const { CentralCore: CentralCore2 } = await import("@fusion/core"); - const central = new CentralCore2(); - await central.init(); - const project = await central.getProject(projectId); - await central.close(); + const project = await withCentralCore(async (central) => await central.getProject(projectId)); if (!project) { throw new ApiError(404, `Project ${projectId} not found`); @@ -677,20 +666,14 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { await options.engineManager.resumeProject(projectId); } else { // Fallback: update CentralCore directly (dev mode) - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - await central.updateProject(projectId, { status: "active" }); - await central.updateProjectHealth(projectId, { status: "active" }); - await central.close(); + await withCentralCore(async (central) => { + await central.updateProject(projectId, { status: "active" }); + await central.updateProjectHealth(projectId, { status: "active" }); + }); } // Fetch and return the updated project - const { CentralCore: CentralCore2 } = await import("@fusion/core"); - const central = new CentralCore2(); - await central.init(); - const project = await central.getProject(projectId); - await central.close(); + const project = await withCentralCore(async (central) => await central.getProject(projectId)); if (!project) { throw new ApiError(404, `Project ${projectId} not found`);