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:
5
.changeset/fn-5411-project-identity-recovery.md
Normal file
5
.changeset/fn-5411-project-identity-recovery.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
64
packages/core/src/__tests__/project-identity.test.ts
Normal file
64
packages/core/src/__tests__/project-identity.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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" }
|
||||
);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
87
packages/core/src/project-identity.ts
Normal file
87
packages/core/src/project-identity.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user