feat(FN-5411): add project identity recovery and identity-aware startup rea

Implements project identity tracking and recovery across the Fusion system (FN-5411), enabling persistent identity for projects across storage migrations, daemon reattaches, and CLI session management. Adds a project identity metadata API and central reattach ensure mechanism, wires identity stampin

Fusion-Task-Id: FN-5411

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5411
This commit is contained in:
gsxdsm
2026-05-23 04:11:09 -07:00
parent fe58a57a7d
commit 687237bd91
28 changed files with 1172 additions and 119 deletions

View File

@@ -22,6 +22,18 @@ vi.mock("@fusion/core", async () => {
getProject = vi.fn().mockResolvedValue(undefined);
getProjectByPath = vi.fn().mockResolvedValue(undefined);
registerProject = vi.fn();
ensureProjectForPath = vi.fn().mockImplementation(async ({ path, name }: { path: string; name?: string }) => ({
outcome: "registered",
project: {
id: "proj_1234567890abcdef",
name: name ?? "project",
path,
status: "initializing",
isolationMode: "in-process",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
}));
updateProject = vi.fn().mockResolvedValue({});
unregisterProject = vi.fn().mockResolvedValue(undefined);
getProjectHealth = vi.fn().mockResolvedValue(undefined);
@@ -33,6 +45,8 @@ vi.mock("@fusion/core", async () => {
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
})),
readProjectIdentity: vi.fn().mockReturnValue(undefined),
writeProjectIdentity: vi.fn(),
};
});

View File

