agent-inbox 0.0.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +113 -0
- package/README.md +195 -1
- package/dist/federation/address.d.ts +24 -0
- package/dist/federation/address.d.ts.map +1 -0
- package/dist/federation/address.js +54 -0
- package/dist/federation/address.js.map +1 -0
- package/dist/federation/connection-manager.d.ts +118 -0
- package/dist/federation/connection-manager.d.ts.map +1 -0
- package/dist/federation/connection-manager.js +369 -0
- package/dist/federation/connection-manager.js.map +1 -0
- package/dist/federation/delivery-queue.d.ts +66 -0
- package/dist/federation/delivery-queue.d.ts.map +1 -0
- package/dist/federation/delivery-queue.js +199 -0
- package/dist/federation/delivery-queue.js.map +1 -0
- package/dist/federation/index.d.ts +7 -0
- package/dist/federation/index.d.ts.map +1 -0
- package/dist/federation/index.js +6 -0
- package/dist/federation/index.js.map +1 -0
- package/dist/federation/routing-engine.d.ts +74 -0
- package/dist/federation/routing-engine.d.ts.map +1 -0
- package/dist/federation/routing-engine.js +158 -0
- package/dist/federation/routing-engine.js.map +1 -0
- package/dist/federation/trust.d.ts +39 -0
- package/dist/federation/trust.d.ts.map +1 -0
- package/dist/federation/trust.js +64 -0
- package/dist/federation/trust.js.map +1 -0
- package/dist/index.d.ts +60 -2
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +217 -18
- package/dist/index.js.map +1 -1
- package/dist/ipc/ipc-server.d.ts +21 -0
- package/dist/ipc/ipc-server.d.ts.map +1 -0
- package/dist/ipc/ipc-server.js +173 -0
- package/dist/ipc/ipc-server.js.map +1 -0
- package/dist/jsonrpc/mail-server.d.ts +45 -0
- package/dist/jsonrpc/mail-server.d.ts.map +1 -0
- package/dist/jsonrpc/mail-server.js +284 -0
- package/dist/jsonrpc/mail-server.js.map +1 -0
- package/dist/map/map-client.d.ts +91 -0
- package/dist/map/map-client.d.ts.map +1 -0
- package/dist/map/map-client.js +202 -0
- package/dist/map/map-client.js.map +1 -0
- package/dist/mcp/mcp-server.d.ts +23 -0
- package/dist/mcp/mcp-server.d.ts.map +1 -0
- package/dist/mcp/mcp-server.js +226 -0
- package/dist/mcp/mcp-server.js.map +1 -0
- package/dist/push/notifier.d.ts +49 -0
- package/dist/push/notifier.d.ts.map +1 -0
- package/dist/push/notifier.js +150 -0
- package/dist/push/notifier.js.map +1 -0
- package/dist/registry/warm-registry.d.ts +63 -0
- package/dist/registry/warm-registry.d.ts.map +1 -0
- package/dist/registry/warm-registry.js +173 -0
- package/dist/registry/warm-registry.js.map +1 -0
- package/dist/router/message-router.d.ts +44 -0
- package/dist/router/message-router.d.ts.map +1 -0
- package/dist/router/message-router.js +137 -0
- package/dist/router/message-router.js.map +1 -0
- package/dist/storage/interface.d.ts +31 -0
- package/dist/storage/interface.d.ts.map +1 -0
- package/dist/storage/interface.js +2 -0
- package/dist/storage/interface.js.map +1 -0
- package/dist/storage/memory.d.ts +28 -0
- package/dist/storage/memory.d.ts.map +1 -0
- package/dist/storage/memory.js +118 -0
- package/dist/storage/memory.js.map +1 -0
- package/dist/storage/sqlite.d.ts +35 -0
- package/dist/storage/sqlite.d.ts.map +1 -0
- package/dist/storage/sqlite.js +445 -0
- package/dist/storage/sqlite.js.map +1 -0
- package/dist/traceability/traceability.d.ts +29 -0
- package/dist/traceability/traceability.d.ts.map +1 -0
- package/dist/traceability/traceability.js +150 -0
- package/dist/traceability/traceability.js.map +1 -0
- package/dist/types.d.ts +261 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/docs/DESIGN.md +1156 -0
- package/docs/PLAN.md +545 -0
- package/hooks/inbox-hook.mjs +119 -0
- package/hooks/register-hook.mjs +69 -0
- package/package.json +33 -25
- package/rules/agent-inbox.md +78 -0
- package/src/federation/address.ts +61 -0
- package/src/federation/connection-manager.ts +458 -0
- package/src/federation/delivery-queue.ts +222 -0
- package/src/federation/index.ts +6 -0
- package/src/federation/routing-engine.ts +188 -0
- package/src/federation/trust.ts +71 -0
- package/src/index.ts +299 -0
- package/src/ipc/ipc-server.ts +207 -0
- package/src/jsonrpc/mail-server.ts +356 -0
- package/src/map/map-client.ts +260 -0
- package/src/mcp/mcp-server.ts +272 -0
- package/src/push/notifier.ts +192 -0
- package/src/registry/warm-registry.ts +210 -0
- package/src/router/message-router.ts +175 -0
- package/src/storage/interface.ts +48 -0
- package/src/storage/memory.ts +145 -0
- package/src/storage/sqlite.ts +645 -0
- package/src/traceability/traceability.ts +183 -0
- package/src/types.ts +297 -0
- package/test/federation/address.test.ts +101 -0
- package/test/federation/connection-manager.test.ts +546 -0
- package/test/federation/delivery-queue.test.ts +159 -0
- package/test/federation/integration.test.ts +823 -0
- package/test/federation/routing-engine.test.ts +117 -0
- package/test/federation/sdk-integration.test.ts +748 -0
- package/test/federation/trust.test.ts +89 -0
- package/test/ipc-jsonrpc.test.ts +113 -0
- package/test/ipc-server.test.ts +197 -0
- package/test/mail-server.test.ts +208 -0
- package/test/map-client.test.ts +408 -0
- package/test/message-router.test.ts +184 -0
- package/test/push-notifier.test.ts +139 -0
- package/test/registry/warm-registry.test.ts +171 -0
- package/test/sqlite-storage.test.ts +243 -0
- package/test/storage.test.ts +196 -0
- package/test/traceability.test.ts +123 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +8 -0
- package/dist/index.d.mts +0 -2
- package/dist/index.mjs +0 -1
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { ConnectionManager } from "../../src/federation/connection-manager.js";
|
|
4
|
+
import type { IncomingMessageHandler } from "../../src/federation/connection-manager.js";
|
|
5
|
+
import type { Message } from "../../src/types.js";
|
|
6
|
+
import type {
|
|
7
|
+
MapConnection,
|
|
8
|
+
MapAgentConnectionClass,
|
|
9
|
+
IncomingMapMessage,
|
|
10
|
+
} from "../../src/map/map-client.js";
|
|
11
|
+
|
|
12
|
+
function makeMessage(
|
|
13
|
+
recipientAddress: string,
|
|
14
|
+
scope = "default"
|
|
15
|
+
): Message {
|
|
16
|
+
return {
|
|
17
|
+
id: "msg-1",
|
|
18
|
+
scope,
|
|
19
|
+
sender_id: "local-agent",
|
|
20
|
+
recipients: [{ agent_id: recipientAddress, kind: "to" }],
|
|
21
|
+
content: { type: "text", text: "hello" },
|
|
22
|
+
importance: "normal",
|
|
23
|
+
metadata: {},
|
|
24
|
+
created_at: new Date().toISOString(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("ConnectionManager", () => {
|
|
29
|
+
let events: EventEmitter;
|
|
30
|
+
let cm: ConnectionManager;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
events = new EventEmitter();
|
|
34
|
+
cm = new ConnectionManager(events, {
|
|
35
|
+
systemId: "test-system",
|
|
36
|
+
trust: { allowedServers: [], scopePermissions: {}, requireAuth: false },
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
cm.destroy();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("getSystemId", () => {
|
|
45
|
+
it("should return configured system ID", () => {
|
|
46
|
+
const sid = cm.getSystemId();
|
|
47
|
+
expect(sid.id).toBe("test-system");
|
|
48
|
+
expect(sid.source).toBe("config");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("federate", () => {
|
|
53
|
+
it("should establish a peer link", async () => {
|
|
54
|
+
const link = await cm.federate({
|
|
55
|
+
systemId: "backend-team",
|
|
56
|
+
url: "ws://localhost:3001",
|
|
57
|
+
});
|
|
58
|
+
expect(link.peerId).toBe("backend-team");
|
|
59
|
+
expect(link.status).toBe("connected");
|
|
60
|
+
expect(cm.isConnected("backend-team")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should emit federation.connected event", async () => {
|
|
64
|
+
const spy = vi.fn();
|
|
65
|
+
events.on("federation.connected", spy);
|
|
66
|
+
await cm.federate({
|
|
67
|
+
systemId: "backend-team",
|
|
68
|
+
url: "ws://localhost:3001",
|
|
69
|
+
});
|
|
70
|
+
expect(spy).toHaveBeenCalledWith("backend-team");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should reject if not in allow-list", async () => {
|
|
74
|
+
const restricted = new ConnectionManager(events, {
|
|
75
|
+
trust: {
|
|
76
|
+
allowedServers: ["approved-only"],
|
|
77
|
+
scopePermissions: {},
|
|
78
|
+
requireAuth: false,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
await expect(
|
|
82
|
+
restricted.federate({
|
|
83
|
+
systemId: "unapproved",
|
|
84
|
+
url: "ws://localhost:3001",
|
|
85
|
+
})
|
|
86
|
+
).rejects.toThrow("Federation denied");
|
|
87
|
+
restricted.destroy();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("disconnect", () => {
|
|
92
|
+
it("should remove peer and routing entries", async () => {
|
|
93
|
+
await cm.federate({
|
|
94
|
+
systemId: "backend-team",
|
|
95
|
+
url: "ws://localhost:3001",
|
|
96
|
+
});
|
|
97
|
+
cm.routing.updateFromExposure("backend-team", [
|
|
98
|
+
{ agentId: "agent-1" },
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
await cm.disconnect("backend-team");
|
|
102
|
+
expect(cm.isConnected("backend-team")).toBe(false);
|
|
103
|
+
expect(cm.routing.lookupAgent("agent-1")).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("route", () => {
|
|
108
|
+
it("should route to connected peer", async () => {
|
|
109
|
+
await cm.federate({
|
|
110
|
+
systemId: "backend-team",
|
|
111
|
+
url: "ws://localhost:3001",
|
|
112
|
+
});
|
|
113
|
+
cm.routing.updateFromExposure("backend-team", [
|
|
114
|
+
{ agentId: "remote-agent" },
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
const spy = vi.fn();
|
|
118
|
+
events.on("federation.route", spy);
|
|
119
|
+
|
|
120
|
+
const msg = makeMessage("remote-agent@backend-team");
|
|
121
|
+
const result = await cm.route(msg);
|
|
122
|
+
expect(result.delivered).toBe(true);
|
|
123
|
+
expect(result.peerId).toBe("backend-team");
|
|
124
|
+
expect(spy).toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should queue for disconnected peer", async () => {
|
|
128
|
+
await cm.federate({
|
|
129
|
+
systemId: "backend-team",
|
|
130
|
+
url: "ws://localhost:3001",
|
|
131
|
+
});
|
|
132
|
+
await cm.disconnect("backend-team");
|
|
133
|
+
|
|
134
|
+
// Agent is still in routing table (resolve via system address)
|
|
135
|
+
const msg = makeMessage("remote-agent@backend-team");
|
|
136
|
+
const result = await cm.route(msg);
|
|
137
|
+
expect(result.delivered).toBe(false);
|
|
138
|
+
expect(result.queued).toBe(true);
|
|
139
|
+
expect(cm.queue.size("backend-team")).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should return error for non-remote recipients", async () => {
|
|
143
|
+
const msg = makeMessage("local-agent");
|
|
144
|
+
const result = await cm.route(msg);
|
|
145
|
+
expect(result.delivered).toBe(false);
|
|
146
|
+
expect(result.error).toContain("No remote recipients");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("getPeers", () => {
|
|
151
|
+
it("should list all peers", async () => {
|
|
152
|
+
await cm.federate({
|
|
153
|
+
systemId: "peer-1",
|
|
154
|
+
url: "ws://localhost:3001",
|
|
155
|
+
});
|
|
156
|
+
await cm.federate({
|
|
157
|
+
systemId: "peer-2",
|
|
158
|
+
url: "ws://localhost:3002",
|
|
159
|
+
});
|
|
160
|
+
expect(cm.getPeers()).toHaveLength(2);
|
|
161
|
+
expect(cm.getConnectedPeers()).toHaveLength(2);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("flush-on-reconnect", () => {
|
|
166
|
+
it("should flush queued messages when peer reconnects", async () => {
|
|
167
|
+
await cm.federate({
|
|
168
|
+
systemId: "backend-team",
|
|
169
|
+
url: "ws://localhost:3001",
|
|
170
|
+
});
|
|
171
|
+
await cm.disconnect("backend-team");
|
|
172
|
+
|
|
173
|
+
// Queue a message
|
|
174
|
+
cm.queue.enqueue("backend-team", makeMessage("agent@backend-team"));
|
|
175
|
+
expect(cm.queue.size("backend-team")).toBe(1);
|
|
176
|
+
|
|
177
|
+
// Reconnect triggers flush
|
|
178
|
+
const routeSpy = vi.fn();
|
|
179
|
+
events.on("federation.route", routeSpy);
|
|
180
|
+
|
|
181
|
+
await cm.federate({
|
|
182
|
+
systemId: "backend-team",
|
|
183
|
+
url: "ws://localhost:3001",
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Queue should be flushed (flush happens synchronously on connect event)
|
|
187
|
+
expect(cm.queue.size("backend-team")).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("transport layer", () => {
|
|
192
|
+
function createMockSdk() {
|
|
193
|
+
const mockConnections: Array<{
|
|
194
|
+
url: string;
|
|
195
|
+
opts: unknown;
|
|
196
|
+
conn: MockMapConnection;
|
|
197
|
+
}> = [];
|
|
198
|
+
|
|
199
|
+
class MockMapConnection implements MapConnection {
|
|
200
|
+
private messageHandler: ((msg: IncomingMapMessage) => void) | null =
|
|
201
|
+
null;
|
|
202
|
+
readonly sentMessages: Array<{
|
|
203
|
+
to: { agentId?: string; scope?: string };
|
|
204
|
+
payload: unknown;
|
|
205
|
+
meta?: Record<string, unknown>;
|
|
206
|
+
}> = [];
|
|
207
|
+
disconnected = false;
|
|
208
|
+
|
|
209
|
+
async send(
|
|
210
|
+
to: { agentId?: string; scope?: string },
|
|
211
|
+
payload: unknown,
|
|
212
|
+
meta?: Record<string, unknown>
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
this.sentMessages.push({ to, payload, meta });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
onMessage(handler: (msg: IncomingMapMessage) => void): void {
|
|
218
|
+
this.messageHandler = handler;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async disconnect(): Promise<void> {
|
|
222
|
+
this.disconnected = true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Test helper: simulate an incoming message from peer
|
|
226
|
+
simulateIncoming(msg: IncomingMapMessage): void {
|
|
227
|
+
this.messageHandler?.(msg);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const mockSdkClass: MapAgentConnectionClass = {
|
|
232
|
+
connect: async (url: string, opts: unknown) => {
|
|
233
|
+
const conn = new MockMapConnection();
|
|
234
|
+
mockConnections.push({ url, opts, conn });
|
|
235
|
+
return conn;
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
return { mockSdkClass, mockConnections, MockMapConnection };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
it("should open real SDK connection when sdkClass is provided", async () => {
|
|
243
|
+
const { mockSdkClass, mockConnections } = createMockSdk();
|
|
244
|
+
const cmWithSdk = new ConnectionManager(
|
|
245
|
+
events,
|
|
246
|
+
{
|
|
247
|
+
systemId: "test-system",
|
|
248
|
+
trust: {
|
|
249
|
+
allowedServers: [],
|
|
250
|
+
scopePermissions: {},
|
|
251
|
+
requireAuth: false,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
{ sdkClass: mockSdkClass }
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
await cmWithSdk.federate({
|
|
258
|
+
systemId: "peer-1",
|
|
259
|
+
url: "ws://peer-1:3001",
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(mockConnections).toHaveLength(1);
|
|
263
|
+
expect(mockConnections[0].url).toBe("ws://peer-1:3001");
|
|
264
|
+
expect(cmWithSdk.hasTransport("peer-1")).toBe(true);
|
|
265
|
+
await cmWithSdk.destroy();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("should send via real connection when routing", async () => {
|
|
269
|
+
const { mockSdkClass, mockConnections } = createMockSdk();
|
|
270
|
+
const cmWithSdk = new ConnectionManager(
|
|
271
|
+
events,
|
|
272
|
+
{
|
|
273
|
+
systemId: "test-system",
|
|
274
|
+
trust: {
|
|
275
|
+
allowedServers: [],
|
|
276
|
+
scopePermissions: {},
|
|
277
|
+
requireAuth: false,
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{ sdkClass: mockSdkClass }
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
await cmWithSdk.federate({
|
|
284
|
+
systemId: "peer-1",
|
|
285
|
+
url: "ws://peer-1:3001",
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const msg = makeMessage("agent-x@peer-1");
|
|
289
|
+
const result = await cmWithSdk.route(msg);
|
|
290
|
+
|
|
291
|
+
expect(result.delivered).toBe(true);
|
|
292
|
+
const conn = mockConnections[0].conn;
|
|
293
|
+
expect(conn.sentMessages).toHaveLength(1);
|
|
294
|
+
expect(conn.sentMessages[0].to.agentId).toBe("agent-x");
|
|
295
|
+
expect(conn.sentMessages[0].meta?.targetAgent).toBe("agent-x");
|
|
296
|
+
expect(conn.sentMessages[0].meta?.senderId).toBe("local-agent");
|
|
297
|
+
expect(conn.sentMessages[0].meta?.sourceSystem).toBe("test-system");
|
|
298
|
+
await cmWithSdk.destroy();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("should NOT emit federation.route event when using real transport", async () => {
|
|
302
|
+
const { mockSdkClass } = createMockSdk();
|
|
303
|
+
const cmWithSdk = new ConnectionManager(
|
|
304
|
+
events,
|
|
305
|
+
{
|
|
306
|
+
systemId: "test-system",
|
|
307
|
+
trust: {
|
|
308
|
+
allowedServers: [],
|
|
309
|
+
scopePermissions: {},
|
|
310
|
+
requireAuth: false,
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
{ sdkClass: mockSdkClass }
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
await cmWithSdk.federate({
|
|
317
|
+
systemId: "peer-1",
|
|
318
|
+
url: "ws://peer-1:3001",
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const routeSpy = vi.fn();
|
|
322
|
+
events.on("federation.route", routeSpy);
|
|
323
|
+
|
|
324
|
+
await cmWithSdk.route(makeMessage("agent-x@peer-1"));
|
|
325
|
+
expect(routeSpy).not.toHaveBeenCalled();
|
|
326
|
+
await cmWithSdk.destroy();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("should queue when real send fails", async () => {
|
|
330
|
+
const failSdkClass: MapAgentConnectionClass = {
|
|
331
|
+
connect: async () => ({
|
|
332
|
+
send: async () => {
|
|
333
|
+
throw new Error("network error");
|
|
334
|
+
},
|
|
335
|
+
onMessage: () => {},
|
|
336
|
+
disconnect: async () => {},
|
|
337
|
+
}),
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const cmWithSdk = new ConnectionManager(
|
|
341
|
+
events,
|
|
342
|
+
{
|
|
343
|
+
systemId: "test-system",
|
|
344
|
+
trust: {
|
|
345
|
+
allowedServers: [],
|
|
346
|
+
scopePermissions: {},
|
|
347
|
+
requireAuth: false,
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
{ sdkClass: failSdkClass }
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
await cmWithSdk.federate({
|
|
354
|
+
systemId: "peer-1",
|
|
355
|
+
url: "ws://peer-1:3001",
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const msg = makeMessage("agent-x@peer-1");
|
|
359
|
+
const result = await cmWithSdk.route(msg);
|
|
360
|
+
|
|
361
|
+
expect(result.delivered).toBe(false);
|
|
362
|
+
expect(result.queued).toBe(true);
|
|
363
|
+
expect(cmWithSdk.queue.size("peer-1")).toBe(1);
|
|
364
|
+
await cmWithSdk.destroy();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("should delegate incoming peer messages to onIncomingMessage handler", async () => {
|
|
368
|
+
const { mockSdkClass, mockConnections } = createMockSdk();
|
|
369
|
+
const incomingSpy = vi.fn();
|
|
370
|
+
|
|
371
|
+
const cmWithSdk = new ConnectionManager(
|
|
372
|
+
events,
|
|
373
|
+
{
|
|
374
|
+
systemId: "test-system",
|
|
375
|
+
trust: {
|
|
376
|
+
allowedServers: [],
|
|
377
|
+
scopePermissions: {},
|
|
378
|
+
requireAuth: false,
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
{ sdkClass: mockSdkClass, onIncomingMessage: incomingSpy }
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
await cmWithSdk.federate({
|
|
385
|
+
systemId: "peer-1",
|
|
386
|
+
url: "ws://peer-1:3001",
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Simulate incoming message from peer
|
|
390
|
+
const conn = mockConnections[0].conn;
|
|
391
|
+
conn.simulateIncoming({
|
|
392
|
+
from: "remote-agent",
|
|
393
|
+
payload: { type: "text", text: "hello from peer" },
|
|
394
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(incomingSpy).toHaveBeenCalledWith({
|
|
398
|
+
from: "remote-agent",
|
|
399
|
+
peerId: "peer-1",
|
|
400
|
+
payload: { type: "text", text: "hello from peer" },
|
|
401
|
+
meta: undefined,
|
|
402
|
+
});
|
|
403
|
+
await cmWithSdk.destroy();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("should close real connections on disconnect", async () => {
|
|
407
|
+
const { mockSdkClass, mockConnections } = createMockSdk();
|
|
408
|
+
const cmWithSdk = new ConnectionManager(
|
|
409
|
+
events,
|
|
410
|
+
{
|
|
411
|
+
systemId: "test-system",
|
|
412
|
+
trust: {
|
|
413
|
+
allowedServers: [],
|
|
414
|
+
scopePermissions: {},
|
|
415
|
+
requireAuth: false,
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
{ sdkClass: mockSdkClass }
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
await cmWithSdk.federate({
|
|
422
|
+
systemId: "peer-1",
|
|
423
|
+
url: "ws://peer-1:3001",
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
await cmWithSdk.disconnect("peer-1");
|
|
427
|
+
|
|
428
|
+
expect(mockConnections[0].conn.disconnected).toBe(true);
|
|
429
|
+
expect(cmWithSdk.hasTransport("peer-1")).toBe(false);
|
|
430
|
+
await cmWithSdk.destroy();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("should close all real connections on destroy", async () => {
|
|
434
|
+
const { mockSdkClass, mockConnections } = createMockSdk();
|
|
435
|
+
const cmWithSdk = new ConnectionManager(
|
|
436
|
+
events,
|
|
437
|
+
{
|
|
438
|
+
systemId: "test-system",
|
|
439
|
+
trust: {
|
|
440
|
+
allowedServers: [],
|
|
441
|
+
scopePermissions: {},
|
|
442
|
+
requireAuth: false,
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
{ sdkClass: mockSdkClass }
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
await cmWithSdk.federate({
|
|
449
|
+
systemId: "peer-1",
|
|
450
|
+
url: "ws://peer-1:3001",
|
|
451
|
+
});
|
|
452
|
+
await cmWithSdk.federate({
|
|
453
|
+
systemId: "peer-2",
|
|
454
|
+
url: "ws://peer-2:3002",
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
await cmWithSdk.destroy();
|
|
458
|
+
|
|
459
|
+
expect(mockConnections[0].conn.disconnected).toBe(true);
|
|
460
|
+
expect(mockConnections[1].conn.disconnected).toBe(true);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("should throw when SDK connection fails", async () => {
|
|
464
|
+
const failConnectSdk: MapAgentConnectionClass = {
|
|
465
|
+
connect: async () => {
|
|
466
|
+
throw new Error("connection refused");
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const cmWithSdk = new ConnectionManager(
|
|
471
|
+
events,
|
|
472
|
+
{
|
|
473
|
+
systemId: "test-system",
|
|
474
|
+
trust: {
|
|
475
|
+
allowedServers: [],
|
|
476
|
+
scopePermissions: {},
|
|
477
|
+
requireAuth: false,
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
{ sdkClass: failConnectSdk }
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
await expect(
|
|
484
|
+
cmWithSdk.federate({
|
|
485
|
+
systemId: "peer-1",
|
|
486
|
+
url: "ws://peer-1:3001",
|
|
487
|
+
})
|
|
488
|
+
).rejects.toThrow("Federation connection failed");
|
|
489
|
+
|
|
490
|
+
expect(cmWithSdk.isConnected("peer-1")).toBe(false);
|
|
491
|
+
await cmWithSdk.destroy();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("should connect with gateway identity using system ID", async () => {
|
|
495
|
+
const { mockSdkClass, mockConnections } = createMockSdk();
|
|
496
|
+
const cmWithSdk = new ConnectionManager(
|
|
497
|
+
events,
|
|
498
|
+
{
|
|
499
|
+
systemId: "my-inbox",
|
|
500
|
+
trust: {
|
|
501
|
+
allowedServers: [],
|
|
502
|
+
scopePermissions: {},
|
|
503
|
+
requireAuth: false,
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
{ sdkClass: mockSdkClass }
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
await cmWithSdk.federate({
|
|
510
|
+
systemId: "peer-1",
|
|
511
|
+
url: "ws://peer-1:3001",
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const connectOpts = mockConnections[0].opts as Record<string, unknown>;
|
|
515
|
+
expect(connectOpts.name).toBe("my-inbox");
|
|
516
|
+
expect(connectOpts.role).toBe("gateway");
|
|
517
|
+
expect((connectOpts.metadata as Record<string, unknown>).systemId).toBe(
|
|
518
|
+
"my-inbox"
|
|
519
|
+
);
|
|
520
|
+
expect(
|
|
521
|
+
(connectOpts.metadata as Record<string, unknown>).peerSystemId
|
|
522
|
+
).toBe("peer-1");
|
|
523
|
+
await cmWithSdk.destroy();
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
describe("updateSystemIdFromMap", () => {
|
|
528
|
+
it("should update system ID from MAP when not explicitly configured", () => {
|
|
529
|
+
const autoCm = new ConnectionManager(events, {});
|
|
530
|
+
const before = autoCm.getSystemId();
|
|
531
|
+
expect(before.source).toBe("auto");
|
|
532
|
+
|
|
533
|
+
autoCm.updateSystemIdFromMap("map-derived-name");
|
|
534
|
+
const after = autoCm.getSystemId();
|
|
535
|
+
expect(after.id).toBe("map-derived-name");
|
|
536
|
+
expect(after.source).toBe("map");
|
|
537
|
+
autoCm.destroy();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("should not override explicit config", () => {
|
|
541
|
+
cm.updateSystemIdFromMap("map-derived-name");
|
|
542
|
+
expect(cm.getSystemId().id).toBe("test-system"); // unchanged
|
|
543
|
+
expect(cm.getSystemId().source).toBe("config");
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { DeliveryQueue } from "../../src/federation/delivery-queue.js";
|
|
4
|
+
import type { Message } from "../../src/types.js";
|
|
5
|
+
|
|
6
|
+
function makeMessage(id: string): Message {
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
scope: "default",
|
|
10
|
+
sender_id: "sender",
|
|
11
|
+
recipients: [{ agent_id: "remote@peer", kind: "to" }],
|
|
12
|
+
content: { type: "text", text: `msg-${id}` },
|
|
13
|
+
importance: "normal",
|
|
14
|
+
metadata: {},
|
|
15
|
+
created_at: new Date().toISOString(),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("DeliveryQueue", () => {
|
|
20
|
+
let events: EventEmitter;
|
|
21
|
+
let queue: DeliveryQueue;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
events = new EventEmitter();
|
|
25
|
+
queue = new DeliveryQueue(events, {
|
|
26
|
+
persistence: "memory",
|
|
27
|
+
maxTTL: 10000,
|
|
28
|
+
maxQueueSize: 3,
|
|
29
|
+
retryStrategy: "exponential",
|
|
30
|
+
retryBaseInterval: 100,
|
|
31
|
+
retryMaxAttempts: 3,
|
|
32
|
+
flushOnReconnect: true,
|
|
33
|
+
overflow: "drop-oldest",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("enqueue", () => {
|
|
38
|
+
it("should add message to queue", () => {
|
|
39
|
+
const id = queue.enqueue("peer-1", makeMessage("m1"));
|
|
40
|
+
expect(id).toBeTruthy();
|
|
41
|
+
expect(queue.size("peer-1")).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should drop oldest on overflow (drop-oldest)", () => {
|
|
45
|
+
queue.enqueue("peer-1", makeMessage("m1"));
|
|
46
|
+
queue.enqueue("peer-1", makeMessage("m2"));
|
|
47
|
+
queue.enqueue("peer-1", makeMessage("m3"));
|
|
48
|
+
queue.enqueue("peer-1", makeMessage("m4"));
|
|
49
|
+
expect(queue.size("peer-1")).toBe(3);
|
|
50
|
+
// m1 should have been dropped
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should reject on overflow (reject-new)", () => {
|
|
54
|
+
const q = new DeliveryQueue(events, {
|
|
55
|
+
persistence: "memory",
|
|
56
|
+
maxTTL: 10000,
|
|
57
|
+
maxQueueSize: 2,
|
|
58
|
+
retryStrategy: "exponential",
|
|
59
|
+
retryBaseInterval: 100,
|
|
60
|
+
retryMaxAttempts: 0,
|
|
61
|
+
flushOnReconnect: true,
|
|
62
|
+
overflow: "reject-new",
|
|
63
|
+
});
|
|
64
|
+
q.enqueue("peer-1", makeMessage("m1"));
|
|
65
|
+
q.enqueue("peer-1", makeMessage("m2"));
|
|
66
|
+
const id = q.enqueue("peer-1", makeMessage("m3"));
|
|
67
|
+
expect(id).toBeNull();
|
|
68
|
+
expect(q.size("peer-1")).toBe(2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should emit enqueued event", () => {
|
|
72
|
+
const spy = vi.fn();
|
|
73
|
+
events.on("queue.enqueued", spy);
|
|
74
|
+
queue.enqueue("peer-1", makeMessage("m1"));
|
|
75
|
+
expect(spy).toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("flush", () => {
|
|
80
|
+
it("should return all queued messages and clear queue", () => {
|
|
81
|
+
queue.enqueue("peer-1", makeMessage("m1"));
|
|
82
|
+
queue.enqueue("peer-1", makeMessage("m2"));
|
|
83
|
+
const flushed = queue.flush("peer-1");
|
|
84
|
+
expect(flushed).toHaveLength(2);
|
|
85
|
+
expect(queue.size("peer-1")).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should return empty array for empty queue", () => {
|
|
89
|
+
expect(queue.flush("peer-1")).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should emit flushed event", () => {
|
|
93
|
+
const spy = vi.fn();
|
|
94
|
+
events.on("queue.flushed", spy);
|
|
95
|
+
queue.enqueue("peer-1", makeMessage("m1"));
|
|
96
|
+
queue.flush("peer-1");
|
|
97
|
+
expect(spy).toHaveBeenCalledWith({ peerId: "peer-1", count: 1 });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("recordAttempt", () => {
|
|
102
|
+
it("should increment attempts", () => {
|
|
103
|
+
queue.enqueue("peer-1", makeMessage("m1"));
|
|
104
|
+
const entries = queue.flush("peer-1");
|
|
105
|
+
// Re-enqueue for testing
|
|
106
|
+
queue.enqueue("peer-1", makeMessage("m1"));
|
|
107
|
+
const retryable = queue.getRetryable("peer-1");
|
|
108
|
+
expect(retryable.length).toBeGreaterThan(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should return false when max attempts exceeded", () => {
|
|
112
|
+
const id = queue.enqueue("peer-1", makeMessage("m1"))!;
|
|
113
|
+
queue.recordAttempt("peer-1", id);
|
|
114
|
+
queue.recordAttempt("peer-1", id);
|
|
115
|
+
const ok = queue.recordAttempt("peer-1", id);
|
|
116
|
+
expect(ok).toBe(false); // 3 attempts = max
|
|
117
|
+
expect(queue.size("peer-1")).toBe(0); // removed
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("tick", () => {
|
|
122
|
+
it("should expire old messages past TTL", () => {
|
|
123
|
+
vi.useFakeTimers();
|
|
124
|
+
queue.enqueue("peer-1", makeMessage("m1"));
|
|
125
|
+
vi.advanceTimersByTime(10001);
|
|
126
|
+
const expired = queue.tick();
|
|
127
|
+
expect(expired).toBe(1);
|
|
128
|
+
expect(queue.size("peer-1")).toBe(0);
|
|
129
|
+
vi.useRealTimers();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should keep messages within TTL", () => {
|
|
133
|
+
vi.useFakeTimers();
|
|
134
|
+
queue.enqueue("peer-1", makeMessage("m1"));
|
|
135
|
+
vi.advanceTimersByTime(5000);
|
|
136
|
+
const expired = queue.tick();
|
|
137
|
+
expect(expired).toBe(0);
|
|
138
|
+
expect(queue.size("peer-1")).toBe(1);
|
|
139
|
+
vi.useRealTimers();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("totalSize", () => {
|
|
144
|
+
it("should sum across all peers", () => {
|
|
145
|
+
queue.enqueue("peer-1", makeMessage("m1"));
|
|
146
|
+
queue.enqueue("peer-2", makeMessage("m2"));
|
|
147
|
+
queue.enqueue("peer-2", makeMessage("m3"));
|
|
148
|
+
expect(queue.totalSize()).toBe(3);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("peers", () => {
|
|
153
|
+
it("should list peers with queued messages", () => {
|
|
154
|
+
queue.enqueue("peer-1", makeMessage("m1"));
|
|
155
|
+
queue.enqueue("peer-2", makeMessage("m2"));
|
|
156
|
+
expect(queue.peers().sort()).toEqual(["peer-1", "peer-2"]);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|