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:
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -488,6 +488,7 @@ export type {
|
||||
PluginSyncEntry,
|
||||
PluginSyncAction,
|
||||
ProjectHealth,
|
||||
ProjectNodePathMapping,
|
||||
ProviderAuthEntry,
|
||||
/** @deprecated Use RegisteredProject instead */
|
||||
ProjectInfo,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user