@@ -156,6 +156,14 @@ const mocks = vi.hoisted(() => {
projects.push(project);
return Promise.resolve(project);
}),
ensureProjectForPath: vi.fn().mockImplementation(async ({ path, name, isolationMode }: { path: string; name?: string; isolationMode?: "in-process" | "child-process" }) => ({
outcome: "registered",
project: await instance.registerProject({
name: name ?? "unnamed",
path,
isolationMode: isolationMode ?? "in-process",
}),
})),
updateProject: vi.fn().mockImplementation((id: string, patch: { status?: string }) => {
const index = projects.findIndex((project) => project.id === id);
if (index >= 0) {

View File

@@ -1,7 +1,7 @@
import { mkdtempSync, existsSync, rmSync, statSync } from "node:fs";
import { mkdtempSync, existsSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { CentralCore } from "@fusion/core";
import { CentralCore, readProjectIdentity } from "@fusion/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ensureCwdProjectRegistered } from "../ensure-project-registered.js";
@@ -44,21 +44,22 @@ describe("ensureCwdProjectRegistered", () => {
});
expect(result?.id).toBe(existing.id);
expect(existsSync(join(cwd, ".fusion"))).toBe(false);
expect(existsSync(join(cwd, ".fusion"))).toBe(true);
expect(readProjectIdentity(cwd)?.id).toBe(existing.id);
expect(registerSpy).not.toHaveBeenCalled();
expect(updateSpy).not.toHaveBeenCalled();
await central.close();
});
it("auto-registers unregistered project when enabled", async () => {
it("auto-registers unregistered project when enabled and persists identity", async () => {
const globalDir = makeTempDir("fn-4266-global-");
const cwd = makeTempDir("fn-4266-project-");
const central = new CentralCore(globalDir);
await central.init();
const registerSpy = vi.spyOn(central, "registerProject");
const ensureSpy = vi.spyOn(central, "ensureProjectForPath");
const updateSpy = vi.spyOn(central, "updateProject");
const result = await ensureCwdProjectRegistered({
@@ -71,14 +72,42 @@ describe("ensureCwdProjectRegistered", () => {
expect(result).not.toBeNull();
expect(existsSync(join(cwd, ".fusion"))).toBe(true);
expect(existsSync(join(cwd, ".fusion", "fusion.db"))).toBe(true);
expect(statSync(join(cwd, ".fusion", "fusion.db")).size).toBe(0);
expect(registerSpy).toHaveBeenCalledWith(
expect(ensureSpy).toHaveBeenCalledWith(
expect.objectContaining({
path: cwd,
isolationMode: "in-process",
}),
);
expect(updateSpy).toHaveBeenCalledWith(expect.any(String), { status: "active" });
expect(readProjectIdentity(cwd)?.id).toBe(result?.id);
await central.close();
});
it("reattaches using stored identity when central row was wiped", async () => {
const globalDir = makeTempDir("fn-4266-global-");
const cwd = makeTempDir("fn-4266-project-");
const central = new CentralCore(globalDir);
await central.init();
const first = await ensureCwdProjectRegistered({
cwd,
central,
logPrefix: "serve",
autoRegister: true,
});
expect(first).not.toBeNull();
await central.unregisterProject(first!.id);
const second = await ensureCwdProjectRegistered({
cwd,
central,
logPrefix: "serve",
autoRegister: true,
});
expect(second?.id).toBe(first?.id);
await central.close();
});
@@ -90,7 +119,7 @@ describe("ensureCwdProjectRegistered", () => {
const central = new CentralCore(globalDir);
await central.init();
const registerSpy = vi.spyOn(central, "registerProject");
const ensureSpy = vi.spyOn(central, "ensureProjectForPath");
const result = await ensureCwdProjectRegistered({
cwd,
@@ -101,7 +130,7 @@ describe("ensureCwdProjectRegistered", () => {
expect(result).toBeNull();
expect(existsSync(join(cwd, ".fusion"))).toBe(false);
expect(registerSpy).not.toHaveBeenCalled();
expect(ensureSpy).not.toHaveBeenCalled();
await central.close();
});
@@ -113,7 +142,7 @@ describe("ensureCwdProjectRegistered", () => {
const central = new CentralCore(globalDir);
await central.init();
vi.spyOn(central, "registerProject").mockRejectedValueOnce(new Error("boom"));
vi.spyOn(central, "ensureProjectForPath").mockRejectedValueOnce(new Error("boom"));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = await ensureCwdProjectRegistered({

View File

@@ -16,6 +16,7 @@ const mockCentralInit = vi.fn();
const mockCentralClose = vi.fn();
const mockGetProjectByPath = vi.fn();
const mockRegisterProject = vi.fn();
const mockEnsureProjectForPath = vi.fn();
const mockUpdateProject = vi.fn().mockResolvedValue({});
const { mockIsValidSqliteDatabaseFile } = vi.hoisted(() => ({
mockIsValidSqliteDatabaseFile: vi.fn(),
@@ -30,6 +31,7 @@ vi.mock("@fusion/core", async () => {
close: mockCentralClose,
getProjectByPath: mockGetProjectByPath,
registerProject: mockRegisterProject,
ensureProjectForPath: mockEnsureProjectForPath,
updateProject: mockUpdateProject,
})),
isQmdAvailable: vi.fn(() => Promise.resolve(true)),
@@ -68,13 +70,29 @@ describe("init command", () => {
name: "test-project",
path: tempProjectDir,
isolationMode: "in-process",
status: "initializing",
createdAt: "",
updatedAt: "",
});
mockEnsureProjectForPath.mockResolvedValue({
outcome: "registered",
project: {
id: "proj_test",
name: "test-project",
path: tempProjectDir,
isolationMode: "in-process",
status: "initializing",
createdAt: "",
updatedAt: "",
},
});
mockIsValidSqliteDatabaseFile.mockImplementation((dbPath: string) => {
if (!existsSync(dbPath)) {
return false;
}
return readFileSync(dbPath).length === 0;
const content = readFileSync(dbPath);
return content.subarray(0, 15).toString("utf8") === "SQLite format 3";
});
});
@@ -114,7 +132,7 @@ describe("init command", () => {
await runInit({ path: tempProjectDir });
expect(existsSync(dbPath)).toBe(true);
expect(statSync(dbPath).size).toBe(0);
expect(statSync(dbPath).size).toBeGreaterThan(0);
});
it("should reject existing invalid fusion.db files", async () => {

View File

@@ -5,6 +5,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockListProjects = vi.fn();
const mockRegisterProject = vi.fn();
const mockEnsureProjectForPath = vi.fn(async (...args: unknown[]) => ({
outcome: "registered",
project: await mockRegisterProject(...args),
}));
const mockUpdateProject = vi.fn().mockResolvedValue({});
const mockUnregisterProject = vi.fn();
const mockGetProject = vi.fn();
@@ -30,6 +34,7 @@ vi.mock("@fusion/core", () => ({
close: mockClose.mockResolvedValue(undefined),
listProjects: mockListProjects,
registerProject: mockRegisterProject,
ensureProjectForPath: mockEnsureProjectForPath,
updateProject: mockUpdateProject,
unregisterProject: mockUnregisterProject,
getProject: mockGetProject,
@@ -45,6 +50,8 @@ vi.mock("@fusion/core", () => ({
listTasks: mockTaskStoreListTasks,
})),
ensureMemoryFileWithBackend: mockEnsureMemoryFileWithBackend,
readProjectIdentity: vi.fn().mockReturnValue(undefined),
writeProjectIdentity: vi.fn(),
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
COLUMN_LABELS: {
triage: "Triage",

View File

@@ -48,6 +48,7 @@ import {
processPullRequestMergeTask,
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
@@ -1589,7 +1590,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Phase 5 removes this fallback entirely.
let cwdEngine: ReturnType<typeof engineManager.getEngine>;
try {
const registered = await centralCoreForEngine.getProjectByPath(cwd).catch(() => null);
const registered = await ensureCwdProjectRegistered({
cwd,
central: centralCoreForEngine,
logPrefix: "dashboard",
autoRegister: true,
});
if (registered) {
// Ensure the cwd project's engine exists before handing HTTP defaults to
// createServer; background startAll may still be warming other projects.

View File

@@ -2,7 +2,13 @@ import { exec } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { promisify } from "node:util";
import type { CentralCore, RegisteredProject } from "@fusion/core";
import {
type CentralCore,
type RegisteredProject,
type ProjectIdentity,
readProjectIdentity,
writeProjectIdentity,
} from "@fusion/core";
const execAsync = promisify(exec);
@@ -13,6 +19,23 @@ export interface EnsureCwdProjectRegisteredOptions {
autoRegister: boolean;
}
function stampProjectIdentityBestEffort(
cwd: string,
project: RegisteredProject,
logPrefix: string,
): void {
try {
writeProjectIdentity(join(cwd, ".fusion"), {
id: project.id,
createdAt: project.createdAt,
});
} catch (error) {
console.warn(
`[${logPrefix}] Could not persist project identity for ${cwd}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
export async function ensureCwdProjectRegistered(
options: EnsureCwdProjectRegisteredOptions,
): Promise<RegisteredProject | null> {
@@ -20,6 +43,7 @@ export async function ensureCwdProjectRegistered(
const existing = await central.getProjectByPath(cwd);
if (existing) {
stampProjectIdentityBestEffort(cwd, existing, logPrefix);
return existing;
}
@@ -41,14 +65,25 @@ export async function ensureCwdProjectRegistered(
}
const projectName = await detectProjectName(cwd);
const project = await central.registerProject({
name: projectName,
const identity: ProjectIdentity | null = existsSync(dbPath) ? readProjectIdentity(fusionDir) : null;
const ensured = await central.ensureProjectForPath({
path: cwd,
isolationMode: "in-process",
identity: identity ?? undefined,
name: projectName,
});
const project = ensured.project;
await central.updateProject(project.id, { status: "active" });
console.log(`[${logPrefix}] Auto-registered project "${project.name}" at ${cwd}`);
stampProjectIdentityBestEffort(cwd, project, logPrefix);
if (ensured.outcome === "reattached") {
console.log(
`[${logPrefix}] Recovered project identity ${project.id} from ${dbPath} (central had no row)`,
);
} else if (ensured.outcome === "registered") {
console.log(`[${logPrefix}] Auto-registered project "${project.name}" at ${cwd}`);
}
return project;
} catch (error) {

View File

@@ -13,7 +13,14 @@ import { join, resolve, basename } from "node:path";
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable, isValidSqliteDatabaseFile } from "@fusion/core";
import {
CentralCore,
QMD_INSTALL_COMMAND,
isQmdAvailable,
isValidSqliteDatabaseFile,
readProjectIdentity,
writeProjectIdentity,
} from "@fusion/core";
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
import { isGitRepo } from "./git.js";
import {
@@ -52,6 +59,14 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
const existing = await central.getProjectByPath(cwd);
if (existing) {
try {
writeProjectIdentity(join(cwd, ".fusion"), {
id: existing.id,
createdAt: existing.createdAt,
});
} catch {
// Best-effort backfill only.
}
console.log(`✓ fn project already initialized: "${existing.name}"`);
console.log(` Path: ${cwd}`);
console.log(`\n Project is registered in the central registry.`);
@@ -131,16 +146,27 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
return;
}
// Register new project
const project = await central.registerProject({
name: projectName,
const identity = existsSync(dbPath) ? readProjectIdentity(fusionDir) : null;
const ensured = await central.ensureProjectForPath({
path: cwd,
isolationMode: "in-process",
identity: identity ?? undefined,
name: projectName,
});
const project = ensured.project;
// Activate the project (registration sets it to 'initializing')
await central.updateProject(project.id, { status: "active" });
try {
writeProjectIdentity(join(cwd, ".fusion"), {
id: project.id,
createdAt: project.createdAt,
});
} catch (identityError) {
console.warn(` ⚠ Could not persist project identity: ${identityError instanceof Error ? identityError.message : String(identityError)}`);
}
maybeInstallClaudeSkillForNewProject(cwd);
console.log(` ✓ Registered in central database`);

View File

@@ -21,6 +21,8 @@ import {
COLUMNS,
COLUMN_LABELS,
type Column,
readProjectIdentity,
writeProjectIdentity,
} from "@fusion/core";
import { resolve, isAbsolute, relative, basename } from "node:path";
import { existsSync, statSync } from "node:fs";
@@ -356,16 +358,27 @@ export async function runProjectAdd(
process.exit(1);
}
// Register the project
const project = await central.registerProject({
name: projectName,
const identity = existsSync(kbDbPath) ? readProjectIdentity(join(absolutePath, ".fusion")) : null;
const ensured = await central.ensureProjectForPath({
path: absolutePath,
isolationMode,
identity: identity ?? undefined,
name: projectName,
});
const project = ensured.project;
// Activate the project (registration sets it to 'initializing')
await central.updateProject(project.id, { status: "active" });
try {
writeProjectIdentity(join(absolutePath, ".fusion"), {
id: project.id,
createdAt: project.createdAt,
});
} catch (identityError) {
console.warn(` ⚠ Warning: Could not persist project identity: ${identityError instanceof Error ? identityError.message : String(identityError)}`);
}
// Bootstrap memory files (non-fatal if it fails)
let memoryInitialized = false;
try {

View File

@@ -11,7 +11,14 @@
import { existsSync, statSync } from "node:fs";
import { basename, dirname, resolve, normalize } from "node:path";
import { createInterface } from "node:readline/promises";
import { CentralCore, isValidSqliteDatabaseFile, type RegisteredProject, type TaskStore } from "@fusion/core";
import {
CentralCore,
isValidSqliteDatabaseFile,
readProjectIdentity,
writeProjectIdentity,
type RegisteredProject,
type TaskStore,
} from "@fusion/core";
import { ProjectManager } from "@fusion/engine";
// Singleton instances for reuse across commands
@@ -246,6 +253,44 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
// 4. Has .fusion/ but not registered
if (interactive) {
console.log(`\n Found fn project at ${fusionDir} but it's not registered.`);
const identity = readProjectIdentity(fusionDir);
if (identity) {
const recover = await promptConfirm(
`Found orphaned project data for this path under id ${identity.id} (createdAt=${identity.createdAt}). Restore it?`,
true,
);
if (recover) {
try {
const ensured = await central.ensureProjectForPath({
path: fusionDir,
identity,
name: basename(fusionDir) || "unnamed",
});
const recoveredProject = ensured.project;
await central.updateProject(recoveredProject.id, { status: "active" });
try {
writeProjectIdentity(fusionDir, {
id: recoveredProject.id,
createdAt: recoveredProject.createdAt,
});
} catch {
// Best-effort stamp only.
}
console.log(`\n ✓ Restored project "${recoveredProject.name}" (${recoveredProject.id})`);
return createResolvedProject(recoveredProject);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
throw new ProjectResolutionError(
`Failed to restore project identity: ${errMsg}`,
"NOT_REGISTERED",
{ directory: fusionDir, error: errMsg },
);
}
}
}
const shouldRegister = await promptConfirm("Register this project now?", true);
if (shouldRegister) {
@@ -257,14 +302,39 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
const finalName = name.trim() || defaultName;
try {
const newProject = await central.registerProject({
name: finalName,
const ensured = await central.ensureProjectForPath({
path: fusionDir,
isolationMode: "in-process",
identity: undefined,
name: finalName,
});
const newProject = ensured.project;
// Activate the project (registration sets it to 'initializing')
await central.updateProject(newProject.id, { status: "active" });
try {
writeProjectIdentity(fusionDir, {
id: newProject.id,
createdAt: newProject.createdAt,
});
} catch (error) {
if (identity && error instanceof Error && error.name === "ProjectIdentityConflictError") {
const overwrite = await promptConfirm(
"Stored identity differs from newly registered id. Overwrite stored identity with new id?",
false,
);
if (!overwrite) {
throw new ProjectResolutionError(
"Registration cancelled to preserve existing stored identity.",
"CANCELLED",
{ directory: fusionDir },
);
}
writeProjectIdentity(fusionDir, {
id: newProject.id,
createdAt: newProject.createdAt,
});
}
}
console.log(`\n ✓ Registered project "${newProject.name}"`);
return createResolvedProject(newProject);
@@ -602,15 +672,25 @@ export async function registerProjectInteractive(
);
}
// Register the project
const project = await central.registerProject({
name,
const identity = readProjectIdentity(join(absPath, ".fusion"));
const ensured = await central.ensureProjectForPath({
path: absPath,
isolationMode: options.isolation ?? "in-process",
identity: identity ?? undefined,
name,
});
const project = ensured.project;
// Activate the project (registration sets it to 'initializing')
await central.updateProject(project.id, { status: "active" });
try {
writeProjectIdentity(join(absPath, ".fusion"), {
id: project.id,
createdAt: project.createdAt,
});
} catch {
// Best-effort stamp only.
}
return createResolvedProject(project);
}