feat(KB-504): add multi-project migration system

- Add MigrationOrchestrator with automatic project detection and registration
- Add FirstRunExperience with setup wizard for new users
- Add backward compatibility layer for single-project workflows
- Integrate migration hook into CLI entry point
- Add comprehensive tests for migration and first-run functionality
- Update AGENTS.md with multi-project migration and dashboard documentation
This commit is contained in:
gsxdsm
2026-04-01 01:40:42 -07:00
parent c6be1323d1
commit d2354560a0
11 changed files with 1949 additions and 1 deletions

View File

@@ -0,0 +1,300 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, basename } from "node:path";
import { FirstRunExperience, createFirstRunExperience } from "../first-run.js";
import { CentralCore } from "../central-core.js";
// Helper to create a temp directory
function createTempDir(): string {
return mkdtempSync(join(tmpdir(), "kb-first-run-test-"));
}
// Helper to create a fake kb project structure
function createFakeKbProject(dir: string): void {
mkdirSync(join(dir, ".kb"), { recursive: true });
writeFileSync(join(dir, ".kb", "kb.db"), "");
}
describe("FirstRunExperience", () => {
let tempDir: string;
let centralCore: CentralCore;
let firstRun: FirstRunExperience;
let originalCwd: string;
beforeEach(async () => {
tempDir = createTempDir();
centralCore = new CentralCore(tempDir);
await centralCore.init();
// Create GlobalSettingsStore with temp directory for isolation
const { GlobalSettingsStore } = await import("../global-settings.js");
const globalSettingsStore = new GlobalSettingsStore(tempDir);
await globalSettingsStore.init();
firstRun = new FirstRunExperience(centralCore, globalSettingsStore);
originalCwd = process.cwd();
});
afterEach(() => {
try {
process.chdir(originalCwd);
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("isFirstRun", () => {
it("should return true when no projects registered", async () => {
const result = await firstRun.isFirstRun();
expect(result).toBe(true);
});
it("should return false when projects are registered", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
await centralCore.registerProject({
name: "my-project",
path: projectDir,
isolationMode: "in-process",
});
const result = await firstRun.isFirstRun();
expect(result).toBe(false);
});
it("should return true when central core is not initialized", async () => {
const uninitializedCore = new CentralCore(tempDir);
const uninitializedFirstRun = new FirstRunExperience(uninitializedCore);
const result = await uninitializedFirstRun.isFirstRun();
expect(result).toBe(true);
});
});
describe("detectOrCreateInitialProject", () => {
it("should detect and register project from cwd", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
// Change to the project directory
process.chdir(projectDir);
const result = await firstRun.detectOrCreateInitialProject();
expect(result.type).toBe("detected");
if (result.type === "detected") {
expect(result.project.name).toBe("my-project");
// Use realpath comparison to handle macOS /private prefix
const realProjectDir = await realpath(projectDir);
expect(result.project.path).toBe(realProjectDir);
}
});
it("should return manual-setup when no project in cwd", async () => {
// Stay in tempDir which has no kb project
process.chdir(tempDir);
const result = await firstRun.detectOrCreateInitialProject();
expect(result.type).toBe("manual-setup");
});
it("should return manual-setup with detected projects", async () => {
// Create a project in a subdirectory
const projectDir = join(tempDir, "sub-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
process.chdir(tempDir);
const result = await firstRun.detectOrCreateInitialProject();
expect(result.type).toBe("manual-setup");
if (result.type === "manual-setup") {
expect(result.detectedFromCwd).toBeDefined();
expect(result.detectedFromCwd!.length).toBeGreaterThan(0);
}
});
});
describe("getSetupState", () => {
it("should return complete setup state for first run", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
process.chdir(tempDir);
const state = await firstRun.getSetupState();
expect(state.isFirstRun).toBe(true);
expect(state.hasDetectedProjects).toBe(true);
expect(state.detectedProjects.length).toBeGreaterThan(0);
expect(state.registeredProjects).toHaveLength(0);
expect(state.recommendedAction).toBe("auto-detect");
});
it("should return create-new when no projects detected", async () => {
process.chdir(tempDir);
const state = await firstRun.getSetupState();
expect(state.isFirstRun).toBe(true);
expect(state.hasDetectedProjects).toBe(false);
expect(state.recommendedAction).toBe("create-new");
});
it("should return manual-setup when not first run and no projects", async () => {
// Register a project first
const projectDir = join(tempDir, "existing-project");
mkdirSync(projectDir, { recursive: true });
await centralCore.registerProject({
name: "existing",
path: projectDir,
isolationMode: "in-process",
});
process.chdir(tempDir);
const state = await firstRun.getSetupState();
expect(state.isFirstRun).toBe(false);
expect(state.registeredProjects).toHaveLength(1);
expect(state.recommendedAction).toBe("manual-setup");
});
});
describe("completeSetup", () => {
it("should register projects and return success", async () => {
const projectDir = join(tempDir, "new-project");
mkdirSync(projectDir, { recursive: true });
const result = await firstRun.completeSetup([
{ path: projectDir, name: "new-project" },
]);
expect(result.success).toBe(true);
expect(result.projects).toHaveLength(1);
expect(result.projects[0].name).toBe("new-project");
expect(result.nextSteps.length).toBeGreaterThan(0);
});
it("should handle multiple projects", async () => {
const project1 = join(tempDir, "project-1");
const project2 = join(tempDir, "project-2");
mkdirSync(project1, { recursive: true });
mkdirSync(project2, { recursive: true });
const result = await firstRun.completeSetup([
{ path: project1, name: "project-1" },
{ path: project2, name: "project-2" },
]);
expect(result.success).toBe(true);
expect(result.projects).toHaveLength(2);
});
it("should skip already registered projects", async () => {
const projectDir = join(tempDir, "existing-project");
mkdirSync(projectDir, { recursive: true });
// Register first
await centralCore.registerProject({
name: "existing-project",
path: projectDir,
isolationMode: "in-process",
});
const result = await firstRun.completeSetup([
{ path: projectDir, name: "existing-project" },
]);
expect(result.success).toBe(true);
expect(result.projects).toHaveLength(1);
// Should use the existing registration
expect(result.projects[0].path).toBe(projectDir);
});
it("should handle invalid paths gracefully", async () => {
const result = await firstRun.completeSetup([
{ path: "/non/existent/path", name: "invalid" },
]);
expect(result.success).toBe(false);
expect(result.projects).toHaveLength(0);
});
it("should set projects to active status", async () => {
const projectDir = join(tempDir, "new-project");
mkdirSync(projectDir, { recursive: true });
const result = await firstRun.completeSetup([
{ path: projectDir, name: "new-project" },
]);
expect(result.projects[0].status).toBe("active");
});
it("should be idempotent (running twice doesn't duplicate)", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
// First call
const result1 = await firstRun.completeSetup([
{ path: projectDir, name: "my-project" },
]);
expect(result1.success).toBe(true);
// Second call - should not error or duplicate
const result2 = await firstRun.completeSetup([
{ path: projectDir, name: "my-project" },
]);
expect(result2.success).toBe(true);
// Should still only have 1 project
const projects = await centralCore.listProjects();
expect(projects).toHaveLength(1);
expect(projects[0].name).toBe("my-project");
});
it("should persist setupComplete in global settings", async () => {
const projectDir = join(tempDir, "new-project");
mkdirSync(projectDir, { recursive: true });
// Before setup, setupComplete should not be set
const settingsBefore = await firstRun["globalSettingsStore"].getSettings();
expect(settingsBefore.setupComplete).toBeUndefined();
// Complete setup
await firstRun.completeSetup([{ path: projectDir, name: "new-project" }]);
// After successful setup, setupComplete should be true
const settingsAfter = await firstRun["globalSettingsStore"].getSettings();
expect(settingsAfter.setupComplete).toBe(true);
});
it("should check setupComplete flag in isFirstRun", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
// Initially should be first run
expect(await firstRun.isFirstRun()).toBe(true);
// Complete setup
await firstRun.completeSetup([{ path: projectDir, name: "my-project" }]);
// After setup, should NOT be first run (due to setupComplete flag)
expect(await firstRun.isFirstRun()).toBe(false);
});
});
describe("createFirstRunExperience", () => {
it("should create a FirstRunExperience instance", () => {
const instance = createFirstRunExperience(centralCore);
expect(instance).toBeInstanceOf(FirstRunExperience);
});
});
});

