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

@@ -3,7 +3,7 @@
*
* Handles the transition from single-project to multi-project mode:
* - 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
* - Provides backward compatibility for single-project workflows
*
@@ -11,19 +11,18 @@
*/
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";
import { CentralCore as CentralCoreClass } from "./central-core.js";
import type { CentralCoreStub } from "./migration-stubs.js";
import { resolveGlobalDir } from "./global-settings.js";
// ── Types ────────────────────────────────────────────────────────────
/** First-run state detection results */
export type FirstRunState =
| "fresh-install" // No central DB, no .fusion/ anywhere
| "needs-migration" // No central DB, but .fusion/fusion.db exists in cwd
| "fresh-install" // No central DB, no .kb/ anywhere
| "needs-migration" // No central DB, but .kb/kb.db exists in cwd
| "setup-wizard" // Central DB exists but has zero projects
| "normal-operation"; // Central DB exists with projects
@@ -33,7 +32,7 @@ export interface DetectedProject {
path: string;
/** Auto-generated or derived project name */
name: string;
/** Whether the project has a valid fusion.db */
/** Whether the project has a valid kb.db */
hasDb: boolean;
}
@@ -59,7 +58,7 @@ export interface ProjectSetupInput {
/** Resolved project context for backward compatibility */
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;
/** Absolute path to project working directory */
workingDirectory: string;
@@ -83,9 +82,8 @@ export class ProjectRequiredError extends Error {
/**
* Detects the first-run state and existing projects for migration.
*
* This class determines which startup path to take using cwd-ancestor scoped
* project discovery (it intentionally does not scan the wider filesystem):
* - Fresh install → No central DB and no kb project found from cwd upward
* This class determines which startup path to take:
* - Fresh install → Show setup wizard
* - Existing single project → Auto-migrate
* - Already migrated → Normal operation
*/
@@ -94,7 +92,7 @@ export class 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) {
this.globalDir = globalDir ?? this.getDefaultGlobalDir();
@@ -104,51 +102,43 @@ export class FirstRunDetector {
* Detect the current first-run state.
*
* Returns one of four states:
* - `"fresh-install"` — No central DB and no kb project found from cwd upward
* - `"needs-migration"` — No central DB, but a `.fusion/fusion.db` project exists in cwd ancestry
* - `"setup-wizard"` — Central DB exists and can be read, but has zero projects
* - `"fresh-install"` — No central DB, no local `.kb/` found
* - `"needs-migration"` — No central DB, but `.kb/kb.db` exists in cwd
* - `"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> {
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();
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.
// If the central DB is present but unreadable/corrupt, fall back to local
// project detection so upgrade migration remains backward-compatible.
// 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 {
return detectFallbackState();
return "setup-wizard";
}
}
try {
const projects = await central.listProjects();
return projects.length === 0 ? "setup-wizard" : "normal-operation";
} catch {
return detectFallbackState();
// Central DB exists but is unreadable - treat as setup wizard
return "setup-wizard";
} finally {
if (shouldClose && central) {
await central.close();
@@ -160,7 +150,7 @@ export class FirstRunDetector {
* Check if the central database exists.
*/
hasCentralDb(): boolean {
const centralDbPath = join(this.globalDir, "fusion-central.db");
const centralDbPath = join(this.globalDir, "kb-central.db");
return existsSync(centralDbPath);
}
@@ -168,13 +158,13 @@ export class FirstRunDetector {
* Get the path to the central database.
*/
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.
*
* 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.
*
* @param cwd — Starting directory (default: process.cwd())
@@ -189,7 +179,7 @@ export class FirstRunDetector {
const home = homedir();
const root = dirname(current) === current ? current : "/"; // Handle Windows vs Unix root
while (true) {
while (current !== home && current !== root) {
if (visited.has(current)) break;
visited.add(current);
@@ -204,10 +194,6 @@ export class FirstRunDetector {
break;
}
if (current === home || current === root) {
break;
}
const parent = dirname(current);
if (parent === current) break;
current = parent;
@@ -284,8 +270,8 @@ export class FirstRunDetector {
* Check if a directory contains a valid kb project.
*/
private hasKbProject(dir: string): boolean {
const kbDir = join(dir, ".fusion");
const dbPath = join(kbDir, "fusion.db");
const kbDir = join(dir, ".kb");
const dbPath = join(kbDir, "kb.db");
if (!existsSync(kbDir)) return false;
if (!existsSync(dbPath)) return false;
@@ -299,7 +285,7 @@ export class FirstRunDetector {
}
private getDefaultGlobalDir(): string {
return resolveGlobalDir();
return join(homedir(), ".pi", "kb");
}
}
@@ -314,13 +300,13 @@ export class FirstRunDetector {
* - Idempotent re-runs
*/
export class MigrationCoordinator {
private readonly central: CentralCoreStub;
private readonly central: CentralCore;
/**
* Create a MigrationCoordinator.
* @param central — Initialized central project registry contract
* @param central — Initialized CentralCore instance
*/
constructor(central: CentralCoreStub) {
constructor(central: CentralCore) {
this.central = central;
}
@@ -334,18 +320,38 @@ export class MigrationCoordinator {
*/
async coordinateMigration(): Promise<MigrationResult> {
const detector = new FirstRunDetector(this.central.getGlobalDir());
const projects = await detector.detectExistingProjects(process.cwd());
const registeredProjects = await this.central.listProjects();
const state = await detector.detectFirstRunState();
if (projects.length > 0 && registeredProjects.length === 0) {
return this.registerSingleProject(projects[0].path);
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,
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;
}
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
try {
const existing = await this.central.getProjectByPath(projectPath);
@@ -413,16 +394,12 @@ export class MigrationCoordinator {
// Register the project
try {
let project = await this.central.registerProject({
const project = await this.central.registerProject({
name: uniqueName,
path: projectPath,
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.projectsRegistered.push(project.id);
} catch (err) {
@@ -447,14 +424,6 @@ export class MigrationCoordinator {
for (const input of projects) {
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
const existing = await this.central.getProjectByPath(input.path);
if (existing) {
@@ -503,35 +472,6 @@ export class MigrationCoordinator {
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 ───────────────────────────────────────────────────
@@ -601,6 +541,23 @@ export class BackwardCompat {
const projects = await this.central.listProjects();
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(
"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) {
// Single project - auto-use it for backward compatibility.
// Single project - auto-use it for backward compatibility
const project = projects[0];
return {
projectId: project.id,
@@ -617,7 +574,7 @@ export class BackwardCompat {
};
}
// Multiple projects - require explicit selection.
// Multiple projects - require explicit selection
throw new ProjectRequiredError(
"Multiple projects registered. Use --project <name> to specify which project to use.",
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 }));
}
/**
* 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;
}
}
}