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:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ export interface ChatStoreEvents {
|
||||
|
||||
export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
constructor(
|
||||
private kbDir: string,
|
||||
private fusionDir: string,
|
||||
private db: Database,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -375,11 +375,6 @@ export type {
|
||||
ProjectSetupInput,
|
||||
ResolvedContext,
|
||||
} from "./migration.js";
|
||||
export {
|
||||
needsCentralMigration,
|
||||
detectExistingProjects,
|
||||
autoMigrateToCentral,
|
||||
} from "./db-migrate.js";
|
||||
|
||||
// ── Memory Insights ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ describe("MissionStore integration with TaskStore", () => {
|
||||
|
||||
const refreshed = missionStore.getFeature(feature.id);
|
||||
expect(refreshed?.taskId).toBeUndefined();
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
// ── Parity: Restart Fidelity Tests ──────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user