View File

@@ -0,0 +1,394 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, basename } from "node:path";
import { MigrationOrchestrator, createMigrationOrchestrator, MAX_AUTO_REGISTER_PROJECTS } from "../migration-orchestrator.js";
import { CentralCore } from "../central-core.js";
// Helper to create a temp directory
function createTempDir(): string {
return mkdtempSync(join(tmpdir(), "kb-migration-test-"));
}
// Helper to create a fake kb project structure
function createFakeKbProject(dir: string): void {
mkdirSync(join(dir, ".kb"), { recursive: true });
// Create an empty file as the database (enough for detection)
writeFileSync(join(dir, ".kb", "kb.db"), "");
}
describe("MigrationOrchestrator", () => {
let tempDir: string;
let centralCore: CentralCore;
let orchestrator: MigrationOrchestrator;
beforeEach(async () => {
tempDir = createTempDir();
centralCore = new CentralCore(tempDir);
await centralCore.init();
orchestrator = new MigrationOrchestrator(centralCore);
});
afterEach(() => {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("detectExistingProjects", () => {
it("should detect a single kb project", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const detected = await orchestrator.detectExistingProjects(projectDir);
expect(detected).toHaveLength(1);
expect(detected[0].path).toBe(projectDir);
expect(detected[0].name).toBe("my-project");
expect(detected[0].hasDb).toBe(true);
});
it("should detect multiple kb projects in subdirectories", async () => {
const project1 = join(tempDir, "project-a");
const project2 = join(tempDir, "project-b");
mkdirSync(project1, { recursive: true });
mkdirSync(project2, { recursive: true });
createFakeKbProject(project1);
createFakeKbProject(project2);
const detected = await orchestrator.detectExistingProjects(tempDir);
expect(detected).toHaveLength(2);
expect(detected.map((p) => p.name).sort()).toEqual(["project-a", "project-b"]);
});
it("should skip node_modules directories", async () => {
const projectDir = join(tempDir, "my-project");
const nodeModules = join(projectDir, "node_modules", "some-package");
mkdirSync(nodeModules, { recursive: true });
createFakeKbProject(nodeModules);
createFakeKbProject(projectDir);
const detected = await orchestrator.detectExistingProjects(tempDir);
// Should only find the main project, not the one in node_modules
expect(detected).toHaveLength(1);
expect(detected[0].name).toBe("my-project");
});
it("should skip hidden directories", async () => {
const projectDir = join(tempDir, "my-project");
const hiddenDir = join(tempDir, ".hidden-project");
mkdirSync(projectDir, { recursive: true });
mkdirSync(hiddenDir, { recursive: true });
createFakeKbProject(projectDir);
createFakeKbProject(hiddenDir);
const detected = await orchestrator.detectExistingProjects(tempDir);
// Should not detect the hidden directory
expect(detected).toHaveLength(1);
expect(detected[0].name).toBe("my-project");
});
it("should skip build and cache directories", async () => {
const projectDir = join(tempDir, "my-project");
const distDir = join(tempDir, "dist", "some-project");
mkdirSync(projectDir, { recursive: true });
mkdirSync(distDir, { recursive: true });
createFakeKbProject(projectDir);
createFakeKbProject(distDir);
const detected = await orchestrator.detectExistingProjects(tempDir);
// Should not detect the one in dist
expect(detected).toHaveLength(1);
expect(detected[0].name).toBe("my-project");
});
it("should respect maxDepth parameter", async () => {
// Create nested structure: temp/a/b/c/project
const nested = join(tempDir, "a", "b", "c", "project");
mkdirSync(nested, { recursive: true });
createFakeKbProject(nested);
// With maxDepth=2, should not find the project at depth 4
const detected = await orchestrator.detectExistingProjects(tempDir, 2);
expect(detected).toHaveLength(0);
// With maxDepth=5, should find it
const detectedDeep = await orchestrator.detectExistingProjects(tempDir, 5);
expect(detectedDeep).toHaveLength(1);
});
it("should stop recursion at a project root (don't look inside projects)", async () => {
const projectDir = join(tempDir, "my-project");
const nestedProject = join(projectDir, "packages", "sub-project");
mkdirSync(nestedProject, { recursive: true });
createFakeKbProject(projectDir);
createFakeKbProject(nestedProject);
const detected = await orchestrator.detectExistingProjects(tempDir);
// Should only detect the top-level project, not the nested one
// (because we stop recursing when we find a project)
expect(detected).toHaveLength(1);
expect(detected[0].name).toBe("my-project");
});
it("should return empty array when no projects found", async () => {
const detected = await orchestrator.detectExistingProjects(tempDir);
expect(detected).toHaveLength(0);
});
it("should throw on non-existent path", async () => {
await expect(
orchestrator.detectExistingProjects("/non/existent/path")
).rejects.toThrow("Scan path does not exist");
});
it("should throw on relative path", async () => {
// A path starting with './' is relative - after resolve() it becomes absolute
// but we need to check before resolving
await expect(
orchestrator.detectExistingProjects("./relative/path")
).rejects.toThrow("Scan path must be absolute");
});
it("should detect projects without valid database as hasDb=false", async () => {
const projectDir = join(tempDir, "incomplete-project");
mkdirSync(projectDir, { recursive: true });
mkdirSync(join(projectDir, ".fusion"), { recursive: true });
// Create directory but no kb.db file
const detected = await orchestrator.detectExistingProjects(projectDir);
expect(detected).toHaveLength(0);
});
});
describe("autoRegisterProjects", () => {
it("should register detected projects", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const detected = [{ path: projectDir, name: "my-project", hasDb: true }];
const registered = await orchestrator.autoRegisterProjects(detected);
expect(registered).toHaveLength(1);
expect(registered[0].name).toBe("my-project");
expect(registered[0].path).toBe(projectDir);
expect(registered[0].isolationMode).toBe("in-process");
expect(registered[0].status).toBe("active");
});
it("should skip projects without valid database", async () => {
const validProject = join(tempDir, "valid");
const invalidProject = join(tempDir, "invalid");
mkdirSync(validProject, { recursive: true });
mkdirSync(join(invalidProject, ".fusion"), { recursive: true });
createFakeKbProject(validProject);
const detected = [
{ path: validProject, name: "valid", hasDb: true },
{ path: invalidProject, name: "invalid", hasDb: false },
];
const registered = await orchestrator.autoRegisterProjects(detected);
expect(registered).toHaveLength(1);
expect(registered[0].name).toBe("valid");
});
it("should skip already registered projects", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
// Register first
await centralCore.registerProject({
name: "my-project",
path: projectDir,
isolationMode: "in-process",
});
// Try to register again
const detected = [{ path: projectDir, name: "my-project", hasDb: true }];
const registered = await orchestrator.autoRegisterProjects(detected);
expect(registered).toHaveLength(0);
});
it("should generate unique names for duplicate basenames", async () => {
const project1 = join(tempDir, "repos", "my-project");
const project2 = join(tempDir, "other", "my-project");
mkdirSync(project1, { recursive: true });
mkdirSync(project2, { recursive: true });
createFakeKbProject(project1);
createFakeKbProject(project2);
// Register first project directly
await centralCore.registerProject({
name: "my-project",
path: project1,
isolationMode: "in-process",
});
// Auto-register should use name-2 for second project
const detected = [
{ path: project1, name: "my-project", hasDb: true },
{ path: project2, name: "my-project", hasDb: true },
];
const registered = await orchestrator.autoRegisterProjects(detected);
expect(registered).toHaveLength(1);
expect(registered[0].name).toBe("my-project-2");
});
it("should skip projects that would create circular references", async () => {
const parent = join(tempDir, "parent-project");
const child = join(parent, "child-project");
mkdirSync(parent, { recursive: true });
mkdirSync(child, { recursive: true });
createFakeKbProject(parent);
createFakeKbProject(child);
// Register parent first
await centralCore.registerProject({
name: "parent-project",
path: parent,
isolationMode: "in-process",
});
// Try to register child (should be skipped as circular)
const detected = [
{ path: parent, name: "parent-project", hasDb: true },
{ path: child, name: "child-project", hasDb: true },
];
const registered = await orchestrator.autoRegisterProjects(detected);
expect(registered).toHaveLength(0); // Both skipped (parent already registered, child circular)
});
it("should throw when exceeding MAX_AUTO_REGISTER_PROJECTS", async () => {
// Create too many projects
const detected: Array<{ path: string; name: string; hasDb: boolean }> = [];
for (let i = 0; i < MAX_AUTO_REGISTER_PROJECTS + 1; i++) {
detected.push({ path: `/project/${i}`, name: `project-${i}`, hasDb: true });
}
await expect(orchestrator.autoRegisterProjects(detected)).rejects.toThrow(
"Too many projects detected"
);
});
});
describe("needsMigration", () => {
it("should return true when no projects registered and projects exist", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const needsMigration = await orchestrator.needsMigration(tempDir);
expect(needsMigration).toBe(true);
});
it("should return false when projects already registered", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
await centralCore.registerProject({
name: "my-project",
path: projectDir,
isolationMode: "in-process",
});
const needsMigration = await orchestrator.needsMigration(tempDir);
expect(needsMigration).toBe(false);
});
it("should return false when no projects exist on filesystem", async () => {
// No projects created
const needsMigration = await orchestrator.needsMigration(tempDir);
expect(needsMigration).toBe(false);
});
});
describe("runMigration", () => {
it("should run full migration with autoRegister", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const result = await orchestrator.runMigration({ startPath: tempDir, autoRegister: true });
expect(result.projectsDetected).toHaveLength(1);
expect(result.projectsRegistered).toHaveLength(1);
expect(result.projectsRegistered[0].name).toBe("my-project");
expect(result.errors).toHaveLength(0);
});
it("should run detection only without autoRegister", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const result = await orchestrator.runMigration({ startPath: tempDir, autoRegister: false });
expect(result.projectsDetected).toHaveLength(1);
expect(result.projectsRegistered).toHaveLength(0);
expect(result.projectsSkipped).toHaveLength(1);
});
it("should support dry-run mode", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const result = await orchestrator.runMigration({ startPath: tempDir, dryRun: true });
expect(result.projectsDetected).toHaveLength(1);
expect(result.projectsRegistered).toHaveLength(0);
expect(result.projectsSkipped[0].reason).toContain("DRY RUN");
});
it("should call progress callback", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const onProgress = vi.fn();
await orchestrator.runMigration({ autoRegister: true, onProgress });
expect(onProgress).toHaveBeenCalled();
});
it("should handle detection errors gracefully", async () => {
// Pass a non-existent directory to cause an error
const nonExistentPath = join(tempDir, "does-not-exist");
const result = await orchestrator.runMigration({ startPath: nonExistentPath });
expect(result.errors).toHaveLength(1);
expect(result.errors[0].error).toContain("Detection failed");
});
});
describe("createMigrationOrchestrator", () => {
it("should create an orchestrator instance", () => {
const instance = createMigrationOrchestrator(centralCore);
expect(instance).toBeInstanceOf(MigrationOrchestrator);
});
});
});

View File

@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore } from "../store.js";
import { CentralCore } from "../central-core.js";
// Helper to create a temp directory
function createTempDir(): string {
return mkdtempSync(join(tmpdir(), "kb-compat-test-"));
}
// Helper to create a fake kb project structure
function createFakeKbProject(dir: string): void {
mkdirSync(join(dir, ".kb"), { recursive: true });
writeFileSync(join(dir, ".kb", "kb.db"), "");
}
describe("TaskStore Backward Compatibility", () => {
let tempDir: string;
let centralCore: CentralCore;
let originalCwd: string;
beforeEach(async () => {
tempDir = createTempDir();
centralCore = new CentralCore(tempDir);
await centralCore.init();
originalCwd = process.cwd();
});
afterEach(async () => {
try {
process.chdir(originalCwd);
await centralCore.close();
rmSync(tempDir, { recursive: true, force: true });
} 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 find project by name when ID not found", 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 legacy mode when no projects registered", async () => {
// No projects registered in central core
process.chdir(tempDir);
const store = await TaskStore.getOrCreateForProject(undefined, centralCore);
expect(store).toBeInstanceOf(TaskStore);
});
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 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);
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);
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);
});
});
});