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

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Per-project `fusion.db` now persists the canonical `projectId` in `__meta.projectIdentity`. If the central registry loses a project row, the next startup reattaches the same id from the stored identity instead of silently minting a new one (which would hide all project-scoped data keyed to the old id). Interactive flows prompt before destructive overwrites.

View File

@@ -61,6 +61,18 @@ If one side succeeds and the other fails, the next scheduler/self-healing tick r
This fencing prevents double-claims: a restarted or delayed stale owner cannot reclaim work once central ownership has been released and lease generation has advanced.
## Recovering after a central DB wipe
If a project's row is deleted from `~/.fusion/fusion-central.db`, Fusion now automatically recovers on next startup:
1. Startup checks central for a row at the project path.
2. If missing, it reads `__meta.projectIdentity` from `<project>/.fusion/fusion.db`.
3. If present, central reattaches that exact `projectId` instead of creating a new one.
This prevents “empty workspace” regressions where project data still exists locally but is keyed to an older `projectId`.
Backups remain the first-line protection strategy (see FN-5407), but this identity reattach path lets operators recover even when no central backup is available.
## Registering and Managing Projects
```bash
@@ -331,3 +343,11 @@ flowchart TD
```
See also: [Architecture](./architecture.md), [CLI Reference](./cli-reference.md), and [Missions](./missions.md).
## Identity persistence and recovery
Each project persists its canonical central identity inside `.fusion/fusion.db` `__meta` as `projectId` and `projectCreatedAt`. Registration paths should use `CentralCore.ensureProjectForPath({ path, identity, ... })` after reading local identity with `readProjectIdentity()`; this reattaches central rows when central was wiped and refuses silent remint if the persisted id is owned by another path.
Dashboard `POST /api/projects` now surfaces this mismatch as `409` with `error: "orphan-identity"` and recovery metadata, and callers can opt into recovery flows with `acceptRecovery: true` behavior at the route layer.
Central DB backup coverage is already enabled by default (`BackupManager` uses `includeCentralDb: true`), so identity recovery data remains in the normal daily backup set.

View File

@@ -339,7 +339,7 @@ The `tasks.githubTracking` JSON column stores per-task GitHub tracking state (`e
| `secrets` | Encrypted secret KV rows (`key` unique) with raw BLOB `value_ciphertext` + per-row random `nonce` (AES-256-GCM), per-secret `access_policy` CHECK (`auto`/`prompt`/`deny`), env-materialization metadata (`env_exportable`, `env_export_key`), and read-audit fields (`last_read_at`, `last_read_by`). Plaintext is never written to the database. |
| `task_documents` | Task-scoped document metadata/content keyed by `(taskId, key)` with current revision pointer. |
| `task_document_revisions` | Immutable revision history for task documents (content snapshots by revision). |
| `__meta` | Schema version + monotonic `lastModified` change detector, plus one-time bootstrap metadata such as `bootstrappedAt`. |
| `__meta` | Schema version + monotonic `lastModified` change detector, plus one-time bootstrap metadata such as `bootstrappedAt` and `projectIdentity`. |
| `missions` | Mission-level planning hierarchy root. |
| `milestones` | Milestones under missions, including dependency lists and validation state. |
| `slices` | Slices under milestones with plan-state/activation metadata. |
@@ -393,6 +393,16 @@ The `tasks.githubTracking` JSON column stores per-task GitHub tracking state (`e
Invariant: after init, every declared column for covered tables exists regardless of `__meta.schemaVersion` whenever the fingerprint is stale or missing, preventing legacy drift from causing `no such column` regressions on newly added fields while keeping unchanged-schema opens fast.
### Project identity row (`__meta.projectIdentity`)
Each project-scoped `.fusion/fusion.db` now stores the canonical central registry identity in `__meta.projectIdentity` as JSON:
```json
{ "id": "proj_0123456789abcdef", "createdAt": "2026-05-21T12:00:00.000Z", "firstSeenPath": "/abs/project/path" }
```
This is written on first successful registration (and back-filled on later startup for older projects). If `~/.fusion/fusion-central.db` loses the row for that path, startup reads this identity and reattaches the same `projectId` instead of minting a new id. That preserves project-scoped rows keyed by `projectId` (`todo_lists`, `chat_sessions`, `project_insights`, etc.).
---
### Chat rooms (migration 70)

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);
}

View File

@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { CentralCore } from "../central-core.js";
import { ProjectIdentityConflictError } from "../project-identity.js";
describe("CentralCore.ensureProjectForPath", () => {
const cleanup: string[] = [];
afterEach(() => cleanup.splice(0).forEach((p) => rmSync(p, { recursive: true, force: true })));
it("covers existing, reattach, fresh, and conflict", async () => {
const globalDir = mkdtempSync(join(tmpdir(), "central-"));
const p1 = mkdtempSync(join(tmpdir(), "proj-a-"));
const p2 = mkdtempSync(join(tmpdir(), "proj-b-"));
mkdirSync(join(p1, ".fusion"));
mkdirSync(join(p2, ".fusion"));
cleanup.push(globalDir, p1, p2);
const central = new CentralCore(globalDir);
await central.init();
const first = await central.ensureProjectForPath({ path: p1, name: "A" });
expect(first.reattached).toBe(false);
const existing = await central.ensureProjectForPath({ path: p1, name: "A" });
expect(existing.outcome).toBe("existing");
await central.unregisterProject(first.project.id);
const events: Array<[string, string]> = [];
central.on("project:reattached", (project, reason) => events.push([project.id, reason]));
const reattached = await central.ensureProjectForPath({
path: p1,
name: "A",
identity: { id: first.project.id, createdAt: first.project.createdAt },
});
expect(reattached.reattached).toBe(true);
expect(events).toEqual([[first.project.id, "identity-recovered"]]);
await expect(
central.ensureProjectForPath({
path: p2,
name: "B",
identity: { id: first.project.id, createdAt: first.project.createdAt },
}),
).rejects.toBeInstanceOf(ProjectIdentityConflictError);
await central.close();
});
});

