fix(FN-0000): harden sqlite startup validation

This commit is contained in:
gsxdsm
2026-04-29 07:58:58 -07:00
parent 858e24468f
commit 3202e578c2
19 changed files with 648 additions and 292 deletions

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -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<typeof mockIsValidSqliteDatabaseFile>) =>
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]);

View File

@@ -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<typeof import("@fusion/core")>("@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<typeof mockIsValidSqliteDatabaseFile>) =>
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 () => {

View File

@@ -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<void> {
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<void> {
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<void> {
// 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`);
}

View File

@@ -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) {

View File

@@ -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"));
}
/**

View File

@@ -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", () => {

View File

@@ -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<T>(
homeDir: string,
fn: (detector: FirstRunDetector) => Promise<T> | T,
): Promise<T> {
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<void> {
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 });
}
});
});
});

View File

@@ -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);
});
});

View File

@@ -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");

View File

@@ -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

View File

@@ -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";

View File

@@ -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);
}
}

View File

@@ -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 `<dir>/<folderName>/<dbName>` exists as a non-empty regular file.
* Check whether `<dir>/<folderName>/<dbName>` 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();
}
}

View File

@@ -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.
}
}
}

View File

@@ -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<typeof import("@fusion/core")>("@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<never> { throw new Error("Reflection service unavailable"); }
async buildReflectionContext(): Promise<never> { throw new Error("Reflection service unavailable"); }
},
}));
function createMockStore(overrides: Partial<TaskStore> = {}): 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);
});
});

View File

@@ -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) {

View File

@@ -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<T>(
run: (central: CentralCoreApi) => Promise<T>,
onError?: (error: unknown) => Promise<T> | T,
): Promise<T> {
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`);