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 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
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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,
|
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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user