feat(FN-1224): add peer gossip protocol for mesh network synchronization
- Add peer exchange types (MeshSyncState, PeerExchangeMessage, PeerGossipConfig) to core types - Add peer merge and sync methods to CentralCore (mergeProject, syncWithPeer, getMeshState) - Add mesh sync API endpoints (GET /api/mesh/state, POST /api/mesh/sync) - Implement PeerExchangeService background gossip engine for periodic peer synchronization - Add comprehensive tests for CentralCore mesh methods and PeerExchangeService - Update memory docs with peer gossip protocol documentation
This commit is contained in:
@@ -1188,6 +1188,241 @@ describe("CentralCore", () => {
|
||||
expect(state.metrics).toEqual(metrics);
|
||||
expect(state.knownPeers).toEqual([]);
|
||||
});
|
||||
|
||||
describe("peer exchange methods", () => {
|
||||
it("should register a gossip peer and preserve its nodeId", async () => {
|
||||
const peerInfo = {
|
||||
nodeId: "node_remote_gossip",
|
||||
nodeName: "Gossip Peer",
|
||||
nodeUrl: "https://gossip.example.com",
|
||||
status: "online" as const,
|
||||
metrics: null,
|
||||
lastSeen: "2026-04-01T12:00:00.000Z",
|
||||
maxConcurrent: 3,
|
||||
};
|
||||
|
||||
const registered = await central.registerGossipPeer(peerInfo);
|
||||
|
||||
expect(registered.id).toBe("node_remote_gossip");
|
||||
expect(registered.name).toBe("Gossip Peer");
|
||||
expect(registered.type).toBe("remote");
|
||||
expect(registered.url).toBe("https://gossip.example.com");
|
||||
expect(registered.status).toBe("online");
|
||||
expect(registered.maxConcurrent).toBe(3);
|
||||
|
||||
// Verify it can be retrieved by the preserved ID
|
||||
const fetched = await central.getNode("node_remote_gossip");
|
||||
expect(fetched?.id).toBe("node_remote_gossip");
|
||||
});
|
||||
|
||||
it("should handle duplicate peer names by appending suffix", async () => {
|
||||
// First, register a local node with the same name
|
||||
await central.registerNode({ name: "Same Name", type: "local" });
|
||||
|
||||
const peerInfo = {
|
||||
nodeId: "node_same_1",
|
||||
nodeName: "Same Name",
|
||||
nodeUrl: "https://same1.example.com",
|
||||
status: "online" as const,
|
||||
metrics: null,
|
||||
lastSeen: "2026-04-01T12:00:00.000Z",
|
||||
maxConcurrent: 2,
|
||||
};
|
||||
|
||||
const registered = await central.registerGossipPeer(peerInfo);
|
||||
|
||||
// Should have suffix added to avoid collision
|
||||
expect(registered.name).toBe("Same Name-2");
|
||||
});
|
||||
|
||||
it("should merge peers - add new peers", async () => {
|
||||
const peerInfo = {
|
||||
nodeId: "node_new_peer",
|
||||
nodeName: "New Peer",
|
||||
nodeUrl: "https://new-peer.example.com",
|
||||
status: "online" as const,
|
||||
metrics: null,
|
||||
lastSeen: "2026-04-01T12:00:00.000Z",
|
||||
maxConcurrent: 2,
|
||||
};
|
||||
|
||||
const result = await central.mergePeers([peerInfo]);
|
||||
|
||||
expect(result.added).toContain("node_new_peer");
|
||||
expect(result.updated).toEqual([]);
|
||||
expect(await central.getNode("node_new_peer")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should merge peers - update stale peers", async () => {
|
||||
// First, register a peer
|
||||
const peerInfo = {
|
||||
nodeId: "node_stale_peer",
|
||||
nodeName: "Stale Peer",
|
||||
nodeUrl: "https://stale-peer.example.com",
|
||||
status: "offline" as const,
|
||||
metrics: null,
|
||||
lastSeen: "2026-04-01T11:00:00.000Z",
|
||||
maxConcurrent: 2,
|
||||
};
|
||||
await central.registerGossipPeer(peerInfo);
|
||||
|
||||
// Now merge with fresher data
|
||||
const fresherPeer = {
|
||||
...peerInfo,
|
||||
status: "online" as const,
|
||||
lastSeen: "2026-04-01T12:30:00.000Z",
|
||||
};
|
||||
|
||||
const result = await central.mergePeers([fresherPeer]);
|
||||
|
||||
expect(result.added).toEqual([]);
|
||||
expect(result.updated).toContain("node_stale_peer");
|
||||
const updated = await central.getNode("node_stale_peer");
|
||||
expect(updated?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("should merge peers - skip fresher local data", async () => {
|
||||
// First, register a peer
|
||||
const peerInfo = {
|
||||
nodeId: "node_fresher_local",
|
||||
nodeName: "Fresher Local",
|
||||
nodeUrl: "https://fresher-local.example.com",
|
||||
status: "online" as const,
|
||||
metrics: null,
|
||||
lastSeen: "2026-04-01T11:00:00.000Z",
|
||||
maxConcurrent: 2,
|
||||
};
|
||||
await central.registerGossipPeer(peerInfo);
|
||||
|
||||
// Manually update to be fresher
|
||||
await central.updateNode("node_fresher_local", {
|
||||
status: "offline",
|
||||
});
|
||||
|
||||
// Now merge with older data - should not update
|
||||
const olderPeer = {
|
||||
...peerInfo,
|
||||
status: "online" as const,
|
||||
lastSeen: "2026-04-01T10:00:00.000Z",
|
||||
};
|
||||
|
||||
const result = await central.mergePeers([olderPeer]);
|
||||
|
||||
expect(result.updated).toEqual([]);
|
||||
const updated = await central.getNode("node_fresher_local");
|
||||
expect(updated?.status).toBe("offline");
|
||||
});
|
||||
|
||||
it("should merge peers - never overwrite local node", async () => {
|
||||
const local = (await central.listNodes()).find((node) => node.type === "local");
|
||||
expect(local).toBeDefined();
|
||||
|
||||
// Create a fake peer info with the local node's ID
|
||||
const fakePeerInfo = {
|
||||
nodeId: local!.id,
|
||||
nodeName: "Fake Local",
|
||||
nodeUrl: "https://fake-local.example.com",
|
||||
status: "online" as const,
|
||||
metrics: null,
|
||||
lastSeen: "2026-04-01T12:00:00.000Z",
|
||||
maxConcurrent: 10,
|
||||
};
|
||||
|
||||
const result = await central.mergePeers([fakePeerInfo]);
|
||||
|
||||
// Should not add or update
|
||||
expect(result.added).toEqual([]);
|
||||
expect(result.updated).toEqual([]);
|
||||
|
||||
// Local node should be unchanged
|
||||
const unchanged = await central.getNode(local!.id);
|
||||
expect(unchanged?.maxConcurrent).toBe(4); // Default local node maxConcurrent
|
||||
});
|
||||
|
||||
it("should merge peers - emit events correctly", async () => {
|
||||
let gossipEvent: { nodeId: string; peer: unknown } | undefined;
|
||||
let stateChangedEvent: { nodeId: string } | undefined;
|
||||
|
||||
central.on("gossip:peer:registered", (payload) => {
|
||||
gossipEvent = payload;
|
||||
});
|
||||
central.on("mesh:state:changed", (payload) => {
|
||||
stateChangedEvent = payload;
|
||||
});
|
||||
|
||||
const peerInfo = {
|
||||
nodeId: "node_event_peer",
|
||||
nodeName: "Event Peer",
|
||||
nodeUrl: "https://event-peer.example.com",
|
||||
status: "online" as const,
|
||||
metrics: null,
|
||||
lastSeen: "2026-04-01T12:00:00.000Z",
|
||||
maxConcurrent: 2,
|
||||
};
|
||||
|
||||
await central.mergePeers([peerInfo]);
|
||||
|
||||
expect(gossipEvent?.nodeId).toBe("node_event_peer");
|
||||
expect(stateChangedEvent?.nodeId).toBeDefined();
|
||||
});
|
||||
|
||||
it("should merge peers - empty input returns empty result", async () => {
|
||||
const result = await central.mergePeers([]);
|
||||
|
||||
expect(result.added).toEqual([]);
|
||||
expect(result.updated).toEqual([]);
|
||||
});
|
||||
|
||||
it("should get local peer info", async () => {
|
||||
const peerInfo = await central.getLocalPeerInfo();
|
||||
|
||||
expect(peerInfo.nodeId).toBeDefined();
|
||||
expect(peerInfo.nodeName).toBe("local");
|
||||
expect(peerInfo.nodeUrl).toBe("");
|
||||
expect(peerInfo.status).toBe("online");
|
||||
expect(peerInfo.lastSeen).toBe("2026-04-01T12:00:00.000Z");
|
||||
expect(peerInfo.maxConcurrent).toBe(4);
|
||||
});
|
||||
|
||||
it("should get all known peer info", async () => {
|
||||
// Register some peers
|
||||
await central.registerGossipPeer({
|
||||
nodeId: "node_all_peer_1",
|
||||
nodeName: "All Peer 1",
|
||||
nodeUrl: "https://all-peer-1.example.com",
|
||||
status: "online" as const,
|
||||
metrics: null,
|
||||
lastSeen: "2026-04-01T12:00:00.000Z",
|
||||
maxConcurrent: 2,
|
||||
});
|
||||
|
||||
await central.registerGossipPeer({
|
||||
nodeId: "node_all_peer_2",
|
||||
nodeName: "All Peer 2",
|
||||
nodeUrl: "https://all-peer-2.example.com",
|
||||
status: "offline" as const,
|
||||
metrics: null,
|
||||
lastSeen: "2026-04-01T11:00:00.000Z",
|
||||
maxConcurrent: 3,
|
||||
});
|
||||
|
||||
const allPeers = await central.getAllKnownPeerInfo();
|
||||
|
||||
// Should include local node plus 2 registered peers
|
||||
expect(allPeers.length).toBeGreaterThanOrEqual(3);
|
||||
expect(allPeers.map((p) => p.nodeId)).toContain("node_all_peer_1");
|
||||
expect(allPeers.map((p) => p.nodeId)).toContain("node_all_peer_2");
|
||||
});
|
||||
|
||||
it("should get all known peer info - empty list", async () => {
|
||||
// Don't register any peers, just check the local node
|
||||
const allPeers = await central.getAllKnownPeerInfo();
|
||||
|
||||
// Should at least include the local node
|
||||
expect(allPeers.length).toBeGreaterThanOrEqual(1);
|
||||
expect(allPeers.some((p) => p.nodeName === "local")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("project health", () => {
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
NodeStatus,
|
||||
SystemMetrics,
|
||||
NodeMeshState,
|
||||
PeerInfo,
|
||||
PeerNode,
|
||||
DiscoveryConfig,
|
||||
DiscoveredNode,
|
||||
@@ -78,7 +79,7 @@ export interface CentralCoreEvents {
|
||||
"node:updated": [node: NodeConfig];
|
||||
/** Emitted when node health status changes */
|
||||
"node:health:changed": [node: NodeConfig];
|
||||
/** Emitted when node metrics are updated */
|
||||
/** Emitted when node metrics is 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 }];
|
||||
@@ -86,6 +87,8 @@ export interface CentralCoreEvents {
|
||||
"mesh:peer:removed": [payload: { nodeId: string; peerNodeId: string }];
|
||||
/** Emitted when a node mesh snapshot changes */
|
||||
"mesh:state:changed": [payload: { nodeId: string; state: NodeMeshState }];
|
||||
/** Emitted when a new node is discovered via gossip peer exchange */
|
||||
"gossip:peer:registered": [payload: { nodeId: string; peer: PeerInfo }];
|
||||
/** Emitted after a remote node connection test completes */
|
||||
"node:connection:test": [result: ConnectionResult];
|
||||
/** Emitted when network discovery starts */
|
||||
@@ -563,6 +566,68 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a remote peer node from gossip exchange.
|
||||
*
|
||||
* This method is used during peer merge to register nodes discovered via
|
||||
* the gossip protocol. It preserves the remote node's ID (rather than
|
||||
* generating a new one) so that cross-node lookups work correctly.
|
||||
*
|
||||
* @param peer — Peer info from the gossip exchange
|
||||
* @returns The registered node
|
||||
*/
|
||||
async registerGossipPeer(peer: PeerInfo): Promise<NodeConfig> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Handle name uniqueness by appending suffix if needed
|
||||
let name = peer.nodeName;
|
||||
let suffix = 1;
|
||||
while (true) {
|
||||
const existing = await this.getNodeByName(name);
|
||||
if (!existing) break;
|
||||
suffix++;
|
||||
name = `${peer.nodeName}-${suffix}`;
|
||||
}
|
||||
|
||||
// Determine URL - use provided URL or empty string for local-style
|
||||
const normalizedUrl = peer.nodeUrl || undefined;
|
||||
|
||||
const node: NodeConfig = {
|
||||
id: peer.nodeId,
|
||||
name,
|
||||
type: "remote",
|
||||
url: normalizedUrl,
|
||||
status: peer.status,
|
||||
capabilities: peer.capabilities,
|
||||
systemMetrics: peer.metrics ?? undefined,
|
||||
maxConcurrent: peer.maxConcurrent,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db!.prepare(
|
||||
`INSERT INTO nodes (id, name, type, url, status, capabilities, systemMetrics, maxConcurrent, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
node.id,
|
||||
node.name,
|
||||
node.type,
|
||||
node.url ?? null,
|
||||
node.status,
|
||||
toJsonNullable(node.capabilities),
|
||||
toJsonNullable(node.systemMetrics),
|
||||
node.maxConcurrent,
|
||||
node.createdAt,
|
||||
node.updatedAt
|
||||
);
|
||||
|
||||
this.db!.bumpLastModified();
|
||||
this.emit("node:registered", node);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a runtime node.
|
||||
*
|
||||
@@ -1001,6 +1066,116 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
return this.getMeshState(localNode.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge incoming peer information from a gossip exchange.
|
||||
*
|
||||
* This method processes a list of peers received from another node during
|
||||
* the gossip protocol. It adds new peers, updates stale entries, and
|
||||
* emits appropriate events for mesh state changes.
|
||||
*
|
||||
* @param incomingPeers — List of peer info from gossip exchange
|
||||
* @returns Object with lists of added and updated node IDs
|
||||
*/
|
||||
async mergePeers(incomingPeers: PeerInfo[]): Promise<{ added: string[]; updated: string[] }> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const added: string[] = [];
|
||||
const updated: string[] = [];
|
||||
|
||||
for (const peer of incomingPeers) {
|
||||
const existing = await this.getNode(peer.nodeId);
|
||||
|
||||
if (!existing) {
|
||||
// New peer - register it
|
||||
const newNode = await this.registerGossipPeer(peer);
|
||||
added.push(newNode.id);
|
||||
this.emit("gossip:peer:registered", { nodeId: newNode.id, peer });
|
||||
} else if (existing.type === "local") {
|
||||
// Never overwrite the local node from incoming peer data
|
||||
continue;
|
||||
} else {
|
||||
// Existing remote node - check if incoming data is fresher
|
||||
const incomingLastSeen = new Date(peer.lastSeen);
|
||||
const localUpdatedAt = new Date(existing.updatedAt);
|
||||
|
||||
if (incomingLastSeen > localUpdatedAt) {
|
||||
// Incoming data is fresher - update the node
|
||||
await this.updateNode(existing.id, {
|
||||
status: peer.status,
|
||||
url: peer.nodeUrl || undefined,
|
||||
capabilities: peer.capabilities,
|
||||
maxConcurrent: peer.maxConcurrent,
|
||||
});
|
||||
|
||||
// Update metrics if provided
|
||||
if (peer.metrics) {
|
||||
await this.updateNodeMetrics(existing.id, peer.metrics);
|
||||
}
|
||||
|
||||
updated.push(existing.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit mesh state changed if any modifications were made
|
||||
if (added.length > 0 || updated.length > 0) {
|
||||
const localNode = await this.getLocalNode();
|
||||
if (localNode) {
|
||||
const state = await this.getMeshState(localNode.id);
|
||||
this.emit("mesh:state:changed", { nodeId: localNode.id, state });
|
||||
}
|
||||
}
|
||||
|
||||
return { added, updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a PeerInfo snapshot of the local node for gossip transmission.
|
||||
*
|
||||
* @returns PeerInfo for the local node with current metrics
|
||||
*/
|
||||
async getLocalPeerInfo(): Promise<PeerInfo> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const localNode = await this.getLocalNode();
|
||||
if (!localNode) {
|
||||
throw new Error("Local node not found");
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId: localNode.id,
|
||||
nodeName: localNode.name,
|
||||
nodeUrl: localNode.url || "",
|
||||
status: localNode.status,
|
||||
metrics: localNode.systemMetrics ?? null,
|
||||
lastSeen: new Date().toISOString(),
|
||||
capabilities: localNode.capabilities,
|
||||
maxConcurrent: localNode.maxConcurrent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PeerInfo snapshots for all known nodes.
|
||||
*
|
||||
* @returns Array of PeerInfo for all nodes in the registry
|
||||
*/
|
||||
async getAllKnownPeerInfo(): Promise<PeerInfo[]> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const nodes = await this.listNodes();
|
||||
|
||||
return nodes.map((node) => ({
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
nodeUrl: node.url || "",
|
||||
status: node.status,
|
||||
metrics: node.systemMetrics ?? null,
|
||||
lastSeen: node.updatedAt,
|
||||
capabilities: node.capabilities,
|
||||
maxConcurrent: node.maxConcurrent,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connectivity to a remote Fusion node without registering it.
|
||||
*/
|
||||
|
||||
@@ -164,7 +164,10 @@ export type {
|
||||
NodeDiscoveryEvent,
|
||||
DiscoveryConfig,
|
||||
DiscoveredNode,
|
||||
PeerInfo,
|
||||
PeerNode,
|
||||
PeerSyncRequest,
|
||||
PeerSyncResponse,
|
||||
ProjectHealth,
|
||||
/** @deprecated Use RegisteredProject instead */
|
||||
ProjectInfo,
|
||||
|
||||
@@ -1549,6 +1549,52 @@ export interface MeshDiscovery {
|
||||
discoveryVersion: number;
|
||||
}
|
||||
|
||||
/** Lightweight snapshot of a known node suitable for gossip transmission. */
|
||||
export interface PeerInfo {
|
||||
/** Unique node identifier. */
|
||||
nodeId: string;
|
||||
/** Display name of the node. */
|
||||
nodeName: string;
|
||||
/** Base URL of the node (empty string for local nodes). */
|
||||
nodeUrl: string;
|
||||
/** Current node status. */
|
||||
status: NodeStatus;
|
||||
/** Latest system metrics snapshot, if available. */
|
||||
metrics: SystemMetrics | null;
|
||||
/** ISO timestamp of when this info was last updated. */
|
||||
lastSeen: string;
|
||||
/** Optional capabilities available on this node. */
|
||||
capabilities?: AgentCapability[];
|
||||
/** Maximum concurrent tasks/runtimes this node can host. */
|
||||
maxConcurrent: number;
|
||||
}
|
||||
|
||||
/** Request payload sent when a node initiates a peer sync. */
|
||||
export interface PeerSyncRequest {
|
||||
/** Node ID of the sender. */
|
||||
senderNodeId: string;
|
||||
/** Base URL of the sender node. */
|
||||
senderNodeUrl: string;
|
||||
/** List of peers known by the sender. */
|
||||
knownPeers: PeerInfo[];
|
||||
/** ISO timestamp of when this sync request was generated. */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/** Response payload returned after a peer sync exchange. */
|
||||
export interface PeerSyncResponse {
|
||||
/** Node ID of the responding node (local node). */
|
||||
senderNodeId: string;
|
||||
/** Base URL of the responding node. */
|
||||
senderNodeUrl: string;
|
||||
/** Full list of peers known by the responding node. */
|
||||
knownPeers: PeerInfo[];
|
||||
/** Peers in the local list that the sender didn't know about. */
|
||||
newPeers: PeerInfo[];
|
||||
/** ISO timestamp of when this response was generated. */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/** A runtime node that can host project execution (local machine or remote host) */
|
||||
export interface NodeConfig {
|
||||
/** Unique node ID (e.g., "node_abc123") */
|
||||
|
||||
Reference in New Issue
Block a user