refactor: remove legacy kb compatibility

Drops the .kb/kb.db migration path, legacy backup filename handling, and
backward-compat test suites. Renames internal kbDir identifiers to
fusionDir and hasKbProject/isValidKbProject to their fusion equivalents.

- Remove needsCentralMigration, autoMigrateToCentral, and the
  "needs-migration" FirstRunState; checkAndMigrate and KB_SKIP_MIGRATION
  env var are gone
- Remove LEGACY_BACKUP_DIR and canonicalizeBackupDir; listBackups no
  longer matches kb-* filenames
- Delete backward-compat.test.ts and store-backward-compat.test.ts;
  update remaining tests to new 3-state first-run model

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-23 16:55:46 -07:00
parent f4e0850f9d
commit f20d0b5a18
36 changed files with 365 additions and 1194 deletions

View File

@@ -12,16 +12,16 @@ function makeTmpDir(): string {
describe("ChatStore", () => {
let tmpDir: string;
let kbDir: string;
let fusionDir: string;
let db: Database;
let store: ChatStore;
beforeEach(() => {
tmpDir = makeTmpDir();
kbDir = join(tmpDir, ".fusion");
db = new Database(kbDir);
fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir);
db.init();
store = new ChatStore(kbDir, db);
store = new ChatStore(fusionDir, db);
});
afterEach(async () => {

View File

@@ -1,215 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdirSync, rmSync, existsSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
import { join } from "node:path";
import { tempWorkspace } from "@fusion/test-utils";
import { TaskStore } from "../store.js";
import { CentralCore } from "../central-core.js";
// Helper to create a fake fusion project structure for the current store implementation
function createFakeFusionProject(dir: string): void {
const fusionDir = join(dir, ".fusion");
mkdirSync(fusionDir, { recursive: true });
const db = new DatabaseSync(join(fusionDir, "kb.db"));
db.exec("CREATE TABLE IF NOT EXISTS sanity (id INTEGER PRIMARY KEY)");
db.close();
}
describe("TaskStore Backward Compatibility", () => {
let tempDir: string;
let centralCore: CentralCore;
let originalCwd: string;
beforeEach(async () => {
tempDir = tempWorkspace("kb-compat-test-");
centralCore = new CentralCore(tempDir);
await centralCore.init();
originalCwd = process.cwd();
});
afterEach(async () => {
try {
process.chdir(originalCwd);
await centralCore.close();
} catch {
// Ignore cleanup errors
}
});
describe("getOrCreateForProject", () => {
it("should create store for specified project ID", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
// Register the project first
const project = await centralCore.registerProject({
name: "my-project",
path: projectDir,
isolationMode: "in-process",
});
const store = await TaskStore.getOrCreateForProject(project.id, centralCore);
expect(store).toBeInstanceOf(TaskStore);
// Verify it's using the correct path
const settings = await store.getSettings();
expect(settings).toBeDefined();
});
it("should fall back to exact project name lookup when ID lookup misses", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
// Register the project
await centralCore.registerProject({
name: "my-project",
path: projectDir,
isolationMode: "in-process",
});
// Look up by name instead of ID
const store = await TaskStore.getOrCreateForProject("my-project", centralCore);
expect(store).toBeInstanceOf(TaskStore);
});
it("should use single registered project when no ID provided", async () => {
const projectDir = join(tempDir, "single-project");
mkdirSync(projectDir, { recursive: true });
// Register exactly one project
await centralCore.registerProject({
name: "single-project",
path: projectDir,
isolationMode: "in-process",
});
const store = await TaskStore.getOrCreateForProject(undefined, centralCore);
expect(store).toBeInstanceOf(TaskStore);
});
it("should throw when multiple projects and no ID specified", async () => {
const project1 = join(tempDir, "project-1");
const project2 = join(tempDir, "project-2");
mkdirSync(project1, { recursive: true });
mkdirSync(project2, { recursive: true });
// Register two projects
await centralCore.registerProject({
name: "project-1",
path: project1,
isolationMode: "in-process",
});
await centralCore.registerProject({
name: "project-2",
path: project2,
isolationMode: "in-process",
});
await expect(
TaskStore.getOrCreateForProject(undefined, centralCore)
).rejects.toThrow("Multiple projects registered");
});
it("should fall back to process.cwd() legacy mode against the current .fusion path", async () => {
const projectDir = join(tempDir, "legacy-project");
mkdirSync(projectDir, { recursive: true });
createFakeFusionProject(projectDir);
process.chdir(projectDir);
const centralDb = join(tempDir, "fusion-central.db");
await centralCore.close();
rmSync(centralDb, { force: true });
centralCore = new CentralCore(tempDir);
const store = await TaskStore.getOrCreateForProject(undefined, centralCore);
expect(store).toBeInstanceOf(TaskStore);
const task = await store.createTask({ description: "legacy task" });
expect(task.id).toBe("FN-001");
expect(existsSync(join(projectDir, ".fusion", "kb.db"))).toBe(true);
expect(existsSync(join(projectDir, ".fusion", "tasks", task.id, "task.json"))).toBe(true);
});
it("should throw when project ID not found", async () => {
await expect(
TaskStore.getOrCreateForProject("non-existent-project", centralCore)
).rejects.toThrow('Project "non-existent-project" not found');
});
it("should find project by exact registered name", async () => {
const projectDir = join(tempDir, "Casey");
mkdirSync(projectDir, { recursive: true });
await centralCore.registerProject({
name: "Casey",
path: projectDir,
isolationMode: "in-process",
});
const store = await TaskStore.getOrCreateForProject("Casey", centralCore);
expect(store).toBeInstanceOf(TaskStore);
});
it("should auto-initialize central core if not provided", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
// Register a project
const { id: projectId } = await centralCore.registerProject({
name: "my-project",
path: projectDir,
isolationMode: "in-process",
});
// Pass the central core explicitly to ensure it uses the right database
const store = await TaskStore.getOrCreateForProject(projectId, centralCore);
expect(store).toBeInstanceOf(TaskStore);
});
});
describe("existing constructor", () => {
it("should still support direct TaskStore construction", async () => {
const projectDir = join(tempDir, "direct-project");
mkdirSync(projectDir, { recursive: true });
// Direct construction should still work
const store = new TaskStore(projectDir, join(projectDir, ".fusion-global-settings"));
await store.init();
expect(store).toBeInstanceOf(TaskStore);
// Should be able to create tasks
const task = await store.createTask({
description: "Test task",
column: "triage",
});
expect(task.id).toBeDefined();
expect(task.description).toBe("Test task");
});
});
describe("events without central core", () => {
it("should emit events in single-project mode", async () => {
const projectDir = join(tempDir, "event-test");
mkdirSync(projectDir, { recursive: true });
const store = new TaskStore(projectDir, join(projectDir, ".fusion-global-settings"));
await store.init();
const taskCreatedListener = vi.fn();
store.on("task:created", taskCreatedListener);
await store.createTask({
description: "Event test task",
column: "triage",
});
expect(taskCreatedListener).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -16,14 +16,14 @@ function sleep(ms: number): Promise<void> {
describe("TaskStore task documents", () => {
let rootDir: string;
let kbDir: string;
let fusionDir: string;
let db: Database;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
kbDir = join(rootDir, ".fusion");
db = new Database(kbDir);
fusionDir = join(rootDir, ".fusion");
db = new Database(fusionDir);
db.init();
store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await store.init();

View File

@@ -54,11 +54,11 @@ export class ArchiveDatabase {
private db: DatabaseSync;
private readonly _fts5Available: boolean;
constructor(kbDir: string) {
if (!existsSync(kbDir)) {
mkdirSync(kbDir, { recursive: true });
constructor(fusionDir: string) {
if (!existsSync(fusionDir)) {
mkdirSync(fusionDir, { recursive: true });
}
this.db = new DatabaseSync(join(kbDir, "archive.db"));
this.db = new DatabaseSync(join(fusionDir, "archive.db"));
this.db.exec("PRAGMA journal_mode = WAL");
this.db.exec("PRAGMA busy_timeout = 5000");
this._fts5Available = probeFts5(this.db);

View File

@@ -34,8 +34,8 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
*/
private get db(): Database {
if (!this._db) {
const kbDir = join(this.rootDir, ".fusion");
this._db = new Database(kbDir);
const fusionDir = join(this.rootDir, ".fusion");
this._db = new Database(fusionDir);
this._db.init();
}
return this._db;

View File

@@ -18,7 +18,7 @@ import type { ProjectSettings } from "./types.js";
describe("BackupManager", () => {
let tempDir: string;
let kbDir: string;
let fusionDir: string;
let backupManager: BackupManager;
beforeEach(async () => {
@@ -27,11 +27,11 @@ describe("BackupManager", () => {
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
kbDir = join(tempDir, ".fusion");
await mkdir(kbDir, { recursive: true });
fusionDir = join(tempDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
// Create a dummy database file
writeFileSync(join(kbDir, "fusion.db"), "dummy database content");
backupManager = new BackupManager(kbDir);
writeFileSync(join(fusionDir, "fusion.db"), "dummy database content");
backupManager = new BackupManager(fusionDir);
});
afterEach(async () => {
@@ -50,7 +50,7 @@ describe("BackupManager", () => {
it("should copy database content correctly", async () => {
const backup = await backupManager.createBackup();
const originalContent = readFileSync(join(kbDir, "fusion.db"), "utf-8");
const originalContent = readFileSync(join(fusionDir, "fusion.db"), "utf-8");
const backupContent = readFileSync(backup.path, "utf-8");
expect(backupContent).toBe(originalContent);
@@ -67,7 +67,7 @@ describe("BackupManager", () => {
it("should create backup directory if it does not exist", async () => {
const customBackupDir = "custom-backups";
const manager = new BackupManager(kbDir, { backupDir: customBackupDir });
const manager = new BackupManager(fusionDir, { backupDir: customBackupDir });
const customBackupPath = join(tempDir, customBackupDir);
expect(existsSync(customBackupPath)).toBe(false);
@@ -195,7 +195,7 @@ describe("BackupManager", () => {
});
it("should delete oldest backups exceeding retention", async () => {
const manager = new BackupManager(kbDir, { retention: 2 });
const manager = new BackupManager(fusionDir, { retention: 2 });
// Create 4 backups by advancing time deterministically
for (let i = 0; i < 4; i++) {
@@ -211,7 +211,7 @@ describe("BackupManager", () => {
});
it("should keep the newest backups after cleanup", async () => {
const manager = new BackupManager(kbDir, { retention: 2 });
const manager = new BackupManager(fusionDir, { retention: 2 });
// Create 4 backups and record their names by advancing time
const backupNames: string[] = [];
@@ -239,13 +239,13 @@ describe("BackupManager", () => {
const backup = await backupManager.createBackup();
// Modify the original database
await writeFile(join(kbDir, "fusion.db"), "modified content");
await writeFile(join(fusionDir, "fusion.db"), "modified content");
// Restore the backup
await backupManager.restoreBackup(backup.filename, { createPreRestoreBackup: false });
// Verify the restore
const restoredContent = readFileSync(join(kbDir, "fusion.db"), "utf-8");
const restoredContent = readFileSync(join(fusionDir, "fusion.db"), "utf-8");
expect(restoredContent).toBe("dummy database content");
});
@@ -365,16 +365,16 @@ describe("createBackupManager", () => {
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
const kbDir = join(tempDir, ".fusion");
await mkdir(kbDir, { recursive: true });
writeFileSync(join(kbDir, "fusion.db"), "test");
const fusionDir = join(tempDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
writeFileSync(join(fusionDir, "fusion.db"), "test");
const settings: Partial<ProjectSettings> = {
autoBackupDir: "custom/backups",
autoBackupRetention: 2,
};
const manager = createBackupManager(kbDir, settings);
const manager = createBackupManager(fusionDir, settings);
// Create 4 backups by advancing time
for (let i = 0; i < 4; i++) {
@@ -392,15 +392,15 @@ describe("createBackupManager", () => {
it("should canonicalize legacy .kb/backups to .fusion/backups in settings", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
const kbDir = join(tempDir, ".fusion");
await mkdir(kbDir, { recursive: true });
writeFileSync(join(kbDir, "fusion.db"), "test");
const fusionDir = join(tempDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
writeFileSync(join(fusionDir, "fusion.db"), "test");
const settings: Partial<ProjectSettings> = {
autoBackupDir: ".kb/backups", // Legacy value
};
const manager = createBackupManager(kbDir, settings);
const manager = createBackupManager(fusionDir, settings);
// Use fake timers and create a backup
vi.useFakeTimers();
@@ -417,15 +417,15 @@ describe("createBackupManager", () => {
it("should preserve non-legacy custom .kb/* directories", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
const kbDir = join(tempDir, ".fusion");
await mkdir(kbDir, { recursive: true });
writeFileSync(join(kbDir, "fusion.db"), "test");
const fusionDir = join(tempDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
writeFileSync(join(fusionDir, "fusion.db"), "test");
const settings: Partial<ProjectSettings> = {
autoBackupDir: ".kb/my-custom-backups", // Custom path, not the legacy default
};
const manager = createBackupManager(kbDir, settings);
const manager = createBackupManager(fusionDir, settings);
// Use fake timers and create a backup
vi.useFakeTimers();
@@ -526,7 +526,7 @@ describe("syncBackupRoutine", () => {
describe("runBackupCommand", () => {
let tempDir: string;
let kbDir: string;
let fusionDir: string;
beforeEach(async () => {
// Use fake timers for deterministic timestamp control
@@ -534,9 +534,9 @@ describe("runBackupCommand", () => {
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
kbDir = join(tempDir, ".fusion");
await mkdir(kbDir, { recursive: true });
writeFileSync(join(kbDir, "fusion.db"), "dummy database content");
fusionDir = join(tempDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
writeFileSync(join(fusionDir, "fusion.db"), "dummy database content");
});
afterEach(async () => {
@@ -554,7 +554,7 @@ describe("runBackupCommand", () => {
autoBackupEnabled: false, // Disabled, but should still work when called manually
};
const result = await runBackupCommand(kbDir, settings);
const result = await runBackupCommand(fusionDir, settings);
// Should succeed even when autoBackupEnabled is false
expect(result.success).toBe(true);
@@ -572,7 +572,7 @@ describe("runBackupCommand", () => {
autoBackupRetention: 7,
};
const result = await runBackupCommand(kbDir, settings);
const result = await runBackupCommand(fusionDir, settings);
expect(result.success).toBe(true);
expect(result.backupPath).toBeDefined();
@@ -590,7 +590,7 @@ describe("runBackupCommand", () => {
autoBackupSchedule: "invalid-cron",
};
const result = await runBackupCommand(kbDir, settings);
const result = await runBackupCommand(fusionDir, settings);
expect(result.success).toBe(false);
expect(result.output).toContain("Invalid backup schedule");
@@ -608,7 +608,7 @@ describe("runBackupCommand", () => {
};
// Create 3 backups first (manually to test cleanup) by advancing time
const manager = createBackupManager(kbDir, settings);
const manager = createBackupManager(fusionDir, settings);
for (let i = 0; i < 3; i++) {
vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`));
await manager.createBackup();
@@ -616,7 +616,7 @@ describe("runBackupCommand", () => {
// Now run backup command
vi.setSystemTime(new Date("2026-01-01T00:00:03.000Z"));
const result = await runBackupCommand(kbDir, settings);
const result = await runBackupCommand(fusionDir, settings);
expect(result.success).toBe(true);
expect(result.deletedCount).toBeGreaterThanOrEqual(1);
@@ -624,7 +624,7 @@ describe("runBackupCommand", () => {
it("should return failure when database file is missing", async () => {
// Remove the database
await rm(join(kbDir, "fusion.db"));
await rm(join(fusionDir, "fusion.db"));
const settings: ProjectSettings = {
maxConcurrent: 2,
@@ -635,7 +635,7 @@ describe("runBackupCommand", () => {
autoBackupEnabled: true,
};
const result = await runBackupCommand(kbDir, settings);
const result = await runBackupCommand(fusionDir, settings);
expect(result.success).toBe(false);
expect(result.output).toContain("failed");

View File

@@ -4,25 +4,6 @@ import { join } from "node:path";
import { CronExpressionParser } from "cron-parser";
import type { ProjectSettings } from "./types.js";
/**
* Legacy backup directory default value from the old .kb storage structure.
* Projects that were created before the .fusion rename may still have this
* value persisted in their config. It is canonicalized to the new default
* so that all backup operations use a consistent directory.
*/
const LEGACY_BACKUP_DIR = ".kb/backups";
/**
* Canonicalizes the backup directory from legacy defaults.
* Only the exact legacy default alias is transformed — custom paths are preserved.
*/
function canonicalizeBackupDir(dir: string | undefined): string | undefined {
if (dir === LEGACY_BACKUP_DIR) {
return ".fusion/backups";
}
return dir;
}
/**
* Metadata for a database backup file.
*/
@@ -52,17 +33,17 @@ export interface BackupOptions {
* cleanup of old backups, and restoration.
*/
export class BackupManager {
private kbDir: string;
private fusionDir: string;
private backupDir: string;
private retention: number;
/**
* Creates a new BackupManager instance.
* @param kbDir - Absolute path to the .fusion directory
* @param fusionDir - Absolute path to the .fusion directory
* @param options - Backup configuration options
*/
constructor(kbDir: string, options?: BackupOptions) {
this.kbDir = kbDir;
constructor(fusionDir: string, options?: BackupOptions) {
this.fusionDir = fusionDir;
this.backupDir = options?.backupDir ?? ".fusion/backups";
this.retention = options?.retention ?? 7;
}
@@ -71,8 +52,8 @@ export class BackupManager {
* Gets the absolute path to the backup directory.
*/
private getBackupDirPath(): string {
// The backupDir is relative to project root, which is parent of kbDir
return join(this.kbDir, "..", this.backupDir);
// The backupDir is relative to project root, which is parent of fusionDir
return join(this.fusionDir, "..", this.backupDir);
}
/**
@@ -80,7 +61,7 @@ export class BackupManager {
* @returns BackupInfo for the newly created backup
*/
async createBackup(): Promise<BackupInfo> {
const sourcePath = join(this.kbDir, "fusion.db");
const sourcePath = join(this.fusionDir, "fusion.db");
const backupDirPath = this.getBackupDirPath();
// Ensure backup directory exists
@@ -124,21 +105,18 @@ export class BackupManager {
const backups: BackupInfo[] = [];
for (const filename of files) {
// Match both new fusion-* and legacy kb-* backup patterns:
// Match fusion-* backup patterns:
// fusion-YYYY-MM-DD-HHmmss.db, fusion-YYYY-MM-DD-HHmmss-N.db,
// fusion-pre-restore-YYYY-MM-DD-HHmmss.db,
// kb-YYYY-MM-DD-HHmmss.db, kb-YYYY-MM-DD-HHmmss-N.db,
// kb-pre-restore-YYYY-MM-DD-HHmmss.db
if (!filename.match(/^(fusion|kb)(-pre-restore)?-\d{4}-\d{2}-\d{2}-\d{6}(-\d+)?\.db$/)) {
// fusion-pre-restore-YYYY-MM-DD-HHmmss.db
if (!filename.match(/^fusion(-pre-restore)?-\d{4}-\d{2}-\d{2}-\d{6}(-\d+)?\.db$/)) {
continue;
}
const filePath = join(backupDirPath, filename);
const stats = await stat(filePath);
// Parse timestamp from filename, supporting both fusion-* and kb-* prefixes
// Also handles counter suffix: fusion-YYYY-MM-DD-HHmmss-N.db
const match = filename.match(/^((?:fusion|kb)(?:-pre-restore)?)-(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})(?:-\d+)?\.db$/);
// Parse timestamp from filename. Also handles counter suffix: fusion-YYYY-MM-DD-HHmmss-N.db
const match = filename.match(/^(fusion(?:-pre-restore)?)-(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})(?:-\d+)?\.db$/);
const createdAt = match
? `${match[2]}-${match[3]}-${match[4]}T${match[5]}:${match[6]}:${match[7]}Z`
: stats.mtime.toISOString();
@@ -211,7 +189,7 @@ export class BackupManager {
): Promise<void> {
const backupDirPath = this.getBackupDirPath();
const sourcePath = join(backupDirPath, filename);
const targetPath = join(this.kbDir, "fusion.db");
const targetPath = join(this.fusionDir, "fusion.db");
// Verify source exists
try {
@@ -304,17 +282,16 @@ export function validateBackupDir(dir: string): boolean {
/**
* Factory function to create a BackupManager with project settings.
* Applies defensive canonicalization for the legacy backup directory default.
* @param kbDir - Absolute path to the .fusion directory
* @param fusionDir - Absolute path to the .fusion directory
* @param settings - Project settings containing backup configuration
* @returns Configured BackupManager instance
*/
export function createBackupManager(
kbDir: string,
fusionDir: string,
settings?: Partial<ProjectSettings>
): BackupManager {
return new BackupManager(kbDir, {
backupDir: canonicalizeBackupDir(settings?.autoBackupDir),
return new BackupManager(fusionDir, {
backupDir: settings?.autoBackupDir,
retention: settings?.autoBackupRetention,
});
}
@@ -327,12 +304,12 @@ export function createBackupManager(
* at the automation/scheduler level. This allows manual backups via CLI even when
* auto-backup is disabled.
*
* @param kbDir - Absolute path to the .fusion directory
* @param fusionDir - Absolute path to the .fusion directory
* @param settings - Project settings
* @returns Result of the backup operation
*/
export async function runBackupCommand(
kbDir: string,
fusionDir: string,
settings: ProjectSettings
): Promise<{ success: boolean; output: string; backupPath?: string; deletedCount?: number }> {
// Validate schedule if provided (for logging purposes)
@@ -344,7 +321,7 @@ export async function runBackupCommand(
}
// Create backup manager with settings
const manager = createBackupManager(kbDir, settings);
const manager = createBackupManager(fusionDir, settings);
try {
// Create the backup

View File

@@ -1,309 +0,0 @@
/**
* Tests for backward compatibility layer
*
* Ensures single-project workflows continue working without --project flags.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
import { CentralCore } from "./central-core.js";
// Helper to create temp directories
function tempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
// Helper to create a fake kb project
function createFakeKbProject(dir: string): void {
const kbDir = join(dir, ".fusion");
mkdirSync(kbDir, { recursive: true });
writeFileSync(join(kbDir, "fusion.db"), "SQLite format 3\x00");
}
describe("Backward Compatibility Layer", () => {
let tempGlobalDir: string;
let central: CentralCore;
beforeEach(async () => {
tempGlobalDir = tempDir("kb-backward-compat-test-");
central = new CentralCore(tempGlobalDir);
await central.init();
});
afterEach(async () => {
await central.close();
try {
rmSync(tempGlobalDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("single project auto-resolution", () => {
it("should auto-resolve single project without --project flag", async () => {
const tempProjectDir = tempDir("kb-single-compat-");
const project = await central.registerProject({
name: "Single Project",
path: tempProjectDir,
});
const compat = new BackwardCompat(central);
// No projectId provided - should auto-resolve to single project
const context = await compat.resolveProjectContext("/any/dir");
expect(context.projectId).toBe(project.id);
expect(context.workingDirectory).toBe(tempProjectDir);
expect(context.isLegacy).toBe(false);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should auto-resolve the single registered project even when cwd is unrelated", async () => {
const tempProjectDir = tempDir("kb-single-path-match-");
const unrelatedDir = tempDir("kb-single-unrelated-");
const project = await central.registerProject({
name: "Path Match Project",
path: tempProjectDir,
});
const compat = new BackwardCompat(central);
const context = await compat.resolveProjectContext(unrelatedDir);
expect(context.projectId).toBe(project.id);
expect(context.workingDirectory).toBe(tempProjectDir);
rmSync(tempProjectDir, { recursive: true, force: true });
rmSync(unrelatedDir, { recursive: true, force: true });
});
it("should use explicit project ID when provided", async () => {
const tempProjectDir = tempDir("kb-explicit-compat-");
const project = await central.registerProject({
name: "Explicit Project",
path: tempProjectDir,
});
const compat = new BackwardCompat(central);
const context = await compat.resolveProjectContext("/some/dir", project.id);
expect(context.projectId).toBe(project.id);
expect(context.workingDirectory).toBe(tempProjectDir);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should find project by name (case-insensitive)", async () => {
const tempProjectDir = tempDir("kb-name-compat-");
const project = await central.registerProject({
name: "My Awesome Project",
path: tempProjectDir,
});
const compat = new BackwardCompat(central);
// Use lowercase name - should still find it
const context = await compat.resolveProjectContext("/some/dir", "my awesome project");
expect(context.projectId).toBe(project.id);
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
describe("multiple projects requires explicit selection", () => {
it("should throw ProjectRequiredError when multiple projects and no selection", async () => {
const tempProjectDir1 = tempDir("kb-multi-compat1-");
const tempProjectDir2 = tempDir("kb-multi-compat2-");
const project1 = await central.registerProject({
name: "Project One",
path: tempProjectDir1,
});
const project2 = await central.registerProject({
name: "Project Two",
path: tempProjectDir2,
});
const compat = new BackwardCompat(central);
await expect(compat.resolveProjectContext("/some/dir")).rejects.toThrow(
ProjectRequiredError
);
try {
await compat.resolveProjectContext("/some/dir");
} catch (err) {
expect(err).toBeInstanceOf(ProjectRequiredError);
// Should provide list of available projects
expect((err as ProjectRequiredError).availableProjects).toHaveLength(2);
const ids = (err as ProjectRequiredError).availableProjects.map((p) => p.id);
expect(ids).toContain(project1.id);
expect(ids).toContain(project2.id);
}
rmSync(tempProjectDir1, { recursive: true, force: true });
rmSync(tempProjectDir2, { recursive: true, force: true });
});
it("should resolve correctly when explicit project provided with multiple projects", async () => {
const tempProjectDir1 = tempDir("kb-multi-explicit1-");
const tempProjectDir2 = tempDir("kb-multi-explicit2-");
const project1 = await central.registerProject({
name: "Project One",
path: tempProjectDir1,
});
await central.registerProject({
name: "Project Two",
path: tempProjectDir2,
});
const compat = new BackwardCompat(central);
// Explicitly select project1
const context = await compat.resolveProjectContext("/some/dir", project1.id);
expect(context.projectId).toBe(project1.id);
expect(context.workingDirectory).toBe(tempProjectDir1);
rmSync(tempProjectDir1, { recursive: true, force: true });
rmSync(tempProjectDir2, { recursive: true, force: true });
});
it("should require explicit selection when cwd is inside one of multiple projects", async () => {
const tempProjectDir1 = tempDir("kb-multi-cwd1-");
const tempProjectDir2 = tempDir("kb-multi-cwd2-");
const nestedDir = join(tempProjectDir1, "src", "nested");
mkdirSync(nestedDir, { recursive: true });
await central.registerProject({
name: "Project One",
path: tempProjectDir1,
});
await central.registerProject({
name: "Project Two",
path: tempProjectDir2,
});
const compat = new BackwardCompat(central);
await expect(compat.resolveProjectContext(nestedDir)).rejects.toThrow(ProjectRequiredError);
rmSync(tempProjectDir1, { recursive: true, force: true });
rmSync(tempProjectDir2, { recursive: true, force: true });
});
it("should require explicit selection when cwd is outside all registered projects", async () => {
const tempProjectDir1 = tempDir("kb-multi-outside1-");
const tempProjectDir2 = tempDir("kb-multi-outside2-");
const unrelatedDir = tempDir("kb-multi-outside-unrelated-");
await central.registerProject({
name: "Project One",
path: tempProjectDir1,
});
await central.registerProject({
name: "Project Two",
path: tempProjectDir2,
});
const compat = new BackwardCompat(central);
await expect(compat.resolveProjectContext(unrelatedDir)).rejects.toThrow(ProjectRequiredError);
rmSync(tempProjectDir1, { recursive: true, force: true });
rmSync(tempProjectDir2, { recursive: true, force: true });
rmSync(unrelatedDir, { recursive: true, force: true });
});
});
describe("legacy mode without central database", () => {
it("should return legacy mode when no central DB", async () => {
// Close and remove central DB
await central.close();
rmSync(join(tempGlobalDir, "fusion-central.db"), { force: true });
// Re-create central but don't init
central = new CentralCore(tempGlobalDir);
const compat = new BackwardCompat(central);
const context = await compat.resolveProjectContext("/some/legacy/dir");
expect(context.isLegacy).toBe(true);
expect(context.projectId).toBe("legacy");
expect(context.workingDirectory).toBe("/some/legacy/dir");
});
it("should report legacy mode correctly", async () => {
// Close and remove central DB
await central.close();
rmSync(join(tempGlobalDir, "fusion-central.db"), { force: true });
central = new CentralCore(tempGlobalDir);
const compat = new BackwardCompat(central);
expect(await compat.isLegacyMode()).toBe(true);
});
it("should report non-legacy mode when central DB exists", async () => {
const compat = new BackwardCompat(central);
expect(await compat.isLegacyMode()).toBe(false);
});
});
describe("no implicit mutation during resolve", () => {
it("should not auto-register a project found in cwd when no projects are registered", async () => {
const projectDir = tempDir("kb-no-auto-migrate-compat-");
createFakeKbProject(projectDir);
const compat = new BackwardCompat(central);
await expect(compat.resolveProjectContext(projectDir)).rejects.toThrow(ProjectRequiredError);
const isRegistered = await central.isProjectRegistered(projectDir);
expect(isRegistered).toBe(false);
rmSync(projectDir, { recursive: true, force: true });
});
});
describe("error messages", () => {
it("should provide helpful error when project not found", async () => {
const compat = new BackwardCompat(central);
await expect(
compat.resolveProjectContext("/some/dir", "nonexistent-project")
).rejects.toThrow(/not found/i);
});
it("should provide helpful error when no projects registered", async () => {
const compat = new BackwardCompat(central);
await expect(compat.resolveProjectContext("/some/dir")).rejects.toThrow(
/no projects registered/i
);
});
});
});
describe("ProjectRequiredError backward compat", () => {
it("should include both id and name for each available project", () => {
const available = [
{ id: "proj_abc123", name: "Frontend" },
{ id: "proj_def456", name: "Backend" },
{ id: "proj_ghi789", name: "Docs" },
];
const error = new ProjectRequiredError(
"Multiple projects available",
available
);
expect(error.availableProjects).toHaveLength(3);
expect(error.availableProjects[0]).toHaveProperty("id");
expect(error.availableProjects[0]).toHaveProperty("name");
});
it("should be catchable as ProjectRequiredError", async () => {
const error = new ProjectRequiredError("test", []);
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(ProjectRequiredError);
expect(error.name).toBe("ProjectRequiredError");
});
});

View File

@@ -44,7 +44,7 @@ export interface ChatStoreEvents {
export class ChatStore extends EventEmitter<ChatStoreEvents> {
constructor(
private kbDir: string,
private fusionDir: string,
private db: Database,
) {
super();

View File

@@ -12,11 +12,11 @@ function makeTmpDir(): string {
describe("detectLegacyData", () => {
let tmpDir: string;
let kbDir: string;
let fusionDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
kbDir = join(tmpDir, ".fusion");
fusionDir = join(tmpDir, ".fusion");
});
afterEach(async () => {
@@ -24,60 +24,60 @@ describe("detectLegacyData", () => {
});
it("returns false for empty directory", () => {
expect(detectLegacyData(kbDir)).toBe(false);
expect(detectLegacyData(fusionDir)).toBe(false);
});
it("returns true when tasks/ exists", async () => {
await mkdir(join(kbDir, "tasks"), { recursive: true });
expect(detectLegacyData(kbDir)).toBe(true);
await mkdir(join(fusionDir, "tasks"), { recursive: true });
expect(detectLegacyData(fusionDir)).toBe(true);
});
it("returns true when config.json exists", async () => {
await mkdir(kbDir, { recursive: true });
await writeFile(join(kbDir, "config.json"), '{"nextId":1}');
expect(detectLegacyData(kbDir)).toBe(true);
await mkdir(fusionDir, { recursive: true });
await writeFile(join(fusionDir, "config.json"), '{"nextId":1}');
expect(detectLegacyData(fusionDir)).toBe(true);
});
it("returns true when activity-log.jsonl exists", async () => {
await mkdir(kbDir, { recursive: true });
await writeFile(join(kbDir, "activity-log.jsonl"), "");
expect(detectLegacyData(kbDir)).toBe(true);
await mkdir(fusionDir, { recursive: true });
await writeFile(join(fusionDir, "activity-log.jsonl"), "");
expect(detectLegacyData(fusionDir)).toBe(true);
});
it("returns true when archive.jsonl exists", async () => {
await mkdir(kbDir, { recursive: true });
await writeFile(join(kbDir, "archive.jsonl"), "");
expect(detectLegacyData(kbDir)).toBe(true);
await mkdir(fusionDir, { recursive: true });
await writeFile(join(fusionDir, "archive.jsonl"), "");
expect(detectLegacyData(fusionDir)).toBe(true);
});
it("returns true when automations/ exists", async () => {
await mkdir(join(kbDir, "automations"), { recursive: true });
expect(detectLegacyData(kbDir)).toBe(true);
await mkdir(join(fusionDir, "automations"), { recursive: true });
expect(detectLegacyData(fusionDir)).toBe(true);
});
it("returns true when agents/ exists", async () => {
await mkdir(join(kbDir, "agents"), { recursive: true });
expect(detectLegacyData(kbDir)).toBe(true);
await mkdir(join(fusionDir, "agents"), { recursive: true });
expect(detectLegacyData(fusionDir)).toBe(true);
});
it("returns false when db already exists", async () => {
await mkdir(join(kbDir, "tasks"), { recursive: true });
await mkdir(join(fusionDir, "tasks"), { recursive: true });
// Create a db file
const db = new Database(kbDir);
const db = new Database(fusionDir);
db.init();
db.close();
expect(detectLegacyData(kbDir)).toBe(false);
expect(detectLegacyData(fusionDir)).toBe(false);
});
});
describe("getMigrationStatus", () => {
let tmpDir: string;
let kbDir: string;
let fusionDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
kbDir = join(tmpDir, ".fusion");
fusionDir = join(tmpDir, ".fusion");
});
afterEach(async () => {
@@ -85,7 +85,7 @@ describe("getMigrationStatus", () => {
});
it("returns all false for empty directory", () => {
const status = getMigrationStatus(kbDir);
const status = getMigrationStatus(fusionDir);
expect(status).toEqual({
hasLegacy: false,
hasDatabase: false,
@@ -94,20 +94,20 @@ describe("getMigrationStatus", () => {
});
it("returns needsMigration when legacy exists but no db", async () => {
await mkdir(join(kbDir, "tasks"), { recursive: true });
const status = getMigrationStatus(kbDir);
await mkdir(join(fusionDir, "tasks"), { recursive: true });
const status = getMigrationStatus(fusionDir);
expect(status.hasLegacy).toBe(true);
expect(status.hasDatabase).toBe(false);
expect(status.needsMigration).toBe(true);
});
it("returns no migration needed when both exist", async () => {
await mkdir(join(kbDir, "tasks"), { recursive: true });
const db = new Database(kbDir);
await mkdir(join(fusionDir, "tasks"), { recursive: true });
const db = new Database(fusionDir);
db.init();
db.close();
const status = getMigrationStatus(kbDir);
const status = getMigrationStatus(fusionDir);
expect(status.hasLegacy).toBe(true);
expect(status.hasDatabase).toBe(true);
expect(status.needsMigration).toBe(false);
@@ -116,14 +116,14 @@ describe("getMigrationStatus", () => {
describe("migrateFromLegacy", () => {
let tmpDir: string;
let kbDir: string;
let fusionDir: string;
let db: Database;
beforeEach(async () => {
tmpDir = makeTmpDir();
kbDir = join(tmpDir, ".fusion");
await mkdir(kbDir, { recursive: true });
db = new Database(kbDir);
fusionDir = join(tmpDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
db = new Database(fusionDir);
db.init();
// Suppress migration console output in tests
vi.spyOn(console, "log").mockImplementation(() => {});
@@ -143,7 +143,7 @@ describe("migrateFromLegacy", () => {
describe("config migration", () => {
it("migrates config.json to config table", async () => {
await writeFile(
join(kbDir, "config.json"),
join(fusionDir, "config.json"),
JSON.stringify({
nextId: 42,
nextWorkflowStepId: 3,
@@ -152,7 +152,7 @@ describe("migrateFromLegacy", () => {
}),
);
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT * FROM config WHERE id = 1").get() as any;
expect(row.nextId).toBe(42);
@@ -176,7 +176,7 @@ describe("migrateFromLegacy", () => {
describe("task migration", () => {
it("migrates task.json files to tasks table", async () => {
const tasksDir = join(kbDir, "tasks");
const tasksDir = join(fusionDir, "tasks");
const taskDir = join(tasksDir, "FN-001");
await mkdir(taskDir, { recursive: true });
@@ -199,7 +199,7 @@ describe("migrateFromLegacy", () => {
await writeFile(join(taskDir, "task.json"), JSON.stringify(task));
await writeFile(join(taskDir, "PROMPT.md"), "# KB-001\n\nTest task");
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT * FROM tasks WHERE id = 'FN-001'").get() as any;
expect(row).toBeDefined();
@@ -213,7 +213,7 @@ describe("migrateFromLegacy", () => {
});
it("skips invalid task.json files", async () => {
const tasksDir = join(kbDir, "tasks");
const tasksDir = join(fusionDir, "tasks");
const validDir = join(tasksDir, "FN-001");
const invalidDir = join(tasksDir, "FN-002");
await mkdir(validDir, { recursive: true });
@@ -235,7 +235,7 @@ describe("migrateFromLegacy", () => {
);
await writeFile(join(invalidDir, "task.json"), "not valid json{{");
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
const valid = db.prepare("SELECT * FROM tasks WHERE id = 'FN-001'").get();
const invalid = db.prepare("SELECT * FROM tasks WHERE id = 'FN-002'").get();
@@ -244,7 +244,7 @@ describe("migrateFromLegacy", () => {
});
it("preserves blob files (PROMPT.md, agent.log, attachments)", async () => {
const tasksDir = join(kbDir, "tasks");
const tasksDir = join(fusionDir, "tasks");
const taskDir = join(tasksDir, "FN-001");
const attachDir = join(taskDir, "attachments");
await mkdir(attachDir, { recursive: true });
@@ -267,7 +267,7 @@ describe("migrateFromLegacy", () => {
await writeFile(join(taskDir, "agent.log"), '{"timestamp":"2025","text":"hello","type":"text"}\n');
await writeFile(join(attachDir, "test.txt"), "attachment content");
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
// Blob files should still exist
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(true);
@@ -287,11 +287,11 @@ describe("migrateFromLegacy", () => {
{ id: "2", timestamp: "2025-01-02T00:00:00.000Z", type: "task:moved", taskId: "FN-001", details: "Moved to todo", metadata: { from: "triage", to: "todo" } },
];
await writeFile(
join(kbDir, "activity-log.jsonl"),
join(fusionDir, "activity-log.jsonl"),
entries.map((e) => JSON.stringify(e)).join("\n") + "\n",
);
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
const rows = db.prepare("SELECT * FROM activityLog ORDER BY timestamp").all() as any[];
expect(rows).toHaveLength(2);
@@ -302,11 +302,11 @@ describe("migrateFromLegacy", () => {
it("skips malformed activity log lines", async () => {
await writeFile(
join(kbDir, "activity-log.jsonl"),
join(fusionDir, "activity-log.jsonl"),
'{"id":"1","timestamp":"2025","type":"task:created","details":"ok"}\nnot json\n{"id":"2","timestamp":"2025","type":"task:moved","details":"ok"}\n',
);
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
const rows = db.prepare("SELECT * FROM activityLog").all();
expect(rows).toHaveLength(2);
@@ -328,9 +328,9 @@ describe("migrateFromLegacy", () => {
updatedAt: "2025-01-01",
archivedAt: "2025-01-15T00:00:00.000Z",
};
await writeFile(join(kbDir, "archive.jsonl"), JSON.stringify(entry) + "\n");
await writeFile(join(fusionDir, "archive.jsonl"), JSON.stringify(entry) + "\n");
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT * FROM archivedTasks WHERE id = 'FN-001'").get() as any;
expect(row).toBeDefined();
@@ -341,7 +341,7 @@ describe("migrateFromLegacy", () => {
describe("automations migration", () => {
it("migrates automation JSON files to automations table", async () => {
const automationsDir = join(kbDir, "automations");
const automationsDir = join(fusionDir, "automations");
await mkdir(automationsDir, { recursive: true });
const schedule = {
@@ -359,7 +359,7 @@ describe("migrateFromLegacy", () => {
};
await writeFile(join(automationsDir, "test-uuid.json"), JSON.stringify(schedule));
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT * FROM automations WHERE id = 'test-uuid'").get() as any;
expect(row).toBeDefined();
@@ -371,7 +371,7 @@ describe("migrateFromLegacy", () => {
describe("agents migration", () => {
it("migrates agent JSON files and heartbeats", async () => {
const agentsDir = join(kbDir, "agents");
const agentsDir = join(fusionDir, "agents");
await mkdir(agentsDir, { recursive: true });
const agent = {
@@ -395,7 +395,7 @@ describe("migrateFromLegacy", () => {
heartbeats.map((h) => JSON.stringify(h)).join("\n") + "\n",
);
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
const agentRow = db.prepare("SELECT * FROM agents WHERE id = 'agent-001'").get() as any;
expect(agentRow).toBeDefined();
@@ -410,36 +410,36 @@ describe("migrateFromLegacy", () => {
describe("backups", () => {
it("backs up config.json, activity-log.jsonl, archive.jsonl", async () => {
await writeFile(join(kbDir, "config.json"), '{"nextId":1}');
await writeFile(join(kbDir, "activity-log.jsonl"), "");
await writeFile(join(kbDir, "archive.jsonl"), "");
await writeFile(join(fusionDir, "config.json"), '{"nextId":1}');
await writeFile(join(fusionDir, "activity-log.jsonl"), "");
await writeFile(join(fusionDir, "archive.jsonl"), "");
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
expect(existsSync(join(kbDir, "config.json.bak"))).toBe(true);
expect(existsSync(join(kbDir, "activity-log.jsonl.bak"))).toBe(true);
expect(existsSync(join(kbDir, "archive.jsonl.bak"))).toBe(true);
expect(existsSync(join(fusionDir, "config.json.bak"))).toBe(true);
expect(existsSync(join(fusionDir, "activity-log.jsonl.bak"))).toBe(true);
expect(existsSync(join(fusionDir, "archive.jsonl.bak"))).toBe(true);
// Originals should be gone
expect(existsSync(join(kbDir, "config.json"))).toBe(false);
expect(existsSync(join(kbDir, "activity-log.jsonl"))).toBe(false);
expect(existsSync(join(kbDir, "archive.jsonl"))).toBe(false);
expect(existsSync(join(fusionDir, "config.json"))).toBe(false);
expect(existsSync(join(fusionDir, "activity-log.jsonl"))).toBe(false);
expect(existsSync(join(fusionDir, "archive.jsonl"))).toBe(false);
});
it("backs up automations/ and agents/ directories", async () => {
await mkdir(join(kbDir, "automations"), { recursive: true });
await mkdir(join(kbDir, "agents"), { recursive: true });
await mkdir(join(fusionDir, "automations"), { recursive: true });
await mkdir(join(fusionDir, "agents"), { recursive: true });
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
expect(existsSync(join(kbDir, "automations.bak"))).toBe(true);
expect(existsSync(join(kbDir, "agents.bak"))).toBe(true);
expect(existsSync(join(kbDir, "automations"))).toBe(false);
expect(existsSync(join(kbDir, "agents"))).toBe(false);
expect(existsSync(join(fusionDir, "automations.bak"))).toBe(true);
expect(existsSync(join(fusionDir, "agents.bak"))).toBe(true);
expect(existsSync(join(fusionDir, "automations"))).toBe(false);
expect(existsSync(join(fusionDir, "agents"))).toBe(false);
});
it("backs up individual task.json files, preserving blob files", async () => {
const tasksDir = join(kbDir, "tasks");
const tasksDir = join(fusionDir, "tasks");
const taskDir = join(tasksDir, "FN-001");
await mkdir(taskDir, { recursive: true });
@@ -459,7 +459,7 @@ describe("migrateFromLegacy", () => {
);
await writeFile(join(taskDir, "PROMPT.md"), "# Test");
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
// tasks/ directory should still exist
expect(existsSync(tasksDir)).toBe(true);
@@ -473,14 +473,14 @@ describe("migrateFromLegacy", () => {
describe("idempotency", () => {
it("does not fail when no legacy data exists", async () => {
// Fresh kbDir with no legacy files
await expect(migrateFromLegacy(kbDir, db)).resolves.not.toThrow();
// Fresh fusionDir with no legacy files
await expect(migrateFromLegacy(fusionDir, db)).resolves.not.toThrow();
});
});
describe("comment migration", () => {
it("deduplicates overlapping steeringComments and comments during legacy import", async () => {
const tasksDir = join(kbDir, "tasks");
const tasksDir = join(fusionDir, "tasks");
const taskDir = join(tasksDir, "FN-002");
await mkdir(taskDir, { recursive: true });
@@ -506,7 +506,7 @@ describe("migrateFromLegacy", () => {
}),
);
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT steeringComments, comments FROM tasks WHERE id = 'FN-002'").get() as any;
expect(JSON.parse(row.steeringComments)).toEqual([
@@ -521,7 +521,7 @@ describe("migrateFromLegacy", () => {
describe("data integrity", () => {
it("preserves all task fields through migration", async () => {
const tasksDir = join(kbDir, "tasks");
const tasksDir = join(fusionDir, "tasks");
const taskDir = join(tasksDir, "FN-001");
await mkdir(taskDir, { recursive: true });
@@ -564,7 +564,7 @@ describe("migrateFromLegacy", () => {
await writeFile(join(taskDir, "task.json"), JSON.stringify(fullTask));
await migrateFromLegacy(kbDir, db);
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT * FROM tasks WHERE id = 'FN-001'").get() as any;
expect(row.id).toBe("FN-001");

View File

@@ -1,22 +1,20 @@
/**
* Migration from legacy file-based storage to SQLite.
*
*
* Detects legacy data (.fusion/tasks/, .fusion/config.json, etc.) and migrates
* it to the SQLite database. After successful migration, original files
* are renamed with .bak suffix as backups.
*
*
* Migration is idempotent: if the database already exists, migration is skipped.
*/
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { readFile, readdir, rename, stat } from "node:fs/promises";
import { join, resolve, dirname } from "node:path";
import { existsSync } from "node:fs";
import { readFile, readdir, rename } from "node:fs/promises";
import { join } from "node:path";
import type { Database } from "./db.js";
import { toJson, toJsonNullable, normalizeTaskComments } from "./db.js";
import type { Task, BoardConfig, ActivityLogEntry, ArchivedTaskEntry, WorkflowStep } from "./types.js";
import type { ScheduledTask } from "./automation.js";
import { resolveGlobalDir } from "./global-settings.js";
// ── Detection ────────────────────────────────────────────────────────
@@ -24,36 +22,36 @@ import { resolveGlobalDir } from "./global-settings.js";
* Check if legacy file-based data exists but no SQLite database is present.
* Returns true if migration is needed.
*/
export function detectLegacyData(kbDir: string): boolean {
const hasDb = existsSync(join(kbDir, "fusion.db"));
export function detectLegacyData(fusionDir: string): boolean {
const hasDb = existsSync(join(fusionDir, "fusion.db"));
if (hasDb) return false;
return (
existsSync(join(kbDir, "tasks")) ||
existsSync(join(kbDir, "config.json")) ||
existsSync(join(kbDir, "agents")) ||
existsSync(join(kbDir, "automations")) ||
existsSync(join(kbDir, "activity-log.jsonl")) ||
existsSync(join(kbDir, "archive.jsonl"))
existsSync(join(fusionDir, "tasks")) ||
existsSync(join(fusionDir, "config.json")) ||
existsSync(join(fusionDir, "agents")) ||
existsSync(join(fusionDir, "automations")) ||
existsSync(join(fusionDir, "activity-log.jsonl")) ||
existsSync(join(fusionDir, "archive.jsonl"))
);
}
/**
* Get the migration status of a fn directory.
*/
export function getMigrationStatus(kbDir: string): {
export function getMigrationStatus(fusionDir: string): {
hasLegacy: boolean;
hasDatabase: boolean;
needsMigration: boolean;
} {
const hasDatabase = existsSync(join(kbDir, "fusion.db"));
const hasDatabase = existsSync(join(fusionDir, "fusion.db"));
const hasLegacy =
existsSync(join(kbDir, "tasks")) ||
existsSync(join(kbDir, "config.json")) ||
existsSync(join(kbDir, "agents")) ||
existsSync(join(kbDir, "automations")) ||
existsSync(join(kbDir, "activity-log.jsonl")) ||
existsSync(join(kbDir, "archive.jsonl"));
existsSync(join(fusionDir, "tasks")) ||
existsSync(join(fusionDir, "config.json")) ||
existsSync(join(fusionDir, "agents")) ||
existsSync(join(fusionDir, "automations")) ||
existsSync(join(fusionDir, "activity-log.jsonl")) ||
existsSync(join(fusionDir, "archive.jsonl"));
return {
hasLegacy,
@@ -70,63 +68,63 @@ export function getMigrationStatus(kbDir: string): {
* prevent migration of other data.
*/
export async function migrateFromLegacy(
kbDir: string,
fusionDir: string,
db: Database,
): Promise<void> {
console.log("[migrate] Starting migration from file-based to SQLite...");
// 1. Migrate config.json
try {
await migrateConfig(kbDir, db);
await migrateConfig(fusionDir, db);
} catch (err) {
console.warn("[migrate] Warning: failed to migrate config.json:", (err as Error).message);
}
// 2. Migrate tasks
try {
await migrateTasks(kbDir, db);
await migrateTasks(fusionDir, db);
} catch (err) {
console.warn("[migrate] Warning: failed to migrate tasks:", (err as Error).message);
}
// 3. Migrate activity log
try {
await migrateActivityLog(kbDir, db);
await migrateActivityLog(fusionDir, db);
} catch (err) {
console.warn("[migrate] Warning: failed to migrate activity log:", (err as Error).message);
}
// 4. Migrate archive
try {
await migrateArchive(kbDir, db);
await migrateArchive(fusionDir, db);
} catch (err) {
console.warn("[migrate] Warning: failed to migrate archive:", (err as Error).message);
}
// 5. Migrate automations
try {
await migrateAutomations(kbDir, db);
await migrateAutomations(fusionDir, db);
} catch (err) {
console.warn("[migrate] Warning: failed to migrate automations:", (err as Error).message);
}
// 6. Migrate agents
try {
await migrateAgents(kbDir, db);
await migrateAgents(fusionDir, db);
} catch (err) {
console.warn("[migrate] Warning: failed to migrate agents:", (err as Error).message);
}
// 7. Create backups
await createBackups(kbDir);
await createBackups(fusionDir);
console.log("[migrate] Migration complete.");
}
// ── Config Migration ─────────────────────────────────────────────────
async function migrateConfig(kbDir: string, db: Database): Promise<void> {
const configPath = join(kbDir, "config.json");
async function migrateConfig(fusionDir: string, db: Database): Promise<void> {
const configPath = join(fusionDir, "config.json");
if (!existsSync(configPath)) return;
const raw = await readFile(configPath, "utf-8");
@@ -207,8 +205,8 @@ async function migrateConfig(kbDir: string, db: Database): Promise<void> {
// ── Task Migration ───────────────────────────────────────────────────
async function migrateTasks(kbDir: string, db: Database): Promise<void> {
const tasksDir = join(kbDir, "tasks");
async function migrateTasks(fusionDir: string, db: Database): Promise<void> {
const tasksDir = join(fusionDir, "tasks");
if (!existsSync(tasksDir)) return;
const entries = await readdir(tasksDir, { withFileTypes: true });
@@ -302,8 +300,8 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
// ── Activity Log Migration ───────────────────────────────────────────
async function migrateActivityLog(kbDir: string, db: Database): Promise<void> {
const logPath = join(kbDir, "activity-log.jsonl");
async function migrateActivityLog(fusionDir: string, db: Database): Promise<void> {
const logPath = join(fusionDir, "activity-log.jsonl");
if (!existsSync(logPath)) return;
const content = await readFile(logPath, "utf-8");
@@ -340,8 +338,8 @@ async function migrateActivityLog(kbDir: string, db: Database): Promise<void> {
// ── Archive Migration ────────────────────────────────────────────────
async function migrateArchive(kbDir: string, db: Database): Promise<void> {
const archivePath = join(kbDir, "archive.jsonl");
async function migrateArchive(fusionDir: string, db: Database): Promise<void> {
const archivePath = join(fusionDir, "archive.jsonl");
if (!existsSync(archivePath)) return;
const content = await readFile(archivePath, "utf-8");
@@ -374,8 +372,8 @@ async function migrateArchive(kbDir: string, db: Database): Promise<void> {
// ── Automations Migration ────────────────────────────────────────────
async function migrateAutomations(kbDir: string, db: Database): Promise<void> {
const automationsDir = join(kbDir, "automations");
async function migrateAutomations(fusionDir: string, db: Database): Promise<void> {
const automationsDir = join(fusionDir, "automations");
if (!existsSync(automationsDir)) return;
const entries = await readdir(automationsDir);
@@ -429,8 +427,8 @@ async function migrateAutomations(kbDir: string, db: Database): Promise<void> {
// ── Agents Migration ─────────────────────────────────────────────────
async function migrateAgents(kbDir: string, db: Database): Promise<void> {
const agentsDir = join(kbDir, "agents");
async function migrateAgents(fusionDir: string, db: Database): Promise<void> {
const agentsDir = join(fusionDir, "agents");
if (!existsSync(agentsDir)) return;
const entries = await readdir(agentsDir);
@@ -515,9 +513,9 @@ async function migrateAgents(kbDir: string, db: Database): Promise<void> {
* task directory are the "migrated" data now in SQLite. We rename individual
* task.json files to task.json.bak instead.
*/
async function createBackups(kbDir: string): Promise<void> {
async function createBackups(fusionDir: string): Promise<void> {
// Backup individual task.json files (preserving blob files in place)
const tasksDir = join(kbDir, "tasks");
const tasksDir = join(fusionDir, "tasks");
if (existsSync(tasksDir)) {
try {
const entries = await readdir(tasksDir, { withFileTypes: true });
@@ -535,7 +533,7 @@ async function createBackups(kbDir: string): Promise<void> {
}
// Backup config.json
const configPath = join(kbDir, "config.json");
const configPath = join(fusionDir, "config.json");
if (existsSync(configPath)) {
try {
await rename(configPath, configPath + ".bak");
@@ -546,7 +544,7 @@ async function createBackups(kbDir: string): Promise<void> {
}
// Backup activity-log.jsonl
const activityLogPath = join(kbDir, "activity-log.jsonl");
const activityLogPath = join(fusionDir, "activity-log.jsonl");
if (existsSync(activityLogPath)) {
try {
await rename(activityLogPath, activityLogPath + ".bak");
@@ -557,7 +555,7 @@ async function createBackups(kbDir: string): Promise<void> {
}
// Backup archive.jsonl
const archivePath = join(kbDir, "archive.jsonl");
const archivePath = join(fusionDir, "archive.jsonl");
if (existsSync(archivePath)) {
try {
await rename(archivePath, archivePath + ".bak");
@@ -568,7 +566,7 @@ async function createBackups(kbDir: string): Promise<void> {
}
// Backup automations directory
const automationsDir = join(kbDir, "automations");
const automationsDir = join(fusionDir, "automations");
if (existsSync(automationsDir)) {
try {
await rename(automationsDir, automationsDir + ".bak");
@@ -579,7 +577,7 @@ async function createBackups(kbDir: string): Promise<void> {
}
// Backup agents directory
const agentsDir = join(kbDir, "agents");
const agentsDir = join(fusionDir, "agents");
if (existsSync(agentsDir)) {
try {
await rename(agentsDir, agentsDir + ".bak");
@@ -589,87 +587,3 @@ async function createBackups(kbDir: string): Promise<void> {
}
}
}
// ── Central Migration ────────────────────────────────────────────────
/**
* Check if migration to central database is needed.
*
* Returns true if:
* - Central DB doesn't exist AND
* - cwd has `.fusion/fusion.db` or `.fusion/fusion.db` (existing single-project)
*
* @param cwd — Current working directory to check
* @param globalDir — Directory for central database. Defaults to `~/.fusion/`.
*/
export function needsCentralMigration(cwd: string, globalDir?: string): boolean {
const centralDbPath = join(resolveGlobalDir(globalDir), "fusion-central.db");
if (existsSync(centralDbPath)) {
return false;
}
let current = resolve(cwd);
const home = homedir();
const root = dirname(current) === current ? current : "/";
while (true) {
const fusionDbPath = join(current, ".fusion", "fusion.db");
if (existsSync(fusionDbPath)) {
try {
const stat = statSync(fusionDbPath);
if (stat.isFile() && stat.size > 0) return true;
} catch { /* fall through */ }
}
const dbPath = join(current, ".fusion", "fusion.db");
if (existsSync(dbPath)) {
try {
const stat = statSync(dbPath);
return stat.isFile() && stat.size > 0;
} catch {
return false;
}
}
if (current === home || current === root) {
break;
}
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
return false;
}
/**
* Detect existing projects by walking up from cwd.
*
* @param cwd — Starting directory (default: process.cwd())
* @param globalDir — Directory for central database. Defaults to `~/.fusion/`.
* @returns Array of detected projects
*/
export async function detectExistingProjects(
cwd?: string,
globalDir?: string
): Promise<Array<{ path: string; name: string; hasDb: boolean }>> {
const { FirstRunDetector } = await import("./migration.js");
const detector = new FirstRunDetector(globalDir);
return detector.detectExistingProjects(cwd);
}
/**
* Auto-migrate an existing single project to central database.
*
* @param existingProjectPath — Absolute path to existing project
* @param central — Initialized CentralCore instance
* @returns Migration result
*/
export async function autoMigrateToCentral(
existingProjectPath: string,
central: import("./central-core.js").CentralCore
): Promise<import("./migration.js").MigrationResult> {
const { MigrationCoordinator } = await import("./migration.js");
const coordinator = new MigrationCoordinator(central);
return coordinator.registerSingleProject(existingProjectPath);
}

View File

@@ -12,13 +12,13 @@ function makeTmpDir(): string {
describe("Database", () => {
let tmpDir: string;
let kbDir: string;
let fusionDir: string;
let db: Database;
beforeEach(() => {
tmpDir = makeTmpDir();
kbDir = join(tmpDir, ".fusion");
db = new Database(kbDir);
fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir);
db.init(); // Explicit init required — createDatabase() does not auto-init
});
@@ -33,11 +33,11 @@ describe("Database", () => {
describe("initialization", () => {
it("creates the database file", () => {
expect(existsSync(join(kbDir, "fusion.db"))).toBe(true);
expect(existsSync(join(fusionDir, "fusion.db"))).toBe(true);
});
it("creates the .fusion directory if missing", () => {
expect(existsSync(kbDir)).toBe(true);
expect(existsSync(fusionDir)).toBe(true);
});
it("sets WAL journal mode", () => {
@@ -206,14 +206,14 @@ describe("Database", () => {
// Close and reopen
db.close();
const db2 = new Database(kbDir);
const db2 = new Database(fusionDir);
db2.init();
expect(db2.getLastModified()).toBe(ts);
db2.close();
// Re-assign so afterEach doesn't fail
db = new Database(kbDir);
db = new Database(fusionDir);
db.init();
});
@@ -422,7 +422,7 @@ describe("Database", () => {
// Close and reopen
db.close();
db = new Database(kbDir);
db = new Database(fusionDir);
db.init();
// Verify foreign key enforcement is active after reopen
@@ -675,10 +675,10 @@ describe("schema migrations", () => {
it("migrates a v1 database by adding missing columns", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const fusionDir = join(tmpDir, ".fusion");
// Create a v1 database manually (without comments and mergeDetails columns)
const db = new Database(kbDir);
const db = new Database(fusionDir);
// Create tables without the new columns
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
@@ -782,8 +782,8 @@ describe("schema migrations", () => {
it("skips migration if already at target version", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const db = new Database(kbDir);
const fusionDir = join(tmpDir, ".fusion");
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(42);
@@ -797,9 +797,9 @@ describe("schema migrations", () => {
it("applies migration 14+15 by creating agentRatings and ai_sessions indexes", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const fusionDir = join(tmpDir, ".fusion");
const db = new Database(kbDir);
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '13')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
@@ -821,9 +821,9 @@ describe("schema migrations", () => {
it("migrates a v16 database by creating mission_events table and indexes", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const fusionDir = join(tmpDir, ".fusion");
const db = new Database(kbDir);
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '16')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
@@ -846,10 +846,10 @@ describe("schema migrations", () => {
it("migrates a v2 database by adding missionId and sliceId columns", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const fusionDir = join(tmpDir, ".fusion");
// Create a v2 database manually (without missionId and sliceId columns)
const db = new Database(kbDir);
const db = new Database(fusionDir);
// Create tables without the new columns (matching v2 schema)
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
@@ -966,9 +966,9 @@ describe("schema migrations", () => {
it("migrates pre-comments databases by copying steering comments into unified comments exactly once", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const fusionDir = join(tmpDir, ".fusion");
const db = new Database(kbDir);
const db = new Database(fusionDir);
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS tasks (
@@ -1042,9 +1042,9 @@ describe("schema migrations", () => {
it("deduplicates overlapping steeringComments and comments during schema upgrade", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const fusionDir = join(tmpDir, ".fusion");
const db = new Database(kbDir);
const db = new Database(fusionDir);
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS tasks (
@@ -1120,13 +1120,13 @@ describe("schema migrations", () => {
describe("FTS5 full-text search", () => {
let tmpDir: string;
let kbDir: string;
let fusionDir: string;
let db: Database;
beforeEach(() => {
tmpDir = makeTmpDir();
kbDir = join(tmpDir, ".fusion");
db = new Database(kbDir);
fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir);
db.init();
});
@@ -1286,11 +1286,11 @@ describe("createDatabase factory", () => {
it("creates a database instance without auto-init", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const db = createDatabase(kbDir);
const fusionDir = join(tmpDir, ".fusion");
const db = createDatabase(fusionDir);
// DB file exists (created on open) but schema not initialized
expect(existsSync(join(kbDir, "fusion.db"))).toBe(true);
expect(existsSync(join(fusionDir, "fusion.db"))).toBe(true);
// Schema is NOT yet created — querying __meta would fail
expect(() => db.getSchemaVersion()).toThrow();
@@ -1299,8 +1299,8 @@ describe("createDatabase factory", () => {
it("works after explicit init()", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const db = createDatabase(kbDir);
const fusionDir = join(tmpDir, ".fusion");
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(42);
@@ -1311,26 +1311,26 @@ describe("createDatabase factory", () => {
it("getPath returns the database file path", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const db = createDatabase(kbDir);
const fusionDir = join(tmpDir, ".fusion");
const db = createDatabase(fusionDir);
expect(db.getPath()).toBe(join(kbDir, "fusion.db"));
expect(db.getPath()).toBe(join(fusionDir, "fusion.db"));
db.close();
});
it("is idempotent when init() called multiple times", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const fusionDir = join(tmpDir, ".fusion");
// First call
const db1 = createDatabase(kbDir);
const db1 = createDatabase(fusionDir);
db1.init();
db1.prepare("UPDATE config SET nextId = 99 WHERE id = 1").run();
db1.close();
// Second call — init should not overwrite data
const db2 = createDatabase(kbDir);
const db2 = createDatabase(fusionDir);
db2.init();
const row = db2.prepare("SELECT nextId FROM config WHERE id = 1").get() as any;
expect(row.nextId).toBe(99);

View File

@@ -618,16 +618,16 @@ export class Database {
private transactionDepth = 0;
private readonly _fts5Available: boolean;
constructor(kbDir: string) {
this.dbPath = join(kbDir, "fusion.db");
constructor(fusionDir: string) {
this.dbPath = join(fusionDir, "fusion.db");
if (!isAbsolute(kbDir)) {
throw new Error(`[fusion] Database constructor requires an absolute kbDir path, got: ${kbDir}`);
if (!isAbsolute(fusionDir)) {
throw new Error(`[fusion] Database constructor requires an absolute fusionDir path, got: ${fusionDir}`);
}
// Ensure .fusion directory exists
if (!existsSync(kbDir)) {
mkdirSync(kbDir, { recursive: true });
if (!existsSync(fusionDir)) {
mkdirSync(fusionDir, { recursive: true });
}
this.db = new DatabaseSync(this.dbPath);
@@ -1896,11 +1896,11 @@ export class Database {
/**
* Create a new Database instance (does NOT initialize schema).
* Callers must call `db.init()` separately.
* @param kbDir - Path to the `.fusion` directory (e.g., `/path/to/project/.fusion`)
* @param fusionDir - Path to the `.fusion` directory (e.g., `/path/to/project/.fusion`)
* @returns Database instance (not yet initialized)
*/
export function createDatabase(kbDir: string): Database {
return new Database(kbDir);
export function createDatabase(fusionDir: string): Database {
return new Database(fusionDir);
}
export { normalizeTaskComments };

View File

@@ -43,13 +43,13 @@ describe("FTS5 runtime guard", () => {
describe("Database", () => {
let tmpDir: string;
let kbDir: string;
let fusionDir: string;
let db: Database;
beforeEach(() => {
tmpDir = makeTmpDir();
kbDir = join(tmpDir, ".fusion");
db = new Database(kbDir);
fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir);
});
afterEach(async () => {
@@ -208,13 +208,13 @@ describe("FTS5 runtime guard", () => {
describe("ArchiveDatabase.search LIKE fallback", () => {
let tmpDir: string;
let kbDir: string;
let fusionDir: string;
let archive: ArchiveDatabase;
beforeEach(() => {
tmpDir = makeTmpDir();
kbDir = join(tmpDir, ".fusion");
archive = new ArchiveDatabase(kbDir);
fusionDir = join(tmpDir, ".fusion");
archive = new ArchiveDatabase(fusionDir);
archive.init();
});

View File

@@ -375,11 +375,6 @@ export type {
ProjectSetupInput,
ResolvedContext,
} from "./migration.js";
export {
needsCentralMigration,
detectExistingProjects,
autoMigrateToCentral,
} from "./db-migrate.js";
// ── Memory Insights ──────────────────────────────────────────────────────

View File

@@ -32,7 +32,7 @@ function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-insight-test-"));
}
let kbDir: string;
let fusionDir: string;
let db: Database;
let store: InsightStore;
@@ -46,8 +46,8 @@ function createProvenance(overrides: Partial<InsightProvenance> = {}): InsightPr
}
beforeEach(() => {
kbDir = makeTmpDir();
db = createDatabase(kbDir);
fusionDir = makeTmpDir();
db = createDatabase(fusionDir);
db.init();
store = new InsightStore(db);
});

View File

@@ -14,7 +14,6 @@ import {
ProjectRequiredError,
type ProjectSetupInput,
} from "./migration.js";
import { needsCentralMigration, autoMigrateToCentral, detectExistingProjects as detectExistingProjectsFromDbMigrate } from "./db-migrate.js";
import { CentralCore } from "./central-core.js";
// Helper to create a fake kb project
@@ -57,17 +56,17 @@ describe("FirstRunDetector", () => {
expect(state).toBe("fresh-install");
});
it("should detect needs-migration when local .fusion/ exists but no central DB", async () => {
it("should detect setup-wizard when local .fusion/ exists but no central DB", async () => {
const tempProjectDir = useIsolatedCwd("kb-needs-migration-");
createFakeKbProject(tempProjectDir);
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("needs-migration");
expect(state).toBe("setup-wizard");
});
it("should detect needs-migration from nested directory inside an existing project", async () => {
it("should detect setup-wizard from nested directory inside an existing project with no central DB", async () => {
const tempProjectDir = tempWorkspace("kb-needs-migration-nested-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "src", "features", "deep");
@@ -79,7 +78,7 @@ describe("FirstRunDetector", () => {
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("needs-migration");
expect(state).toBe("setup-wizard");
} finally {
process.chdir(originalCwd);
}
@@ -128,7 +127,7 @@ describe("FirstRunDetector", () => {
}
});
it("should fall back to needs-migration when central DB exists but is unreadable", async () => {
it("should return fresh-install when central DB exists but is unreadable", async () => {
const tempProjectDir = useIsolatedCwd("kb-corrupt-central-");
createFakeKbProject(tempProjectDir);
@@ -138,7 +137,7 @@ describe("FirstRunDetector", () => {
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("needs-migration");
expect(state).toBe("fresh-install");
});
it("should return fresh-install when central DB exists but is unreadable and no local project is found", async () => {
@@ -264,83 +263,6 @@ describe("FirstRunDetector", () => {
});
});
describe("db-migrate wrappers", () => {
it("should forward detectExistingProjects through db-migrate wrapper", async () => {
const tempGlobalDir = tempWorkspace("kb-dbmigrate-detect-global-");
const tempProjectDir = tempWorkspace("kb-dbmigrate-detect-project-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "src", "nested");
mkdirSync(nestedDir, { recursive: true });
const detected = await detectExistingProjectsFromDbMigrate(nestedDir, tempGlobalDir);
expect(detected).toHaveLength(1);
expect(detected[0].path).toBe(tempProjectDir);
});
it("should autoMigrateToCentral and register the project", async () => {
const tempGlobalDir = tempWorkspace("kb-dbmigrate-auto-global-");
const tempProjectDir = tempWorkspace("kb-dbmigrate-auto-project-");
createFakeKbProject(tempProjectDir);
const central = new CentralCore(tempGlobalDir);
await central.init();
try {
const result = await autoMigrateToCentral(tempProjectDir, central);
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(1);
const project = await central.getProject(result.projectsRegistered[0]);
expect(project).toBeDefined();
expect(project!.path).toBe(tempProjectDir);
expect(project!.status).toBe("active");
} finally {
await central.close();
}
});
it("should autoMigrateToCentral idempotently on repeat runs", async () => {
const tempGlobalDir = tempWorkspace("kb-dbmigrate-idempotent-global-");
const tempProjectDir = tempWorkspace("kb-dbmigrate-idempotent-project-");
createFakeKbProject(tempProjectDir);
const central = new CentralCore(tempGlobalDir);
await central.init();
try {
const result1 = await autoMigrateToCentral(tempProjectDir, central);
const result2 = await autoMigrateToCentral(tempProjectDir, central);
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
expect(result1.projectsRegistered[0]).toBe(result2.projectsRegistered[0]);
} finally {
await central.close();
}
});
});
describe("needsCentralMigration", () => {
it("should detect migration need from nested directory inside a project", () => {
const tempGlobalDir = tempWorkspace("kb-needs-central-global-");
const tempProjectDir = tempWorkspace("kb-needs-central-project-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "src", "nested");
mkdirSync(nestedDir, { recursive: true });
expect(needsCentralMigration(nestedDir, tempGlobalDir)).toBe(true);
});
it("should detect migration need from the project root itself", () => {
const tempGlobalDir = tempWorkspace("kb-needs-central-root-global-");
const tempProjectDir = tempWorkspace("kb-needs-central-root-project-");
createFakeKbProject(tempProjectDir);
expect(needsCentralMigration(tempProjectDir, tempGlobalDir)).toBe(true);
});
});
describe("MigrationCoordinator", () => {
let tempGlobalDir: string;
let central: CentralCore;

View File

@@ -1,17 +1,16 @@
/**
* Migration and First-Run Experience
*
* Handles the transition from single-project to multi-project mode:
* - Detects first-run state (fresh install, needs migration, setup wizard, normal)
* - Auto-discovers existing .kb/ directories for migration
* - Coordinates migration to central database
* Handles the multi-project setup flow:
* - Detects first-run state (fresh install, setup wizard, normal)
* - Auto-discovers existing .fusion/ directories for registration
* - Coordinates project registration to central database
* - Provides backward compatibility for single-project workflows
*
* @module migration
*/
import { existsSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join, resolve, basename, dirname } from "node:path";
import type { CentralCore } from "./central-core.js";
@@ -19,8 +18,7 @@ import { CentralCore as CentralCoreClass } from "./central-core.js";
/**
* Check whether `<dir>/<folderName>/<dbName>` exists as a non-empty regular file.
* Used to decide if a directory contains either a legacy (.kb/kb.db) or
* 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 {
const projectDir = join(dir, folderName);
@@ -41,18 +39,17 @@ function hasProjectDbFile(dir: string, folderName: string, dbName: string): bool
/** First-run state detection results */
export type FirstRunState =
| "fresh-install" // No central DB, no .kb/ anywhere
| "needs-migration" // No central DB, but .kb/kb.db exists in cwd
| "fresh-install" // No central DB, no .fusion/ anywhere
| "setup-wizard" // Central DB exists but has zero projects
| "normal-operation"; // Central DB exists with projects
/** Detected project for migration consideration */
/** Detected project for registration consideration */
export interface DetectedProject {
/** Absolute path to project directory */
path: string;
/** Auto-generated or derived project name */
name: string;
/** Whether the project has a valid kb.db */
/** Whether the project has a valid fusion.db */
hasDb: boolean;
}
@@ -100,12 +97,11 @@ export class ProjectRequiredError extends Error {
// ── FirstRunDetector ─────────────────────────────────────────────────
/**
* Detects the first-run state and existing projects for migration.
* Detects the first-run state and existing projects for registration.
*
* This class determines which startup path to take:
* - Fresh install → Show setup wizard
* - Existing single project → Auto-migrate
* - Already migrated → Normal operation
* - Already set up → Normal operation
*/
export class FirstRunDetector {
private readonly globalDir: string;
@@ -121,12 +117,11 @@ export class FirstRunDetector {
/**
* Detect the current first-run state.
*
* Returns one of four states:
* - `"fresh-install"` — No central DB, no local `.kb/` found
* - `"needs-migration"` — No central DB, but `.kb/kb.db` exists in cwd
* Returns one of three states:
* - `"fresh-install"` — No central DB, no local `.fusion/` found
* - `"setup-wizard"` — Central DB exists but has zero projects
* - `"normal-operation"` — Central DB exists with one or more projects
*
*
* @param existingCentral — Optional existing CentralCore instance to use instead of creating a new one
*/
async detectFirstRunState(existingCentral?: CentralCore): Promise<FirstRunState> {
@@ -136,26 +131,24 @@ export class FirstRunDetector {
// No central DB - check for local project in cwd or parent directories
const cwd = process.cwd();
const detected = await this.detectExistingProjects(cwd);
return detected.length > 0 ? "needs-migration" : "fresh-install";
return detected.length > 0 ? "setup-wizard" : "fresh-install";
}
// Central DB exists - check if it has projects
let central: CentralCore | undefined = existingCentral;
let shouldClose = false;
if (!central) {
try {
central = new CentralCoreClass(this.globalDir);
await central.init();
shouldClose = true;
} catch {
// Central DB exists but is unreadable — fall back to local detection
const cwd = process.cwd();
const detected = await this.detectExistingProjects(cwd);
return detected.length > 0 ? "needs-migration" : "fresh-install";
// Central DB exists but is unreadable — treat as fresh install
return "fresh-install";
}
}
try {
const projects = await central.listProjects();
return projects.length === 0 ? "setup-wizard" : "normal-operation";
@@ -187,7 +180,7 @@ export class FirstRunDetector {
/**
* Detect existing projects by walking up the directory tree.
*
* Starting from `cwd`, walks up looking for `.kb/kb.db` files.
* Starting from `cwd`, walks up looking for `.fusion/fusion.db` files.
* Stops at home directory or root.
*
* @param cwd — Starting directory (default: process.cwd())
@@ -206,7 +199,7 @@ export class FirstRunDetector {
if (visited.has(current)) break;
visited.add(current);
if (this.hasKbProject(current)) {
if (this.hasFusionProject(current)) {
const name = await this.generateProjectName(current);
projects.push({
path: current,
@@ -295,12 +288,10 @@ export class FirstRunDetector {
}
/**
* Check if a directory contains a valid kb project.
* Check if a directory contains a valid fusion project (.fusion/fusion.db).
*/
private hasKbProject(dir: string): boolean {
// Check for current .fusion/fusion.db or legacy .kb/kb.db
return hasProjectDbFile(dir, ".fusion", "fusion.db") ||
hasProjectDbFile(dir, ".kb", "kb.db");
private hasFusionProject(dir: string): boolean {
return hasProjectDbFile(dir, ".fusion", "fusion.db");
}
private getDefaultGlobalDir(): string {
@@ -311,10 +302,10 @@ export class FirstRunDetector {
// ── MigrationCoordinator ─────────────────────────────────────────────
/**
* Coordinates migration and setup flows.
* Coordinates project setup flows.
*
* Orchestrates:
* - Auto-migration of existing single projects
* - Auto-registration of existing single projects
* - Setup wizard project registration
* - Idempotent re-runs
*/
@@ -330,11 +321,10 @@ export class MigrationCoordinator {
}
/**
* Coordinate the full migration flow based on current state.
* Coordinate the full setup flow based on current state.
*
* Detects state and executes appropriate migration path:
* - needs-migration → Auto-register existing project
* - setup-wizard → No-op (call completeSetup separately)
* Detects state and executes appropriate path:
* - setup-wizard with local project → Auto-register existing project
* - others → No-op
*/
async coordinateMigration(): Promise<MigrationResult> {
@@ -342,19 +332,6 @@ export class MigrationCoordinator {
const state = await detector.detectFirstRunState();
switch (state) {
case "needs-migration": {
// Find the project in cwd
const projects = await detector.detectExistingProjects(process.cwd());
if (projects.length === 0) {
return {
success: false,
projectsRegistered: [],
errors: ["No existing kb project found for migration"],
};
}
return this.registerSingleProject(projects[0].path);
}
case "fresh-install":
return {
success: true,
@@ -386,7 +363,7 @@ export class MigrationCoordinator {
}
/**
* Register a single existing project (for auto-migration).
* Register a single existing project (for auto-registration).
*
* @param projectPath — Absolute path to project
* @returns Migration result
@@ -404,9 +381,9 @@ export class MigrationCoordinator {
return result;
}
// Validate it's an actual kb project
// Validate it's an actual fusion project
const detector = new FirstRunDetector(this.central.getGlobalDir());
if (!this.isValidKbProject(projectPath)) {
if (!this.isValidFusionProject(projectPath)) {
result.errors.push(`Path is not a valid kb project: ${projectPath}`);
return result;
}
@@ -479,8 +456,8 @@ export class MigrationCoordinator {
for (const input of projects) {
try {
// Validate it's a valid kb project
if (!this.isValidKbProject(input.path)) {
// Validate it's a valid fusion project
if (!this.isValidFusionProject(input.path)) {
result.success = false;
result.errors.push(`Path is not a valid kb project: ${input.path}`);
continue;
@@ -539,11 +516,10 @@ export class MigrationCoordinator {
}
/**
* Check if a directory is a valid kb project (has .fusion/fusion.db or .kb/kb.db).
* Check if a directory is a valid fusion project (has .fusion/fusion.db).
*/
private isValidKbProject(dir: string): boolean {
return hasProjectDbFile(dir, ".fusion", "fusion.db") ||
hasProjectDbFile(dir, ".kb", "kb.db");
private isValidFusionProject(dir: string): boolean {
return hasProjectDbFile(dir, ".fusion", "fusion.db");
}
}
@@ -666,12 +642,4 @@ export class BackwardCompat {
const all = await this.central.listProjects();
return all.map((p) => ({ id: p.id, name: p.name }));
}
/**
* Check if a directory contains a current .fusion project or legacy .kb project.
*/
private hasProjectData(dir: string): boolean {
return hasProjectDbFile(dir, ".fusion", "fusion.db") ||
hasProjectDbFile(dir, ".kb", "kb.db");
}
}

View File

@@ -390,7 +390,7 @@ describe("MissionStore integration with TaskStore", () => {
const refreshed = missionStore.getFeature(feature.id);
expect(refreshed?.taskId).toBeUndefined();
});
}, 15000);
// ── Parity: Restart Fidelity Tests ──────────────────────────────────

View File

@@ -25,16 +25,16 @@ function createTaskInDb(
describe("MissionStore", () => {
let tmpDir: string;
let kbDir: string;
let fusionDir: string;
let db: Database;
let store: MissionStore;
beforeEach(() => {
tmpDir = makeTmpDir();
kbDir = join(tmpDir, ".fusion");
db = new Database(kbDir);
fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir);
db.init();
store = new MissionStore(kbDir, db);
store = new MissionStore(fusionDir, db);
});
afterEach(async () => {
@@ -1663,7 +1663,7 @@ describe("MissionStore", () => {
it("throws if feature not found", async () => {
// Need a TaskStore reference for this test
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageFeature("F-NONEXISTENT")).rejects.toThrow(
@@ -1673,7 +1673,7 @@ describe("MissionStore", () => {
it("throws if feature is already triaged", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1693,7 +1693,7 @@ describe("MissionStore", () => {
it("creates a task and links it to the feature", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1725,7 +1725,7 @@ describe("MissionStore", () => {
it("uses provided title and description overrides", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1746,7 +1746,7 @@ describe("MissionStore", () => {
it("emits feature:linked event", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const linkedHandler = vi.fn();
@@ -1781,7 +1781,7 @@ describe("MissionStore", () => {
it("throws if slice not found", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageSlice("SL-NONEXISTENT")).rejects.toThrow(
@@ -1791,7 +1791,7 @@ describe("MissionStore", () => {
it("triages all defined features in a slice", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1818,7 +1818,7 @@ describe("MissionStore", () => {
it("skips already triaged features", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1840,7 +1840,7 @@ describe("MissionStore", () => {
it("returns empty array if no defined features", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1861,7 +1861,7 @@ describe("MissionStore", () => {
ms: MissionStore;
}> {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir, join(kbDir, ".fusion-global-settings"));
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
const ms = ts.getMissionStore();
return { ts, ms };
}
@@ -1979,7 +1979,7 @@ describe("MissionStore", () => {
// The MissionStore was created via TaskStore, so taskStore is available.
// To make triage fail, we'll delete the task from the DB after it's created.
// Instead, let's use a MissionStore WITHOUT a TaskStore but with autoAdvance.
const storeNoTs = new MissionStore(kbDir, db);
const storeNoTs = new MissionStore(fusionDir, db);
const mission2 = storeNoTs.createMission({ title: "Mission 2" });
storeNoTs.updateMission(mission2.id, { autoAdvance: true });

View File

@@ -133,12 +133,12 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
/**
* Creates a new MissionStore instance.
*
* @param kbDir - Path to the .fusion directory (e.g., /path/to/project/.fusion)
* @param fusionDir - Path to the .fusion directory (e.g., /path/to/project/.fusion)
* @param db - Shared Database instance (same instance used by TaskStore)
* @param taskStore - Optional TaskStore reference for triage operations that create tasks
*/
constructor(
private kbDir: string,
private fusionDir: string,
private db: Database,
private taskStore?: import("./store.js").TaskStore,
) {

View File

@@ -55,8 +55,8 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
*/
private get db(): Database {
if (!this._db) {
const kbDir = join(this.rootDir, ".fusion");
this._db = new Database(kbDir);
const fusionDir = join(this.rootDir, ".fusion");
this._db = new Database(fusionDir);
this._db.init();
}
return this._db;

View File

@@ -51,8 +51,8 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
*/
private get db(): Database {
if (!this._db) {
const kbDir = `${this.rootDir}/.fusion`;
this._db = new Database(kbDir);
const fusionDir = `${this.rootDir}/.fusion`;
this._db = new Database(fusionDir);
this._db.init();
}
return this._db;

View File

@@ -26,14 +26,14 @@ function makeTmpDir(): string {
describe("Run Audit Integration", () => {
let rootDir: string;
let kbDir: string;
let fusionDir: string;
let db: Database;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
kbDir = join(rootDir, ".fusion");
db = new Database(kbDir);
fusionDir = join(rootDir, ".fusion");
db = new Database(fusionDir);
db.init();
store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await store.init();

View File

@@ -13,14 +13,14 @@ function makeTmpDir(): string {
describe("Run Audit", () => {
let rootDir: string;
let kbDir: string;
let fusionDir: string;
let db: Database;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
kbDir = join(rootDir, ".fusion");
db = new Database(kbDir);
fusionDir = join(rootDir, ".fusion");
db = new Database(fusionDir);
db.init();
store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await store.init();

View File

@@ -20,8 +20,8 @@ import {
// Helper to create a temporary test environment
function createTestEnv() {
const tempDir = mkdtempSync(join(tmpdir(), "kb-settings-test-"));
const kbDir = join(tempDir, ".fusion");
const tasksDir = join(kbDir, "tasks");
const fusionDir = join(tempDir, ".fusion");
const tasksDir = join(fusionDir, "tasks");
const globalSettingsDir = join(tempDir, "global-settings");
mkdirSync(tasksDir, { recursive: true });
@@ -29,7 +29,7 @@ function createTestEnv() {
// Create initial config.json
writeFileSync(
join(kbDir, "config.json"),
join(fusionDir, "config.json"),
JSON.stringify({ nextId: 1, settings: {} }),
);
@@ -39,7 +39,7 @@ function createTestEnv() {
JSON.stringify({}),
);
return { tempDir, kbDir, tasksDir, globalSettingsDir };
return { tempDir, fusionDir, tasksDir, globalSettingsDir };
}
// Helper to clean up test environment

View File

@@ -20,13 +20,6 @@ import { ensureMemoryFileWithBackend } from "./project-memory.js";
import { runCommandAsync } from "./run-command.js";
import { createLogger } from "./logger.js";
/**
* Legacy backup directory default value from the old .kb storage structure.
* Projects that were created before the .fusion rename may still have this
* value persisted in their config. It is canonicalized to the new default
* so that all backup operations use a consistent directory.
*/
const LEGACY_BACKUP_DIR = ".kb/backups";
const TASK_ACTIVITY_LOG_ENTRY_LIMIT = 1_000;
const TASK_ACTIVITY_LOG_OUTCOME_LIMIT = 4_000;
const ARCHIVE_AGENT_LOG_SNAPSHOT_LIMIT = 25;
@@ -96,24 +89,9 @@ function compactTaskActivityLog(entries: TaskLogEntry[]): TaskLogEntry[] {
}
/**
* Canonicalizes a settings object by resolving legacy defaults.
* Currently handles the .kb/backups → .fusion/backups migration and
* strips legacy fields that are no longer valid.
*
* This function applies only the exact-match legacy alias transformation.
* Other custom .kb/* paths are preserved as-is.
* Canonicalizes a settings object by stripping legacy fields that are no longer valid.
*/
function canonicalizeSettings(settings: Settings): Settings {
// Canonicalize the legacy backup directory default to the new location.
// Only the exact legacy default value is transformed — custom paths like
// ".kb/my-custom-backups" are preserved unchanged.
if ((settings as Partial<ProjectSettings>).autoBackupDir === LEGACY_BACKUP_DIR) {
return {
...settings,
autoBackupDir: ".fusion/backups",
};
}
// Strip legacy globalMaxConcurrent from project settings - this field was
// deprecated in favor of the global-level maxConcurrent in concurrency settings.
const { globalMaxConcurrent, ...rest } = settings as Settings & { globalMaxConcurrent?: number };
@@ -203,7 +181,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* optional blob files must tolerate missing files/directories because cleanup, migration,
* or manual filesystem changes can remove them independently of the database row.
*/
private kbDir: string;
private fusionDir: string;
private tasksDir: string;
private configPath: string;
/** SQLite database for structured data storage */
@@ -254,9 +232,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
constructor(private rootDir: string, globalSettingsDir?: string) {
super();
this.setMaxListeners(100);
this.kbDir = join(rootDir, ".fusion");
this.tasksDir = join(this.kbDir, "tasks");
this.configPath = join(this.kbDir, "config.json");
this.fusionDir = join(rootDir, ".fusion");
this.tasksDir = join(this.fusionDir, "tasks");
this.configPath = join(this.fusionDir, "config.json");
const resolvedGlobalSettingsDir = globalSettingsDir
?? (process.env.VITEST === "true" ? join(rootDir, ".fusion-global-settings") : undefined);
this.globalSettingsStore = new GlobalSettingsStore(resolvedGlobalSettingsDir);
@@ -268,10 +246,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
*/
private get db(): Database {
if (!this._db) {
this._db = new Database(this.kbDir);
this._db = new Database(this.fusionDir);
this._db.init();
// Auto-migrate legacy data if needed
if (detectLegacyData(this.kbDir)) {
if (detectLegacyData(this.fusionDir)) {
// Note: migrateFromLegacy is async but we need sync access.
// The init() method handles async migration. This getter
// just ensures the DB is available for synchronous operations.
@@ -282,7 +260,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private get archiveDb(): ArchiveDatabase {
if (!this._archiveDb) {
this._archiveDb = new ArchiveDatabase(this.kbDir);
this._archiveDb = new ArchiveDatabase(this.fusionDir);
this._archiveDb.init();
this.migrateLegacyArchiveEntriesToArchiveDb();
}
@@ -294,13 +272,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Initialize SQLite database
if (!this._db) {
this._db = new Database(this.kbDir);
this._db = new Database(this.fusionDir);
this._db.init();
}
// Auto-migrate from legacy file-based storage
if (detectLegacyData(this.kbDir)) {
await migrateFromLegacy(this.kbDir, this._db);
if (detectLegacyData(this.fusionDir)) {
await migrateFromLegacy(this.fusionDir, this._db);
}
await this.migrateActiveArchivedTasksToArchiveDb();
await this.importLegacyAgentLogsOnce();
@@ -1092,7 +1070,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* Returns the combined view that most consumers should use. Project-level
* values in `.fusion/config.json` override global values from `~/.fusion/settings.json`.
*
* Settings are canonicalized to resolve legacy defaults (e.g., `.kb/backups` → `.fusion/backups`).
*
*/
async getSettings(): Promise<Settings> {
const [globalSettings, config] = await Promise.all([
@@ -1116,7 +1094,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
*
* Note: Do NOT use this method when you need workflow steps — use `getSettings()` instead.
*
* Settings are canonicalized to resolve legacy defaults (e.g., `.kb/backups` → `.fusion/backups`).
*
*/
async getSettingsFast(): Promise<Settings> {
const [globalSettings, row] = await Promise.all([
@@ -1138,7 +1116,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* project-level settings independently (useful for the UI to show
* which scope a value comes from).
*
* Settings are canonicalized to resolve legacy defaults (e.g., `.kb/backups` → `.fusion/backups`).
*
*/
async getSettingsByScope(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
const [globalSettings, config] = await Promise.all([
@@ -1171,7 +1149,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* uses the cached global settings from `GlobalSettingsStore`. Use this for
* read-heavy paths like the settings page that don't need workflow steps.
*
* Settings are canonicalized to resolve legacy defaults (e.g., `.kb/backups` → `.fusion/backups`).
*
*/
async getSettingsByScopeFast(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
const [globalSettings, row] = await Promise.all([
@@ -5064,7 +5042,7 @@ ${stepsSection}`;
/** Return the `.fusion` directory path (e.g. `/project/.fusion`). */
getFusionDir(): string {
return this.kbDir;
return this.fusionDir;
}
getTasksDir(): string {
@@ -5310,7 +5288,7 @@ ${notificationsSection}`;
*/
getMissionStore(): MissionStore {
if (!this.missionStore) {
this.missionStore = new MissionStore(this.kbDir, this.db, this);
this.missionStore = new MissionStore(this.fusionDir, this.db, this);
}
return this.missionStore;
}