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 ad5bed22f8
commit b434cc2d38
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 storage (multi-project)
- **Central DB**: `~/.fusion/fusion-central.db` - **Central DB**: `~/.fusion/fusion-central.db`
- Schema in `packages/core/src/central-db.ts` - 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 ### Memory files
- OpenClaw-style memory workspace: - OpenClaw-style memory workspace:
@@ -923,6 +923,7 @@ Multi-project orchestration spans core + engine.
- Unified central activity feed - Unified central activity feed
- Global concurrency state - Global concurrency state
- Node registry (`local` / `remote`) - Node registry (`local` / `remote`)
- Per-project/per-node working-directory mappings (`projectNodePathMappings`)
### Engine orchestration ### Engine orchestration
- `HybridExecutor` (`packages/engine/src/hybrid-executor.ts`) is the top-level orchestrator - `HybridExecutor` (`packages/engine/src/hybrid-executor.ts`) is the top-level orchestrator

View File

@@ -89,11 +89,14 @@ Projects can run with:
## Node Routing ## 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`) 1. **Project runtime placement** (`projects.nodeId` in `~/.fusion/fusion-central.db`)
- Decides where a project runtime is hosted in multi-project orchestration. - 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. - Decides where tasks route when they do not have a per-task override.
These fields are intentionally distinct. These fields are intentionally distinct.
@@ -106,7 +109,7 @@ These fields are intentionally distinct.
- `isolationMode: "in-process"` + remote `projects.nodeId``RemoteNodeRuntime` - `isolationMode: "in-process"` + remote `projects.nodeId``RemoteNodeRuntime`
- `isolationMode: "in-process"` + local/unset/missing node assignment → `InProcessRuntime` - `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`) ### 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. 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 - `defaultNodeId` (project settings): task-level dispatch default
- `projects.nodeId` (central registry): which node hosts the project runtime in multi-project mode - `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: See also:
- [Task Management → Node Routing](./task-management.md#node-routing) - [Task Management → Node Routing](./task-management.md#node-routing)

View File

@@ -39,7 +39,7 @@ describe("CentralDatabase", () => {
it("should initialize schema version", () => { it("should initialize schema version", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(7); expect(db.getSchemaVersion()).toBe(8);
}); });
it("should seed lastModified on init", () => { it("should seed lastModified on init", () => {
@@ -94,6 +94,7 @@ describe("CentralDatabase", () => {
expect(tableNames).toContain("globalConcurrency"); expect(tableNames).toContain("globalConcurrency");
expect(tableNames).toContain("nodes"); expect(tableNames).toContain("nodes");
expect(tableNames).toContain("peerNodes"); expect(tableNames).toContain("peerNodes");
expect(tableNames).toContain("projectNodePathMappings");
expect(tableNames).toContain("__meta"); expect(tableNames).toContain("__meta");
}); });
@@ -166,6 +167,8 @@ describe("CentralDatabase", () => {
expect(indexNames).toContain("idxNodesStatus"); expect(indexNames).toContain("idxNodesStatus");
expect(indexNames).toContain("idxNodesType"); expect(indexNames).toContain("idxNodesType");
expect(indexNames).toContain("idxPeerNodesNodeId"); expect(indexNames).toContain("idxPeerNodesNodeId");
expect(indexNames).toContain("idxProjectNodePathMappingsProjectId");
expect(indexNames).toContain("idxProjectNodePathMappingsNodeId");
}); });
}); });
@@ -214,7 +217,7 @@ describe("CentralDatabase", () => {
db.init(); 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 nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name); const nodeColumnNames = nodeColumns.map((column) => column.name);
@@ -279,7 +282,7 @@ describe("CentralDatabase", () => {
db.init(); 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 nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name); const nodeColumnNames = nodeColumns.map((column) => column.name);
@@ -367,7 +370,7 @@ describe("CentralDatabase", () => {
db.init(); 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 nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
expect(nodeColumns.map((column) => column.name)).toContain("dockerConfig"); expect(nodeColumns.map((column) => column.name)).toContain("dockerConfig");
@@ -445,6 +448,89 @@ describe("CentralDatabase", () => {
} | undefined; } | undefined;
expect(insertedNode?.dockerConfig).toBeTruthy(); 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", () => { 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, SettingsSyncResult,
GlobalSettings, GlobalSettings,
ProviderAuthEntry, ProviderAuthEntry,
ProjectNodePathMapping,
} from "./types.js"; } from "./types.js";
import { getAppVersion, parseSemver } from "./app-version.js"; import { getAppVersion, parseSemver } from "./app-version.js";
import { validateDockerNodeConfig } from "./types.js"; import { validateDockerNodeConfig } from "./types.js";
@@ -294,6 +295,21 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
toJsonNullable(project.settings) 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 // Initialize health record
this.db!.prepare( this.db!.prepare(
`INSERT INTO projectHealth (projectId, status, updatedAt, totalTasksCompleted, totalTasksFailed) `INSERT INTO projectHealth (projectId, status, updatedAt, totalTasksCompleted, totalTasksFailed)
@@ -438,28 +454,48 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
updatedAt: now, updatedAt: now,
}; };
this.db!.prepare( this.db!.transaction(() => {
`UPDATE projects SET this.db!.prepare(
name = ?, `UPDATE projects SET
path = ?, name = ?,
status = ?, path = ?,
isolationMode = ?, status = ?,
updatedAt = ?, isolationMode = ?,
lastActivityAt = ?, updatedAt = ?,
nodeId = ?, lastActivityAt = ?,
settings = ? nodeId = ?,
WHERE id = ?` settings = ?
).run( WHERE id = ?`
updated.name, ).run(
updated.path, updated.name,
updated.status, updated.path,
updated.isolationMode, updated.status,
updated.updatedAt, updated.isolationMode,
updated.lastActivityAt ?? null, updated.updatedAt,
updated.nodeId ?? null, updated.lastActivityAt ?? null,
toJsonNullable(updated.settings), updated.nodeId ?? null,
id 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.db!.bumpLastModified();
this.emit("project:updated", updated); this.emit("project:updated", updated);
@@ -1655,6 +1691,151 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
return updated; 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 ────────────────────────────────────────────────── // ── 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: { private rowToProject(row: {
id: string; id: string;
name: 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> { private async getLocalNode(): Promise<NodeConfig | undefined> {
const row = this.db! const row = this.db!
.prepare("SELECT * FROM nodes WHERE type = 'local' ORDER BY createdAt ASC LIMIT 1") .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 ─────────────────────────────────────────────────── // ── Schema Definition ───────────────────────────────────────────────────
const CENTRAL_SCHEMA_VERSION = 7; const CENTRAL_SCHEMA_VERSION = 8;
const CENTRAL_SCHEMA_SQL = ` const CENTRAL_SCHEMA_SQL = `
-- Projects table (project registry) -- 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 idxProjectsPath ON projects(path);
CREATE INDEX IF NOT EXISTS idxProjectsStatus ON projects(status); 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) -- Project health table (mutable state, updated frequently)
CREATE TABLE IF NOT EXISTS projectHealth ( CREATE TABLE IF NOT EXISTS projectHealth (
projectId TEXT PRIMARY KEY, 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. // 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 ──────────────────────────────────────────────── // ── Central Database Class ────────────────────────────────────────────────
export class CentralDatabase { export class CentralDatabase {
@@ -345,6 +374,39 @@ export class CentralDatabase {
migrated = true; 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) { if (migrated) {
this.db this.db
.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value") .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, PluginSyncEntry,
PluginSyncAction, PluginSyncAction,
ProjectHealth, ProjectHealth,
ProjectNodePathMapping,
ProviderAuthEntry, ProviderAuthEntry,
/** @deprecated Use RegisteredProject instead */ /** @deprecated Use RegisteredProject instead */
ProjectInfo, ProjectInfo,

View File

@@ -3282,6 +3282,20 @@ export interface RegisteredProject {
/** @deprecated Use RegisteredProject instead */ /** @deprecated Use RegisteredProject instead */
export type ProjectInfo = RegisteredProject; 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 */ /** Health metrics for a registered project */
export interface ProjectHealth { export interface ProjectHealth {
/** Project ID reference */ /** Project ID reference */