feat(FN-1227): add mesh state and system metrics support to CentralCore

- Add SystemMetrics, PeerNode, NodeMeshState, and MeshDiscovery types and export new mesh/metrics APIs from @fusion/core
- Introduce collectSystemMetrics with CPU, memory, storage, and uptime collection (including check-disk-space integration) plus dedicated unit tests
- Bump central DB schema to v3 with nodes.systemMetrics/knownPeers and peerNodes table, including migration coverage for v2->v3 upgrades
- Extend CentralCore with node metrics updates, peer register/unregister/list operations, mesh snapshot reporting, and event emissions backed by expanded test coverage
This commit is contained in:
gsxdsm
2026-04-08 11:21:18 -07:00
parent 2ef1fb0f72
commit 9bb5773d5a
10 changed files with 851 additions and 2 deletions

View File

@@ -35,6 +35,7 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"check-disk-space": "^3.4.0",
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"extract-zip": "^2.0.1", "extract-zip": "^2.0.1",
"yaml": "^2.8.3" "yaml": "^2.8.3"

View File

@@ -4,11 +4,13 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { CentralCore } from "./central-core.js"; import { CentralCore } from "./central-core.js";
import { NodeConnection, type ConnectionResult } from "./node-connection.js"; import { NodeConnection, type ConnectionResult } from "./node-connection.js";
import * as systemMetrics from "./system-metrics.js";
import type { import type {
RegisteredProject, RegisteredProject,
ProjectHealth, ProjectHealth,
CentralActivityLogEntry, CentralActivityLogEntry,
GlobalConcurrencyState, GlobalConcurrencyState,
SystemMetrics,
} from "./types.js"; } from "./types.js";
describe("CentralCore", () => { describe("CentralCore", () => {
@@ -871,6 +873,188 @@ describe("CentralCore", () => {
expect(healthSpy).not.toHaveBeenCalled(); expect(healthSpy).not.toHaveBeenCalled();
expect(emittedResult).toEqual(connectionResult); expect(emittedResult).toEqual(connectionResult);
}); });
it("should update node metrics and emit node:metrics:updated", async () => {
const local = (await central.listNodes()).find((node) => node.type === "local");
expect(local).toBeDefined();
const metrics: SystemMetrics = {
cpuUsage: 23,
memoryUsed: 200,
memoryTotal: 500,
storageUsed: 1_500,
storageTotal: 4_000,
uptime: 12_000,
reportedAt: "2026-04-01T12:00:00.000Z",
};
let eventPayload: { nodeId: string; metrics: SystemMetrics } | undefined;
central.on("node:metrics:updated", (payload) => {
eventPayload = payload;
});
const updated = await central.updateNodeMetrics(local!.id, metrics);
expect(updated.systemMetrics).toEqual(metrics);
expect(eventPayload).toEqual({ nodeId: local!.id, metrics });
});
it("should register peer nodes, list peers, and keep knownPeers in sync", async () => {
const local = (await central.listNodes()).find((node) => node.type === "local");
expect(local).toBeDefined();
const firstPeer = await central.registerPeerNode({
nodeId: local!.id,
peerNodeId: "node_peer_b",
name: "Peer B",
url: "https://peer-b.example",
});
const secondPeer = await central.registerPeerNode({
nodeId: local!.id,
peerNodeId: "node_peer_a",
name: "Peer A",
url: "https://peer-a.example",
});
expect(firstPeer.peerNodeId).toBe("node_peer_b");
expect(secondPeer.peerNodeId).toBe("node_peer_a");
const peers = await central.listPeers(local!.id);
expect(peers.map((peer) => peer.name)).toEqual(["Peer A", "Peer B"]);
const storedNode = await central.getNode(local!.id);
expect(storedNode?.knownPeers).toEqual(expect.arrayContaining(["node_peer_a", "node_peer_b"]));
expect(storedNode?.knownPeers).toHaveLength(2);
});
it("should emit mesh:peer:added and mesh:peer:removed events", async () => {
const local = (await central.listNodes()).find((node) => node.type === "local");
expect(local).toBeDefined();
let addedPayload:
| {
nodeId: string;
peer: {
peerNodeId: string;
};
}
| undefined;
let removedPayload: { nodeId: string; peerNodeId: string } | undefined;
central.on("mesh:peer:added", (payload) => {
addedPayload = payload as typeof addedPayload;
});
central.on("mesh:peer:removed", (payload) => {
removedPayload = payload;
});
await central.registerPeerNode({
nodeId: local!.id,
peerNodeId: "node_peer_event",
name: "Peer Event",
url: "https://peer-event.example",
});
expect(addedPayload?.nodeId).toBe(local!.id);
expect(addedPayload?.peer.peerNodeId).toBe("node_peer_event");
await central.unregisterPeerNode(local!.id, "node_peer_event");
expect(removedPayload).toEqual({ nodeId: local!.id, peerNodeId: "node_peer_event" });
});
it("should handle duplicate peer registration idempotently", async () => {
const local = (await central.listNodes()).find((node) => node.type === "local");
expect(local).toBeDefined();
await central.registerPeerNode({
nodeId: local!.id,
peerNodeId: "node_dup_peer",
name: "Peer Original",
url: "https://peer-original.example",
});
await central.registerPeerNode({
nodeId: local!.id,
peerNodeId: "node_dup_peer",
name: "Peer Updated",
url: "https://peer-updated.example",
});
const peers = await central.listPeers(local!.id);
expect(peers).toHaveLength(1);
expect(peers[0].peerNodeId).toBe("node_dup_peer");
expect(peers[0].name).toBe("Peer Updated");
const node = await central.getNode(local!.id);
expect(node?.knownPeers).toEqual(["node_dup_peer"]);
});
it("should unregister peers and remove IDs from knownPeers", async () => {
const local = (await central.listNodes()).find((node) => node.type === "local");
expect(local).toBeDefined();
await central.registerPeerNode({
nodeId: local!.id,
peerNodeId: "node_peer_remove",
name: "Peer Remove",
url: "https://peer-remove.example",
});
await central.unregisterPeerNode(local!.id, "node_peer_remove");
const peers = await central.listPeers(local!.id);
expect(peers).toHaveLength(0);
const node = await central.getNode(local!.id);
expect(node?.knownPeers ?? []).not.toContain("node_peer_remove");
});
it("should return mesh state with metrics and peers", async () => {
const local = (await central.listNodes()).find((node) => node.type === "local");
expect(local).toBeDefined();
const metrics: SystemMetrics = {
cpuUsage: 45,
memoryUsed: 100,
memoryTotal: 200,
storageUsed: 300,
storageTotal: 500,
uptime: 90_000,
reportedAt: "2026-04-01T12:00:00.000Z",
};
await central.updateNodeMetrics(local!.id, metrics);
await central.registerPeerNode({
nodeId: local!.id,
peerNodeId: "node_mesh_peer",
name: "Mesh Peer",
url: "https://mesh-peer.example",
});
const state = await central.getMeshState(local!.id);
expect(state.nodeId).toBe(local!.id);
expect(state.metrics).toEqual(metrics);
expect(state.knownPeers).toHaveLength(1);
expect(state.knownPeers[0].peerNodeId).toBe("node_mesh_peer");
});
it("should report local mesh state using collected system metrics", async () => {
const metrics: SystemMetrics = {
cpuUsage: 18,
memoryUsed: 150,
memoryTotal: 250,
storageUsed: 1_000,
storageTotal: 2_000,
uptime: 50_000,
reportedAt: "2026-04-01T12:00:00.000Z",
};
const metricsSpy = vi.spyOn(systemMetrics, "collectSystemMetrics").mockResolvedValue(metrics);
const state = await central.reportMeshState();
expect(metricsSpy).toHaveBeenCalledTimes(1);
expect(state.nodeName).toBe("local");
expect(state.metrics).toEqual(metrics);
expect(state.knownPeers).toEqual([]);
});
}); });
describe("project health", () => { describe("project health", () => {

View File

@@ -44,10 +44,14 @@ import type {
AgentCapability, AgentCapability,
NodeConfig, NodeConfig,
NodeStatus, NodeStatus,
SystemMetrics,
NodeMeshState,
PeerNode,
} from "./types.js"; } from "./types.js";
import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js"; import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js";
import { resolveGlobalDir } from "./global-settings.js"; import { resolveGlobalDir } from "./global-settings.js";
import { NodeConnection } from "./node-connection.js"; import { NodeConnection } from "./node-connection.js";
import { collectSystemMetrics } from "./system-metrics.js";
import type { ConnectionOptions, ConnectionResult } from "./node-connection.js"; import type { ConnectionOptions, ConnectionResult } from "./node-connection.js";
// ── Event Types ─────────────────────────────────────────────────────────── // ── Event Types ───────────────────────────────────────────────────────────
@@ -71,6 +75,14 @@ export interface CentralCoreEvents {
"node:updated": [node: NodeConfig]; "node:updated": [node: NodeConfig];
/** Emitted when node health status changes */ /** Emitted when node health status changes */
"node:health:changed": [node: NodeConfig]; "node:health:changed": [node: NodeConfig];
/** Emitted when node metrics are updated */
"node:metrics:updated": [payload: { nodeId: string; metrics: SystemMetrics }];
/** Emitted when a mesh peer is added for a node */
"mesh:peer:added": [payload: { nodeId: string; peer: PeerNode }];
/** Emitted when a mesh peer is removed for a node */
"mesh:peer:removed": [payload: { nodeId: string; peerNodeId: string }];
/** Emitted when a node mesh snapshot changes */
"mesh:state:changed": [payload: { nodeId: string; state: NodeMeshState }];
/** Emitted after a remote node connection test completes */ /** Emitted after a remote node connection test completes */
"node:connection:test": [result: ConnectionResult]; "node:connection:test": [result: ConnectionResult];
/** Emitted when global concurrency state changes */ /** Emitted when global concurrency state changes */
@@ -555,6 +567,8 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
apiKey: string | null; apiKey: string | null;
status: string; status: string;
capabilities: string | null; capabilities: string | null;
systemMetrics: string | null;
knownPeers: string | null;
maxConcurrent: number; maxConcurrent: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -580,6 +594,8 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
apiKey: string | null; apiKey: string | null;
status: string; status: string;
capabilities: string | null; capabilities: string | null;
systemMetrics: string | null;
knownPeers: string | null;
maxConcurrent: number; maxConcurrent: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -604,6 +620,8 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
apiKey: string | null; apiKey: string | null;
status: string; status: string;
capabilities: string | null; capabilities: string | null;
systemMetrics: string | null;
knownPeers: string | null;
maxConcurrent: number; maxConcurrent: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -654,6 +672,8 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
apiKey = ?, apiKey = ?,
status = ?, status = ?,
capabilities = ?, capabilities = ?,
systemMetrics = ?,
knownPeers = ?,
maxConcurrent = ?, maxConcurrent = ?,
updatedAt = ? updatedAt = ?
WHERE id = ?` WHERE id = ?`
@@ -664,6 +684,8 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
updated.apiKey ?? null, updated.apiKey ?? null,
updated.status, updated.status,
toJsonNullable(updated.capabilities), toJsonNullable(updated.capabilities),
toJsonNullable(updated.systemMetrics),
toJsonNullable(updated.knownPeers),
updated.maxConcurrent, updated.maxConcurrent,
updated.updatedAt, updated.updatedAt,
id id
@@ -728,6 +750,221 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
return nextStatus; return nextStatus;
} }
/**
* Update metrics for a registered node.
*/
async updateNodeMetrics(id: string, metrics: SystemMetrics): Promise<NodeConfig> {
this.ensureInitialized();
const node = await this.getNode(id);
if (!node) {
throw new Error(`Node not found: ${id}`);
}
const now = new Date().toISOString();
this.db!
.prepare("UPDATE nodes SET systemMetrics = ?, updatedAt = ? WHERE id = ?")
.run(toJsonNullable(metrics), now, id);
this.db!.bumpLastModified();
const updated = await this.getNode(id);
if (!updated) {
throw new Error(`Node not found after metrics update: ${id}`);
}
this.emit("node:metrics:updated", { nodeId: id, metrics });
this.emit("node:updated", updated);
const state = await this.getMeshState(id);
this.emit("mesh:state:changed", { nodeId: id, state });
return updated;
}
/**
* List all known peers for a node.
*/
async listPeers(nodeId: string): Promise<PeerNode[]> {
this.ensureInitialized();
const rows = this.db!
.prepare("SELECT * FROM peerNodes WHERE nodeId = ? ORDER BY name")
.all(nodeId) as Array<{
id: string;
nodeId: string;
peerNodeId: string;
name: string;
url: string;
status: string;
lastSeen: string;
connectedAt: string;
}>;
return rows.map((row) => this.rowToPeerNode(row));
}
/**
* Register or update a peer node for mesh discovery.
*/
async registerPeerNode(input: {
nodeId: string;
peerNodeId: string;
name: string;
url: string;
}): Promise<PeerNode> {
this.ensureInitialized();
const node = await this.getNode(input.nodeId);
if (!node) {
throw new Error(`Node not found: ${input.nodeId}`);
}
const now = new Date().toISOString();
this.db!.transaction(() => {
this.db!
.prepare(
`INSERT INTO peerNodes (id, nodeId, peerNodeId, name, url, status, lastSeen, connectedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(nodeId, peerNodeId) DO UPDATE SET
name = excluded.name,
url = excluded.url,
status = excluded.status,
lastSeen = excluded.lastSeen`
)
.run(
`peer_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
input.nodeId,
input.peerNodeId,
input.name,
input.url,
"offline",
now,
now,
);
const knownPeers = new Set(node.knownPeers ?? []);
knownPeers.add(input.peerNodeId);
this.db!
.prepare("UPDATE nodes SET knownPeers = ?, updatedAt = ? WHERE id = ?")
.run(toJson(Array.from(knownPeers)), now, input.nodeId);
});
this.db!.bumpLastModified();
const row = this.db!
.prepare("SELECT * FROM peerNodes WHERE nodeId = ? AND peerNodeId = ?")
.get(input.nodeId, input.peerNodeId) as
| {
id: string;
nodeId: string;
peerNodeId: string;
name: string;
url: string;
status: string;
lastSeen: string;
connectedAt: string;
}
| undefined;
if (!row) {
throw new Error(
`Failed to load peer node after registration: ${input.nodeId}/${input.peerNodeId}`,
);
}
const peer = this.rowToPeerNode(row);
this.emit("mesh:peer:added", { nodeId: input.nodeId, peer });
const updatedNode = await this.getNode(input.nodeId);
if (updatedNode) {
this.emit("node:updated", updatedNode);
}
const state = await this.getMeshState(input.nodeId);
this.emit("mesh:state:changed", { nodeId: input.nodeId, state });
return peer;
}
/**
* Remove a peer node relationship.
*/
async unregisterPeerNode(nodeId: string, peerNodeId: string): Promise<void> {
this.ensureInitialized();
const node = await this.getNode(nodeId);
if (!node) {
throw new Error(`Node not found: ${nodeId}`);
}
const now = new Date().toISOString();
this.db!.transaction(() => {
this.db!.prepare("DELETE FROM peerNodes WHERE nodeId = ? AND peerNodeId = ?").run(nodeId, peerNodeId);
const knownPeers = (node.knownPeers ?? []).filter((id) => id !== peerNodeId);
this.db!
.prepare("UPDATE nodes SET knownPeers = ?, updatedAt = ? WHERE id = ?")
.run(toJson(knownPeers), now, nodeId);
});
this.db!.bumpLastModified();
this.emit("mesh:peer:removed", { nodeId, peerNodeId });
const updatedNode = await this.getNode(nodeId);
if (updatedNode) {
this.emit("node:updated", updatedNode);
}
const state = await this.getMeshState(nodeId);
this.emit("mesh:state:changed", { nodeId, state });
}
/**
* Get mesh state for a node (or the local node by default).
*/
async getMeshState(nodeId?: string): Promise<NodeMeshState> {
this.ensureInitialized();
const node = nodeId ? await this.getNode(nodeId) : await this.getLocalNode();
if (!node) {
throw new Error(nodeId ? `Node not found: ${nodeId}` : "Local node not found");
}
const peers = await this.listPeers(node.id);
return {
nodeId: node.id,
nodeName: node.name,
nodeUrl: node.url,
status: node.status,
metrics: node.systemMetrics ?? null,
lastSeen: node.updatedAt,
connectedAt: node.createdAt,
knownPeers: peers,
};
}
/**
* Collect a fresh local mesh state snapshot.
*/
async reportMeshState(): Promise<NodeMeshState> {
this.ensureInitialized();
const localNode = await this.getLocalNode();
if (!localNode) {
throw new Error("Local node not found");
}
const metrics = await collectSystemMetrics(this.db!.getPath());
await this.updateNodeMetrics(localNode.id, metrics);
return this.getMeshState(localNode.id);
}
/** /**
* Test connectivity to a remote Fusion node without registering it. * Test connectivity to a remote Fusion node without registering it.
*/ */
@@ -1400,6 +1637,8 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
apiKey: string | null; apiKey: string | null;
status: string; status: string;
capabilities: string | null; capabilities: string | null;
systemMetrics: string | null;
knownPeers: string | null;
maxConcurrent: number; maxConcurrent: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -1412,12 +1651,59 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
apiKey: row.apiKey ?? undefined, apiKey: row.apiKey ?? undefined,
status: row.status as NodeStatus, status: row.status as NodeStatus,
capabilities: fromJson<AgentCapability[]>(row.capabilities), capabilities: fromJson<AgentCapability[]>(row.capabilities),
systemMetrics: fromJson<SystemMetrics>(row.systemMetrics),
knownPeers: fromJson<string[]>(row.knownPeers),
maxConcurrent: row.maxConcurrent, maxConcurrent: row.maxConcurrent,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };
} }
private rowToPeerNode(row: {
id: string;
nodeId: string;
peerNodeId: string;
name: string;
url: string;
status: string;
lastSeen: string;
connectedAt: string;
}): PeerNode {
return {
id: row.id,
nodeId: row.nodeId,
peerNodeId: row.peerNodeId,
name: row.name,
url: row.url,
status: row.status as NodeStatus,
lastSeen: row.lastSeen,
connectedAt: row.connectedAt,
};
}
private async getLocalNode(): Promise<NodeConfig | undefined> {
const row = this.db!
.prepare("SELECT * FROM nodes WHERE type = 'local' ORDER BY createdAt ASC LIMIT 1")
.get() as
| {
id: string;
name: string;
type: string;
url: string | null;
apiKey: string | null;
status: string;
capabilities: string | null;
systemMetrics: string | null;
knownPeers: string | null;
maxConcurrent: number;
createdAt: string;
updatedAt: string;
}
| undefined;
return row ? this.rowToNode(row) : undefined;
}
private rowToHealth(row: { private rowToHealth(row: {
projectId: string; projectId: string;
status: string; status: string;

View File

@@ -39,7 +39,7 @@ describe("CentralDatabase", () => {
it("should initialize schema version", () => { it("should initialize schema version", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(2); expect(db.getSchemaVersion()).toBe(3);
}); });
it("should seed lastModified on init", () => { it("should seed lastModified on init", () => {
@@ -93,6 +93,7 @@ describe("CentralDatabase", () => {
expect(tableNames).toContain("centralActivityLog"); expect(tableNames).toContain("centralActivityLog");
expect(tableNames).toContain("globalConcurrency"); expect(tableNames).toContain("globalConcurrency");
expect(tableNames).toContain("nodes"); expect(tableNames).toContain("nodes");
expect(tableNames).toContain("peerNodes");
expect(tableNames).toContain("__meta"); expect(tableNames).toContain("__meta");
}); });
@@ -106,6 +107,39 @@ describe("CentralDatabase", () => {
expect(columnNames).toContain("nodeId"); expect(columnNames).toContain("nodeId");
}); });
it("should include systemMetrics and knownPeers columns on nodes table", () => {
db.init();
const columns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{
name: string;
}>;
const columnNames = columns.map((column) => column.name);
expect(columnNames).toContain("systemMetrics");
expect(columnNames).toContain("knownPeers");
});
it("should create peerNodes table with expected columns", () => {
db.init();
const columns = db.prepare("PRAGMA table_info(peerNodes)").all() as Array<{
name: string;
}>;
const columnNames = columns.map((column) => column.name);
expect(columnNames).toEqual(
expect.arrayContaining([
"id",
"nodeId",
"peerNodeId",
"name",
"url",
"status",
"lastSeen",
"connectedAt",
]),
);
});
it("should create required indexes", () => { it("should create required indexes", () => {
db.init(); db.init();
const indexes = db const indexes = db
@@ -119,6 +153,71 @@ describe("CentralDatabase", () => {
expect(indexNames).toContain("idxActivityLogProjectId"); expect(indexNames).toContain("idxActivityLogProjectId");
expect(indexNames).toContain("idxNodesStatus"); expect(indexNames).toContain("idxNodesStatus");
expect(indexNames).toContain("idxNodesType"); expect(indexNames).toContain("idxNodesType");
expect(indexNames).toContain("idxPeerNodesNodeId");
});
});
describe("schema migrations", () => {
it("should migrate from v2 to v3 with mesh node columns and peer table", () => {
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,
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 __meta (key, value) VALUES ('schemaVersion', '2')").run();
db.prepare("INSERT INTO __meta (key, value) VALUES ('lastModified', ?)").run(String(Date.now()));
db.prepare(
"INSERT INTO nodes (id, name, type, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)",
).run("node_legacy", "legacy", "local", now, now);
db.init();
expect(db.getSchemaVersion()).toBe(3);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name);
expect(nodeColumnNames).toContain("systemMetrics");
expect(nodeColumnNames).toContain("knownPeers");
const peerTable = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='peerNodes'")
.get() as { name: string } | undefined;
expect(peerTable?.name).toBe("peerNodes");
const peerIndexes = db
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='peerNodes'")
.all() as Array<{ name: string }>;
expect(peerIndexes.map((index) => index.name)).toContain("idxPeerNodesNodeId");
}); });
}); });

View File

@@ -23,7 +23,7 @@ export { toJson, toJsonNullable, fromJson };
// ── Schema Definition ─────────────────────────────────────────────────── // ── Schema Definition ───────────────────────────────────────────────────
const CENTRAL_SCHEMA_VERSION = 2; const CENTRAL_SCHEMA_VERSION = 3;
const CENTRAL_SCHEMA_SQL = ` const CENTRAL_SCHEMA_SQL = `
-- Projects table (project registry) -- Projects table (project registry)
@@ -96,6 +96,8 @@ CREATE TABLE IF NOT EXISTS nodes (
apiKey TEXT, apiKey TEXT,
status TEXT NOT NULL DEFAULT 'offline', status TEXT NOT NULL DEFAULT 'offline',
capabilities TEXT, capabilities TEXT,
systemMetrics TEXT,
knownPeers TEXT,
maxConcurrent INTEGER NOT NULL DEFAULT 2, maxConcurrent INTEGER NOT NULL DEFAULT 2,
createdAt TEXT NOT NULL, createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL updatedAt TEXT NOT NULL
@@ -103,6 +105,21 @@ CREATE TABLE IF NOT EXISTS nodes (
CREATE INDEX IF NOT EXISTS idxNodesStatus ON nodes(status); CREATE INDEX IF NOT EXISTS idxNodesStatus ON nodes(status);
CREATE INDEX IF NOT EXISTS idxNodesType ON nodes(type); CREATE INDEX IF NOT EXISTS idxNodesType ON nodes(type);
-- Peer nodes table (mesh awareness graph per node)
CREATE TABLE IF NOT EXISTS peerNodes (
id TEXT PRIMARY KEY,
nodeId TEXT NOT NULL,
peerNodeId TEXT NOT NULL,
name TEXT NOT NULL,
url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
lastSeen TEXT NOT NULL,
connectedAt TEXT NOT NULL,
UNIQUE(nodeId, peerNodeId),
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxPeerNodesNodeId ON peerNodes(nodeId);
-- Schema version tracking -- Schema version tracking
CREATE TABLE IF NOT EXISTS __meta ( CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -127,6 +144,29 @@ CREATE INDEX IF NOT EXISTS idxNodesStatus ON nodes(status);
CREATE INDEX IF NOT EXISTS idxNodesType ON nodes(type); CREATE INDEX IF NOT EXISTS idxNodesType ON nodes(type);
`; `;
const CENTRAL_SCHEMA_V3_MIGRATION_SQL = `
ALTER TABLE nodes ADD COLUMN systemMetrics TEXT;
ALTER TABLE nodes ADD COLUMN knownPeers TEXT;
CREATE TABLE IF NOT EXISTS peerNodes (
id TEXT PRIMARY KEY,
nodeId TEXT NOT NULL,
peerNodeId TEXT NOT NULL,
name TEXT NOT NULL,
url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
lastSeen TEXT NOT NULL,
connectedAt TEXT NOT NULL,
UNIQUE(nodeId, peerNodeId),
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxPeerNodesNodeId ON peerNodes(nodeId);
`;
const CENTRAL_SCHEMA_V3_CREATE_PEERS_SQL = CENTRAL_SCHEMA_V3_MIGRATION_SQL
.split("\n")
.filter((line) => !line.trim().startsWith("ALTER TABLE nodes ADD COLUMN"))
.join("\n");
// ── Central Database Class ──────────────────────────────────────────────── // ── Central Database Class ────────────────────────────────────────────────
export class CentralDatabase { export class CentralDatabase {
@@ -161,11 +201,28 @@ export class CentralDatabase {
this.db.exec(CENTRAL_SCHEMA_SQL); this.db.exec(CENTRAL_SCHEMA_SQL);
const currentVersion = this.getSchemaVersion(); const currentVersion = this.getSchemaVersion();
let migrated = false;
if (currentVersion < 2) { if (currentVersion < 2) {
this.db.exec(CENTRAL_SCHEMA_V2_MIGRATION_SQL); this.db.exec(CENTRAL_SCHEMA_V2_MIGRATION_SQL);
if (!this.hasColumn("projects", "nodeId")) { if (!this.hasColumn("projects", "nodeId")) {
this.db.exec("ALTER TABLE projects ADD COLUMN nodeId TEXT"); this.db.exec("ALTER TABLE projects ADD COLUMN nodeId TEXT");
} }
migrated = true;
}
if (currentVersion < 3) {
if (!this.hasColumn("nodes", "systemMetrics")) {
this.db.exec("ALTER TABLE nodes ADD COLUMN systemMetrics TEXT");
}
if (!this.hasColumn("nodes", "knownPeers")) {
this.db.exec("ALTER TABLE nodes ADD COLUMN knownPeers TEXT");
}
this.db.exec(CENTRAL_SCHEMA_V3_CREATE_PEERS_SQL);
migrated = true;
}
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")
.run(String(CENTRAL_SCHEMA_VERSION)); .run(String(CENTRAL_SCHEMA_VERSION));

View File

@@ -141,6 +141,7 @@ export { CentralCore } from "./central-core.js";
export type { CentralCoreEvents } from "./central-core.js"; export type { CentralCoreEvents } from "./central-core.js";
export { CentralDatabase, createCentralDatabase } from "./central-db.js"; export { CentralDatabase, createCentralDatabase } from "./central-db.js";
export { NodeConnection } from "./node-connection.js"; export { NodeConnection } from "./node-connection.js";
export { collectSystemMetrics } from "./system-metrics.js";
export type { export type {
ConnectionErrorType, ConnectionErrorType,
ConnectionOptions, ConnectionOptions,
@@ -152,12 +153,16 @@ export type {
CentralActivityLogEntry, CentralActivityLogEntry,
GlobalConcurrencyState, GlobalConcurrencyState,
IsolationMode, IsolationMode,
MeshDiscovery,
MigrationOptions, MigrationOptions,
NodeConfig, NodeConfig,
NodeMeshState,
NodeStatus, NodeStatus,
PeerNode,
ProjectHealth, ProjectHealth,
/** @deprecated Use RegisteredProject instead */ /** @deprecated Use RegisteredProject instead */
ProjectInfo, ProjectInfo,
SystemMetrics,
ProjectStatus, ProjectStatus,
RegisteredProject, RegisteredProject,
SetupCompletionResult, SetupCompletionResult,

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { collectSystemMetrics } from "./system-metrics.js";
const { checkDiskSpaceMock } = vi.hoisted(() => ({
checkDiskSpaceMock: vi.fn(),
}));
vi.mock("check-disk-space", () => ({
default: checkDiskSpaceMock,
}));
describe("collectSystemMetrics", () => {
beforeEach(() => {
vi.clearAllMocks();
checkDiskSpaceMock.mockResolvedValue({
diskPath: "/",
free: 250_000,
size: 1_000_000,
});
});
it("returns a valid SystemMetrics object", async () => {
const metrics = await collectSystemMetrics();
expect(metrics).toEqual(
expect.objectContaining({
cpuUsage: expect.any(Number),
memoryUsed: expect.any(Number),
memoryTotal: expect.any(Number),
storageUsed: expect.any(Number),
storageTotal: expect.any(Number),
uptime: expect.any(Number),
reportedAt: expect.any(String),
}),
);
});
it("returns cpuUsage between 0 and 100", async () => {
const metrics = await collectSystemMetrics();
expect(metrics.cpuUsage).toBeGreaterThanOrEqual(0);
expect(metrics.cpuUsage).toBeLessThanOrEqual(100);
});
it("returns memoryUsed less than or equal to memoryTotal", async () => {
const metrics = await collectSystemMetrics();
expect(metrics.memoryUsed).toBeLessThanOrEqual(metrics.memoryTotal);
});
it("returns storageUsed less than or equal to storageTotal", async () => {
const metrics = await collectSystemMetrics();
expect(metrics.storageUsed).toBeLessThanOrEqual(metrics.storageTotal);
});
it("returns uptime greater than 0", async () => {
const metrics = await collectSystemMetrics();
expect(metrics.uptime).toBeGreaterThan(0);
});
it("returns a valid ISO timestamp in reportedAt", async () => {
const metrics = await collectSystemMetrics();
expect(new Date(metrics.reportedAt).toISOString()).toBe(metrics.reportedAt);
});
it("passes dbPath through to check-disk-space", async () => {
const customPath = "/tmp/kb-metrics-db";
await collectSystemMetrics(customPath);
expect(checkDiskSpaceMock).toHaveBeenCalledWith(customPath);
});
});

View File

@@ -0,0 +1,63 @@
import { cpus, totalmem, freemem, uptime as getUptime } from "node:os";
import * as checkDiskSpaceModule from "check-disk-space";
import type { SystemMetrics } from "./types.js";
const checkDiskSpace = ((checkDiskSpaceModule as { default?: unknown }).default ??
checkDiskSpaceModule) as (directoryPath: string) => Promise<{
diskPath: string;
free: number;
size: number;
}>;
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
function toNonNegative(value: number): number {
if (!Number.isFinite(value) || value < 0) {
return 0;
}
return value;
}
/**
* Collect host-level system metrics for mesh state reporting.
*/
export async function collectSystemMetrics(dbPath?: string): Promise<SystemMetrics> {
const cpuTimes = cpus();
let busyTime = 0;
let totalTime = 0;
for (const cpu of cpuTimes) {
const busy = cpu.times.user + cpu.times.nice + cpu.times.sys;
const total = busy + cpu.times.idle + cpu.times.irq;
busyTime += busy;
totalTime += total;
}
const cpuUsage = totalTime > 0 ? (busyTime / totalTime) * 100 : 0;
const memoryTotal = toNonNegative(totalmem());
const rawMemoryUsed = memoryTotal - toNonNegative(freemem());
const memoryUsed = clamp(rawMemoryUsed, 0, memoryTotal);
const diskPath = dbPath ?? process.cwd();
const diskSpace = await checkDiskSpace(diskPath);
const storageTotal = toNonNegative(diskSpace.size);
const rawStorageUsed = storageTotal - toNonNegative(diskSpace.free);
const storageUsed = clamp(rawStorageUsed, 0, storageTotal);
const uptime = toNonNegative(getUptime() * 1000);
return {
cpuUsage: clamp(cpuUsage, 0, 100),
memoryUsed,
memoryTotal,
storageUsed,
storageTotal,
uptime,
reportedAt: new Date().toISOString(),
};
}

View File

@@ -1336,6 +1336,76 @@ export type ProjectStatus = "active" | "paused" | "errored" | "initializing";
/** Node connectivity/health status in the central registry */ /** Node connectivity/health status in the central registry */
export type NodeStatus = "online" | "offline" | "connecting" | "error"; export type NodeStatus = "online" | "offline" | "connecting" | "error";
/** Host-level resource and uptime metrics reported by a node. */
export interface SystemMetrics {
/** CPU utilization percentage (0-100). */
cpuUsage: number;
/** Used system memory in bytes. */
memoryUsed: number;
/** Total system memory in bytes. */
memoryTotal: number;
/** Used storage space in bytes. */
storageUsed: number;
/** Total storage space in bytes. */
storageTotal: number;
/** Node uptime in milliseconds. */
uptime: number;
/** ISO timestamp for when the metrics snapshot was captured. */
reportedAt: string;
}
/** A peer node known by a local node in the mesh graph. */
export interface PeerNode {
/** Unique id for this node-peer relationship. */
id: string;
/** Local node id that owns this peer entry. */
nodeId: string;
/** Remote node identifier for this peer relationship. */
peerNodeId: string;
/** Remote peer display name. */
name: string;
/** Remote peer base URL. */
url: string;
/** Last known peer connectivity status. */
status: NodeStatus;
/** ISO timestamp when the peer was last observed. */
lastSeen: string;
/** ISO timestamp when the peer relationship was created. */
connectedAt: string;
}
/** Full mesh status snapshot for a node. */
export interface NodeMeshState {
/** Node id for this snapshot. */
nodeId: string;
/** Display name of the reporting node. */
nodeName: string;
/** Optional base URL (undefined for local nodes). */
nodeUrl: string | undefined;
/** Current node status. */
status: NodeStatus;
/** Latest metrics payload for the node. */
metrics: SystemMetrics | null;
/** ISO timestamp when the node was last seen. */
lastSeen: string;
/** ISO timestamp when this node was connected/registered. */
connectedAt: string;
/** Expanded peer list for the node. */
knownPeers: PeerNode[];
}
/** Lightweight mesh discovery record for propagating peer awareness. */
export interface MeshDiscovery {
/** Node id that generated this discovery payload. */
nodeId: string;
/** Known peer node ids for the reporting node. */
knownPeers: string[];
/** ISO timestamp for latest discovery refresh. */
lastDiscoveryAt: string;
/** Monotonic version for discovery state updates. */
discoveryVersion: number;
}
/** A runtime node that can host project execution (local machine or remote host) */ /** A runtime node that can host project execution (local machine or remote host) */
export interface NodeConfig { export interface NodeConfig {
/** Unique node ID (e.g., "node_abc123") */ /** Unique node ID (e.g., "node_abc123") */
@@ -1352,6 +1422,10 @@ export interface NodeConfig {
status: NodeStatus; status: NodeStatus;
/** Optional capabilities available on this node */ /** Optional capabilities available on this node */
capabilities?: AgentCapability[]; capabilities?: AgentCapability[];
/** Optional latest host metrics for this node. */
systemMetrics?: SystemMetrics;
/** Optional list of known peer node IDs. */
knownPeers?: string[];
/** Maximum concurrent tasks/runtimes this node can host */ /** Maximum concurrent tasks/runtimes this node can host */
maxConcurrent: number; maxConcurrent: number;
/** ISO-8601 timestamp of creation */ /** ISO-8601 timestamp of creation */

9
pnpm-lock.yaml generated
View File

@@ -72,6 +72,9 @@ importers:
packages/core: packages/core:
dependencies: dependencies:
check-disk-space:
specifier: ^3.4.0
version: 3.4.0
cron-parser: cron-parser:
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.5.0 version: 5.5.0
@@ -2570,6 +2573,10 @@ packages:
chardet@2.1.1: chardet@2.1.1:
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
check-disk-space@3.4.0:
resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==}
engines: {node: '>=16'}
check-error@2.1.3: check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
@@ -8152,6 +8159,8 @@ snapshots:
chardet@2.1.1: {} chardet@2.1.1: {}
check-disk-space@3.4.0: {}
check-error@2.1.3: {} check-error@2.1.3: {}
chokidar@4.0.3: chokidar@4.0.3: