feat(FN-3503): add project-node path mapping persistence APIs and schema

Adds project-node path mapping infrastructure in the core database layer (schema, migration backfill, and persistence APIs in `central-core.ts` and `central-db.ts`) with accompanying documentation. Also introduces a review-before-apply draft gate for agent onboarding and the agent detail view's mail

Fusion-Task-Id: FN-3503
This commit is contained in:
Fusion
2026-05-07 12:32:49 -07:00
committed by gsxdsm
parent d578c5f536
commit cf44f20066
9 changed files with 568 additions and 31 deletions

View File

@@ -803,7 +803,7 @@ SQLite schema is initialized in `packages/core/src/db.ts` and uses:
### Central storage (multi-project)
- **Central DB**: `~/.fusion/fusion-central.db`
- Schema in `packages/core/src/central-db.ts`
- `projects`, `projectHealth`, `centralActivityLog`, `globalConcurrency`, `nodes`, `peerNodes`, `settingsSyncState`, `__meta`
- `projects`, `projectHealth`, `centralActivityLog`, `globalConcurrency`, `nodes`, `peerNodes`, `projectNodePathMappings`, `settingsSyncState`, `__meta`
### Memory files
- OpenClaw-style memory workspace:
@@ -923,6 +923,7 @@ Multi-project orchestration spans core + engine.
- Unified central activity feed
- Global concurrency state
- Node registry (`local` / `remote`)
- Per-project/per-node working-directory mappings (`projectNodePathMappings`)
### Engine orchestration
- `HybridExecutor` (`packages/engine/src/hybrid-executor.ts`) is the top-level orchestrator

View File

@@ -89,11 +89,14 @@ Projects can run with:
## Node Routing
Multi-project deployments use two related node fields at different layers:
Multi-project deployments use three related node/path records at different layers:
1. **Project runtime placement** (`projects.nodeId` in `~/.fusion/fusion-central.db`)
- Decides where a project runtime is hosted in multi-project orchestration.
2. **Task dispatch default** (`defaultNodeId` in project settings)
2. **Project working-directory mapping** (`projectNodePathMappings` in `~/.fusion/fusion-central.db`)
- Stores the absolute path for a project on each node (`projectId` + `nodeId` key).
- Local mappings are auto-created from `projects.path` at registration and kept in sync when local canonical path changes.
3. **Task dispatch default** (`defaultNodeId` in project settings)
- Decides where tasks route when they do not have a per-task override.
These fields are intentionally distinct.
@@ -106,7 +109,7 @@ These fields are intentionally distinct.
- `isolationMode: "in-process"` + remote `projects.nodeId` → `RemoteNodeRuntime`
- `isolationMode: "in-process"` + local/unset/missing node assignment → `InProcessRuntime`
So `projects.nodeId` is a **project host-node assignment**, not a per-task override.
So `projects.nodeId` is a **project host-node assignment**, not a per-task override, and not the node-specific working-directory source of truth (that lives in `projectNodePathMappings`).
### Task routing defaults (`defaultNodeId` + `Task.nodeId`)

View File

@@ -343,8 +343,11 @@ Routing precedence for task dispatch is:
Fusion also stores `projects.nodeId` in the **central registry database** (`~/.fusion/fusion-central.db`). That value is a multi-project runtime placement field used by `ProjectManager` (for selecting remote vs local project runtime), not the same setting as `defaultNodeId` task dispatch routing.
Node-specific project working directories are persisted separately in central DB table `projectNodePathMappings` (`projectId` + `nodeId` + `path`). Do not treat `projects.nodeId` as the path source of truth.
- `defaultNodeId` (project settings): task-level dispatch default
- `projects.nodeId` (central registry): which node hosts the project runtime in multi-project mode
- `projectNodePathMappings.path` (central registry): working-directory path for that project on that specific node
See also:
- [Task Management → Node Routing](./task-management.md#node-routing)

View File

@@ -39,7 +39,7 @@ describe("CentralDatabase", () => {
it("should initialize schema version", () => {
db.init();
expect(db.getSchemaVersion()).toBe(7);
expect(db.getSchemaVersion()).toBe(8);
});
it("should seed lastModified on init", () => {
@@ -94,6 +94,7 @@ describe("CentralDatabase", () => {
expect(tableNames).toContain("globalConcurrency");
expect(tableNames).toContain("nodes");
expect(tableNames).toContain("peerNodes");
expect(tableNames).toContain("projectNodePathMappings");
expect(tableNames).toContain("__meta");
});
@@ -166,6 +167,8 @@ describe("CentralDatabase", () => {
expect(indexNames).toContain("idxNodesStatus");
expect(indexNames).toContain("idxNodesType");
expect(indexNames).toContain("idxPeerNodesNodeId");
expect(indexNames).toContain("idxProjectNodePathMappingsProjectId");
expect(indexNames).toContain("idxProjectNodePathMappingsNodeId");
});
});
@@ -214,7 +217,7 @@ describe("CentralDatabase", () => {
db.init();
expect(db.getSchemaVersion()).toBe(7);
expect(db.getSchemaVersion()).toBe(8);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name);
@@ -279,7 +282,7 @@ describe("CentralDatabase", () => {
db.init();
expect(db.getSchemaVersion()).toBe(7);
expect(db.getSchemaVersion()).toBe(8);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name);
@@ -367,7 +370,7 @@ describe("CentralDatabase", () => {
db.init();
expect(db.getSchemaVersion()).toBe(7);
expect(db.getSchemaVersion()).toBe(8);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
expect(nodeColumns.map((column) => column.name)).toContain("dockerConfig");
@@ -445,6 +448,89 @@ describe("CentralDatabase", () => {
} | undefined;
expect(insertedNode?.dockerConfig).toBeTruthy();
});
it("should migrate from v7 to v8 and backfill local node path mappings from projects.path", () => {
const now = new Date().toISOString();
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
isolationMode TEXT NOT NULL DEFAULT 'in-process',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
lastActivityAt TEXT,
nodeId TEXT,
settings TEXT
);
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK (type IN ('local', 'remote')),
url TEXT,
apiKey TEXT,
status TEXT NOT NULL DEFAULT 'offline',
capabilities TEXT,
systemMetrics TEXT,
knownPeers TEXT,
versionInfo TEXT,
pluginVersions TEXT,
dockerConfig TEXT,
maxConcurrent INTEGER NOT NULL DEFAULT 2,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY,
value TEXT
);
`);
db.prepare("INSERT INTO nodes (id, name, type, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)").run(
"node_local",
"local",
"local",
now,
now,
);
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_1",
"Project One",
"/tmp/proj-1",
"active",
"in-process",
now,
now,
);
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_2",
"Project Two",
"/tmp/proj-2",
"active",
"in-process",
now,
now,
);
db.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '7')").run();
db.prepare("INSERT INTO __meta (key, value) VALUES ('lastModified', ?)").run(String(Date.now()));
db.init();
expect(db.getSchemaVersion()).toBe(8);
const mappings = db
.prepare("SELECT projectId, nodeId, path FROM projectNodePathMappings ORDER BY projectId")
.all() as Array<{ projectId: string; nodeId: string; path: string }>;
expect(mappings).toEqual([
{ projectId: "proj_1", nodeId: "node_local", path: "/tmp/proj-1" },
{ projectId: "proj_2", nodeId: "node_local", path: "/tmp/proj-2" },
]);
});
});
describe("transactions", () => {

View File

@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CentralCore } from "../central-core.js";
describe("CentralCore project-node path mappings", () => {
let tempDir: string;
let central: CentralCore;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-central-mapping-test-"));
central = new CentralCore(tempDir);
await central.init();
});
afterEach(async () => {
await central.close();
rmSync(tempDir, { recursive: true, force: true });
});
it("creates a local-node mapping when registering a project", async () => {
const projectPath = join(tempDir, "project-a");
mkdirSync(projectPath);
const project = await central.registerProject({ name: "Project A", path: projectPath });
const localNode = (await central.listNodes()).find((node) => node.type === "local");
const mapping = await central.getProjectNodePathMapping(project.id, localNode!.id);
expect(mapping?.path).toBe(projectPath);
});
it("keeps local mapping in sync when project.path is updated", async () => {
const projectPath = join(tempDir, "project-b");
const projectPathNext = join(tempDir, "project-b-renamed");
mkdirSync(projectPath);
mkdirSync(projectPathNext);
const project = await central.registerProject({ name: "Project B", path: projectPath });
const localNode = (await central.listNodes()).find((node) => node.type === "local");
await central.updateProject(project.id, { path: projectPathNext });
const mapping = await central.getProjectNodePathMapping(project.id, localNode!.id);
expect(mapping?.path).toBe(projectPathNext);
});
it("supports create/update/list/remove mapping CRUD", async () => {
const projectPath = join(tempDir, "project-c");
mkdirSync(projectPath);
const project = await central.registerProject({ name: "Project C", path: projectPath });
const remoteNode = await central.registerNode({
name: "remote-c",
type: "remote",
url: "https://remote-c.example",
apiKey: "secret",
});
const created = await central.createProjectNodePathMapping({
projectId: project.id,
nodeId: remoteNode.id,
path: "/srv/project-c",
});
expect(created.path).toBe("/srv/project-c");
const updated = await central.updateProjectNodePathMapping({
projectId: project.id,
nodeId: remoteNode.id,
path: "/srv/project-c-next",
});
expect(updated.path).toBe("/srv/project-c-next");
const listedByProject = await central.listProjectNodePathMappings({ projectId: project.id });
expect(listedByProject.some((row) => row.nodeId === remoteNode.id)).toBe(true);
await central.removeProjectNodePathMapping(project.id, remoteNode.id);
const removed = await central.getProjectNodePathMapping(project.id, remoteNode.id);
expect(removed).toBeUndefined();
});
it("rejects unknown project/node and duplicate/conflicting mappings", async () => {
const projectPath = join(tempDir, "project-d");
mkdirSync(projectPath);
const project = await central.registerProject({ name: "Project D", path: projectPath });
const remoteNode = await central.registerNode({
name: "remote-d",
type: "remote",
url: "https://remote-d.example",
apiKey: "secret",
});
await expect(
central.createProjectNodePathMapping({
projectId: "proj_missing",
nodeId: remoteNode.id,
path: "/x",
}),
).rejects.toThrow("Project not found");
await expect(
central.createProjectNodePathMapping({
projectId: project.id,
nodeId: "node_missing",
path: "/x",
}),
).rejects.toThrow("Node not found");
await central.createProjectNodePathMapping({
projectId: project.id,
nodeId: remoteNode.id,
path: "/srv/project-d",
});
await expect(
central.createProjectNodePathMapping({
projectId: project.id,
nodeId: remoteNode.id,
path: "/srv/project-d-other",
}),
).rejects.toThrow("already exists");
await expect(
central.updateProjectNodePathMapping({
projectId: project.id,
nodeId: "node_missing",
path: "/y",
}),
).rejects.toThrow("Node not found");
});
it("cleans up mappings when project or node is deleted", async () => {
const projectPath = join(tempDir, "project-e");
mkdirSync(projectPath);
const project = await central.registerProject({ name: "Project E", path: projectPath });
const remoteNode = await central.registerNode({
name: "remote-e",
type: "remote",
url: "https://remote-e.example",
apiKey: "secret",
});
await central.createProjectNodePathMapping({
projectId: project.id,
nodeId: remoteNode.id,
path: "/srv/project-e",
});
await central.unregisterNode(remoteNode.id);
expect(await central.getProjectNodePathMapping(project.id, remoteNode.id)).toBeUndefined();
const localNode = (await central.listNodes()).find((node) => node.type === "local");
expect(localNode).toBeDefined();
await central.unregisterProject(project.id);
expect(await central.getProjectNodePathMapping(project.id, localNode!.id)).toBeUndefined();
});
});

View File

@@ -65,6 +65,7 @@ import type {
SettingsSyncResult,
GlobalSettings,
ProviderAuthEntry,
ProjectNodePathMapping,
} from "./types.js";
import { getAppVersion, parseSemver } from "./app-version.js";
import { validateDockerNodeConfig } from "./types.js";
@@ -294,6 +295,21 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
toJsonNullable(project.settings)
);
const localNode = this.db!
.prepare("SELECT id FROM nodes WHERE type = 'local' ORDER BY createdAt ASC LIMIT 1")
.get() as { id: string } | undefined;
if (localNode) {
this.db!
.prepare(
`INSERT INTO projectNodePathMappings (projectId, nodeId, path, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(projectId, nodeId) DO UPDATE SET
path = excluded.path,
updatedAt = excluded.updatedAt`
)
.run(project.id, localNode.id, project.path, now, now);
}
// Initialize health record
this.db!.prepare(
`INSERT INTO projectHealth (projectId, status, updatedAt, totalTasksCompleted, totalTasksFailed)
@@ -438,28 +454,48 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
updatedAt: now,
};
this.db!.prepare(
`UPDATE projects SET
name = ?,
path = ?,
status = ?,
isolationMode = ?,
updatedAt = ?,
lastActivityAt = ?,
nodeId = ?,
settings = ?
WHERE id = ?`
).run(
updated.name,
updated.path,
updated.status,
updated.isolationMode,
updated.updatedAt,
updated.lastActivityAt ?? null,
updated.nodeId ?? null,
toJsonNullable(updated.settings),
id
);
this.db!.transaction(() => {
this.db!.prepare(
`UPDATE projects SET
name = ?,
path = ?,
status = ?,
isolationMode = ?,
updatedAt = ?,
lastActivityAt = ?,
nodeId = ?,
settings = ?
WHERE id = ?`
).run(
updated.name,
updated.path,
updated.status,
updated.isolationMode,
updated.updatedAt,
updated.lastActivityAt ?? null,
updated.nodeId ?? null,
toJsonNullable(updated.settings),
id
);
if (updated.path !== project.path) {
const localNode = this.db!
.prepare("SELECT id FROM nodes WHERE type = 'local' ORDER BY createdAt ASC LIMIT 1")
.get() as { id: string } | undefined;
if (localNode) {
this.db!
.prepare(
`INSERT INTO projectNodePathMappings (projectId, nodeId, path, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(projectId, nodeId) DO UPDATE SET
path = excluded.path,
updatedAt = excluded.updatedAt`
)
.run(id, localNode.id, updated.path, now, now);
}
}
});
this.db!.bumpLastModified();
this.emit("project:updated", updated);
@@ -1655,6 +1691,151 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
return updated;
}
async createProjectNodePathMapping(input: {
projectId: string;
nodeId: string;
path: string;
}): Promise<ProjectNodePathMapping> {
this.ensureInitialized();
await this.assertProjectNodeMappingTargetsExist(input.projectId, input.nodeId);
const existing = await this.getProjectNodePathMapping(input.projectId, input.nodeId);
if (existing) {
throw new Error(`Project/node mapping already exists: ${input.projectId}/${input.nodeId}`);
}
const now = new Date().toISOString();
this.db!
.prepare(
`INSERT INTO projectNodePathMappings (projectId, nodeId, path, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?)`
)
.run(input.projectId, input.nodeId, input.path, now, now);
this.db!.bumpLastModified();
return {
projectId: input.projectId,
nodeId: input.nodeId,
path: input.path,
createdAt: now,
updatedAt: now,
};
}
async updateProjectNodePathMapping(input: {
projectId: string;
nodeId: string;
path: string;
}): Promise<ProjectNodePathMapping> {
this.ensureInitialized();
await this.assertProjectNodeMappingTargetsExist(input.projectId, input.nodeId);
const existing = await this.getProjectNodePathMapping(input.projectId, input.nodeId);
if (!existing) {
throw new Error(`Project/node mapping not found: ${input.projectId}/${input.nodeId}`);
}
const now = new Date().toISOString();
this.db!
.prepare(
`UPDATE projectNodePathMappings
SET path = ?, updatedAt = ?
WHERE projectId = ? AND nodeId = ?`
)
.run(input.path, now, input.projectId, input.nodeId);
this.db!.bumpLastModified();
return {
...existing,
path: input.path,
updatedAt: now,
};
}
async getProjectNodePathMapping(
projectId: string,
nodeId: string,
): Promise<ProjectNodePathMapping | undefined> {
this.ensureInitialized();
const row = this.db!
.prepare("SELECT * FROM projectNodePathMappings WHERE projectId = ? AND nodeId = ?")
.get(projectId, nodeId) as
| {
projectId: string;
nodeId: string;
path: string;
createdAt: string;
updatedAt: string;
}
| undefined;
return row ? this.rowToProjectNodePathMapping(row) : undefined;
}
async listProjectNodePathMappings(filters?: {
projectId?: string;
nodeId?: string;
}): Promise<ProjectNodePathMapping[]> {
this.ensureInitialized();
if (filters?.projectId && filters?.nodeId) {
const row = await this.getProjectNodePathMapping(filters.projectId, filters.nodeId);
return row ? [row] : [];
}
if (filters?.projectId) {
const rows = this.db!
.prepare("SELECT * FROM projectNodePathMappings WHERE projectId = ? ORDER BY nodeId")
.all(filters.projectId) as Array<{
projectId: string;
nodeId: string;
path: string;
createdAt: string;
updatedAt: string;
}>;
return rows.map((row) => this.rowToProjectNodePathMapping(row));
}
if (filters?.nodeId) {
const rows = this.db!
.prepare("SELECT * FROM projectNodePathMappings WHERE nodeId = ? ORDER BY projectId")
.all(filters.nodeId) as Array<{
projectId: string;
nodeId: string;
path: string;
createdAt: string;
updatedAt: string;
}>;
return rows.map((row) => this.rowToProjectNodePathMapping(row));
}
const rows = this.db!
.prepare("SELECT * FROM projectNodePathMappings ORDER BY projectId, nodeId")
.all() as Array<{
projectId: string;
nodeId: string;
path: string;
createdAt: string;
updatedAt: string;
}>;
return rows.map((row) => this.rowToProjectNodePathMapping(row));
}
async removeProjectNodePathMapping(projectId: string, nodeId: string): Promise<void> {
this.ensureInitialized();
const result = this.db!
.prepare("DELETE FROM projectNodePathMappings WHERE projectId = ? AND nodeId = ?")
.run(projectId, nodeId) as { changes?: number };
if ((result.changes ?? 0) > 0) {
this.db!.bumpLastModified();
}
}
// ── Project Health API ──────────────────────────────────────────────────
/**
@@ -2216,6 +2397,18 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
}
}
private async assertProjectNodeMappingTargetsExist(projectId: string, nodeId: string): Promise<void> {
const project = await this.getProject(projectId);
if (!project) {
throw new Error(`Project not found: ${projectId}`);
}
const node = await this.getNode(nodeId);
if (!node) {
throw new Error(`Node not found: ${nodeId}`);
}
}
private rowToProject(row: {
id: string;
name: string;
@@ -2342,6 +2535,22 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
};
}
private rowToProjectNodePathMapping(row: {
projectId: string;
nodeId: string;
path: string;
createdAt: string;
updatedAt: string;
}): ProjectNodePathMapping {
return {
projectId: row.projectId,
nodeId: row.nodeId,
path: row.path,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private async getLocalNode(): Promise<NodeConfig | undefined> {
const row = this.db!
.prepare("SELECT * FROM nodes WHERE type = 'local' ORDER BY createdAt ASC LIMIT 1")

View File

@@ -23,7 +23,7 @@ export { toJson, toJsonNullable, fromJson };
// ── Schema Definition ───────────────────────────────────────────────────
const CENTRAL_SCHEMA_VERSION = 7;
const CENTRAL_SCHEMA_VERSION = 8;
const CENTRAL_SCHEMA_SQL = `
-- Projects table (project registry)
@@ -42,6 +42,20 @@ CREATE TABLE IF NOT EXISTS projects (
CREATE INDEX IF NOT EXISTS idxProjectsPath ON projects(path);
CREATE INDEX IF NOT EXISTS idxProjectsStatus ON projects(status);
-- Per-project, per-node working directory mappings
CREATE TABLE IF NOT EXISTS projectNodePathMappings (
projectId TEXT NOT NULL,
nodeId TEXT NOT NULL,
path TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (projectId, nodeId),
FOREIGN KEY (projectId) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxProjectNodePathMappingsProjectId ON projectNodePathMappings(projectId);
CREATE INDEX IF NOT EXISTS idxProjectNodePathMappingsNodeId ON projectNodePathMappings(nodeId);
-- Project health table (mutable state, updated frequently)
CREATE TABLE IF NOT EXISTS projectHealth (
projectId TEXT PRIMARY KEY,
@@ -256,6 +270,21 @@ CREATE INDEX IF NOT EXISTS idxManagedDockerNodesNodeId ON managedDockerNodes(nod
// V7 migration adds dockerConfig persistence to nodes for Docker-managed runtime config updates.
const CENTRAL_SCHEMA_V8_MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS projectNodePathMappings (
projectId TEXT NOT NULL,
nodeId TEXT NOT NULL,
path TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (projectId, nodeId),
FOREIGN KEY (projectId) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxProjectNodePathMappingsProjectId ON projectNodePathMappings(projectId);
CREATE INDEX IF NOT EXISTS idxProjectNodePathMappingsNodeId ON projectNodePathMappings(nodeId);
`;
// ── Central Database Class ────────────────────────────────────────────────
export class CentralDatabase {
@@ -345,6 +374,39 @@ export class CentralDatabase {
migrated = true;
}
if (currentVersion < 8) {
this.db.exec(CENTRAL_SCHEMA_V8_MIGRATION_SQL);
const localNodeRow = this.db
.prepare("SELECT id FROM nodes WHERE type = 'local' ORDER BY createdAt ASC LIMIT 1")
.get() as { id: string } | undefined;
if (localNodeRow) {
this.db.prepare(
`INSERT OR IGNORE INTO projectNodePathMappings (projectId, nodeId, path, createdAt, updatedAt)
SELECT id, ?, path, createdAt, updatedAt
FROM projects`
).run(localNodeRow.id);
this.db.prepare(
`UPDATE projectNodePathMappings
SET path = (
SELECT projects.path
FROM projects
WHERE projects.id = projectNodePathMappings.projectId
),
updatedAt = (
SELECT projects.updatedAt
FROM projects
WHERE projects.id = projectNodePathMappings.projectId
)
WHERE nodeId = ?`
).run(localNodeRow.id);
}
migrated = true;
}
if (migrated) {
this.db
.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")

View File

@@ -488,6 +488,7 @@ export type {
PluginSyncEntry,
PluginSyncAction,
ProjectHealth,
ProjectNodePathMapping,
ProviderAuthEntry,
/** @deprecated Use RegisteredProject instead */
ProjectInfo,

View File

@@ -3282,6 +3282,20 @@ export interface RegisteredProject {
/** @deprecated Use RegisteredProject instead */
export type ProjectInfo = RegisteredProject;
/** A persisted per-project, per-node working directory path mapping. */
export interface ProjectNodePathMapping {
/** Project ID reference */
projectId: string;
/** Node ID reference */
nodeId: string;
/** Absolute working-directory path for this project on this node */
path: string;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/** Health metrics for a registered project */
export interface ProjectHealth {
/** Project ID reference */