feat(FN-1078): add central node registry support
- Add NodeConfig/NodeStatus types, export node types from @fusion/core, and include optional nodeId on registered projects - Upgrade central DB schema to v2 with a nodes table, node indexes, and projects.nodeId migration handling - Implement CentralCore node APIs for register/update/list/get/unregister, health checks, and project-to-node assignment flows with emitted events - Seed a default local node during central init and mark it online using global concurrency settings - Expand central-core and central-db tests to cover node defaults, schema changes, lifecycle operations, health transitions, and assignment behavior
This commit is contained in:
@@ -42,6 +42,30 @@ describe("CentralCore", () => {
|
||||
expect(central.isInitialized()).toBe(true);
|
||||
});
|
||||
|
||||
it("should create a default online local node on init", async () => {
|
||||
await central.init();
|
||||
|
||||
const nodes = await central.listNodes();
|
||||
const localNodes = nodes.filter((node) => node.type === "local");
|
||||
expect(localNodes).toHaveLength(1);
|
||||
expect(localNodes[0].name).toBe("local");
|
||||
expect(localNodes[0].status).toBe("online");
|
||||
expect(localNodes[0].maxConcurrent).toBe(4);
|
||||
});
|
||||
|
||||
it("should not create duplicate default local nodes across re-initialization", async () => {
|
||||
await central.init();
|
||||
await central.close();
|
||||
|
||||
central = new CentralCore(tempDir);
|
||||
await central.init();
|
||||
|
||||
const nodes = await central.listNodes();
|
||||
const localNodes = nodes.filter((node) => node.type === "local");
|
||||
expect(localNodes).toHaveLength(1);
|
||||
expect(localNodes[0].name).toBe("local");
|
||||
});
|
||||
|
||||
it("should close and clean up", async () => {
|
||||
await central.init();
|
||||
await central.close();
|
||||
@@ -534,6 +558,175 @@ describe("CentralCore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("node management", () => {
|
||||
beforeEach(async () => {
|
||||
await central.init();
|
||||
});
|
||||
|
||||
it("should register and retrieve a node", async () => {
|
||||
const node = await central.registerNode({
|
||||
name: "executor-node-a",
|
||||
type: "local",
|
||||
maxConcurrent: 3,
|
||||
});
|
||||
|
||||
expect(node.id).toMatch(/^node_[a-f0-9]+$/);
|
||||
expect(node.name).toBe("executor-node-a");
|
||||
expect(node.type).toBe("local");
|
||||
expect(node.status).toBe("offline");
|
||||
expect(node.maxConcurrent).toBe(3);
|
||||
|
||||
const fetched = await central.getNode(node.id);
|
||||
expect(fetched).toEqual(node);
|
||||
|
||||
const byName = await central.getNodeByName("executor-node-a");
|
||||
expect(byName?.id).toBe(node.id);
|
||||
});
|
||||
|
||||
it("should reject duplicate node names", async () => {
|
||||
await central.registerNode({ name: "dup-node", type: "local" });
|
||||
|
||||
await expect(
|
||||
central.registerNode({ name: "dup-node", type: "local" }),
|
||||
).rejects.toThrow("already exists");
|
||||
});
|
||||
|
||||
it("should validate node type constraints on register", async () => {
|
||||
await expect(
|
||||
central.registerNode({ name: "remote-missing-url", type: "remote" }),
|
||||
).rejects.toThrow("must include a url");
|
||||
|
||||
await expect(
|
||||
central.registerNode({
|
||||
name: "local-with-url",
|
||||
type: "local",
|
||||
url: "https://example.com",
|
||||
}),
|
||||
).rejects.toThrow("must not include url or apiKey");
|
||||
|
||||
await expect(
|
||||
central.registerNode({
|
||||
name: "local-with-key",
|
||||
type: "local",
|
||||
apiKey: "abc",
|
||||
}),
|
||||
).rejects.toThrow("must not include url or apiKey");
|
||||
});
|
||||
|
||||
it("should update nodes and enforce type constraints", async () => {
|
||||
const remote = await central.registerNode({
|
||||
name: "remote-node",
|
||||
type: "remote",
|
||||
url: "https://node.example.com",
|
||||
apiKey: "secret",
|
||||
});
|
||||
|
||||
const updated = await central.updateNode(remote.id, {
|
||||
status: "connecting",
|
||||
maxConcurrent: 4,
|
||||
});
|
||||
|
||||
expect(updated.status).toBe("connecting");
|
||||
expect(updated.maxConcurrent).toBe(4);
|
||||
|
||||
await expect(
|
||||
central.updateNode(remote.id, {
|
||||
type: "local",
|
||||
}),
|
||||
).rejects.toThrow("must not include url or apiKey");
|
||||
});
|
||||
|
||||
it("should list nodes ordered by name", async () => {
|
||||
await central.registerNode({ name: "z-node", type: "local" });
|
||||
await central.registerNode({ name: "a-node", type: "local" });
|
||||
|
||||
const nodes = await central.listNodes();
|
||||
const names = nodes.map((node) => node.name);
|
||||
expect(names).toContain("a-node");
|
||||
expect(names).toContain("z-node");
|
||||
expect(names.indexOf("a-node")).toBeLessThan(names.indexOf("z-node"));
|
||||
});
|
||||
|
||||
it("should assign and unassign projects to nodes", async () => {
|
||||
const projectPath = join(tempDir, "node-assignment");
|
||||
mkdirSync(projectPath);
|
||||
projectPaths.push(projectPath);
|
||||
|
||||
const project = await central.registerProject({
|
||||
name: "Node Assignment",
|
||||
path: projectPath,
|
||||
});
|
||||
const node = await central.registerNode({ name: "assign-node", type: "local" });
|
||||
|
||||
const assigned = await central.assignProjectToNode(project.id, node.id);
|
||||
expect(assigned.nodeId).toBe(node.id);
|
||||
expect((await central.getProject(project.id))?.nodeId).toBe(node.id);
|
||||
|
||||
const unassigned = await central.unassignProjectFromNode(project.id);
|
||||
expect(unassigned.nodeId).toBeUndefined();
|
||||
expect((await central.getProject(project.id))?.nodeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should throw when assigning to unknown project or node", async () => {
|
||||
const node = await central.registerNode({ name: "assignment-target", type: "local" });
|
||||
|
||||
await expect(central.assignProjectToNode("proj_missing", node.id)).rejects.toThrow("Project not found");
|
||||
|
||||
const projectPath = join(tempDir, "node-assignment-errors");
|
||||
mkdirSync(projectPath);
|
||||
projectPaths.push(projectPath);
|
||||
|
||||
const project = await central.registerProject({
|
||||
name: "Node Assignment Errors",
|
||||
path: projectPath,
|
||||
});
|
||||
|
||||
await expect(central.assignProjectToNode(project.id, "node_missing")).rejects.toThrow("Node not found");
|
||||
await expect(central.unassignProjectFromNode("proj_missing")).rejects.toThrow("Project not found");
|
||||
});
|
||||
|
||||
it("should unassign projects when a node is unregistered", async () => {
|
||||
const projectPath = join(tempDir, "node-unregister");
|
||||
mkdirSync(projectPath);
|
||||
projectPaths.push(projectPath);
|
||||
|
||||
const project = await central.registerProject({
|
||||
name: "Node Unregister",
|
||||
path: projectPath,
|
||||
});
|
||||
const node = await central.registerNode({ name: "ephemeral-node", type: "local" });
|
||||
|
||||
await central.assignProjectToNode(project.id, node.id);
|
||||
await central.unregisterNode(node.id);
|
||||
|
||||
expect(await central.getNode(node.id)).toBeUndefined();
|
||||
expect((await central.getProject(project.id))?.nodeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should be idempotent when unregistering missing nodes", async () => {
|
||||
await expect(central.unregisterNode("node_missing")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should check local node health and emit node:health:changed", async () => {
|
||||
const node = await central.registerNode({ name: "local-health", type: "local" });
|
||||
|
||||
let emittedNodeId: string | undefined;
|
||||
let emittedStatus: string | undefined;
|
||||
central.on("node:health:changed", (updated) => {
|
||||
emittedNodeId = updated.id;
|
||||
emittedStatus = updated.status;
|
||||
});
|
||||
|
||||
const status = await central.checkNodeHealth(node.id);
|
||||
expect(status).toBe("online");
|
||||
|
||||
const stored = await central.getNode(node.id);
|
||||
expect(stored?.status).toBe("online");
|
||||
expect(emittedNodeId).toBe(node.id);
|
||||
expect(emittedStatus).toBe("online");
|
||||
});
|
||||
});
|
||||
|
||||
describe("project health", () => {
|
||||
beforeEach(async () => {
|
||||
await central.init();
|
||||
|
||||
@@ -41,6 +41,9 @@ import type {
|
||||
ProjectStatus,
|
||||
ActivityEventType,
|
||||
ProjectSettings,
|
||||
AgentCapability,
|
||||
NodeConfig,
|
||||
NodeStatus,
|
||||
} from "./types.js";
|
||||
import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js";
|
||||
import { resolveGlobalDir } from "./global-settings.js";
|
||||
@@ -58,6 +61,14 @@ export interface CentralCoreEvents {
|
||||
"project:health:changed": [health: ProjectHealth];
|
||||
/** Emitted when a new activity is logged */
|
||||
"activity:logged": [entry: CentralActivityLogEntry];
|
||||
/** Emitted when a node is registered */
|
||||
"node:registered": [node: NodeConfig];
|
||||
/** Emitted when a node is unregistered */
|
||||
"node:unregistered": [nodeId: string];
|
||||
/** Emitted when node metadata is updated */
|
||||
"node:updated": [node: NodeConfig];
|
||||
/** Emitted when node health status changes */
|
||||
"node:health:changed": [node: NodeConfig];
|
||||
/** Emitted when global concurrency state changes */
|
||||
"concurrency:changed": [state: GlobalConcurrencyState];
|
||||
}
|
||||
@@ -98,6 +109,24 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
const existingLocal = this.db
|
||||
.prepare("SELECT id FROM nodes WHERE type = 'local' LIMIT 1")
|
||||
.get() as { id: string } | undefined;
|
||||
|
||||
if (!existingLocal) {
|
||||
const concurrency = this.db
|
||||
.prepare("SELECT globalMaxConcurrent FROM globalConcurrency WHERE id = 1")
|
||||
.get() as { globalMaxConcurrent: number } | undefined;
|
||||
const maxConcurrent = concurrency?.globalMaxConcurrent ?? 2;
|
||||
|
||||
const localNode = await this.registerNode({
|
||||
name: "local",
|
||||
type: "local",
|
||||
maxConcurrent,
|
||||
});
|
||||
await this.updateNode(localNode.id, { status: "online" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,6 +266,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastActivityAt: string | null;
|
||||
nodeId: string | null;
|
||||
settings: string | null;
|
||||
}
|
||||
| undefined;
|
||||
@@ -265,6 +295,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastActivityAt: string | null;
|
||||
nodeId: string | null;
|
||||
settings: string | null;
|
||||
}
|
||||
| undefined;
|
||||
@@ -291,6 +322,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastActivityAt: string | null;
|
||||
nodeId: string | null;
|
||||
settings: string | null;
|
||||
}>;
|
||||
|
||||
@@ -333,6 +365,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
isolationMode = ?,
|
||||
updatedAt = ?,
|
||||
lastActivityAt = ?,
|
||||
nodeId = ?,
|
||||
settings = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
@@ -342,6 +375,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
updated.isolationMode,
|
||||
updated.updatedAt,
|
||||
updated.lastActivityAt ?? null,
|
||||
updated.nodeId ?? null,
|
||||
toJsonNullable(updated.settings),
|
||||
id
|
||||
);
|
||||
@@ -400,6 +434,349 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
return reconciled;
|
||||
}
|
||||
|
||||
// ── Node Registry API ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register a new runtime node.
|
||||
*
|
||||
* @param input — Node registration input
|
||||
* @returns The registered node
|
||||
* @throws Error if constraints are violated or name already exists
|
||||
*/
|
||||
async registerNode(input: {
|
||||
name: string;
|
||||
type: "local" | "remote";
|
||||
url?: string;
|
||||
apiKey?: string;
|
||||
capabilities?: AgentCapability[];
|
||||
maxConcurrent?: number;
|
||||
}): Promise<NodeConfig> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const name = input.name.trim();
|
||||
if (!name) {
|
||||
throw new Error("Node name is required");
|
||||
}
|
||||
|
||||
const existingByName = await this.getNodeByName(name);
|
||||
if (existingByName) {
|
||||
throw new Error(`Node already exists with name: ${name}`);
|
||||
}
|
||||
|
||||
const normalizedUrl = input.url?.trim();
|
||||
if (input.type === "remote" && !normalizedUrl) {
|
||||
throw new Error("Remote nodes must include a url");
|
||||
}
|
||||
if (input.type === "local" && (normalizedUrl || input.apiKey)) {
|
||||
throw new Error("Local nodes must not include url or apiKey");
|
||||
}
|
||||
|
||||
const maxConcurrent = input.maxConcurrent ?? 2;
|
||||
if (!Number.isFinite(maxConcurrent) || maxConcurrent < 1) {
|
||||
throw new Error(`Node maxConcurrent must be >= 1: ${maxConcurrent}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const node: NodeConfig = {
|
||||
id: `node_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
|
||||
name,
|
||||
type: input.type,
|
||||
url: normalizedUrl || undefined,
|
||||
apiKey: input.apiKey || undefined,
|
||||
status: "offline",
|
||||
capabilities: input.capabilities,
|
||||
maxConcurrent,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db!.prepare(
|
||||
`INSERT INTO nodes (id, name, type, url, apiKey, status, capabilities, maxConcurrent, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
node.id,
|
||||
node.name,
|
||||
node.type,
|
||||
node.url ?? null,
|
||||
node.apiKey ?? null,
|
||||
node.status,
|
||||
toJsonNullable(node.capabilities),
|
||||
node.maxConcurrent,
|
||||
node.createdAt,
|
||||
node.updatedAt
|
||||
);
|
||||
|
||||
this.db!.bumpLastModified();
|
||||
this.emit("node:registered", node);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a runtime node.
|
||||
*
|
||||
* Idempotent. Projects assigned to this node are automatically unassigned.
|
||||
*
|
||||
* @param id — Node ID to unregister
|
||||
*/
|
||||
async unregisterNode(id: string): Promise<void> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const node = await this.getNode(id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
this.db!.transaction(() => {
|
||||
this.db!.prepare("UPDATE projects SET nodeId = NULL, updatedAt = ? WHERE nodeId = ?").run(now, id);
|
||||
this.db!.prepare("DELETE FROM nodes WHERE id = ?").run(id);
|
||||
});
|
||||
|
||||
this.db!.bumpLastModified();
|
||||
this.emit("node:unregistered", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a node by ID.
|
||||
*/
|
||||
async getNode(id: string): Promise<NodeConfig | undefined> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const row = this.db!.prepare("SELECT * FROM nodes WHERE id = ?").get(id) as
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
url: string | null;
|
||||
apiKey: string | null;
|
||||
status: string;
|
||||
capabilities: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!row) return undefined;
|
||||
return this.rowToNode(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a node by unique name.
|
||||
*/
|
||||
async getNodeByName(name: string): Promise<NodeConfig | undefined> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const row = this.db!.prepare("SELECT * FROM nodes WHERE name = ?").get(name) as
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
url: string | null;
|
||||
apiKey: string | null;
|
||||
status: string;
|
||||
capabilities: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!row) return undefined;
|
||||
return this.rowToNode(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all nodes ordered by name.
|
||||
*/
|
||||
async listNodes(): Promise<NodeConfig[]> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const rows = this.db!.prepare("SELECT * FROM nodes ORDER BY name").all() as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
url: string | null;
|
||||
apiKey: string | null;
|
||||
status: string;
|
||||
capabilities: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => this.rowToNode(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node metadata.
|
||||
*/
|
||||
async updateNode(
|
||||
id: string,
|
||||
updates: Partial<Omit<NodeConfig, "id" | "createdAt">>
|
||||
): Promise<NodeConfig> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const node = await this.getNode(id);
|
||||
if (!node) {
|
||||
throw new Error(`Node not found: ${id}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated: NodeConfig = {
|
||||
...node,
|
||||
...updates,
|
||||
id,
|
||||
createdAt: node.createdAt,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (!Number.isFinite(updated.maxConcurrent) || updated.maxConcurrent < 1) {
|
||||
throw new Error(`Node maxConcurrent must be >= 1: ${updated.maxConcurrent}`);
|
||||
}
|
||||
|
||||
if (updated.type === "remote" && !updated.url) {
|
||||
throw new Error("Remote nodes must include a url");
|
||||
}
|
||||
if (updated.type === "local" && (updated.url || updated.apiKey)) {
|
||||
throw new Error("Local nodes must not include url or apiKey");
|
||||
}
|
||||
|
||||
this.db!.prepare(
|
||||
`UPDATE nodes SET
|
||||
name = ?,
|
||||
type = ?,
|
||||
url = ?,
|
||||
apiKey = ?,
|
||||
status = ?,
|
||||
capabilities = ?,
|
||||
maxConcurrent = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
updated.name,
|
||||
updated.type,
|
||||
updated.url ?? null,
|
||||
updated.apiKey ?? null,
|
||||
updated.status,
|
||||
toJsonNullable(updated.capabilities),
|
||||
updated.maxConcurrent,
|
||||
updated.updatedAt,
|
||||
id
|
||||
);
|
||||
|
||||
this.db!.bumpLastModified();
|
||||
this.emit("node:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check node health and update stored status.
|
||||
*/
|
||||
async checkNodeHealth(id: string): Promise<NodeStatus> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const node = await this.getNode(id);
|
||||
if (!node) {
|
||||
throw new Error(`Node not found: ${id}`);
|
||||
}
|
||||
|
||||
let nextStatus: NodeStatus;
|
||||
|
||||
if (node.type === "local") {
|
||||
nextStatus = "online";
|
||||
} else if (!node.url) {
|
||||
nextStatus = "error";
|
||||
} else {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5_000);
|
||||
|
||||
try {
|
||||
const healthUrl = new URL("/api/health", node.url).toString();
|
||||
const response = await fetch(healthUrl, {
|
||||
method: "GET",
|
||||
headers: node.apiKey ? { Authorization: `Bearer ${node.apiKey}` } : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
nextStatus = response.ok ? "online" : "offline";
|
||||
} catch {
|
||||
nextStatus = "error";
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStatus !== node.status) {
|
||||
const now = new Date().toISOString();
|
||||
const updated: NodeConfig = {
|
||||
...node,
|
||||
status: nextStatus,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db!
|
||||
.prepare("UPDATE nodes SET status = ?, updatedAt = ? WHERE id = ?")
|
||||
.run(nextStatus, now, id);
|
||||
this.db!.bumpLastModified();
|
||||
this.emit("node:health:changed", updated);
|
||||
}
|
||||
|
||||
return nextStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a project to a node.
|
||||
*/
|
||||
async assignProjectToNode(projectId: string, nodeId: string): Promise<RegisteredProject> {
|
||||
this.ensureInitialized();
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
this.db!.prepare("UPDATE projects SET nodeId = ?, updatedAt = ? WHERE id = ?").run(node.id, now, projectId);
|
||||
this.db!.bumpLastModified();
|
||||
|
||||
const updated: RegisteredProject = {
|
||||
...project,
|
||||
nodeId: node.id,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.emit("project:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unassign a project from any node.
|
||||
*/
|
||||
async unassignProjectFromNode(projectId: string): Promise<RegisteredProject> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const project = await this.getProject(projectId);
|
||||
if (!project) {
|
||||
throw new Error(`Project not found: ${projectId}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
this.db!.prepare("UPDATE projects SET nodeId = NULL, updatedAt = ? WHERE id = ?").run(now, projectId);
|
||||
this.db!.bumpLastModified();
|
||||
|
||||
const updated: RegisteredProject = {
|
||||
...project,
|
||||
nodeId: undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.emit("project:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── Project Health API ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -928,6 +1305,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastActivityAt: string | null;
|
||||
nodeId: string | null;
|
||||
settings: string | null;
|
||||
}): RegisteredProject {
|
||||
return {
|
||||
@@ -939,10 +1317,37 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
lastActivityAt: row.lastActivityAt ?? undefined,
|
||||
nodeId: row.nodeId ?? undefined,
|
||||
settings: fromJson<ProjectSettings>(row.settings),
|
||||
};
|
||||
}
|
||||
|
||||
private rowToNode(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
url: string | null;
|
||||
apiKey: string | null;
|
||||
status: string;
|
||||
capabilities: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}): NodeConfig {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type as NodeConfig["type"],
|
||||
url: row.url ?? undefined,
|
||||
apiKey: row.apiKey ?? undefined,
|
||||
status: row.status as NodeStatus,
|
||||
capabilities: fromJson<AgentCapability[]>(row.capabilities),
|
||||
maxConcurrent: row.maxConcurrent,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private rowToHealth(row: {
|
||||
projectId: string;
|
||||
status: string;
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("CentralDatabase", () => {
|
||||
|
||||
it("should initialize schema version", () => {
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(1);
|
||||
expect(db.getSchemaVersion()).toBe(2);
|
||||
});
|
||||
|
||||
it("should seed lastModified on init", () => {
|
||||
@@ -62,6 +62,26 @@ describe("CentralDatabase", () => {
|
||||
expect(row?.queuedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("should apply nodes defaults when optional values are omitted", () => {
|
||||
db.init();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, name, type, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("node_test", "local-test", "local", now, now);
|
||||
|
||||
const row = db.prepare("SELECT status, maxConcurrent FROM nodes WHERE id = ?").get("node_test") as
|
||||
| {
|
||||
status: string;
|
||||
maxConcurrent: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.status).toBe("offline");
|
||||
expect(row?.maxConcurrent).toBe(2);
|
||||
});
|
||||
|
||||
it("should create all required tables", () => {
|
||||
db.init();
|
||||
const tables = db
|
||||
@@ -72,9 +92,20 @@ describe("CentralDatabase", () => {
|
||||
expect(tableNames).toContain("projectHealth");
|
||||
expect(tableNames).toContain("centralActivityLog");
|
||||
expect(tableNames).toContain("globalConcurrency");
|
||||
expect(tableNames).toContain("nodes");
|
||||
expect(tableNames).toContain("__meta");
|
||||
});
|
||||
|
||||
it("should include nodeId column on projects table", () => {
|
||||
db.init();
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(projects)").all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
const columnNames = columns.map((column) => column.name);
|
||||
expect(columnNames).toContain("nodeId");
|
||||
});
|
||||
|
||||
it("should create required indexes", () => {
|
||||
db.init();
|
||||
const indexes = db
|
||||
@@ -86,6 +117,8 @@ describe("CentralDatabase", () => {
|
||||
expect(indexNames).toContain("idxActivityLogTimestamp");
|
||||
expect(indexNames).toContain("idxActivityLogType");
|
||||
expect(indexNames).toContain("idxActivityLogProjectId");
|
||||
expect(indexNames).toContain("idxNodesStatus");
|
||||
expect(indexNames).toContain("idxNodesType");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export { toJson, toJsonNullable, fromJson };
|
||||
|
||||
// ── Schema Definition ───────────────────────────────────────────────────
|
||||
|
||||
const CENTRAL_SCHEMA_VERSION = 1;
|
||||
const CENTRAL_SCHEMA_VERSION = 2;
|
||||
|
||||
const CENTRAL_SCHEMA_SQL = `
|
||||
-- Projects table (project registry)
|
||||
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
lastActivityAt TEXT,
|
||||
nodeId TEXT,
|
||||
settings TEXT -- JSON ProjectSettings snapshot
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxProjectsPath ON projects(path);
|
||||
@@ -86,6 +87,22 @@ CREATE TABLE IF NOT EXISTS globalConcurrency (
|
||||
INSERT OR IGNORE INTO globalConcurrency (id, globalMaxConcurrent, currentlyActive, queuedCount)
|
||||
VALUES (1, 4, 0, 0);
|
||||
|
||||
-- Nodes table (runtime hosts for project execution)
|
||||
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,
|
||||
maxConcurrent INTEGER NOT NULL DEFAULT 2,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxNodesStatus ON nodes(status);
|
||||
CREATE INDEX IF NOT EXISTS idxNodesType ON nodes(type);
|
||||
|
||||
-- Schema version tracking
|
||||
CREATE TABLE IF NOT EXISTS __meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -93,6 +110,23 @@ CREATE TABLE IF NOT EXISTS __meta (
|
||||
);
|
||||
`;
|
||||
|
||||
const CENTRAL_SCHEMA_V2_MIGRATION_SQL = `
|
||||
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,
|
||||
maxConcurrent INTEGER NOT NULL DEFAULT 2,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxNodesStatus ON nodes(status);
|
||||
CREATE INDEX IF NOT EXISTS idxNodesType ON nodes(type);
|
||||
`;
|
||||
|
||||
// ── Central Database Class ────────────────────────────────────────────────
|
||||
|
||||
export class CentralDatabase {
|
||||
@@ -126,15 +160,32 @@ export class CentralDatabase {
|
||||
init(): void {
|
||||
this.db.exec(CENTRAL_SCHEMA_SQL);
|
||||
|
||||
// Seed schemaVersion and lastModified idempotently
|
||||
this.db.exec(
|
||||
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('schemaVersion', '${CENTRAL_SCHEMA_VERSION}')`,
|
||||
);
|
||||
const currentVersion = this.getSchemaVersion();
|
||||
if (currentVersion < 2) {
|
||||
this.db.exec(CENTRAL_SCHEMA_V2_MIGRATION_SQL);
|
||||
if (!this.hasColumn("projects", "nodeId")) {
|
||||
this.db.exec("ALTER TABLE projects ADD COLUMN nodeId TEXT");
|
||||
}
|
||||
this.db
|
||||
.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
||||
.run(String(CENTRAL_SCHEMA_VERSION));
|
||||
} else {
|
||||
this.db.exec(
|
||||
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('schemaVersion', '${CENTRAL_SCHEMA_VERSION}')`,
|
||||
);
|
||||
}
|
||||
|
||||
// Seed lastModified idempotently
|
||||
this.db.exec(
|
||||
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('lastModified', '${Date.now()}')`,
|
||||
);
|
||||
}
|
||||
|
||||
private hasColumn(table: string, column: string): boolean {
|
||||
const rows = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
return rows.some((row) => row.name === column);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection.
|
||||
*/
|
||||
|
||||
@@ -128,18 +128,20 @@ export type { MissionStoreEvents, MissionSummary } from "./mission-store.js";
|
||||
export { CentralCore } from "./central-core.js";
|
||||
export type { CentralCoreEvents } from "./central-core.js";
|
||||
export { CentralDatabase, createCentralDatabase } from "./central-db.js";
|
||||
export type {
|
||||
RegisteredProject,
|
||||
export type {
|
||||
CentralActivityLogEntry,
|
||||
GlobalConcurrencyState,
|
||||
IsolationMode,
|
||||
MigrationOptions,
|
||||
NodeConfig,
|
||||
NodeStatus,
|
||||
ProjectHealth,
|
||||
/** @deprecated Use RegisteredProject instead */
|
||||
ProjectInfo,
|
||||
IsolationMode,
|
||||
ProjectStatus,
|
||||
ProjectHealth,
|
||||
CentralActivityLogEntry,
|
||||
GlobalConcurrencyState,
|
||||
MigrationOptions,
|
||||
SetupState,
|
||||
ProjectStatus,
|
||||
RegisteredProject,
|
||||
SetupCompletionResult,
|
||||
SetupState,
|
||||
} from "./types.js";
|
||||
|
||||
// ── Migration and First-Run Experience ────────────────────────────────
|
||||
|
||||
@@ -1247,6 +1247,33 @@ export type IsolationMode = "in-process" | "child-process";
|
||||
/** Project status in the central registry */
|
||||
export type ProjectStatus = "active" | "paused" | "errored" | "initializing";
|
||||
|
||||
/** Node connectivity/health status in the central registry */
|
||||
export type NodeStatus = "online" | "offline" | "connecting" | "error";
|
||||
|
||||
/** A runtime node that can host project execution (local machine or remote host) */
|
||||
export interface NodeConfig {
|
||||
/** Unique node ID (e.g., "node_abc123") */
|
||||
id: string;
|
||||
/** Display name (unique across all nodes) */
|
||||
name: string;
|
||||
/** Node type */
|
||||
type: "local" | "remote";
|
||||
/** Base URL for remote nodes. Undefined for local nodes. */
|
||||
url?: string;
|
||||
/** API key used for authenticating requests to remote nodes. */
|
||||
apiKey?: string;
|
||||
/** Current node status */
|
||||
status: NodeStatus;
|
||||
/** Optional capabilities available on this node */
|
||||
capabilities?: AgentCapability[];
|
||||
/** Maximum concurrent tasks/runtimes this node can host */
|
||||
maxConcurrent: number;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** A project registered in the central database */
|
||||
export interface RegisteredProject {
|
||||
/** Unique project ID (e.g., "proj_abc123") */
|
||||
@@ -1259,6 +1286,8 @@ export interface RegisteredProject {
|
||||
status: ProjectStatus;
|
||||
/** Execution isolation mode */
|
||||
isolationMode: IsolationMode;
|
||||
/** Optional runtime node assignment */
|
||||
nodeId?: string;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
|
||||
Reference in New Issue
Block a user