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:
@@ -35,6 +35,7 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"check-disk-space": "^3.4.0",
|
||||
"cron-parser": "^5.5.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"yaml": "^2.8.3"
|
||||
|
||||
@@ -4,11 +4,13 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { CentralCore } from "./central-core.js";
|
||||
import { NodeConnection, type ConnectionResult } from "./node-connection.js";
|
||||
import * as systemMetrics from "./system-metrics.js";
|
||||
import type {
|
||||
RegisteredProject,
|
||||
ProjectHealth,
|
||||
CentralActivityLogEntry,
|
||||
GlobalConcurrencyState,
|
||||
SystemMetrics,
|
||||
} from "./types.js";
|
||||
|
||||
describe("CentralCore", () => {
|
||||
@@ -871,6 +873,188 @@ describe("CentralCore", () => {
|
||||
expect(healthSpy).not.toHaveBeenCalled();
|
||||
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", () => {
|
||||
|
||||
@@ -44,10 +44,14 @@ import type {
|
||||
AgentCapability,
|
||||
NodeConfig,
|
||||
NodeStatus,
|
||||
SystemMetrics,
|
||||
NodeMeshState,
|
||||
PeerNode,
|
||||
} from "./types.js";
|
||||
import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js";
|
||||
import { resolveGlobalDir } from "./global-settings.js";
|
||||
import { NodeConnection } from "./node-connection.js";
|
||||
import { collectSystemMetrics } from "./system-metrics.js";
|
||||
import type { ConnectionOptions, ConnectionResult } from "./node-connection.js";
|
||||
|
||||
// ── Event Types ───────────────────────────────────────────────────────────
|
||||
@@ -71,6 +75,14 @@ export interface CentralCoreEvents {
|
||||
"node:updated": [node: NodeConfig];
|
||||
/** Emitted when node health status changes */
|
||||
"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 */
|
||||
"node:connection:test": [result: ConnectionResult];
|
||||
/** Emitted when global concurrency state changes */
|
||||
@@ -555,6 +567,8 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
apiKey: string | null;
|
||||
status: string;
|
||||
capabilities: string | null;
|
||||
systemMetrics: string | null;
|
||||
knownPeers: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -580,6 +594,8 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
apiKey: string | null;
|
||||
status: string;
|
||||
capabilities: string | null;
|
||||
systemMetrics: string | null;
|
||||
knownPeers: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -604,6 +620,8 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
apiKey: string | null;
|
||||
status: string;
|
||||
capabilities: string | null;
|
||||
systemMetrics: string | null;
|
||||
knownPeers: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -654,6 +672,8 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
apiKey = ?,
|
||||
status = ?,
|
||||
capabilities = ?,
|
||||
systemMetrics = ?,
|
||||
knownPeers = ?,
|
||||
maxConcurrent = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?`
|
||||
@@ -664,6 +684,8 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
updated.apiKey ?? null,
|
||||
updated.status,
|
||||
toJsonNullable(updated.capabilities),
|
||||
toJsonNullable(updated.systemMetrics),
|
||||
toJsonNullable(updated.knownPeers),
|
||||
updated.maxConcurrent,
|
||||
updated.updatedAt,
|
||||
id
|
||||
@@ -728,6 +750,221 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
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.
|
||||
*/
|
||||
@@ -1400,6 +1637,8 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
apiKey: string | null;
|
||||
status: string;
|
||||
capabilities: string | null;
|
||||
systemMetrics: string | null;
|
||||
knownPeers: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -1412,12 +1651,59 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
apiKey: row.apiKey ?? undefined,
|
||||
status: row.status as NodeStatus,
|
||||
capabilities: fromJson<AgentCapability[]>(row.capabilities),
|
||||
systemMetrics: fromJson<SystemMetrics>(row.systemMetrics),
|
||||
knownPeers: fromJson<string[]>(row.knownPeers),
|
||||
maxConcurrent: row.maxConcurrent,
|
||||
createdAt: row.createdAt,
|
||||
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: {
|
||||
projectId: string;
|
||||
status: string;
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("CentralDatabase", () => {
|
||||
|
||||
it("should initialize schema version", () => {
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(2);
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
});
|
||||
|
||||
it("should seed lastModified on init", () => {
|
||||
@@ -93,6 +93,7 @@ describe("CentralDatabase", () => {
|
||||
expect(tableNames).toContain("centralActivityLog");
|
||||
expect(tableNames).toContain("globalConcurrency");
|
||||
expect(tableNames).toContain("nodes");
|
||||
expect(tableNames).toContain("peerNodes");
|
||||
expect(tableNames).toContain("__meta");
|
||||
});
|
||||
|
||||
@@ -106,6 +107,39 @@ describe("CentralDatabase", () => {
|
||||
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", () => {
|
||||
db.init();
|
||||
const indexes = db
|
||||
@@ -119,6 +153,71 @@ describe("CentralDatabase", () => {
|
||||
expect(indexNames).toContain("idxActivityLogProjectId");
|
||||
expect(indexNames).toContain("idxNodesStatus");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export { toJson, toJsonNullable, fromJson };
|
||||
|
||||
// ── Schema Definition ───────────────────────────────────────────────────
|
||||
|
||||
const CENTRAL_SCHEMA_VERSION = 2;
|
||||
const CENTRAL_SCHEMA_VERSION = 3;
|
||||
|
||||
const CENTRAL_SCHEMA_SQL = `
|
||||
-- Projects table (project registry)
|
||||
@@ -96,6 +96,8 @@ CREATE TABLE IF NOT EXISTS nodes (
|
||||
apiKey TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'offline',
|
||||
capabilities TEXT,
|
||||
systemMetrics TEXT,
|
||||
knownPeers TEXT,
|
||||
maxConcurrent INTEGER NOT NULL DEFAULT 2,
|
||||
createdAt 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 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
|
||||
CREATE TABLE IF NOT EXISTS __meta (
|
||||
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);
|
||||
`;
|
||||
|
||||
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 ────────────────────────────────────────────────
|
||||
|
||||
export class CentralDatabase {
|
||||
@@ -161,11 +201,28 @@ export class CentralDatabase {
|
||||
this.db.exec(CENTRAL_SCHEMA_SQL);
|
||||
|
||||
const currentVersion = this.getSchemaVersion();
|
||||
let migrated = false;
|
||||
|
||||
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");
|
||||
}
|
||||
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
|
||||
.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
||||
.run(String(CENTRAL_SCHEMA_VERSION));
|
||||
|
||||
@@ -141,6 +141,7 @@ export { CentralCore } from "./central-core.js";
|
||||
export type { CentralCoreEvents } from "./central-core.js";
|
||||
export { CentralDatabase, createCentralDatabase } from "./central-db.js";
|
||||
export { NodeConnection } from "./node-connection.js";
|
||||
export { collectSystemMetrics } from "./system-metrics.js";
|
||||
export type {
|
||||
ConnectionErrorType,
|
||||
ConnectionOptions,
|
||||
@@ -152,12 +153,16 @@ export type {
|
||||
CentralActivityLogEntry,
|
||||
GlobalConcurrencyState,
|
||||
IsolationMode,
|
||||
MeshDiscovery,
|
||||
MigrationOptions,
|
||||
NodeConfig,
|
||||
NodeMeshState,
|
||||
NodeStatus,
|
||||
PeerNode,
|
||||
ProjectHealth,
|
||||
/** @deprecated Use RegisteredProject instead */
|
||||
ProjectInfo,
|
||||
SystemMetrics,
|
||||
ProjectStatus,
|
||||
RegisteredProject,
|
||||
SetupCompletionResult,
|
||||
|
||||
71
packages/core/src/system-metrics.test.ts
Normal file
71
packages/core/src/system-metrics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
63
packages/core/src/system-metrics.ts
Normal file
63
packages/core/src/system-metrics.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@@ -1336,6 +1336,76 @@ export type ProjectStatus = "active" | "paused" | "errored" | "initializing";
|
||||
/** Node connectivity/health status in the central registry */
|
||||
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) */
|
||||
export interface NodeConfig {
|
||||
/** Unique node ID (e.g., "node_abc123") */
|
||||
@@ -1352,6 +1422,10 @@ export interface NodeConfig {
|
||||
status: NodeStatus;
|
||||
/** Optional capabilities available on this node */
|
||||
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 */
|
||||
maxConcurrent: number;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -72,6 +72,9 @@ importers:
|
||||
|
||||
packages/core:
|
||||
dependencies:
|
||||
check-disk-space:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
cron-parser:
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
@@ -2570,6 +2573,10 @@ packages:
|
||||
chardet@2.1.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -8152,6 +8159,8 @@ snapshots:
|
||||
|
||||
chardet@2.1.1: {}
|
||||
|
||||
check-disk-space@3.4.0: {}
|
||||
|
||||
check-error@2.1.3: {}
|
||||
|
||||
chokidar@4.0.3:
|
||||
|
||||
Reference in New Issue
Block a user