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,
|
"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"
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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 */
|
/** 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
9
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user