fix(FN-0000): harden sqlite startup validation
This commit is contained in:
5
.changeset/fix-sqlite-startup-validation.md
Normal file
5
.changeset/fix-sqlite-startup-validation.md
Normal 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.
|
||||||
@@ -117,6 +117,16 @@ describe("project-context", () => {
|
|||||||
expect(found?.path).toBe(resolve(projectPath));
|
expect(found?.path).toBe(resolve(projectPath));
|
||||||
expect(found?.name).toBe("legacy-project");
|
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", () => {
|
describe("formatProjectLine", () => {
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import { TaskStore } from "@fusion/core";
|
import { TaskStore } from "@fusion/core";
|
||||||
|
|
||||||
|
const { mockIsValidSqliteDatabaseFile } = vi.hoisted(() => ({
|
||||||
|
mockIsValidSqliteDatabaseFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock fs module
|
// Mock fs module
|
||||||
vi.mock("node:fs", () => ({
|
vi.mock("node:fs", () => ({
|
||||||
existsSync: vi.fn(),
|
existsSync: vi.fn(),
|
||||||
@@ -23,6 +27,8 @@ vi.mock("@fusion/core", async () => {
|
|||||||
getProjectHealth = vi.fn().mockResolvedValue(undefined);
|
getProjectHealth = vi.fn().mockResolvedValue(undefined);
|
||||||
isInitialized = vi.fn().mockReturnValue(true);
|
isInitialized = vi.fn().mockReturnValue(true);
|
||||||
},
|
},
|
||||||
|
isValidSqliteDatabaseFile: (...args: Parameters<typeof mockIsValidSqliteDatabaseFile>) =>
|
||||||
|
mockIsValidSqliteDatabaseFile(...args),
|
||||||
TaskStore: vi.fn().mockImplementation(() => ({
|
TaskStore: vi.fn().mockImplementation(() => ({
|
||||||
init: vi.fn().mockResolvedValue(undefined),
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
listTasks: vi.fn().mockResolvedValue([]),
|
listTasks: vi.fn().mockResolvedValue([]),
|
||||||
@@ -69,6 +75,7 @@ describe("Project Resolver", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
resetProjectResolution();
|
resetProjectResolution();
|
||||||
|
mockIsValidSqliteDatabaseFile.mockReturnValue(false);
|
||||||
vi.mocked(TaskStore).mockImplementation(() => ({
|
vi.mocked(TaskStore).mockImplementation(() => ({
|
||||||
init: vi.fn().mockResolvedValue(undefined),
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
listTasks: vi.fn().mockResolvedValue([]),
|
listTasks: vi.fn().mockResolvedValue([]),
|
||||||
@@ -81,49 +88,38 @@ describe("Project Resolver", () => {
|
|||||||
|
|
||||||
describe("findKbDir", () => {
|
describe("findKbDir", () => {
|
||||||
it("should find .fusion directory in current path", () => {
|
it("should find .fusion directory in current path", () => {
|
||||||
vi.mocked(existsSync)
|
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/project/.fusion/fusion.db");
|
||||||
.mockReturnValueOnce(true)
|
|
||||||
.mockReturnValue(false);
|
|
||||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
|
||||||
|
|
||||||
const result = findKbDir("/project");
|
const result = findKbDir("/project");
|
||||||
expect(result).toBe("/project");
|
expect(result).toBe("/project");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should walk up parent directories to find .fusion", () => {
|
it("should walk up parent directories to find .fusion", () => {
|
||||||
vi.mocked(existsSync)
|
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/a/b/.fusion/fusion.db");
|
||||||
.mockReturnValueOnce(false)
|
|
||||||
.mockReturnValueOnce(true)
|
|
||||||
.mockReturnValue(false);
|
|
||||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
|
||||||
|
|
||||||
const result = findKbDir("/a/b/c");
|
const result = findKbDir("/a/b/c");
|
||||||
expect(result).toBe("/a/b");
|
expect(result).toBe("/a/b");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return null if no .fusion found", () => {
|
it("should return null if no .fusion found", () => {
|
||||||
vi.mocked(existsSync).mockReturnValue(false);
|
|
||||||
const result = findKbDir("/some/path");
|
const result = findKbDir("/some/path");
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return null if .fusion is not a directory", () => {
|
it("should return null if fusion.db is not a valid SQLite database", () => {
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
mockIsValidSqliteDatabaseFile.mockReturnValue(false);
|
||||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => false } as any);
|
|
||||||
const result = findKbDir("/project");
|
const result = findKbDir("/project");
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isKbProject", () => {
|
describe("isKbProject", () => {
|
||||||
it("should return true if .fusion directory exists", () => {
|
it("should return true if fusion.db is a valid SQLite database", () => {
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/project/.fusion/fusion.db");
|
||||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
|
||||||
expect(isKbProject("/project")).toBe(true);
|
expect(isKbProject("/project")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false if .fusion directory does not exist", () => {
|
it("should return false if fusion.db is invalid or missing", () => {
|
||||||
vi.mocked(existsSync).mockReturnValue(false);
|
|
||||||
expect(isKbProject("/project")).toBe(false);
|
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 () => {
|
it("should throw NOT_REGISTERED if .fusion exists but project not registered", async () => {
|
||||||
vi.mocked(existsSync).mockReturnValueOnce(true).mockReturnValue(true);
|
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/unregistered/.fusion/fusion.db");
|
||||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
|
||||||
|
|
||||||
const core = await getCentralCore();
|
const core = await getCentralCore();
|
||||||
core.listProjects.mockResolvedValue([]);
|
core.listProjects.mockResolvedValue([]);
|
||||||
@@ -213,8 +208,6 @@ describe("Project Resolver", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw NO_PROJECTS when no projects registered and no .fusion found", async () => {
|
it("should throw NO_PROJECTS when no projects registered and no .fusion found", async () => {
|
||||||
vi.mocked(existsSync).mockReturnValue(false);
|
|
||||||
|
|
||||||
const core = await getCentralCore();
|
const core = await getCentralCore();
|
||||||
core.listProjects.mockResolvedValue([]);
|
core.listProjects.mockResolvedValue([]);
|
||||||
|
|
||||||
@@ -283,11 +276,8 @@ describe("Project Resolver", () => {
|
|||||||
updatedAt: "",
|
updatedAt: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(existsSync).mockImplementation((path) => {
|
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/workspace/cwd-match/.fusion/fusion.db");
|
||||||
const p = String(path);
|
vi.mocked(existsSync).mockImplementation((path) => String(path) === "/workspace/cwd-match");
|
||||||
return p === "/workspace/cwd-match/.fusion" || p === "/workspace/cwd-match";
|
|
||||||
});
|
|
||||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
|
||||||
|
|
||||||
const core = await getCentralCore();
|
const core = await getCentralCore();
|
||||||
core.listProjects.mockResolvedValue([mockProject]);
|
core.listProjects.mockResolvedValue([mockProject]);
|
||||||
@@ -329,8 +319,8 @@ describe("Project Resolver", () => {
|
|||||||
updatedAt: "",
|
updatedAt: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(existsSync).mockImplementation((path) => String(path) === "/workspace/missing-cwd/.fusion");
|
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/workspace/missing-cwd/.fusion/fusion.db");
|
||||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
vi.mocked(existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
const core = await getCentralCore();
|
const core = await getCentralCore();
|
||||||
core.listProjects.mockResolvedValue([match]);
|
core.listProjects.mockResolvedValue([match]);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { runInit } from "../init.js";
|
import { runInit } from "../init.js";
|
||||||
@@ -17,20 +17,29 @@ const mockCentralClose = vi.fn();
|
|||||||
const mockGetProjectByPath = vi.fn();
|
const mockGetProjectByPath = vi.fn();
|
||||||
const mockRegisterProject = vi.fn();
|
const mockRegisterProject = vi.fn();
|
||||||
const mockUpdateProject = vi.fn().mockResolvedValue({});
|
const mockUpdateProject = vi.fn().mockResolvedValue({});
|
||||||
|
const { mockIsValidSqliteDatabaseFile } = vi.hoisted(() => ({
|
||||||
vi.mock("@fusion/core", () => ({
|
mockIsValidSqliteDatabaseFile: vi.fn(),
|
||||||
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(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
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 {
|
function tempDir(prefix: string): string {
|
||||||
return mkdtempSync(join(tmpdir(), prefix));
|
return mkdtempSync(join(tmpdir(), prefix));
|
||||||
}
|
}
|
||||||
@@ -62,6 +71,13 @@ describe("init command", () => {
|
|||||||
path: tempProjectDir,
|
path: tempProjectDir,
|
||||||
isolationMode: "in-process",
|
isolationMode: "in-process",
|
||||||
});
|
});
|
||||||
|
mockIsValidSqliteDatabaseFile.mockImplementation((dbPath: string) => {
|
||||||
|
if (!existsSync(dbPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFileSync(dbPath).length === 0;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -100,6 +116,18 @@ describe("init command", () => {
|
|||||||
await runInit({ path: tempProjectDir });
|
await runInit({ path: tempProjectDir });
|
||||||
|
|
||||||
expect(existsSync(dbPath)).toBe(true);
|
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 () => {
|
it("should be idempotent - report already initialized", async () => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { join, resolve, basename } from "node:path";
|
|||||||
import { exec } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
const execAsync = promisify(exec);
|
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 { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
|
||||||
import { isGitRepo } from "./git.js";
|
import { isGitRepo } from "./git.js";
|
||||||
import {
|
import {
|
||||||
@@ -41,9 +41,11 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
|
|||||||
const cwd = options.path ? resolve(options.path) : process.cwd();
|
const cwd = options.path ? resolve(options.path) : process.cwd();
|
||||||
const fusionDir = join(cwd, ".fusion");
|
const fusionDir = join(cwd, ".fusion");
|
||||||
const dbPath = join(fusionDir, "fusion.db");
|
const dbPath = join(fusionDir, "fusion.db");
|
||||||
|
const hasDbPath = existsSync(dbPath);
|
||||||
|
const hasValidDb = hasDbPath && isValidSqliteDatabaseFile(dbPath);
|
||||||
|
|
||||||
// Check if already initialized
|
// Check if already initialized
|
||||||
if (existsSync(fusionDir) && existsSync(dbPath)) {
|
if (existsSync(fusionDir) && hasDbPath && hasValidDb) {
|
||||||
// Check if registered in central DB
|
// Check if registered in central DB
|
||||||
const central = new CentralCore();
|
const central = new CentralCore();
|
||||||
await central.init();
|
await central.init();
|
||||||
@@ -69,6 +71,13 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
|
|||||||
return;
|
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
|
// Get or generate project name
|
||||||
const projectName = options.name ?? await detectProjectName(cwd);
|
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)
|
// Create fusion.db (empty SQLite file)
|
||||||
if (!existsSync(dbPath)) {
|
if (!existsSync(dbPath)) {
|
||||||
// SQLite database header for an empty database
|
// A zero-byte bootstrap file is a valid SQLite starting point.
|
||||||
const sqliteHeader = Buffer.from([
|
writeFileSync(dbPath, "");
|
||||||
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66,
|
|
||||||
0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00
|
|
||||||
]);
|
|
||||||
writeFileSync(dbPath, sqliteHeader);
|
|
||||||
console.log(` ✓ Created fusion.db`);
|
console.log(` ✓ Created fusion.db`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,8 @@
|
|||||||
* for operating on tasks across multiple registered projects.
|
* 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 { resolve, dirname, basename } from "node:path";
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
|
|
||||||
/** Project context for CLI operations */
|
/** Project context for CLI operations */
|
||||||
export interface ProjectContext {
|
export interface ProjectContext {
|
||||||
@@ -187,7 +186,7 @@ export async function detectProjectFromCwd(
|
|||||||
while (true) {
|
while (true) {
|
||||||
// Check for fn database
|
// Check for fn database
|
||||||
const kbPath = resolve(currentDir, ".fusion", "fusion.db");
|
const kbPath = resolve(currentDir, ".fusion", "fusion.db");
|
||||||
if (existsSync(kbPath)) {
|
if (isValidSqliteDatabaseFile(kbPath)) {
|
||||||
// Found a fn project - check if it's registered
|
// Found a fn project - check if it's registered
|
||||||
const project = await central.getProjectByPath(currentDir);
|
const project = await central.getProjectByPath(currentDir);
|
||||||
if (project) {
|
if (project) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import { basename, dirname, resolve, normalize } from "node:path";
|
import { basename, dirname, resolve, normalize } from "node:path";
|
||||||
import { createInterface } from "node:readline/promises";
|
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";
|
import { ProjectManager } from "@fusion/engine";
|
||||||
|
|
||||||
// Singleton instances for reuse across commands
|
// Singleton instances for reuse across commands
|
||||||
@@ -106,8 +106,8 @@ export function findKbDir(startPath: string): string | null {
|
|||||||
|
|
||||||
// Safety limit to prevent infinite loops
|
// Safety limit to prevent infinite loops
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
const kbPath = resolve(current, ".fusion");
|
const dbPath = resolve(current, ".fusion", "fusion.db");
|
||||||
if (existsSync(kbPath) && statSync(kbPath).isDirectory()) {
|
if (isValidSqliteDatabaseFile(dbPath)) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,8 +454,7 @@ export async function isProjectNameTaken(
|
|||||||
* Validate that a path contains an initialized fn project (.fusion/ directory exists).
|
* Validate that a path contains an initialized fn project (.fusion/ directory exists).
|
||||||
*/
|
*/
|
||||||
export function isKbProject(path: string): boolean {
|
export function isKbProject(path: string): boolean {
|
||||||
const kbPath = resolve(path, ".fusion");
|
return isValidSqliteDatabaseFile(resolve(path, ".fusion", "fusion.db"));
|
||||||
return existsSync(kbPath) && statSync(kbPath).isDirectory();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -170,6 +170,17 @@ describe("MigrationOrchestrator", () => {
|
|||||||
|
|
||||||
expect(detected).toHaveLength(0);
|
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", () => {
|
describe("autoRegisterProjects", () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tempWorkspace, useIsolatedCwd } from "@fusion/test-utils";
|
import { tempWorkspace, useIsolatedCwd } from "@fusion/test-utils";
|
||||||
@@ -20,10 +20,48 @@ import { CentralCore } from "../central-core.js";
|
|||||||
function createFakeKbProject(dir: string): void {
|
function createFakeKbProject(dir: string): void {
|
||||||
const kbDir = join(dir, ".fusion");
|
const kbDir = join(dir, ".fusion");
|
||||||
mkdirSync(kbDir, { recursive: true });
|
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");
|
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
|
// Helper to create a fake git remote
|
||||||
async function initGitRepo(dir: string, remoteUrl?: string): Promise<void> {
|
async function initGitRepo(dir: string, remoteUrl?: string): Promise<void> {
|
||||||
const { execFile } = await import("node:child_process");
|
const { execFile } = await import("node:child_process");
|
||||||
@@ -222,6 +260,16 @@ describe("FirstRunDetector", () => {
|
|||||||
|
|
||||||
expect(projects).toHaveLength(0);
|
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", () => {
|
describe("generateProjectName", () => {
|
||||||
@@ -260,6 +308,18 @@ describe("FirstRunDetector", () => {
|
|||||||
const detector = new FirstRunDetector(tempGlobalDir);
|
const detector = new FirstRunDetector(tempGlobalDir);
|
||||||
expect(detector.getCentralDbPath()).toBe(join(tempGlobalDir, "fusion-central.db"));
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
53
packages/core/src/__tests__/sqlite-validation.test.ts
Normal file
53
packages/core/src/__tests__/sqlite-validation.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -220,7 +220,12 @@ export class CentralDatabase {
|
|||||||
mkdirSync(this.globalDir, { recursive: true });
|
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
|
// Enable WAL mode for concurrent reader/writer access
|
||||||
this.db.exec("PRAGMA journal_mode = WAL");
|
this.db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
|||||||
@@ -677,7 +677,12 @@ export class Database {
|
|||||||
mkdirSync(fusionDir, { recursive: true });
|
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
|
// WAL is meaningless for `:memory:` connections — SQLite ignores it
|
||||||
// and there's no other writer to coordinate with — so we skip it. The
|
// and there's no other writer to coordinate with — so we skip it. The
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export type { Statement } from "./db.js";
|
|||||||
export { ArchiveDatabase } from "./archive-db.js";
|
export { ArchiveDatabase } from "./archive-db.js";
|
||||||
export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js";
|
export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js";
|
||||||
export { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.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 { 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 { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, reconcileClaudeCliPaths, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js";
|
||||||
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";
|
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import type {
|
|||||||
RegisteredProject,
|
RegisteredProject,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import type { CentralCore } from "./central-core.js";
|
import type { CentralCore } from "./central-core.js";
|
||||||
|
import { isValidSqliteDatabaseFile } from "./sqlite-validation.js";
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -200,38 +201,10 @@ export class MigrationOrchestrator {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a directory contains a valid kb project.
|
* 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 {
|
private isKbProject(dir: string): boolean {
|
||||||
// Check current layout: .fusion/fusion.db
|
return isValidSqliteDatabaseFile(join(dir, ".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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -481,4 +454,4 @@ export class MigrationOrchestrator {
|
|||||||
*/
|
*/
|
||||||
export function createMigrationOrchestrator(centralCore: CentralCore): MigrationOrchestrator {
|
export function createMigrationOrchestrator(centralCore: CentralCore): MigrationOrchestrator {
|
||||||
return new MigrationOrchestrator(centralCore);
|
return new MigrationOrchestrator(centralCore);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,18 +10,20 @@
|
|||||||
* @module migration
|
* @module migration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { homedir, tmpdir } from "node:os";
|
import { homedir, tmpdir } from "node:os";
|
||||||
import { isAbsolute, join, resolve, basename, dirname } from "node:path";
|
import { isAbsolute, join, resolve, basename, dirname } from "node:path";
|
||||||
import type { CentralCore } from "./central-core.js";
|
import type { CentralCore } from "./central-core.js";
|
||||||
import { CentralCore as CentralCoreClass } 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 {
|
function getHomeDir(): string {
|
||||||
return process.env.HOME || process.env.USERPROFILE || homedir();
|
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.
|
* Used to decide if a directory contains a current (.fusion/fusion.db) project database.
|
||||||
*/
|
*/
|
||||||
function hasProjectDbFile(dir: string, folderName: string, dbName: string): boolean {
|
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);
|
const dbPath = join(projectDir, dbName);
|
||||||
|
|
||||||
if (!existsSync(projectDir)) return false;
|
if (!existsSync(projectDir)) return false;
|
||||||
if (!existsSync(dbPath)) return false;
|
return isValidSqliteDatabaseFile(dbPath);
|
||||||
|
|
||||||
try {
|
|
||||||
const stat = statSync(dbPath);
|
|
||||||
return stat.isFile() && stat.size > 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
@@ -303,7 +298,7 @@ export class FirstRunDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultGlobalDir(): string {
|
private getDefaultGlobalDir(): string {
|
||||||
return join(getHomeDir(), ".pi", "kb");
|
return resolveGlobalDir();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
packages/core/src/sqlite-validation.ts
Normal file
38
packages/core/src/sqlite-validation.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
packages/dashboard/src/__tests__/setup-routes.test.ts
Normal file
157
packages/dashboard/src/__tests__/setup-routes.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3765,7 +3765,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
try {
|
try {
|
||||||
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
|
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
|
||||||
const shouldClose = !options?.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();
|
const state = await central.getGlobalConcurrencyState();
|
||||||
if (shouldClose) await central.close();
|
if (shouldClose) await central.close();
|
||||||
@@ -3794,7 +3794,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
try {
|
try {
|
||||||
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
|
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
|
||||||
const shouldClose = !options?.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 });
|
const state = await central.updateGlobalConcurrency({ globalMaxConcurrent });
|
||||||
if (shouldClose) await central.close();
|
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) => {
|
router.get("/first-run-status", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const { CentralCore, FirstRunDetector } = await import("@fusion/core");
|
||||||
const central = new CentralCore();
|
const central = options?.centralCore ?? new CentralCore();
|
||||||
await central.init();
|
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) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -3841,18 +3863,29 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
*/
|
*/
|
||||||
router.get("/setup-state", async (_req, res) => {
|
router.get("/setup-state", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { FirstRunDetector } = await import("@fusion/core");
|
const { CentralCore, FirstRunDetector } = await import("@fusion/core");
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const central = options?.centralCore ?? new CentralCore();
|
||||||
|
const shouldClose = !options?.centralCore;
|
||||||
const detector = new FirstRunDetector();
|
const detector = new FirstRunDetector(central.getGlobalDir());
|
||||||
const state = await detector.detectFirstRunState();
|
|
||||||
const detectedProjects = await detector.detectExistingProjects(process.cwd());
|
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
|
try {
|
||||||
const central = new CentralCore();
|
if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) {
|
||||||
await central.init();
|
await central.init();
|
||||||
const projects = await central.listProjects();
|
}
|
||||||
await central.close();
|
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({
|
res.json({
|
||||||
state,
|
state,
|
||||||
@@ -3890,8 +3923,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
throw badRequest("projects must be an array");
|
throw badRequest("projects must be an array");
|
||||||
}
|
}
|
||||||
|
|
||||||
const central = new CentralCore();
|
const central = options?.centralCore ?? new CentralCore();
|
||||||
await central.init();
|
const shouldClose = !options?.centralCore;
|
||||||
|
|
||||||
|
if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) {
|
||||||
|
await central.init();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const coordinator = new MigrationCoordinator(central);
|
const coordinator = new MigrationCoordinator(central);
|
||||||
@@ -3903,7 +3940,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
errors: result.errors,
|
errors: result.errors,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await central.close();
|
if (shouldClose) {
|
||||||
|
await central.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as fsPromises from "node:fs/promises";
|
import * as fsPromises from "node:fs/promises";
|
||||||
import { dirname, isAbsolute, join } from "node:path";
|
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 { ApiError, badRequest, notFound } from "../api-error.js";
|
||||||
import { execFileAsync } from "../exec-file.js";
|
import { execFileAsync } from "../exec-file.js";
|
||||||
import { getOrCreateProjectStore } from "../project-store-resolver.js";
|
import { getOrCreateProjectStore } from "../project-store-resolver.js";
|
||||||
@@ -17,9 +18,33 @@ const {
|
|||||||
export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
||||||
const { router, options, runtimeLogger, prioritizeProjectsForCurrentDirectory, rethrowAsApiError } = 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) ───────────────────────
|
// ── Project Management Routes (Multi-Project Support) ───────────────────────
|
||||||
// These routes require CentralCore which is imported dynamically to avoid
|
// These routes require CentralCore for the shared project registry.
|
||||||
// circular dependencies and ensure the central database is initialized.
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/projects
|
* GET /api/projects
|
||||||
@@ -28,16 +53,20 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/projects", async (_req, res) => {
|
router.get("/projects", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const projects = await withCentralCore(
|
||||||
const central = new CentralCore();
|
async (central) => {
|
||||||
await central.init();
|
// Reconcile stale "initializing" projects before listing so the
|
||||||
|
// dashboard never shows permanent loading spinners for legacy records.
|
||||||
// Reconcile stale "initializing" projects before listing so the
|
await central.reconcileProjectStatuses();
|
||||||
// dashboard never shows permanent loading spinners for legacy records.
|
return prioritizeProjectsForCurrentDirectory(await central.listProjects());
|
||||||
await central.reconcileProjectStatuses();
|
},
|
||||||
|
(error) => {
|
||||||
const projects = prioritizeProjectsForCurrentDirectory(await central.listProjects());
|
runtimeLogger.child("projects").warn(
|
||||||
await central.close();
|
`Failed to list registered projects: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
res.json(projects);
|
res.json(projects);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -56,18 +85,26 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/projects/across-nodes", async (_req, res) => {
|
router.get("/projects/across-nodes", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const { localProjects, allNodes } = await withCentralCore(
|
||||||
const central = new CentralCore();
|
async (central) => {
|
||||||
await central.init();
|
// Reconcile stale "initializing" projects before listing
|
||||||
|
await central.reconcileProjectStatuses();
|
||||||
|
|
||||||
// Reconcile stale "initializing" projects before listing
|
// Get local projects and registered nodes in parallel
|
||||||
await central.reconcileProjectStatuses();
|
const [projects, nodes] = await Promise.all([
|
||||||
|
central.listProjects(),
|
||||||
|
central.listNodes(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Get local projects and registered nodes in parallel
|
return { localProjects: projects, allNodes: nodes };
|
||||||
const [localProjects, allNodes] = await Promise.all([
|
},
|
||||||
central.listProjects(),
|
(error) => {
|
||||||
central.listNodes(),
|
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
|
// Filter to online remote nodes with URLs
|
||||||
const remoteNodes = allNodes.filter(
|
const remoteNodes = allNodes.filter(
|
||||||
@@ -79,7 +116,6 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
// no cross-node aggregation overhead.
|
// no cross-node aggregation overhead.
|
||||||
if (remoteNodes.length === 0) {
|
if (remoteNodes.length === 0) {
|
||||||
const prioritizedProjects = prioritizeProjectsForCurrentDirectory(localProjects);
|
const prioritizedProjects = prioritizeProjectsForCurrentDirectory(localProjects);
|
||||||
await central.close();
|
|
||||||
res.json(prioritizedProjects);
|
res.json(prioritizedProjects);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -159,8 +195,6 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
// Apply directory prioritization
|
// Apply directory prioritization
|
||||||
const prioritizedProjects = prioritizeProjectsForCurrentDirectory(mergedProjects);
|
const prioritizedProjects = prioritizeProjectsForCurrentDirectory(mergedProjects);
|
||||||
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
res.json(prioritizedProjects);
|
res.json(prioritizedProjects);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -304,20 +338,18 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
hasFusionDir = false;
|
hasFusionDir = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const activeProject = await withCentralCore(async (central) => {
|
||||||
const central = new CentralCore();
|
const project = await central.registerProject({
|
||||||
await central.init();
|
name: normalizedName,
|
||||||
|
path: normalizedPath,
|
||||||
|
isolationMode,
|
||||||
|
nodeId,
|
||||||
|
});
|
||||||
|
|
||||||
const project = await central.registerProject({
|
// Activate the project (registration sets it to 'initializing')
|
||||||
name: normalizedName,
|
return await central.updateProject(project.id, { status: "active" });
|
||||||
path: normalizedPath,
|
|
||||||
isolationMode,
|
|
||||||
nodeId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activate the project (registration sets it to 'initializing')
|
|
||||||
const activeProject = await central.updateProject(project.id, { status: "active" });
|
|
||||||
|
|
||||||
// Bootstrap memory files (non-blocking, non-fatal)
|
// Bootstrap memory files (non-blocking, non-fatal)
|
||||||
ensureMemoryFileWithBackend(normalizedPath).catch(() => {
|
ensureMemoryFileWithBackend(normalizedPath).catch(() => {
|
||||||
// Memory bootstrap failure is non-fatal - project registration succeeded
|
// 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 } });
|
res.status(201).json({ ...activeProject, _meta: { hasFusionDir: hasFusionDir ? undefined : false } });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -379,15 +408,19 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get list of existing projects to check for duplicates
|
// Get list of existing projects to check for duplicates
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const existingProjects = await withCentralCore(
|
||||||
const central = new CentralCore();
|
async (central) => await central.listProjects(),
|
||||||
await central.init();
|
(error) => {
|
||||||
const existingProjects = await central.listProjects();
|
runtimeLogger.child("projects:detect").warn(
|
||||||
await central.close();
|
`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));
|
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 }> = [];
|
const detected: Array<{ path: string; suggestedName: string; existing: boolean }> = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -397,25 +430,7 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
|
|
||||||
const dirPath = join(searchPath, entry.name);
|
const dirPath = join(searchPath, entry.name);
|
||||||
// Check for .fusion/fusion.db or .fusion directory (async to avoid blocking event loop)
|
if (isValidSqliteDatabaseFile(join(dirPath, ".fusion", "fusion.db"))) {
|
||||||
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) {
|
|
||||||
detected.push({
|
detected.push({
|
||||||
path: dirPath,
|
path: dirPath,
|
||||||
suggestedName: entry.name,
|
suggestedName: entry.name,
|
||||||
@@ -442,12 +457,7 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/projects/:id", async (req, res) => {
|
router.get("/projects/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const project = await withCentralCore(async (central) => await central.getProject(req.params.id));
|
||||||
const central = new CentralCore();
|
|
||||||
await central.init();
|
|
||||||
|
|
||||||
const project = await central.getProject(req.params.id);
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw notFound("Project not found");
|
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 (status !== undefined) updates.status = status as import("@fusion/core").ProjectStatus;
|
||||||
if (isolationMode !== undefined) updates.isolationMode = isolationMode as "in-process" | "child-process";
|
if (isolationMode !== undefined) updates.isolationMode = isolationMode as "in-process" | "child-process";
|
||||||
|
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const resultProject = await withCentralCore(async (central) => {
|
||||||
const central = new CentralCore();
|
const project = await central.updateProject(req.params.id, updates);
|
||||||
await central.init();
|
if (!project) {
|
||||||
|
throw notFound("Project not found");
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
res.json(resultProject);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -515,12 +520,9 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.delete("/projects/:id", async (req, res) => {
|
router.delete("/projects/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
await withCentralCore(async (central) => {
|
||||||
const central = new CentralCore();
|
await central.unregisterProject(req.params.id);
|
||||||
await central.init();
|
});
|
||||||
|
|
||||||
await central.unregisterProject(req.params.id);
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -541,49 +543,47 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/projects/:id/health", async (req, res) => {
|
router.get("/projects/:id/health", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const health = await withCentralCore(async (central) => {
|
||||||
const central = new CentralCore();
|
const project = await central.getProject(req.params.id);
|
||||||
await central.init();
|
if (!project) {
|
||||||
|
throw notFound("Project not found");
|
||||||
|
}
|
||||||
|
|
||||||
const project = await central.getProject(req.params.id);
|
// Use the project-scoped store resolver to get the correct store for
|
||||||
if (!project) {
|
// this project. This ensures we compute counts from the right project,
|
||||||
await central.close();
|
// regardless of which project is the dashboard's default.
|
||||||
throw notFound("Project not found");
|
const projectStore = await getOrCreateProjectStore(req.params.id);
|
||||||
}
|
|
||||||
|
|
||||||
// Use the project-scoped store resolver to get the correct store for
|
// Compute live task counts from the project-specific store
|
||||||
// this project. This ensures we compute counts from the right project,
|
const tasks = await projectStore.listTasks({ slim: true });
|
||||||
// regardless of which project is the dashboard's default.
|
const activeCols = new Set(["triage", "todo", "in-progress", "in-review"]);
|
||||||
const projectStore = await getOrCreateProjectStore(req.params.id);
|
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
|
// Get central health metadata (if available) to preserve non-count fields
|
||||||
const tasks = await projectStore.listTasks({ slim: true });
|
const centralHealth = await central.getProjectHealth(req.params.id);
|
||||||
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
|
// Build response: use central health as base if available, otherwise synthesize
|
||||||
const centralHealth = await central.getProjectHealth(req.params.id);
|
const healthBase = centralHealth ?? {
|
||||||
await central.close();
|
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
|
return {
|
||||||
const healthBase = centralHealth ?? {
|
...healthBase,
|
||||||
projectId: req.params.id,
|
activeTaskCount,
|
||||||
status: project.status ?? "active",
|
inFlightAgentCount,
|
||||||
activeTaskCount: 0,
|
totalTasksCompleted,
|
||||||
inFlightAgentCount: 0,
|
};
|
||||||
totalTasksCompleted: 0,
|
|
||||||
totalTasksFailed: 0,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
...healthBase,
|
|
||||||
activeTaskCount,
|
|
||||||
inFlightAgentCount,
|
|
||||||
totalTasksCompleted,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
res.json(health);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -599,12 +599,7 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/projects/:id/config", async (req, res) => {
|
router.get("/projects/:id/config", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const project = await withCentralCore(async (central) => await central.getProject(req.params.id));
|
||||||
const central = new CentralCore();
|
|
||||||
await central.init();
|
|
||||||
|
|
||||||
const project = await central.getProject(req.params.id);
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw notFound("Project not found");
|
throw notFound("Project not found");
|
||||||
@@ -635,20 +630,14 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
await options.engineManager.pauseProject(projectId);
|
await options.engineManager.pauseProject(projectId);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: update CentralCore directly (dev mode)
|
// Fallback: update CentralCore directly (dev mode)
|
||||||
const { CentralCore } = await import("@fusion/core");
|
await withCentralCore(async (central) => {
|
||||||
const central = new CentralCore();
|
await central.updateProject(projectId, { status: "paused" });
|
||||||
await central.init();
|
await central.updateProjectHealth(projectId, { status: "paused" });
|
||||||
await central.updateProject(projectId, { status: "paused" });
|
});
|
||||||
await central.updateProjectHealth(projectId, { status: "paused" });
|
|
||||||
await central.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and return the updated project
|
// Fetch and return the updated project
|
||||||
const { CentralCore: CentralCore2 } = await import("@fusion/core");
|
const project = await withCentralCore(async (central) => await central.getProject(projectId));
|
||||||
const central = new CentralCore2();
|
|
||||||
await central.init();
|
|
||||||
const project = await central.getProject(projectId);
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ApiError(404, `Project ${projectId} not found`);
|
throw new ApiError(404, `Project ${projectId} not found`);
|
||||||
@@ -677,20 +666,14 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
await options.engineManager.resumeProject(projectId);
|
await options.engineManager.resumeProject(projectId);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: update CentralCore directly (dev mode)
|
// Fallback: update CentralCore directly (dev mode)
|
||||||
const { CentralCore } = await import("@fusion/core");
|
await withCentralCore(async (central) => {
|
||||||
const central = new CentralCore();
|
await central.updateProject(projectId, { status: "active" });
|
||||||
await central.init();
|
await central.updateProjectHealth(projectId, { status: "active" });
|
||||||
await central.updateProject(projectId, { status: "active" });
|
});
|
||||||
await central.updateProjectHealth(projectId, { status: "active" });
|
|
||||||
await central.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and return the updated project
|
// Fetch and return the updated project
|
||||||
const { CentralCore: CentralCore2 } = await import("@fusion/core");
|
const project = await withCentralCore(async (central) => await central.getProject(projectId));
|
||||||
const central = new CentralCore2();
|
|
||||||
await central.init();
|
|
||||||
const project = await central.getProject(projectId);
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ApiError(404, `Project ${projectId} not found`);
|
throw new ApiError(404, `Project ${projectId} not found`);
|
||||||
|
|||||||
Reference in New Issue
Block a user