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:
gsxdsm
2026-04-09 13:22:29 -07:00
parent fc23e522a7
commit 008f664183
11 changed files with 1473 additions and 1 deletions

View File

@@ -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", () => {

View File

@@ -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.
*/

View File

@@ -164,7 +164,10 @@ export type {
NodeDiscoveryEvent,
DiscoveryConfig,
DiscoveredNode,
PeerInfo,
PeerNode,
PeerSyncRequest,
PeerSyncResponse,
ProjectHealth,
/** @deprecated Use RegisteredProject instead */
ProjectInfo,

View File

@@ -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") */