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:
379
packages/dashboard/src/__tests__/mesh-routes.test.ts
Normal file
379
packages/dashboard/src/__tests__/mesh-routes.test.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { request } from "../test-request.js";
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
// Request helper type for the test-request module
|
||||
type TestRequestFn = (
|
||||
app: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => void,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: string,
|
||||
headers?: Record<string, string>
|
||||
) => Promise<{ status: number; body: unknown; headers: Record<string, string | string[] | undefined> }>;
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||
const mockMergePeers = vi.fn().mockResolvedValue({ added: [], updated: [] });
|
||||
const mockGetAllKnownPeerInfo = vi.fn().mockResolvedValue([]);
|
||||
const mockGetLocalPeerInfo = vi.fn();
|
||||
const mockGetNode = vi.fn();
|
||||
const mockUpdateNode = vi.fn();
|
||||
const mockGetLocalNode = vi.fn();
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
CentralCore: vi.fn().mockImplementation(() => ({
|
||||
init: mockInit,
|
||||
close: mockClose,
|
||||
mergePeers: mockMergePeers,
|
||||
getAllKnownPeerInfo: mockGetAllKnownPeerInfo,
|
||||
getLocalPeerInfo: mockGetLocalPeerInfo,
|
||||
getNode: mockGetNode,
|
||||
updateNode: mockUpdateNode,
|
||||
getLocalNode: mockGetLocalNode,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
getRootDir(): string {
|
||||
return "/tmp/fn-1224";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
|
||||
};
|
||||
}
|
||||
|
||||
getMissionStore() {
|
||||
return {
|
||||
listMissions: vi.fn().mockResolvedValue([]),
|
||||
createMission: vi.fn(),
|
||||
getMission: vi.fn(),
|
||||
updateMission: vi.fn(),
|
||||
deleteMission: vi.fn(),
|
||||
listTemplates: vi.fn().mockResolvedValue([]),
|
||||
createTemplate: vi.fn(),
|
||||
getTemplate: vi.fn(),
|
||||
updateTemplate: vi.fn(),
|
||||
deleteTemplate: vi.fn(),
|
||||
instantiateMission: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
async listTasks(): Promise<Task[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function makePeerInfo(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
nodeId: "node_peer_1",
|
||||
nodeName: "Peer Node 1",
|
||||
nodeUrl: "https://peer-1.example.com",
|
||||
status: "online",
|
||||
metrics: null,
|
||||
lastSeen: "2026-04-01T12:00:00.000Z",
|
||||
maxConcurrent: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeNodeConfig(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: "node_remote_1",
|
||||
name: "Remote Node",
|
||||
type: "remote",
|
||||
url: "https://remote.example.com",
|
||||
apiKey: undefined,
|
||||
status: "online",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "2026-04-01T10:00:00.000Z",
|
||||
updatedAt: "2026-04-01T12:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("POST /api/mesh/sync", () => {
|
||||
let app: ReturnType<typeof createServer>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mock implementations
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockClose.mockResolvedValue(undefined);
|
||||
mockMergePeers.mockResolvedValue({ added: [], updated: [] });
|
||||
mockGetAllKnownPeerInfo.mockResolvedValue([]);
|
||||
mockGetLocalPeerInfo.mockResolvedValue({
|
||||
nodeId: "node_local",
|
||||
nodeName: "local",
|
||||
nodeUrl: "",
|
||||
status: "online",
|
||||
metrics: null,
|
||||
lastSeen: "2026-04-01T12:00:00.000Z",
|
||||
maxConcurrent: 4,
|
||||
});
|
||||
mockGetNode.mockResolvedValue(undefined);
|
||||
mockUpdateNode.mockResolvedValue({ id: "node_remote", status: "online" });
|
||||
mockGetLocalNode.mockResolvedValue({
|
||||
id: "node_local",
|
||||
name: "local",
|
||||
type: "local",
|
||||
status: "online",
|
||||
maxConcurrent: 4,
|
||||
createdAt: "2026-04-01T10:00:00.000Z",
|
||||
updatedAt: "2026-04-01T12:00:00.000Z",
|
||||
});
|
||||
|
||||
const store = new MockStore();
|
||||
app = createServer(store);
|
||||
});
|
||||
|
||||
it("should merge peers and return sync response", async () => {
|
||||
const peers = [makePeerInfo({ nodeId: "node_new" })];
|
||||
const allKnownPeers = [
|
||||
makePeerInfo({ nodeId: "node_local", nodeName: "local" }),
|
||||
makePeerInfo({ nodeId: "node_new" }),
|
||||
makePeerInfo({ nodeId: "node_existing" }),
|
||||
];
|
||||
|
||||
mockMergePeers.mockResolvedValue({ added: ["node_new"], updated: [] });
|
||||
mockGetAllKnownPeerInfo.mockResolvedValue(allKnownPeers);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/mesh/sync",
|
||||
JSON.stringify({
|
||||
senderNodeId: "node_remote",
|
||||
senderNodeUrl: "https://remote.example.com",
|
||||
knownPeers: peers,
|
||||
timestamp: "2026-04-01T12:00:00.000Z",
|
||||
}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockMergePeers).toHaveBeenCalledWith(peers);
|
||||
expect(response.body).toMatchObject({
|
||||
senderNodeId: "node_local",
|
||||
knownPeers: allKnownPeers,
|
||||
timestamp: expect.any(String),
|
||||
});
|
||||
expect((response.body as any).newPeers).toHaveLength(2); // node_local and node_existing (node_new was in knownPeers)
|
||||
});
|
||||
|
||||
it("should reject missing senderNodeId with 400", async () => {
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/mesh/sync",
|
||||
JSON.stringify({ knownPeers: [] }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toMatchObject({ error: "senderNodeId is required" });
|
||||
});
|
||||
|
||||
it("should reject non-array knownPeers with 400", async () => {
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/mesh/sync",
|
||||
JSON.stringify({
|
||||
senderNodeId: "node_remote",
|
||||
knownPeers: "not-an-array",
|
||||
}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toMatchObject({ error: "knownPeers must be an array" });
|
||||
});
|
||||
|
||||
it("should reject malformed peer entries with 400", async () => {
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/mesh/sync",
|
||||
JSON.stringify({
|
||||
senderNodeId: "node_remote",
|
||||
knownPeers: [
|
||||
{ nodeId: "valid" }, // missing nodeName and status
|
||||
],
|
||||
}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((response.body as any).error).toContain("Each knownPeers entry must have");
|
||||
});
|
||||
|
||||
it("should update sender node status to online", async () => {
|
||||
mockGetNode.mockResolvedValue(makeNodeConfig({ id: "node_remote", status: "offline" }));
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/mesh/sync",
|
||||
JSON.stringify({
|
||||
senderNodeId: "node_remote",
|
||||
senderNodeUrl: "https://remote.example.com",
|
||||
knownPeers: [],
|
||||
timestamp: "2026-04-01T12:00:00.000Z",
|
||||
}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockUpdateNode).toHaveBeenCalledWith("node_remote", { status: "online" });
|
||||
});
|
||||
|
||||
it("should silently skip update if sender node not found", async () => {
|
||||
mockGetNode.mockResolvedValue(undefined);
|
||||
mockUpdateNode.mockRejectedValue(new Error("Node not found"));
|
||||
|
||||
// Should not throw, just silently skip
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/mesh/sync",
|
||||
JSON.stringify({
|
||||
senderNodeId: "node_unknown",
|
||||
senderNodeUrl: "https://unknown.example.com",
|
||||
knownPeers: [],
|
||||
timestamp: "2026-04-01T12:00:00.000Z",
|
||||
}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should validate API key when sender has one configured", async () => {
|
||||
mockGetNode.mockResolvedValue(makeNodeConfig({ apiKey: "secret-key" }));
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/mesh/sync",
|
||||
JSON.stringify({
|
||||
senderNodeId: "node_remote",
|
||||
senderNodeUrl: "https://remote.example.com",
|
||||
knownPeers: [],
|
||||
timestamp: "2026-04-01T12:00:00.000Z",
|
||||
}),
|
||||
{ "Content-Type": "application/json", "Authorization": "Bearer wrong-key" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toMatchObject({ error: "Unauthorized" });
|
||||
});
|
||||
|
||||
it("should accept request with correct API key", async () => {
|
||||
mockGetNode.mockResolvedValue(makeNodeConfig({ apiKey: "correct-key" }));
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/mesh/sync",
|
||||
JSON.stringify({
|
||||
senderNodeId: "node_remote",
|
||||
senderNodeUrl: "https://remote.example.com",
|
||||
knownPeers: [],
|
||||
timestamp: "2026-04-01T12:00:00.000Z",
|
||||
}),
|
||||
{ "Content-Type": "application/json", "Authorization": "Bearer correct-key" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockMergePeers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should allow request without auth when sender has no API key", async () => {
|
||||
mockGetNode.mockResolvedValue(makeNodeConfig({ apiKey: undefined }));
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/mesh/sync",
|
||||
JSON.stringify({
|
||||
senderNodeId: "node_remote",
|
||||
senderNodeUrl: "https://remote.example.com",
|
||||
knownPeers: [],
|
||||
timestamp: "2026-04-01T12:00:00.000Z",
|
||||
}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockMergePeers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle empty knownPeers array", async () => {
|
||||
const localPeer = makePeerInfo({ nodeId: "node_local", nodeName: "local" });
|
||||
mockGetAllKnownPeerInfo.mockResolvedValue([localPeer]);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/mesh/sync",
|
||||
JSON.stringify({
|
||||
senderNodeId: "node_remote",
|
||||
senderNodeUrl: "https://remote.example.com",
|
||||
knownPeers: [],
|
||||
timestamp: "2026-04-01T12:00:00.000Z",
|
||||
}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockMergePeers).toHaveBeenCalledWith([]);
|
||||
expect((response.body as any).newPeers).toHaveLength(1); // All local peers are "new" to sender
|
||||
expect((response.body as any).newPeers[0].nodeId).toBe("node_local");
|
||||
});
|
||||
|
||||
it("should compute newPeers correctly - sender knows some peers", async () => {
|
||||
const allKnownPeers = [
|
||||
makePeerInfo({ nodeId: "node_local", nodeName: "local" }),
|
||||
makePeerInfo({ nodeId: "node_a" }),
|
||||
makePeerInfo({ nodeId: "node_b" }),
|
||||
makePeerInfo({ nodeId: "node_c" }),
|
||||
];
|
||||
|
||||
mockGetAllKnownPeerInfo.mockResolvedValue(allKnownPeers);
|
||||
|
||||
// Sender knows node_a and node_b, but not node_c or node_local
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/mesh/sync",
|
||||
JSON.stringify({
|
||||
senderNodeId: "node_remote",
|
||||
senderNodeUrl: "https://remote.example.com",
|
||||
knownPeers: [
|
||||
makePeerInfo({ nodeId: "node_a" }),
|
||||
makePeerInfo({ nodeId: "node_b" }),
|
||||
],
|
||||
timestamp: "2026-04-01T12:00:00.000Z",
|
||||
}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((response.body as any).newPeers).toHaveLength(2);
|
||||
const newPeerIds = (response.body as any).newPeers.map((p: { nodeId: string }) => p.nodeId);
|
||||
expect(newPeerIds).toContain("node_local");
|
||||
expect(newPeerIds).toContain("node_c");
|
||||
expect(newPeerIds).not.toContain("node_a");
|
||||
expect(newPeerIds).not.toContain("node_b");
|
||||
});
|
||||
});
|
||||
@@ -10671,6 +10671,90 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/mesh/sync
|
||||
* Exchange peer information with another node for gossip protocol.
|
||||
*
|
||||
* Request body: PeerSyncRequest
|
||||
* Response body: PeerSyncResponse
|
||||
*/
|
||||
router.post("/mesh/sync", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
// Validate required fields
|
||||
const senderNodeId = req.body?.senderNodeId;
|
||||
if (!senderNodeId) {
|
||||
throw badRequest("senderNodeId is required");
|
||||
}
|
||||
|
||||
const knownPeers = req.body?.knownPeers;
|
||||
if (!Array.isArray(knownPeers)) {
|
||||
throw badRequest("knownPeers must be an array");
|
||||
}
|
||||
|
||||
// Optional: validate knownPeers entries have required fields
|
||||
for (const peer of knownPeers) {
|
||||
if (!peer?.nodeId || !peer?.nodeName || typeof peer?.status !== "string") {
|
||||
throw badRequest("Each knownPeers entry must have nodeId, nodeName, and status");
|
||||
}
|
||||
}
|
||||
|
||||
// Get sender node from registry to validate auth
|
||||
const senderNode = await central.getNode(senderNodeId);
|
||||
|
||||
// Auth validation: if sender is registered with an apiKey, validate it
|
||||
if (senderNode?.apiKey) {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
|
||||
|
||||
if (!token || token !== senderNode.apiKey) {
|
||||
await central.close();
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge incoming peer data
|
||||
await central.mergePeers(knownPeers);
|
||||
|
||||
// Update sender node status to online (it sent us a request, so it's alive)
|
||||
try {
|
||||
await central.updateNode(senderNodeId, { status: "online" });
|
||||
} catch {
|
||||
// Silently skip if sender node not found in local registry
|
||||
}
|
||||
|
||||
// Get all known peers
|
||||
const allKnownPeers = await central.getAllKnownPeerInfo();
|
||||
|
||||
// Calculate newPeers - peers the sender doesn't know about
|
||||
const senderKnownIds = new Set(knownPeers.map((p: { nodeId: string }) => p.nodeId));
|
||||
const newPeers = allKnownPeers.filter((peer) => !senderKnownIds.has(peer.nodeId));
|
||||
|
||||
// Get local node info
|
||||
const localPeer = await central.getLocalPeerInfo();
|
||||
|
||||
await central.close();
|
||||
|
||||
// Return sync response
|
||||
res.json({
|
||||
senderNodeId: localPeer.nodeId,
|
||||
senderNodeUrl: localPeer.nodeUrl,
|
||||
knownPeers: allKnownPeers,
|
||||
newPeers,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Node Discovery Routes (mDNS / DNS-SD) ────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user