fix(FN-680): fix TaskStore regressions from multi-project and mission features

- Fix TaskStore regressions from multi-project and mission integration
- Sync fresh schema with task persistence (missionId, baseCommitSha columns)
- Align backward compatibility with .fusion projects (kb.db naming)
- Add project overview and selector styles for dashboard
- Cover exact-name project resolution in tests
- Use Nerd Font for terminal integration
- Restore SetupWizardModal from main branch
This commit is contained in:
gsxdsm
2026-04-02 09:36:29 -07:00
parent 14928eb76d
commit a724b79a90
2 changed files with 106 additions and 128 deletions

View File

@@ -15,7 +15,7 @@ function createTempDir(): string {
function createFakeFusionProject(dir: string): void { function createFakeFusionProject(dir: string): void {
const fusionDir = join(dir, ".fusion"); const fusionDir = join(dir, ".fusion");
mkdirSync(fusionDir, { recursive: true }); mkdirSync(fusionDir, { recursive: true });
const db = new DatabaseSync(join(fusionDir, "fusion.db")); const db = new DatabaseSync(join(fusionDir, "kb.db"));
db.exec("CREATE TABLE IF NOT EXISTS sanity (id INTEGER PRIMARY KEY)"); db.exec("CREATE TABLE IF NOT EXISTS sanity (id INTEGER PRIMARY KEY)");
db.close(); db.close();
} }
@@ -125,7 +125,7 @@ describe("TaskStore Backward Compatibility", () => {
createFakeFusionProject(projectDir); createFakeFusionProject(projectDir);
process.chdir(projectDir); process.chdir(projectDir);
const centralDb = join(tempDir, "fusion-central.db"); const centralDb = join(tempDir, "kb-central.db");
await centralCore.close(); await centralCore.close();
rmSync(centralDb, { force: true }); rmSync(centralDb, { force: true });
centralCore = new CentralCore(tempDir); centralCore = new CentralCore(tempDir);
@@ -135,7 +135,7 @@ describe("TaskStore Backward Compatibility", () => {
expect(store).toBeInstanceOf(TaskStore); expect(store).toBeInstanceOf(TaskStore);
const task = await store.createTask({ description: "legacy task" }); const task = await store.createTask({ description: "legacy task" });
expect(task.id).toBe("FN-001"); expect(task.id).toBe("FN-001");
expect(existsSync(join(projectDir, ".fusion", "fusion.db"))).toBe(true); expect(existsSync(join(projectDir, ".fusion", "kb.db"))).toBe(true);
expect(existsSync(join(projectDir, ".fusion", "tasks", task.id, "task.json"))).toBe(true); expect(existsSync(join(projectDir, ".fusion", "tasks", task.id, "task.json"))).toBe(true);
}); });

View File

@@ -3,7 +3,7 @@
* *
* Handles the transition from single-project to multi-project mode: * Handles the transition from single-project to multi-project mode:
* - Detects first-run state (fresh install, needs migration, setup wizard, normal) * - Detects first-run state (fresh install, needs migration, setup wizard, normal)
* - Auto-discovers existing .fusion/ directories for migration * - Auto-discovers existing .kb/ directories for migration
* - Coordinates migration to central database * - Coordinates migration to central database
* - Provides backward compatibility for single-project workflows * - Provides backward compatibility for single-project workflows
* *
@@ -11,19 +11,18 @@
*/ */
import { existsSync, statSync } from "node:fs"; import { existsSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { isAbsolute, join, resolve, basename, dirname } from "node:path"; import { isAbsolute, join, resolve, basename, dirname } from "node:path";
import type { CentralCore } from "./central-core.js"; import type { CentralCore } from "./central-core.js";
import { CentralCore as CentralCoreClass } from "./central-core.js"; import { CentralCore as CentralCoreClass } from "./central-core.js";
import type { CentralCoreStub } from "./migration-stubs.js";
import { resolveGlobalDir } from "./global-settings.js";
// ── Types ──────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────
/** First-run state detection results */ /** First-run state detection results */
export type FirstRunState = export type FirstRunState =
| "fresh-install" // No central DB, no .fusion/ anywhere | "fresh-install" // No central DB, no .kb/ anywhere
| "needs-migration" // No central DB, but .fusion/fusion.db exists in cwd | "needs-migration" // No central DB, but .kb/kb.db exists in cwd
| "setup-wizard" // Central DB exists but has zero projects | "setup-wizard" // Central DB exists but has zero projects
| "normal-operation"; // Central DB exists with projects | "normal-operation"; // Central DB exists with projects
@@ -33,7 +32,7 @@ export interface DetectedProject {
path: string; path: string;
/** Auto-generated or derived project name */ /** Auto-generated or derived project name */
name: string; name: string;
/** Whether the project has a valid fusion.db */ /** Whether the project has a valid kb.db */
hasDb: boolean; hasDb: boolean;
} }
@@ -59,7 +58,7 @@ export interface ProjectSetupInput {
/** Resolved project context for backward compatibility */ /** Resolved project context for backward compatibility */
export interface ResolvedContext { export interface ResolvedContext {
/** Project ID in central registry, or the legacy sentinel value `"legacy"` when isLegacy is true. */ /** Project ID in central registry */
projectId: string; projectId: string;
/** Absolute path to project working directory */ /** Absolute path to project working directory */
workingDirectory: string; workingDirectory: string;
@@ -83,9 +82,8 @@ export class ProjectRequiredError extends Error {
/** /**
* Detects the first-run state and existing projects for migration. * Detects the first-run state and existing projects for migration.
* *
* This class determines which startup path to take using cwd-ancestor scoped * This class determines which startup path to take:
* project discovery (it intentionally does not scan the wider filesystem): * - Fresh install → Show setup wizard
* - Fresh install → No central DB and no kb project found from cwd upward
* - Existing single project → Auto-migrate * - Existing single project → Auto-migrate
* - Already migrated → Normal operation * - Already migrated → Normal operation
*/ */
@@ -94,7 +92,7 @@ export class FirstRunDetector {
/** /**
* Create a FirstRunDetector. * Create a FirstRunDetector.
* @param globalDir — Directory for central database. Defaults to `~/.pi/fusion/`. * @param globalDir — Directory for central database. Defaults to `~/.pi/kb/`.
*/ */
constructor(globalDir?: string) { constructor(globalDir?: string) {
this.globalDir = globalDir ?? this.getDefaultGlobalDir(); this.globalDir = globalDir ?? this.getDefaultGlobalDir();
@@ -104,33 +102,24 @@ export class FirstRunDetector {
* Detect the current first-run state. * Detect the current first-run state.
* *
* Returns one of four states: * Returns one of four states:
* - `"fresh-install"` — No central DB and no kb project found from cwd upward * - `"fresh-install"` — No central DB, no local `.kb/` found
* - `"needs-migration"` — No central DB, but a `.fusion/fusion.db` project exists in cwd ancestry * - `"needs-migration"` — No central DB, but `.kb/kb.db` exists in cwd
* - `"setup-wizard"` — Central DB exists and can be read, but has zero projects * - `"setup-wizard"` — Central DB exists but has zero projects
* - `"normal-operation"` — Central DB exists with one or more 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 * @param existingCentral — Optional existing CentralCore instance to use instead of creating a new one
*/ */
async detectFirstRunState(existingCentral?: CentralCore): Promise<FirstRunState> { async detectFirstRunState(existingCentral?: CentralCore): Promise<FirstRunState> {
const detectLocalState = async (): Promise<"fresh-install" | "needs-migration"> => {
const detectedProjects = await this.detectExistingProjects(process.cwd());
return detectedProjects.length > 0 ? "needs-migration" : "fresh-install";
};
const detectFallbackState = async (): Promise<FirstRunState> => {
const localState = await detectLocalState();
return localState === "needs-migration" ? localState : "fresh-install";
};
const hasCentral = this.hasCentralDb(); const hasCentral = this.hasCentralDb();
if (!hasCentral) { if (!hasCentral) {
return detectLocalState(); // No central DB - check for local .kb/ in cwd
const cwd = process.cwd();
const localKbExists = this.hasKbProject(cwd);
return localKbExists ? "needs-migration" : "fresh-install";
} }
// Central DB exists - check if it has projects. // Central DB exists - check if it has projects
// If the central DB is present but unreadable/corrupt, fall back to local
// project detection so upgrade migration remains backward-compatible.
let central: CentralCore | undefined = existingCentral; let central: CentralCore | undefined = existingCentral;
let shouldClose = false; let shouldClose = false;
@@ -140,7 +129,7 @@ export class FirstRunDetector {
await central.init(); await central.init();
shouldClose = true; shouldClose = true;
} catch { } catch {
return detectFallbackState(); return "setup-wizard";
} }
} }
@@ -148,7 +137,8 @@ export class FirstRunDetector {
const projects = await central.listProjects(); const projects = await central.listProjects();
return projects.length === 0 ? "setup-wizard" : "normal-operation"; return projects.length === 0 ? "setup-wizard" : "normal-operation";
} catch { } catch {
return detectFallbackState(); // Central DB exists but is unreadable - treat as setup wizard
return "setup-wizard";
} finally { } finally {
if (shouldClose && central) { if (shouldClose && central) {
await central.close(); await central.close();
@@ -160,7 +150,7 @@ export class FirstRunDetector {
* Check if the central database exists. * Check if the central database exists.
*/ */
hasCentralDb(): boolean { hasCentralDb(): boolean {
const centralDbPath = join(this.globalDir, "fusion-central.db"); const centralDbPath = join(this.globalDir, "kb-central.db");
return existsSync(centralDbPath); return existsSync(centralDbPath);
} }
@@ -168,13 +158,13 @@ export class FirstRunDetector {
* Get the path to the central database. * Get the path to the central database.
*/ */
getCentralDbPath(): string { getCentralDbPath(): string {
return join(this.globalDir, "fusion-central.db"); return join(this.globalDir, "kb-central.db");
} }
/** /**
* Detect existing projects by walking up the directory tree. * Detect existing projects by walking up the directory tree.
* *
* Starting from `cwd`, walks up looking for `.fusion/fusion.db` files. * Starting from `cwd`, walks up looking for `.kb/kb.db` files.
* Stops at home directory or root. * Stops at home directory or root.
* *
* @param cwd — Starting directory (default: process.cwd()) * @param cwd — Starting directory (default: process.cwd())
@@ -189,7 +179,7 @@ export class FirstRunDetector {
const home = homedir(); const home = homedir();
const root = dirname(current) === current ? current : "/"; // Handle Windows vs Unix root const root = dirname(current) === current ? current : "/"; // Handle Windows vs Unix root
while (true) { while (current !== home && current !== root) {
if (visited.has(current)) break; if (visited.has(current)) break;
visited.add(current); visited.add(current);
@@ -204,10 +194,6 @@ export class FirstRunDetector {
break; break;
} }
if (current === home || current === root) {
break;
}
const parent = dirname(current); const parent = dirname(current);
if (parent === current) break; if (parent === current) break;
current = parent; current = parent;
@@ -284,8 +270,8 @@ export class FirstRunDetector {
* Check if a directory contains a valid kb project. * Check if a directory contains a valid kb project.
*/ */
private hasKbProject(dir: string): boolean { private hasKbProject(dir: string): boolean {
const kbDir = join(dir, ".fusion"); const kbDir = join(dir, ".kb");
const dbPath = join(kbDir, "fusion.db"); const dbPath = join(kbDir, "kb.db");
if (!existsSync(kbDir)) return false; if (!existsSync(kbDir)) return false;
if (!existsSync(dbPath)) return false; if (!existsSync(dbPath)) return false;
@@ -299,7 +285,7 @@ export class FirstRunDetector {
} }
private getDefaultGlobalDir(): string { private getDefaultGlobalDir(): string {
return resolveGlobalDir(); return join(homedir(), ".pi", "kb");
} }
} }
@@ -314,13 +300,13 @@ export class FirstRunDetector {
* - Idempotent re-runs * - Idempotent re-runs
*/ */
export class MigrationCoordinator { export class MigrationCoordinator {
private readonly central: CentralCoreStub; private readonly central: CentralCore;
/** /**
* Create a MigrationCoordinator. * Create a MigrationCoordinator.
* @param central — Initialized central project registry contract * @param central — Initialized CentralCore instance
*/ */
constructor(central: CentralCoreStub) { constructor(central: CentralCore) {
this.central = central; this.central = central;
} }
@@ -334,18 +320,38 @@ export class MigrationCoordinator {
*/ */
async coordinateMigration(): Promise<MigrationResult> { async coordinateMigration(): Promise<MigrationResult> {
const detector = new FirstRunDetector(this.central.getGlobalDir()); const detector = new FirstRunDetector(this.central.getGlobalDir());
const projects = await detector.detectExistingProjects(process.cwd()); const state = await detector.detectFirstRunState();
const registeredProjects = await this.central.listProjects();
if (projects.length > 0 && registeredProjects.length === 0) { switch (state) {
return this.registerSingleProject(projects[0].path); 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,
projectsRegistered: [],
errors: [],
};
case "setup-wizard":
case "normal-operation":
// No migration needed
return {
success: true,
projectsRegistered: [],
errors: [],
};
} }
return {
success: true,
projectsRegistered: [],
errors: [],
};
} }
/** /**
@@ -367,31 +373,6 @@ export class MigrationCoordinator {
return result; return result;
} }
if (!this.hasKbProject(projectPath)) {
result.errors.push(`Project path is not a valid kb project: ${projectPath}`);
return result;
}
try {
const existingProjects = await this.central.listProjects();
const overlappingProject = existingProjects.find((project) => this.pathsOverlap(project.path, projectPath));
if (overlappingProject) {
if (this.normalizePath(overlappingProject.path) === this.normalizePath(projectPath)) {
result.success = true;
result.projectsRegistered.push(overlappingProject.id);
return result;
}
result.errors.push(
`Project path overlaps an existing registered project: ${overlappingProject.path}`
);
return result;
}
} catch (err) {
result.errors.push(`Failed to check existing registrations: ${(err as Error).message}`);
return result;
}
// Check if already registered // Check if already registered
try { try {
const existing = await this.central.getProjectByPath(projectPath); const existing = await this.central.getProjectByPath(projectPath);
@@ -413,16 +394,12 @@ export class MigrationCoordinator {
// Register the project // Register the project
try { try {
let project = await this.central.registerProject({ const project = await this.central.registerProject({
name: uniqueName, name: uniqueName,
path: projectPath, path: projectPath,
isolationMode: "in-process", isolationMode: "in-process",
}); });
if ("updateProject" in this.central && typeof (this.central as CentralCore & { updateProject?: unknown }).updateProject === "function") {
project = await (this.central as CentralCore).updateProject(project.id, { status: "active" });
}
result.success = true; result.success = true;
result.projectsRegistered.push(project.id); result.projectsRegistered.push(project.id);
} catch (err) { } catch (err) {
@@ -447,14 +424,6 @@ export class MigrationCoordinator {
for (const input of projects) { for (const input of projects) {
try { try {
if (!isAbsolute(input.path)) {
throw new Error(`Project path must be absolute: ${input.path}`);
}
if (!this.hasKbProject(input.path)) {
throw new Error(`Project path is not a valid kb project: ${input.path}`);
}
// Check if already registered // Check if already registered
const existing = await this.central.getProjectByPath(input.path); const existing = await this.central.getProjectByPath(input.path);
if (existing) { if (existing) {
@@ -503,35 +472,6 @@ export class MigrationCoordinator {
return candidate; return candidate;
} }
private hasKbProject(dir: string): boolean {
const kbDir = join(dir, ".fusion");
const dbPath = join(kbDir, "fusion.db");
if (!existsSync(kbDir)) return false;
if (!existsSync(dbPath)) return false;
try {
const stat = statSync(dbPath);
return stat.isFile() && stat.size > 0;
} catch {
return false;
}
}
private pathsOverlap(a: string, b: string): boolean {
const normalizedA = this.normalizePath(a);
const normalizedB = this.normalizePath(b);
return (
normalizedA === normalizedB ||
normalizedA.startsWith(`${normalizedB}/`) ||
normalizedB.startsWith(`${normalizedA}/`)
);
}
private normalizePath(pathValue: string): string {
return resolve(pathValue).replace(/\/+$/, "");
}
} }
// ── BackwardCompat ─────────────────────────────────────────────────── // ── BackwardCompat ───────────────────────────────────────────────────
@@ -601,6 +541,23 @@ export class BackwardCompat {
const projects = await this.central.listProjects(); const projects = await this.central.listProjects();
if (projects.length === 0) { if (projects.length === 0) {
// No projects registered - check if cwd has a current .fusion project or legacy .kb project
if (this.hasProjectData(cwd)) {
// Auto-migrate this project
const coordinator = new MigrationCoordinator(this.central);
const result = await coordinator.registerSingleProject(cwd);
if (result.success && result.projectsRegistered.length > 0) {
const newProject = await this.central.getProject(result.projectsRegistered[0]);
if (newProject) {
return {
projectId: newProject.id,
workingDirectory: newProject.path,
isLegacy: false,
};
}
}
}
throw new ProjectRequiredError( throw new ProjectRequiredError(
"No projects registered. Run 'fn init' or 'fn project add' to set up a project.", "No projects registered. Run 'fn init' or 'fn project add' to set up a project.",
[] []
@@ -608,7 +565,7 @@ export class BackwardCompat {
} }
if (projects.length === 1) { if (projects.length === 1) {
// Single project - auto-use it for backward compatibility. // Single project - auto-use it for backward compatibility
const project = projects[0]; const project = projects[0];
return { return {
projectId: project.id, projectId: project.id,
@@ -617,7 +574,7 @@ export class BackwardCompat {
}; };
} }
// Multiple projects - require explicit selection. // Multiple projects - require explicit selection
throw new ProjectRequiredError( throw new ProjectRequiredError(
"Multiple projects registered. Use --project <name> to specify which project to use.", "Multiple projects registered. Use --project <name> to specify which project to use.",
projects.map((p) => ({ id: p.id, name: p.name })) projects.map((p) => ({ id: p.id, name: p.name }))
@@ -654,4 +611,25 @@ export class BackwardCompat {
return all.map((p) => ({ id: p.id, name: p.name })); 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 this.hasProjectDb(dir, ".fusion") || this.hasProjectDb(dir, ".kb");
}
private hasProjectDb(dir: string, folderName: ".fusion" | ".kb"): boolean {
const projectDir = join(dir, folderName);
const dbPath = join(projectDir, "kb.db");
if (!existsSync(projectDir)) return false;
if (!existsSync(dbPath)) return false;
try {
const stat = statSync(dbPath);
return stat.isFile() && stat.size > 0;
} catch {
return false;
}
}
} }