View File

@@ -246,6 +246,111 @@ describe("CentralCore", () => {
const retrieved = await central.getProject(project.id);
expect(retrieved?.nodeId).toBeUndefined();
});
it("should reattach using supplied id and initialize mapping + health", async () => {
const projectPath = join(tempDir, "reattach-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.reattachProject({
id: "proj_0123456789abcdef",
name: "Reattach",
path: projectPath,
});
expect(project.id).toBe("proj_0123456789abcdef");
const mappings = await central.listProjectNodePathMappingsForProject(project.id);
expect(mappings.length).toBeGreaterThan(0);
const health = await central.getProjectHealth(project.id);
expect(health?.projectId).toBe(project.id);
});
it("should reject reattach when id is already bound to another path", async () => {
const projectPathA = join(tempDir, "reattach-a");
const projectPathB = join(tempDir, "reattach-b");
mkdirSync(projectPathA);
mkdirSync(projectPathB);
projectPaths.push(projectPathA, projectPathB);
await central.reattachProject({
id: "proj_0123456789abcdef",
name: "A",
path: projectPathA,
});
await expect(
central.reattachProject({
id: "proj_0123456789abcdef",
name: "B",
path: projectPathB,
}),
).rejects.toThrow("already registered at a different path");
});
it("should reject reattach when path already has another id", async () => {
const projectPath = join(tempDir, "reattach-dup-path");
mkdirSync(projectPath);
projectPaths.push(projectPath);
await central.registerProject({ name: "Original", path: projectPath });
await expect(
central.reattachProject({
id: "proj_0123456789abcdef",
name: "Other",
path: projectPath,
}),
).rejects.toThrow("refusing silent reassignment");
});
it("should ensure project for path with existing, reattached, and registered outcomes", async () => {
const existingPath = join(tempDir, "ensure-existing");
const reattachPath = join(tempDir, "ensure-reattach");
const newPath = join(tempDir, "ensure-new");
mkdirSync(existingPath);
mkdirSync(reattachPath);
mkdirSync(newPath);
projectPaths.push(existingPath, reattachPath, newPath);
const existing = await central.registerProject({ name: "Existing", path: existingPath });
const existingResult = await central.ensureProjectForPath({ path: existingPath });
expect(existingResult.outcome).toBe("existing");
expect(existingResult.project.id).toBe(existing.id);
const reattachResult = await central.ensureProjectForPath({
path: reattachPath,
identity: { id: "proj_fedcba9876543210", createdAt: "2026-05-20T00:00:00.000Z" },
name: "Recovered",
});
expect(reattachResult.outcome).toBe("reattached");
expect(reattachResult.project.id).toBe("proj_fedcba9876543210");
const registeredResult = await central.ensureProjectForPath({ path: newPath, name: "New" });
expect(registeredResult.outcome).toBe("registered");
expect(registeredResult.project.id).toMatch(/^proj_[a-f0-9]{16}$/);
});
it("should error when ensure identity id is already bound to another path", async () => {
const originalPath = join(tempDir, "ensure-id-original");
const secondPath = join(tempDir, "ensure-id-second");
mkdirSync(originalPath);
mkdirSync(secondPath);
projectPaths.push(originalPath, secondPath);
await central.reattachProject({
id: "proj_0123456789abcdef",
name: "Original",
path: originalPath,
});
await expect(
central.ensureProjectForPath({
path: secondPath,
identity: { id: "proj_0123456789abcdef", createdAt: "2026-05-20T00:00:00.000Z" },
name: "Second",
}),
).rejects.toThrow("Project identity conflict");
});
});
describe("project unregistration", () => {

View File

@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { CentralCore } from "../central-core.js";
import { Database, readProjectIdentity, writeProjectIdentity } from "../db.js";
describe("FN-5411: project identity recovery", () => {
const cleanup: string[] = [];
afterEach(() => {
for (const dir of cleanup.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("reattaches stored project identity after central projects wipe", async () => {
const globalDir = mkdtempSync(join(tmpdir(), "fn-5411-global-"));
const projectDir = mkdtempSync(join(tmpdir(), "fn-5411-project-"));
cleanup.push(globalDir, projectDir);
const central = new CentralCore(globalDir);
await central.init();
const first = await central.ensureProjectForPath({
path: projectDir,
name: "identity-recovery",
});
const oldId = first.project.id;
writeProjectIdentity(projectDir, {
id: oldId,
createdAt: first.project.createdAt,
firstSeenPath: projectDir,
});
const db = new Database(join(projectDir, ".fusion"));
db.init();
const now = new Date().toISOString();
db.prepare("INSERT INTO todo_lists (id, projectId, title, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)")
.run("todo_1", oldId, "List", now, now);
db.prepare("INSERT INTO chat_sessions (id, agentId, title, status, projectId, createdAt, updatedAt, inFlightGeneration) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.run("chat_1", "agent_1", "Chat", "active", oldId, now, now, "none");
db.prepare("INSERT INTO project_insights (id, projectId, title, content, category, status, fingerprint, provenance, lastRunId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("ins_1", oldId, "Insight", "Body", "architecture", "generated", "fp_1", "test", null, now, now);
db.close();
await central.unregisterProject(oldId);
const storedIdentity = readProjectIdentity(projectDir);
const second = await central.ensureProjectForPath({
path: projectDir,
identity: storedIdentity ? { id: storedIdentity.id, createdAt: storedIdentity.createdAt } : undefined,
name: "identity-recovery",
});
expect(second.outcome).toBe("reattached");
expect(second.project.id).toBe(oldId);
const verifyDb = new Database(join(projectDir, ".fusion"));
verifyDb.init();
const todoCount = verifyDb.prepare("SELECT COUNT(*) as count FROM todo_lists WHERE projectId = ?").get(oldId) as { count: number };
const chatCount = verifyDb.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE projectId = ?").get(oldId) as { count: number };
const insightCount = verifyDb.prepare("SELECT COUNT(*) as count FROM project_insights WHERE projectId = ?").get(oldId) as { count: number };
verifyDb.close();
expect(todoCount.count).toBe(1);
expect(chatCount.count).toBe(1);
expect(insightCount.count).toBe(1);
expect(readProjectIdentity(projectDir)?.id).toBe(oldId);
const all = await central.listProjects();
expect(all).toHaveLength(1);
expect(all[0]?.id).toBe(oldId);
await central.close();
});
});

View File

@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
ProjectIdentityMismatchError,
readProjectIdentity,
writeProjectIdentity,
} from "../project-identity.js";
import { DatabaseSync } from "../sqlite-adapter.js";
describe("project identity", () => {
it("returns null for missing db", () => {
const dir = mkdtempSync(join(tmpdir(), "pid-"));
mkdirSync(join(dir, ".fusion"));
expect(readProjectIdentity(join(dir, ".fusion"))).toBeNull();
});
it("writes and reads identity", () => {
const dir = mkdtempSync(join(tmpdir(), "pid-"));
const fusionDir = join(dir, ".fusion");
mkdirSync(fusionDir);
writeProjectIdentity(fusionDir, { id: "proj_0123456789abcdef", createdAt: "2026-01-01T00:00:00.000Z" });
expect(readProjectIdentity(fusionDir)?.id).toBe("proj_0123456789abcdef");
});
it("throws mismatch on different id", () => {
const dir = mkdtempSync(join(tmpdir(), "pid-"));
const fusionDir = join(dir, ".fusion");
mkdirSync(fusionDir);
writeProjectIdentity(fusionDir, { id: "proj_0123456789abcdef", createdAt: "2026-01-01T00:00:00.000Z" });
expect(() =>
writeProjectIdentity(fusionDir, { id: "proj_fedcba9876543210", createdAt: "2026-01-01T00:00:00.000Z" }),
).toThrow(ProjectIdentityMismatchError);
});
it("returns null for corrupted db", () => {
const dir = mkdtempSync(join(tmpdir(), "pid-"));
const fusionDir = join(dir, ".fusion");
mkdirSync(fusionDir);
writeFileSync(join(fusionDir, "fusion.db"), "not sqlite");
expect(readProjectIdentity(fusionDir)).toBeNull();
});
it("rejects malformed id on write", () => {
const dir = mkdtempSync(join(tmpdir(), "pid-"));
const fusionDir = join(dir, ".fusion");
mkdirSync(fusionDir);
expect(() => writeProjectIdentity(fusionDir, { id: "bad", createdAt: "x" })).toThrow(TypeError);
});
it("returns null and logs for malformed stored id", () => {
const dir = mkdtempSync(join(tmpdir(), "pid-"));
const fusionDir = join(dir, ".fusion");
mkdirSync(fusionDir);
writeProjectIdentity(fusionDir, { id: "proj_0123456789abcdef", createdAt: "2026-01-01T00:00:00.000Z" });
const db = new DatabaseSync(join(fusionDir, "fusion.db"));
db.prepare("UPDATE __meta SET value = 'bad' WHERE key = 'projectId'").run();
db.close();
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
expect(readProjectIdentity(fusionDir)).toBeNull();
warn.mockRestore();
});
});

View File

@@ -88,11 +88,17 @@ import { NodeDiscovery } from "./node-discovery.js";
import { collectSystemMetrics } from "./system-metrics.js";
import type { ConnectionOptions, ConnectionResult } from "./node-connection.js";
import { createAuthMaterialSnapshot, createProjectSettingsSnapshot, validateSnapshotEnvelope, type AuthMaterialSnapshot, type ProjectSettingsSnapshot } from "./shared-mesh-state.js";
import {
ProjectIdentityConflictError,
type ProjectIdentity,
} from "./project-identity.js";
// ── Event Types ───────────────────────────────────────────────────────────
export interface CentralCoreEvents {
/** Emitted when a new project is registered */
"project:registered": [project: RegisteredProject];
/** Emitted when a project is reattached using stored identity */
"project:reattached": [project: RegisteredProject, reason: string];
/** Emitted when a project is unregistered */
"project:unregistered": [projectId: string];
/** Emitted when project metadata is updated */
@@ -141,6 +147,21 @@ export interface CentralCoreEvents {
// ── CentralCore Class ─────────────────────────────────────────────────────
export interface EnsureProjectForPathInput {
path: string;
identity?: ProjectIdentity | null;
name?: string;
isolationMode?: IsolationMode;
nodeId?: string;
settings?: ProjectSettings;
}
export interface EnsureProjectForPathResult {
project: RegisteredProject;
reattached: boolean;
outcome: "existing" | "reattached" | "registered";
}
export class CentralCore extends EventEmitter<CentralCoreEvents> {
private db: CentralDatabase | null = null;
private readonly globalDir: string;
@@ -242,55 +263,20 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
// ── Project Registry API ────────────────────────────────────────────────
/**
* Register a new project in the central database.
*
* @param input — Project registration input
* @returns The registered project
* @throws Error if path doesn't exist, isn't absolute, or is already registered
*/
async registerProject(input: {
name: string;
path: string;
isolationMode?: IsolationMode;
settings?: ProjectSettings;
nodeId?: string;
}): Promise<RegisteredProject> {
this.ensureInitialized();
// Validate path
if (!isAbsolute(input.path)) {
throw new Error(`Project path must be absolute: ${input.path}`);
private validateProjectPath(projectPath: string): void {
if (!isAbsolute(projectPath)) {
throw new Error(`Project path must be absolute: ${projectPath}`);
}
if (!existsSync(input.path)) {
throw new Error(`Project path does not exist: ${input.path}`);
if (!existsSync(projectPath)) {
throw new Error(`Project path does not exist: ${projectPath}`);
}
if (!statSync(input.path).isDirectory()) {
throw new Error(`Project path must be a directory: ${input.path}`);
if (!statSync(projectPath).isDirectory()) {
throw new Error(`Project path must be a directory: ${projectPath}`);
}
}
// Check for duplicate path
const existingByPath = await this.getProjectByPath(input.path);
if (existingByPath) {
throw new Error(`Project already registered at path: ${input.path}`);
}
const now = new Date().toISOString();
const project: RegisteredProject = {
id: `proj_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
name: input.name,
path: input.path,
status: "initializing",
isolationMode: input.isolationMode ?? "in-process",
nodeId: input.nodeId,
createdAt: now,
updatedAt: now,
lastActivityAt: now,
settings: input.settings,
};
private insertProjectRow(project: RegisteredProject, now: string): void {
this.db!.transaction(() => {
// Insert project
this.db!.prepare(
`INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt, lastActivityAt, nodeId, settings)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
@@ -322,18 +308,148 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
.run(project.id, localNode.id, project.path, now, now);
}
// Initialize health record
this.db!.prepare(
`INSERT INTO projectHealth (projectId, status, updatedAt, totalTasksCompleted, totalTasksFailed)
VALUES (?, ?, ?, 0, 0)`
).run(project.id, project.status, now);
});
}
/**
* Register a new project in the central database.
*/
async registerProject(input: {
id?: string;
name: string;
path: string;
isolationMode?: IsolationMode;
settings?: ProjectSettings;
nodeId?: string;
}): Promise<RegisteredProject> {
this.ensureInitialized();
this.validateProjectPath(input.path);
const existingByPath = await this.getProjectByPath(input.path);
if (existingByPath) {
throw new Error(`Project already registered at path: ${input.path}`);
}
if (input.id && !/^proj_[a-f0-9]{16}$/.test(input.id)) {
throw new Error(`Invalid project id format: ${input.id}`);
}
const now = new Date().toISOString();
const project: RegisteredProject = {
id: input.id ?? `proj_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
name: input.name,
path: input.path,
status: "initializing",
isolationMode: input.isolationMode ?? "in-process",
nodeId: input.nodeId,
createdAt: now,
updatedAt: now,
lastActivityAt: now,
settings: input.settings,
};
this.insertProjectRow(project, now);
this.db!.bumpLastModified();
this.emit("project:registered", project);
return project;
}
async reattachProject(input: {
id: string;
name: string;
path: string;
isolationMode?: IsolationMode;
settings?: ProjectSettings;
nodeId?: string;
}): Promise<RegisteredProject> {
this.ensureInitialized();
this.validateProjectPath(input.path);
if (!/^proj_[a-f0-9]{16}$/.test(input.id)) {
throw new Error(`Invalid project id format: ${input.id}`);
}
const existingById = await this.getProject(input.id);
if (existingById) {
if (existingById.path === input.path) {
return existingById;
}
throw new Error(
`Project id ${input.id} is already registered at a different path: ${existingById.path}`,
);
}
const existingByPath = await this.getProjectByPath(input.path);
if (existingByPath && existingByPath.id !== input.id) {
throw new Error(
`Project path ${input.path} is already registered with id ${existingByPath.id}; refusing silent reassignment`,
);
}
const now = new Date().toISOString();
const project: RegisteredProject = {
id: input.id,
name: input.name,
path: input.path,
status: "initializing",
isolationMode: input.isolationMode ?? "in-process",
nodeId: input.nodeId,
createdAt: now,
updatedAt: now,
lastActivityAt: now,
settings: input.settings,
};
this.insertProjectRow(project, now);
this.db!.bumpLastModified();
console.log(
`[central] reattached project ${project.id} at ${project.path} using stored identity (createdAt=${now})`,
);
this.emit("project:reattached", project, "identity-recovered");
return project;
}
async ensureProjectForPath(input: EnsureProjectForPathInput): Promise<EnsureProjectForPathResult> {
this.ensureInitialized();
const existing = await this.getProjectByPath(input.path);
if (existing) {
return { project: existing, reattached: false, outcome: "existing" };
}
if (input.identity?.id) {
const byId = await this.getProject(input.identity.id);
if (!byId) {
const reattached = await this.registerProject({
id: input.identity.id,
name: input.name ?? basename(input.path),
path: input.path,
isolationMode: input.isolationMode,
nodeId: input.nodeId,
settings: input.settings,
});
this.emit("project:reattached", reattached, "identity-recovered");
return { project: reattached, reattached: true, outcome: "reattached" };
}
if (byId.path !== input.path) {
throw new ProjectIdentityConflictError(input.identity.id, byId.path, input.path);
}
return { project: byId, reattached: false, outcome: "existing" };
}
const registered = await this.registerProject({
name: input.name ?? basename(input.path),
path: input.path,
isolationMode: input.isolationMode,
nodeId: input.nodeId,
settings: input.settings,
});
return { project: registered, reattached: false, outcome: "registered" };
}
/**
* Unregister a project from the central database.
* Cascades to delete health records and activity log entries.

View File

@@ -9,7 +9,7 @@
*/
import { DatabaseSync } from "./sqlite-adapter.js";
import { isAbsolute, join } from "node:path";
import { basename, isAbsolute, join } from "node:path";
import { mkdirSync, existsSync, statSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
@@ -30,6 +30,35 @@ export interface VacuumResult {
durationMs: number;
}
export interface ProjectIdentity {
id: string;
createdAt: string;
firstSeenPath: string;
}
export class ProjectIdentityConflictError extends Error {
readonly storedId: string;
readonly storedPath: string;
readonly incomingId: string;
readonly incomingPath: string;
constructor(input: {
storedId: string;
storedPath: string;
incomingId: string;
incomingPath: string;
}) {
super(
`Project identity conflict: stored id ${input.storedId} (${input.storedPath}) does not match incoming id ${input.incomingId} (${input.incomingPath})`,
);
this.name = "ProjectIdentityConflictError";
this.storedId = input.storedId;
this.storedPath = input.storedPath;
this.incomingId = input.incomingId;
this.incomingPath = input.incomingPath;
}
}
const DEFAULT_SQLITE_BUSY_TIMEOUT_MS = 5_000;
const DEFAULT_SQLITE_LOCK_RECOVERY_WINDOW_MS = 1_000;
const DEFAULT_SQLITE_LOCK_RECOVERY_DELAY_MS = 50;
@@ -3896,6 +3925,41 @@ export class Database {
this.db.prepare("INSERT OR REPLACE INTO __meta (key, value) VALUES (?, ?)").run(key, value);
}
// IDENTITY KEY: Per-project durable identity used to recover central project rows.
private static readonly PROJECT_IDENTITY_META_KEY = "projectIdentity";
getProjectIdentity(): ProjectIdentity | undefined {
const value = this.getMetaValue(Database.PROJECT_IDENTITY_META_KEY);
return fromJson<ProjectIdentity>(value);
}
setProjectIdentity(identity: ProjectIdentity, options?: { force?: boolean }): void {
const stored = this.getProjectIdentity();
const force = options?.force === true;
if (stored) {
if (stored.id === identity.id) {
return;
}
if (!force) {
throw new ProjectIdentityConflictError({
storedId: stored.id,
storedPath: stored.firstSeenPath,
incomingId: identity.id,
incomingPath: identity.firstSeenPath,
});
}
}
this.setMetaValue(Database.PROJECT_IDENTITY_META_KEY, JSON.stringify(identity));
}
clearProjectIdentity(): void {
this.db
.prepare("DELETE FROM __meta WHERE key = ?")
.run(Database.PROJECT_IDENTITY_META_KEY);
}
/**
* Get the last modification timestamp (epoch ms).
* Returns 0 if the value is not set.
@@ -3958,4 +4022,42 @@ export function createDatabase(fusionDir: string, options?: { inMemory?: boolean
return new Database(fusionDir, options);
}
function resolveFusionDirForProject(projectPath: string): string {
return basename(projectPath) === ".fusion" ? projectPath : join(projectPath, ".fusion");
}
export function readProjectIdentity(projectPath: string): ProjectIdentity | undefined {
const fusionDir = resolveFusionDirForProject(projectPath);
const dbPath = join(fusionDir, "fusion.db");
if (!existsSync(dbPath)) {
return undefined;
}
const db = new Database(fusionDir);
try {
db.init();
return db.getProjectIdentity();
} finally {
db.close();
}
}
export function writeProjectIdentity(
projectPath: string,
identity: ProjectIdentity,
options?: { force?: boolean },
): void {
const fusionDir = resolveFusionDirForProject(projectPath);
if (!existsSync(fusionDir)) {
mkdirSync(fusionDir, { recursive: true });
}
const db = new Database(fusionDir);
try {
db.init();
db.setProjectIdentity(identity, options);
} finally {
db.close();
}
}
export { normalizeTaskComments };

View File

@@ -198,16 +198,16 @@ export class FirstRunExperience {
continue;
}
// Register the project
const newProject = await this.centralCore.registerProject({
const ensured = await this.centralCore.ensureProjectForPath({
name: project.name,
path: project.path,
identity: project.identity,
isolationMode: project.isolationMode ?? "in-process",
});
// Activate the project
const activeProject = await this.centralCore.updateProject(
newProject.id,
ensured.project.id,
{ status: "active" }
);

View File

@@ -223,7 +223,19 @@ export {
DistributedTaskIdError,
} from "./distributed-task-id.js";
export type { DistributedTaskIdAllocator } from "./distributed-task-id.js";
export { Database, createDatabase, toJson, toJsonNullable, fromJson } from "./db.js";
export {
Database,
createDatabase,
toJson,
toJsonNullable,
fromJson,
} from "./db.js";
export {
ProjectIdentityConflictError,
ProjectIdentityMismatchError,
readProjectIdentity,
writeProjectIdentity,
} from "./project-identity.js";
export { ProcessSupervisor, superviseSpawn } from "./process-supervisor.js";
export type {
SuperviseSpawnOptions,
@@ -232,6 +244,8 @@ export type {
} from "./process-supervisor.js";
export { DatabaseSync } from "./sqlite-adapter.js";
export type { Statement, VacuumResult } from "./db.js";
export type { ProjectIdentity } from "./project-identity.js";
export type { EnsureProjectForPathInput, EnsureProjectForPathResult } from "./central-core.js";
export { ArchiveDatabase } from "./archive-db.js";
export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js";
export { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js";

View File

@@ -269,14 +269,14 @@ export class MigrationOrchestrator {
]);
try {
const newProject = await this.centralCore.registerProject({
const ensured = await this.centralCore.ensureProjectForPath({
name: uniqueName,
path: project.path,
isolationMode: "in-process",
});
// Update status to active (registration sets it to 'initializing')
const activeProject = await this.centralCore.updateProject(newProject.id, { status: "active" });
const activeProject = await this.centralCore.updateProject(ensured.project.id, { status: "active" });
registered.push(activeProject);
} catch (err) {

View File

@@ -17,6 +17,7 @@ import type { CentralCore } from "./central-core.js";
import { CentralCore as CentralCoreClass } from "./central-core.js";
import { resolveGlobalDir } from "./global-settings.js";
import { isValidSqliteDatabaseFile } from "./sqlite-validation.js";
import { readProjectIdentity, writeProjectIdentity, type ProjectIdentity } from "./project-identity.js";
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir();
@@ -50,6 +51,8 @@ export interface DetectedProject {
name: string;
/** Whether the project has a valid fusion.db */
hasDb: boolean;
/** Persisted project identity id if present */
identityId?: string;
}
/** Result of a migration operation */
@@ -70,6 +73,8 @@ export interface ProjectSetupInput {
name: string;
/** Isolation mode preference */
isolationMode?: "in-process" | "child-process";
/** Persisted local project identity for central recovery */
identity?: ProjectIdentity | null;
}
/** Resolved project context for backward compatibility */
@@ -232,10 +237,12 @@ export class FirstRunDetector {
if (this.hasFusionProject(current)) {
const name = await this.generateProjectName(current);
const identity = readProjectIdentity(join(current, ".fusion"));
projects.push({
path: current,
name,
hasDb: true,
identityId: identity?.id,
});
// Only detect one project - stop at first match
break;
@@ -479,14 +486,24 @@ export class MigrationCoordinator {
// Register the project
try {
const project = await this.central.registerProject({
name: uniqueName,
const identity = readProjectIdentity(join(projectPath, ".fusion"));
const ensured = await this.central.ensureProjectForPath({
path: projectPath,
isolationMode: "in-process",
identity: identity ? { id: identity.id, createdAt: identity.createdAt } : undefined,
name: uniqueName,
});
const project = ensured.project;
// Activate the project after successful registration
await this.central.updateProject(project.id, { status: "active" });
try {
writeProjectIdentity(join(projectPath, ".fusion"), {
id: project.id,
createdAt: project.createdAt,
});
} catch {
// Best-effort stamp only.
}
result.success = true;
result.projectsRegistered.push(project.id);
@@ -529,15 +546,24 @@ export class MigrationCoordinator {
// Ensure unique name
const uniqueName = await this.ensureUniqueName(input.name);
// Register
const project = await this.central.registerProject({
name: uniqueName,
const identity = input.identity ?? readProjectIdentity(join(input.path, ".fusion"));
const ensured = await this.central.ensureProjectForPath({
path: input.path,
isolationMode: input.isolationMode ?? "in-process",
identity: identity ? { id: identity.id, createdAt: identity.createdAt } : undefined,
name: uniqueName,
});
const project = ensured.project;
// Activate after registration
await this.central.updateProject(project.id, { status: "active" });
try {
writeProjectIdentity(join(input.path, ".fusion"), {
id: project.id,
createdAt: project.createdAt,
});
} catch {
// Best-effort stamp only.
}
result.projectsRegistered.push(project.id);
} catch (err) {

View File

@@ -0,0 +1,87 @@
import { existsSync, mkdirSync } from "node:fs";
import { basename, join } from "node:path";
import { DatabaseSync } from "./sqlite-adapter.js";
import { createLogger } from "./logger.js";
const log = createLogger("project-identity");
const PROJECT_ID_RE = /^proj_[a-f0-9]{16}$/;
export type ProjectIdentity = { id: string; createdAt: string };
export class ProjectIdentityMismatchError extends Error {
constructor(public readonly existingId: string, public readonly incomingId: string) {
super(`Project identity mismatch: existing id ${existingId} differs from incoming id ${incomingId}`);
this.name = "ProjectIdentityMismatchError";
}
}
export class ProjectIdentityConflictError extends Error {
constructor(
public readonly projectId: string,
public readonly existingPath: string,
public readonly incomingPath: string,
) {
super(
`Project identity conflict: id ${projectId} already belongs to ${existingPath} (incoming path: ${incomingPath})`,
);
this.name = "ProjectIdentityConflictError";
}
}
function resolveFusionDir(inputPath: string): string {
return basename(inputPath) === ".fusion" ? inputPath : join(inputPath, ".fusion");
}
function readMeta(db: DatabaseSync, key: string): string | undefined {
const row = db.prepare("SELECT value FROM __meta WHERE key = ?").get(key) as { value?: string } | undefined;
return row?.value;
}
export function readProjectIdentity(fusionDir: string): ProjectIdentity | null {
const dbPath = join(resolveFusionDir(fusionDir), "fusion.db");
if (!existsSync(dbPath)) return null;
let db: DatabaseSync | undefined;
try {
db = new DatabaseSync(dbPath);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
const id = readMeta(db, "projectId");
const createdAt = readMeta(db, "projectCreatedAt");
if (!id || !createdAt) return null;
if (!PROJECT_ID_RE.test(id)) {
log.warn(`Ignoring malformed stored projectId '${id}' in ${dbPath}`);
return null;
}
return { id, createdAt };
} catch (error) {
log.warn(`Unable to read project identity from ${dbPath}: ${error instanceof Error ? error.message : String(error)}`);
return null;
} finally {
db?.close();
}
}
export function writeProjectIdentity(fusionDir: string, identity: ProjectIdentity): void {
if (!PROJECT_ID_RE.test(identity.id)) {
throw new TypeError(`Invalid project identity id: ${identity.id}`);
}
const resolvedFusionDir = resolveFusionDir(fusionDir);
if (!existsSync(resolvedFusionDir)) {
mkdirSync(resolvedFusionDir, { recursive: true });
}
const dbPath = join(resolvedFusionDir, "fusion.db");
let db: DatabaseSync | undefined;
try {
db = new DatabaseSync(dbPath);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
const existingId = readMeta(db, "projectId");
if (existingId && existingId !== identity.id) {
throw new ProjectIdentityMismatchError(existingId, identity.id);
}
db.prepare("INSERT OR REPLACE INTO __meta (key, value) VALUES (?, ?)").run("projectId", identity.id);
db.prepare("INSERT OR REPLACE INTO __meta (key, value) VALUES (?, ?)").run("projectCreatedAt", identity.createdAt);
} finally {
db?.close();
}
}

View File

@@ -6075,6 +6075,8 @@ export interface DetectedProject {
name: string;
/** Whether the project has a valid fusion.db */
hasDb: boolean;
/** Persisted project identity id if present */
identityId?: string;
}
/** Setup state for the first-run wizard UI */
@@ -6089,6 +6091,8 @@ export interface SetupState {
registeredProjects: RegisteredProject[];
/** Recommended action based on current state */
recommendedAction: "auto-detect" | "create-new" | "manual-setup";
/** Local identities whose central rows are missing */
orphanIdentities?: Array<{ path: string; identityId: string }>;
}
/** Input for setting up a project via the wizard */
@@ -6099,6 +6103,8 @@ export interface ProjectSetupInput {
name: string;
/** Isolation mode preference */
isolationMode?: "in-process" | "child-process";
/** Persisted local identity for central re-attachment */
identity?: { id: string; createdAt: string } | null;
}
/** Result of completing the first-run setup */

View File

@@ -18,6 +18,7 @@ const {
mockListProjects,
mockGetProject,
mockRegisterProject,
mockEnsureProjectForPath,
mockUpdateProject,
mockUnregisterProject,
mockGetProjectHealth,
@@ -31,6 +32,8 @@ const {
mockListNodes,
mockGetNode,
mockEnsureMemoryFileWithBackend,
mockReadProjectIdentity,
mockWriteProjectIdentity,
mockListProjectNodePathMappingsForProject,
mockGetProjectNodePathMapping,
mockUpsertProjectNodePathMapping,
@@ -53,6 +56,18 @@ const {
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}),
mockEnsureProjectForPath: vi.fn().mockResolvedValue({
outcome: "registered",
project: {
id: "proj_test123",
name: "Test Project",
path: "/test/path",
status: "initializing",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
}),
mockUpdateProject: vi.fn().mockResolvedValue({
id: "proj_test123",
name: "Test Project",
@@ -93,6 +108,8 @@ const {
mockListNodes: vi.fn().mockResolvedValue([]),
mockGetNode: vi.fn().mockResolvedValue(null),
mockEnsureMemoryFileWithBackend: vi.fn().mockResolvedValue(true),
mockReadProjectIdentity: vi.fn().mockReturnValue(undefined),
mockWriteProjectIdentity: vi.fn(),
mockListProjectNodePathMappingsForProject: vi.fn().mockResolvedValue([]),
mockGetProjectNodePathMapping: vi.fn().mockResolvedValue(undefined),
mockUpsertProjectNodePathMapping: vi.fn(),
@@ -135,6 +152,7 @@ vi.mock("@fusion/core", async () => {
listProjects: mockListProjects,
getProject: mockGetProject,
registerProject: mockRegisterProject,
ensureProjectForPath: mockEnsureProjectForPath,
updateProject: mockUpdateProject,
unregisterProject: mockUnregisterProject,
getProjectHealth: mockGetProjectHealth,
@@ -150,6 +168,8 @@ vi.mock("@fusion/core", async () => {
removeProjectNodePathMapping: mockRemoveProjectNodePathMapping,
})),
ensureMemoryFileWithBackend: mockEnsureMemoryFileWithBackend,
readProjectIdentity: mockReadProjectIdentity,
writeProjectIdentity: mockWriteProjectIdentity,
};
});
@@ -684,6 +704,19 @@ describe("POST /api/projects route handler", () => {
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockEnsureProjectForPath.mockResolvedValue({
outcome: "registered",
project: {
id: "proj_test123",
name: "Test Project",
path: "/test/path",
status: "initializing",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
});
mockReadProjectIdentity.mockReturnValue(undefined);
mockUpdateProject.mockResolvedValue({
id: "proj_test123",
name: "Test Project",
@@ -709,17 +742,18 @@ describe("POST /api/projects route handler", () => {
);
expect(res.status).toBe(201);
expect(mockRegisterProject).toHaveBeenCalledWith({
name: "Test Project",
expect(mockEnsureProjectForPath).toHaveBeenCalledWith({
path: "/tmp",
identity: undefined,
name: "Test Project",
isolationMode: "in-process",
nodeId: undefined,
});
expect(mockUpdateProject).toHaveBeenCalledWith("proj_test123", { status: "active" });
expect((res.body as any).status).toBe("active");
});
it("passes nodeId to registerProject when provided", async () => {
const store = new MockStoreForRoutes();
it("passes nodeId to ensureProjectForPath when provided", async () => { const store = new MockStoreForRoutes();
const app = await createApp(store);
const res = await request(
@@ -735,14 +769,67 @@ describe("POST /api/projects route handler", () => {
);
expect(res.status).toBe(201);
expect(mockRegisterProject).toHaveBeenCalledWith({
name: "Remote Project",
expect(mockEnsureProjectForPath).toHaveBeenCalledWith({
path: "/tmp",
identity: undefined,
name: "Remote Project",
isolationMode: "in-process",
nodeId: "node-remote-1",
});
});
it("returns outcome metadata and stamps identity on successful registration", async () => {
const store = new MockStoreForRoutes();
const app = await createApp(store);
mockEnsureProjectForPath.mockResolvedValueOnce({
outcome: "reattached",
project: {
id: "proj_test123",
name: "Recovered Project",
path: "/tmp",
status: "initializing",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
});
const res = await request(
app,
"POST",
"/api/projects",
JSON.stringify({ name: "Recovered Project", path: "/tmp" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect((res.body as any).outcome).toBe("reattached");
expect(mockWriteProjectIdentity).toHaveBeenCalledWith(
"/tmp/.fusion",
expect.objectContaining({ id: "proj_test123" }),
);
});
it("returns 500 when stored identity read fails", async () => {
const store = new MockStoreForRoutes();
const app = await createApp(store);
mockReadProjectIdentity.mockImplementationOnce(() => {
throw new Error("bad identity json");
});
const res = await request(
app,
"POST",
"/api/projects",
JSON.stringify({ name: "Test Project", path: "/tmp" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect((res.body as any).error).toContain("bad identity json");
});
it("calls ensureMemoryFileWithBackend after project activation", async () => {
const store = new MockStoreForRoutes();
const app = await createApp(store);
@@ -804,9 +891,10 @@ describe("POST /api/projects route handler", () => {
);
expect(res.status).toBe(201);
expect(mockRegisterProject).toHaveBeenCalledWith({
name: "Cloned Project",
expect(mockEnsureProjectForPath).toHaveBeenCalledWith({
path: cloneDestination,
identity: undefined,
name: "Cloned Project",
isolationMode: "in-process",
nodeId: undefined,
});
@@ -838,7 +926,7 @@ describe("POST /api/projects route handler", () => {
expect(res.status).toBe(400);
expect((res.body as { error?: string }).error).toContain("Git clone failed");
expect(mockRegisterProject).not.toHaveBeenCalled();
expect(mockEnsureProjectForPath).not.toHaveBeenCalled();
expect(mockFsRm).toHaveBeenCalledWith("/tmp/broken-clone", { recursive: true, force: true });
}, 15_000);
@@ -864,7 +952,7 @@ describe("POST /api/projects route handler", () => {
expect(res.status).toBe(400);
expect((res.body as { error?: string }).error).toContain("Clone destination must be empty");
expect(mockExecFileAsync).not.toHaveBeenCalled();
expect(mockRegisterProject).not.toHaveBeenCalled();
expect(mockEnsureProjectForPath).not.toHaveBeenCalled();
});
it("rejects clone mode when cloneUrl is blank", async () => {

View File

@@ -1,6 +1,12 @@
import * as fsPromises from "node:fs/promises";
import { dirname, isAbsolute, join } from "node:path";
import { ensureMemoryFileWithBackend, isValidSqliteDatabaseFile } from "@fusion/core";
import {
ensureMemoryFileWithBackend,
isValidSqliteDatabaseFile,
ProjectIdentityConflictError,
readProjectIdentity,
writeProjectIdentity,
} from "@fusion/core";
import type { CentralCore as CentralCoreApi } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import { execFileAsync } from "../exec-file.js";
@@ -338,16 +344,29 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
hasFusionDir = false;
}
const activeProject = await withCentralCore(async (central) => {
const project = await central.registerProject({
name: normalizedName,
const activeProjectWithOutcome = await withCentralCore(async (central) => {
const identity = readProjectIdentity(fusionDirPath);
const ensured = await central.ensureProjectForPath({
path: normalizedPath,
identity: identity ?? undefined,
name: normalizedName,
isolationMode,
nodeId,
});
const project = ensured.project;
// Activate the project (registration sets it to 'initializing')
return await central.updateProject(project.id, { status: "active" });
const activeProject = await central.updateProject(project.id, { status: "active" });
try {
writeProjectIdentity(fusionDirPath, {
id: activeProject.id,
createdAt: activeProject.createdAt,
});
} catch {
// Best-effort stamp only.
}
return { activeProject, outcome: ensured.outcome };
});
// Bootstrap memory files (non-blocking, non-fatal)
@@ -363,9 +382,9 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
if (options?.onProjectRegistered) {
try {
options.onProjectRegistered({
id: activeProject.id,
name: activeProject.name,
path: activeProject.path,
id: activeProjectWithOutcome.activeProject.id,
name: activeProjectWithOutcome.activeProject.name,
path: activeProjectWithOutcome.activeProject.path,
});
} catch (hookErr) {
runtimeLogger.warn(
@@ -373,11 +392,22 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
);
}
}
res.status(201).json({ ...activeProject, _meta: { hasFusionDir: hasFusionDir ? undefined : false } });
res.status(201).json({
...activeProjectWithOutcome.activeProject,
outcome: activeProjectWithOutcome.outcome,
_meta: { hasFusionDir: hasFusionDir ? undefined : false },
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof ProjectIdentityConflictError) {
throw new ApiError(409, "orphan-identity", {
projectId: err.projectId,
path: err.incomingPath,
message: err.message,
});
}
const status = (err instanceof Error ? err.message : String(err)).includes("already registered")
? 409
: (err instanceof Error ? err.message : String(err)).includes("Duplicate path")

View File

@@ -221,6 +221,7 @@ function ensureProjectRegistered(opts) {
const nameLiteral = JSON.stringify(projectNameFromPackage());
const helper = `
import { CentralCore } from "./packages/core/src/central-core.ts";
import { readProjectIdentity, writeProjectIdentity } from "./packages/core/src/db.ts";
import { ensureMemoryFileWithBackend } from "./packages/core/src/project-memory.ts";
async function main() {
@@ -255,14 +256,29 @@ function ensureProjectRegistered(opts) {
name = baseName.slice(0, 64 - suffixText.length) + suffixText;
}
const project = await central.registerProject({
name,
const identity = readProjectIdentity(root);
const ensured = await central.ensureProjectForPath({
path: root,
isolationMode: "in-process",
identity: identity ? { id: identity.id, createdAt: identity.createdAt } : undefined,
name,
});
const project = ensured.project;
await central.updateProject(project.id, { status: "active" });
try {
writeProjectIdentity(root, {
id: project.id,
createdAt: project.createdAt,
firstSeenPath: root,
});
} catch {
// best effort
}
await ensureMemoryFileWithBackend(root).catch(() => false);
console.log("Registered project " + name);
if (ensured.outcome === "reattached") {
console.log("[start-local] Reattached project " + project.id + " at " + root + " using stored identity");
} else {
console.log("Registered project " + name);
}
} finally {
await central.close();
}