agent-inbox 0.0.1 → 0.1.1
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 +20 -0
- package/dist/ipc/ipc-server.d.ts.map +1 -0
- package/dist/ipc/ipc-server.js +152 -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 +253 -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 +180 -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 +287 -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 +138 -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,458 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as crypto from "node:crypto";
|
|
6
|
+
import type {
|
|
7
|
+
FederatedAddress,
|
|
8
|
+
FederationConfig,
|
|
9
|
+
FederationLink,
|
|
10
|
+
FederationPeerConfig,
|
|
11
|
+
Message,
|
|
12
|
+
SystemId,
|
|
13
|
+
} from "../types.js";
|
|
14
|
+
import type {
|
|
15
|
+
MapConnection,
|
|
16
|
+
MapAgentConnectionClass,
|
|
17
|
+
IncomingMapMessage,
|
|
18
|
+
} from "../map/map-client.js";
|
|
19
|
+
import { RoutingEngine } from "./routing-engine.js";
|
|
20
|
+
import { DeliveryQueue } from "./delivery-queue.js";
|
|
21
|
+
import { TrustManager } from "./trust.js";
|
|
22
|
+
import { parseAddress, isRemoteAddress } from "./address.js";
|
|
23
|
+
|
|
24
|
+
export interface DeliveryResult {
|
|
25
|
+
delivered: boolean;
|
|
26
|
+
peerId?: string;
|
|
27
|
+
queued?: boolean;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Callback for handling incoming federation messages.
|
|
33
|
+
* Wired to router.routeMessage() by index.ts.
|
|
34
|
+
*/
|
|
35
|
+
export type IncomingMessageHandler = (incoming: {
|
|
36
|
+
from: string;
|
|
37
|
+
peerId: string;
|
|
38
|
+
payload: unknown;
|
|
39
|
+
meta?: Record<string, unknown>;
|
|
40
|
+
}) => void;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Manages MAP connections and federation peer links.
|
|
44
|
+
*
|
|
45
|
+
* Coordinates routing, delivery queuing, and trust enforcement
|
|
46
|
+
* for cross-system messaging. When an SDK connect function is provided,
|
|
47
|
+
* opens real MAP connections to federation peers for actual message transport.
|
|
48
|
+
*/
|
|
49
|
+
export class ConnectionManager {
|
|
50
|
+
private peers = new Map<string, FederationLink>();
|
|
51
|
+
private connections = new Map<string, MapConnection>();
|
|
52
|
+
private systemId: SystemId;
|
|
53
|
+
private sdkClass: MapAgentConnectionClass | null;
|
|
54
|
+
private onIncoming: IncomingMessageHandler | null;
|
|
55
|
+
readonly routing: RoutingEngine;
|
|
56
|
+
readonly queue: DeliveryQueue;
|
|
57
|
+
readonly trust: TrustManager;
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
private events: EventEmitter,
|
|
61
|
+
private config: FederationConfig = {},
|
|
62
|
+
opts?: {
|
|
63
|
+
sdkClass?: MapAgentConnectionClass;
|
|
64
|
+
onIncomingMessage?: IncomingMessageHandler;
|
|
65
|
+
}
|
|
66
|
+
) {
|
|
67
|
+
this.systemId = this.resolveSystemId();
|
|
68
|
+
this.routing = new RoutingEngine(events, config.routing);
|
|
69
|
+
this.queue = new DeliveryQueue(events, config.deliveryQueue);
|
|
70
|
+
this.trust = new TrustManager(config.trust);
|
|
71
|
+
this.sdkClass = opts?.sdkClass ?? null;
|
|
72
|
+
this.onIncoming = opts?.onIncomingMessage ?? null;
|
|
73
|
+
|
|
74
|
+
// Wire up flush-on-reconnect
|
|
75
|
+
this.events.on("federation.connected", (peerId: string) => {
|
|
76
|
+
if (config.deliveryQueue?.flushOnReconnect !== false) {
|
|
77
|
+
const queued = this.queue.flush(peerId);
|
|
78
|
+
if (queued.length > 0) {
|
|
79
|
+
this.events.emit("federation.flushing", {
|
|
80
|
+
peerId,
|
|
81
|
+
count: queued.length,
|
|
82
|
+
});
|
|
83
|
+
for (const entry of queued) {
|
|
84
|
+
this.route(entry.message).catch(() => {
|
|
85
|
+
// Re-queue if delivery still fails
|
|
86
|
+
this.queue.enqueue(peerId, entry.message);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get the resolved system ID for this Agent Inbox instance.
|
|
96
|
+
*/
|
|
97
|
+
getSystemId(): SystemId {
|
|
98
|
+
return this.systemId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Establish federation with a peer. Uses MAP federation/connect protocol.
|
|
103
|
+
* If an SDK class was injected, opens a real MAP connection to the peer.
|
|
104
|
+
* Returns the federation link, or throws if trust check fails.
|
|
105
|
+
*/
|
|
106
|
+
async federate(peer: FederationPeerConfig): Promise<FederationLink> {
|
|
107
|
+
// Trust check
|
|
108
|
+
if (!this.trust.canConnect(peer.systemId)) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Federation denied: system "${peer.systemId}" not in allowed servers list`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Open real MAP connection if SDK is available
|
|
115
|
+
let conn: MapConnection | undefined;
|
|
116
|
+
if (this.sdkClass) {
|
|
117
|
+
try {
|
|
118
|
+
conn = await this.sdkClass.connect(peer.url, {
|
|
119
|
+
name: this.systemId.id,
|
|
120
|
+
role: "gateway",
|
|
121
|
+
scopes: ["federation"],
|
|
122
|
+
capabilities: { trajectory: { canReport: false } },
|
|
123
|
+
metadata: {
|
|
124
|
+
systemId: this.systemId.id,
|
|
125
|
+
type: "federation-gateway",
|
|
126
|
+
peerSystemId: peer.systemId,
|
|
127
|
+
},
|
|
128
|
+
reconnection: {
|
|
129
|
+
enabled: true,
|
|
130
|
+
maxRetries: 5,
|
|
131
|
+
baseDelayMs: 1000,
|
|
132
|
+
maxDelayMs: 30000,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Register incoming message handler
|
|
137
|
+
conn.onMessage((msg: IncomingMapMessage) => {
|
|
138
|
+
this.handlePeerMessage(peer.systemId, msg);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
this.connections.set(peer.systemId, conn);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Federation connection failed for "${peer.systemId}": ${err instanceof Error ? err.message : err}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const link: FederationLink = {
|
|
150
|
+
peerId: peer.systemId,
|
|
151
|
+
sessionId: crypto.randomUUID(),
|
|
152
|
+
status: "connected",
|
|
153
|
+
exposure: peer.exposure ?? { agents: "all" },
|
|
154
|
+
url: peer.url,
|
|
155
|
+
connectedAt: new Date().toISOString(),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
this.peers.set(peer.systemId, link);
|
|
159
|
+
this.events.emit("federation.connected", peer.systemId);
|
|
160
|
+
return link;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Disconnect from a federation peer.
|
|
165
|
+
* Closes the real MAP connection if one exists.
|
|
166
|
+
*/
|
|
167
|
+
async disconnect(peerId: string): Promise<void> {
|
|
168
|
+
const link = this.peers.get(peerId);
|
|
169
|
+
if (!link) return;
|
|
170
|
+
|
|
171
|
+
// Close real connection
|
|
172
|
+
const conn = this.connections.get(peerId);
|
|
173
|
+
if (conn) {
|
|
174
|
+
try {
|
|
175
|
+
await conn.disconnect();
|
|
176
|
+
} catch {
|
|
177
|
+
// Best-effort disconnect
|
|
178
|
+
}
|
|
179
|
+
this.connections.delete(peerId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
link.status = "disconnected";
|
|
183
|
+
this.routing.removePeer(peerId);
|
|
184
|
+
this.peers.delete(peerId);
|
|
185
|
+
this.events.emit("federation.disconnected", peerId);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Route a message to the correct federation peer.
|
|
190
|
+
* Resolves the address, checks trust, and delivers or queues.
|
|
191
|
+
* If a real connection exists, sends via conn.send(); otherwise emits event.
|
|
192
|
+
*/
|
|
193
|
+
async route(message: Message): Promise<DeliveryResult> {
|
|
194
|
+
// Determine target for each recipient
|
|
195
|
+
for (const recipient of message.recipients) {
|
|
196
|
+
const addr = parseAddress(recipient.agent_id);
|
|
197
|
+
if (!isRemoteAddress(addr)) continue;
|
|
198
|
+
|
|
199
|
+
const peerId = this.routing.resolveRoute(addr);
|
|
200
|
+
if (!peerId) {
|
|
201
|
+
// Route unknown — try strategies
|
|
202
|
+
return this.handleUnknownRoute(addr, message);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Check trust
|
|
206
|
+
if (!this.trust.canRoute(peerId, message.scope)) {
|
|
207
|
+
return {
|
|
208
|
+
delivered: false,
|
|
209
|
+
peerId,
|
|
210
|
+
error: `Trust policy denies routing to scope "${message.scope}" from system "${peerId}"`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const link = this.peers.get(peerId);
|
|
215
|
+
if (!link || link.status !== "connected") {
|
|
216
|
+
// TODO: When resolved peerId has no peer link (e.g., system-qualified address
|
|
217
|
+
// targeting a system we're not directly connected to), fall through to
|
|
218
|
+
// handleUnknownRoute so broadcast/hierarchical strategies can attempt delivery
|
|
219
|
+
// via connected peers or upstream hubs instead of immediately queuing.
|
|
220
|
+
this.queue.enqueue(peerId, message);
|
|
221
|
+
return { delivered: false, peerId, queued: true };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Deliver — use real connection if available, otherwise emit event
|
|
225
|
+
const sendResult = await this.sendToPeer(peerId, addr, message);
|
|
226
|
+
if (sendResult) {
|
|
227
|
+
recipient.delivered_at = new Date().toISOString();
|
|
228
|
+
return { delivered: true, peerId };
|
|
229
|
+
}
|
|
230
|
+
// Send failed — queue for retry
|
|
231
|
+
this.queue.enqueue(peerId, message);
|
|
232
|
+
return { delivered: false, peerId, queued: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { delivered: false, error: "No remote recipients found" };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Send a message to a peer via real connection or event emission.
|
|
240
|
+
* Returns true if send succeeded (or event was emitted).
|
|
241
|
+
*/
|
|
242
|
+
private async sendToPeer(
|
|
243
|
+
peerId: string,
|
|
244
|
+
addr: FederatedAddress,
|
|
245
|
+
message: Message
|
|
246
|
+
): Promise<boolean> {
|
|
247
|
+
const conn = this.connections.get(peerId);
|
|
248
|
+
if (conn) {
|
|
249
|
+
// Real transport — send via MAP SDK connection
|
|
250
|
+
try {
|
|
251
|
+
await conn.send(
|
|
252
|
+
{ agentId: addr.agent, scope: addr.scope },
|
|
253
|
+
message.content,
|
|
254
|
+
{
|
|
255
|
+
messageId: message.id,
|
|
256
|
+
senderId: message.sender_id,
|
|
257
|
+
sourceSystem: this.systemId.id,
|
|
258
|
+
targetAgent: addr.agent,
|
|
259
|
+
importance: message.importance,
|
|
260
|
+
...(message.subject ? { subject: message.subject } : {}),
|
|
261
|
+
...(message.thread_tag ? { threadTag: message.thread_tag } : {}),
|
|
262
|
+
...(message.in_reply_to ? { inReplyTo: message.in_reply_to } : {}),
|
|
263
|
+
}
|
|
264
|
+
);
|
|
265
|
+
return true;
|
|
266
|
+
} catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// No real connection — emit event (for testing / event-based mode)
|
|
272
|
+
this.events.emit("federation.route", { peerId, message });
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Handle an incoming message from a federation peer connection.
|
|
278
|
+
* Delegates to the injected message handler (wired to router.routeMessage).
|
|
279
|
+
*/
|
|
280
|
+
private handlePeerMessage(peerId: string, msg: IncomingMapMessage): void {
|
|
281
|
+
this.events.emit("federation.message.received", { peerId, message: msg });
|
|
282
|
+
|
|
283
|
+
if (this.onIncoming) {
|
|
284
|
+
this.onIncoming({
|
|
285
|
+
from: msg.from,
|
|
286
|
+
peerId,
|
|
287
|
+
payload: msg.payload,
|
|
288
|
+
meta: msg.meta,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Handle routing to an unknown agent. Behavior depends on strategy.
|
|
295
|
+
*/
|
|
296
|
+
private async handleUnknownRoute(
|
|
297
|
+
addr: FederatedAddress,
|
|
298
|
+
message: Message
|
|
299
|
+
): Promise<DeliveryResult> {
|
|
300
|
+
const strategy = this.routing.getStrategy();
|
|
301
|
+
|
|
302
|
+
if (strategy === "broadcast") {
|
|
303
|
+
// Forward to all connected peers
|
|
304
|
+
const connectedPeers = this.getConnectedPeers();
|
|
305
|
+
if (connectedPeers.length === 0) {
|
|
306
|
+
return { delivered: false, error: "No connected peers for broadcast" };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Try real connections first, fall back to event
|
|
310
|
+
for (const peer of connectedPeers) {
|
|
311
|
+
await this.sendToPeer(peer.peerId, addr, message);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
this.events.emit("federation.broadcast", {
|
|
315
|
+
message,
|
|
316
|
+
peers: connectedPeers.map((p) => p.peerId),
|
|
317
|
+
timeout: this.routing.getBroadcastTimeout(),
|
|
318
|
+
});
|
|
319
|
+
return { delivered: true };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (strategy === "hierarchical") {
|
|
323
|
+
// Delegate to upstream hubs
|
|
324
|
+
const upstream = this.routing.getUpstream();
|
|
325
|
+
for (const hubId of upstream) {
|
|
326
|
+
const link = this.peers.get(hubId);
|
|
327
|
+
if (link?.status === "connected") {
|
|
328
|
+
const sent = await this.sendToPeer(hubId, addr, message);
|
|
329
|
+
if (sent) {
|
|
330
|
+
return { delivered: true, peerId: hubId };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return { delivered: false, error: "No reachable upstream hubs" };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Table strategy with refreshOnMiss — emit event for transport layer
|
|
338
|
+
if (this.routing.shouldRefreshOnMiss()) {
|
|
339
|
+
this.events.emit("federation.refresh", { address: addr });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Queue for later if we have a system hint
|
|
343
|
+
if (addr.system) {
|
|
344
|
+
this.queue.enqueue(addr.system, message);
|
|
345
|
+
return { delivered: false, peerId: addr.system, queued: true };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
delivered: false,
|
|
350
|
+
error: `No route to agent "${addr.agent}"`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Get all federation peer links.
|
|
356
|
+
*/
|
|
357
|
+
getPeers(): FederationLink[] {
|
|
358
|
+
return Array.from(this.peers.values());
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get only connected peers.
|
|
363
|
+
*/
|
|
364
|
+
getConnectedPeers(): FederationLink[] {
|
|
365
|
+
return Array.from(this.peers.values()).filter(
|
|
366
|
+
(p) => p.status === "connected"
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Get a specific peer link.
|
|
372
|
+
*/
|
|
373
|
+
getPeer(peerId: string): FederationLink | undefined {
|
|
374
|
+
return this.peers.get(peerId);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Check if a peer is connected.
|
|
379
|
+
*/
|
|
380
|
+
isConnected(peerId: string): boolean {
|
|
381
|
+
return this.peers.get(peerId)?.status === "connected";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Check if a real MAP SDK connection exists for a peer.
|
|
386
|
+
*/
|
|
387
|
+
hasTransport(peerId: string): boolean {
|
|
388
|
+
return this.connections.has(peerId);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Clean up all state. Call on shutdown.
|
|
393
|
+
*/
|
|
394
|
+
async destroy(): Promise<void> {
|
|
395
|
+
// Close all real connections
|
|
396
|
+
for (const [peerId, conn] of this.connections) {
|
|
397
|
+
try {
|
|
398
|
+
await conn.disconnect();
|
|
399
|
+
} catch {
|
|
400
|
+
// Best-effort
|
|
401
|
+
}
|
|
402
|
+
this.connections.delete(peerId);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
this.routing.destroy();
|
|
406
|
+
this.queue.destroy();
|
|
407
|
+
this.peers.clear();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Resolve the system ID using tiered precedence:
|
|
412
|
+
* 1. Explicit config (INBOX_SYSTEM_ID)
|
|
413
|
+
* 2. Auto-generated (persisted to file for stability across restarts)
|
|
414
|
+
*
|
|
415
|
+
* Note: Tier 2 (MAP systemInfo) is handled after MAP connection is established.
|
|
416
|
+
*/
|
|
417
|
+
private resolveSystemId(): SystemId {
|
|
418
|
+
// Tier 1: Explicit config
|
|
419
|
+
if (this.config.systemId) {
|
|
420
|
+
return { id: this.config.systemId, source: "config" };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Tier 3: Auto-generated (with persistence)
|
|
424
|
+
const idFile = path.join(
|
|
425
|
+
os.homedir(),
|
|
426
|
+
".claude",
|
|
427
|
+
"agent-inbox",
|
|
428
|
+
"system-id"
|
|
429
|
+
);
|
|
430
|
+
try {
|
|
431
|
+
const existing = fs.readFileSync(idFile, "utf-8").trim();
|
|
432
|
+
if (existing) {
|
|
433
|
+
return { id: existing, source: "auto" };
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
// File doesn't exist — generate new ID
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const newId = `inbox-${crypto.randomBytes(4).toString("hex")}`;
|
|
440
|
+
try {
|
|
441
|
+
fs.mkdirSync(path.dirname(idFile), { recursive: true });
|
|
442
|
+
fs.writeFileSync(idFile, newId);
|
|
443
|
+
} catch {
|
|
444
|
+
// Best-effort persistence
|
|
445
|
+
}
|
|
446
|
+
return { id: newId, source: "auto" };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Update system ID from MAP server's systemInfo (Tier 2).
|
|
451
|
+
* Only used if no explicit config was set.
|
|
452
|
+
*/
|
|
453
|
+
updateSystemIdFromMap(mapSystemName: string): void {
|
|
454
|
+
if (this.systemId.source === "config") return; // Explicit config takes precedence
|
|
455
|
+
this.systemId = { id: mapSystemName, source: "map" };
|
|
456
|
+
this.events.emit("system.id.updated", this.systemId);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { ulid } from "ulid";
|
|
3
|
+
import type { Message, DeliveryQueueConfig, QueuedMessage } from "../types.js";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONFIG: DeliveryQueueConfig = {
|
|
6
|
+
persistence: "memory",
|
|
7
|
+
maxTTL: 86_400_000, // 24h
|
|
8
|
+
maxQueueSize: 10_000,
|
|
9
|
+
retryStrategy: "exponential",
|
|
10
|
+
retryBaseInterval: 1_000,
|
|
11
|
+
retryMaxAttempts: 0, // unlimited until TTL
|
|
12
|
+
flushOnReconnect: true,
|
|
13
|
+
overflow: "drop-oldest",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Delivery queue for messages to offline or unreachable federation peers.
|
|
18
|
+
*
|
|
19
|
+
* Supports configurable TTL, overflow policy, and retry strategy.
|
|
20
|
+
* Currently memory-only; SQLite persistence is a future enhancement.
|
|
21
|
+
*/
|
|
22
|
+
export class DeliveryQueue {
|
|
23
|
+
private queues = new Map<string, QueuedMessage[]>();
|
|
24
|
+
private config: DeliveryQueueConfig;
|
|
25
|
+
private tickTimer?: ReturnType<typeof setInterval>;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private events: EventEmitter,
|
|
29
|
+
config?: Partial<DeliveryQueueConfig>
|
|
30
|
+
) {
|
|
31
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Enqueue a message for a peer. Returns the queued message ID, or null if rejected.
|
|
36
|
+
*/
|
|
37
|
+
enqueue(peerId: string, message: Message): string | null {
|
|
38
|
+
let queue = this.queues.get(peerId);
|
|
39
|
+
if (!queue) {
|
|
40
|
+
queue = [];
|
|
41
|
+
this.queues.set(peerId, queue);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check queue size limit
|
|
45
|
+
if (queue.length >= this.config.maxQueueSize) {
|
|
46
|
+
switch (this.config.overflow) {
|
|
47
|
+
case "drop-oldest":
|
|
48
|
+
queue.shift();
|
|
49
|
+
break;
|
|
50
|
+
case "drop-newest":
|
|
51
|
+
return null; // Don't enqueue
|
|
52
|
+
case "reject-new":
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const now = new Date().toISOString();
|
|
58
|
+
const entry: QueuedMessage = {
|
|
59
|
+
id: ulid(),
|
|
60
|
+
peerId,
|
|
61
|
+
message,
|
|
62
|
+
enqueuedAt: now,
|
|
63
|
+
attempts: 0,
|
|
64
|
+
nextRetry: now, // Ready to send immediately
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
queue.push(entry);
|
|
68
|
+
this.events.emit("queue.enqueued", { peerId, messageId: entry.id });
|
|
69
|
+
return entry.id;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Flush all queued messages for a peer (on reconnect).
|
|
74
|
+
* Returns the messages and removes them from the queue.
|
|
75
|
+
*/
|
|
76
|
+
flush(peerId: string): QueuedMessage[] {
|
|
77
|
+
const queue = this.queues.get(peerId);
|
|
78
|
+
if (!queue || queue.length === 0) return [];
|
|
79
|
+
|
|
80
|
+
const messages = [...queue];
|
|
81
|
+
this.queues.delete(peerId);
|
|
82
|
+
this.events.emit("queue.flushed", { peerId, count: messages.length });
|
|
83
|
+
return messages;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get messages ready for retry. Does not remove them from the queue.
|
|
88
|
+
*/
|
|
89
|
+
getRetryable(peerId: string): QueuedMessage[] {
|
|
90
|
+
const queue = this.queues.get(peerId);
|
|
91
|
+
if (!queue) return [];
|
|
92
|
+
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
return queue.filter((entry) => {
|
|
95
|
+
if (!entry.nextRetry) return true;
|
|
96
|
+
return new Date(entry.nextRetry).getTime() <= now;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Record a retry attempt for a message. Updates attempt count and next retry time.
|
|
102
|
+
* Returns false if max attempts exceeded (message should be dropped).
|
|
103
|
+
*/
|
|
104
|
+
recordAttempt(peerId: string, messageId: string): boolean {
|
|
105
|
+
const queue = this.queues.get(peerId);
|
|
106
|
+
if (!queue) return false;
|
|
107
|
+
|
|
108
|
+
const entry = queue.find((e) => e.id === messageId);
|
|
109
|
+
if (!entry) return false;
|
|
110
|
+
|
|
111
|
+
entry.attempts++;
|
|
112
|
+
entry.lastAttempt = new Date().toISOString();
|
|
113
|
+
|
|
114
|
+
// Check max attempts
|
|
115
|
+
if (this.config.retryMaxAttempts > 0 && entry.attempts >= this.config.retryMaxAttempts) {
|
|
116
|
+
this.removeEntry(peerId, messageId);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Calculate next retry
|
|
121
|
+
const delay =
|
|
122
|
+
this.config.retryStrategy === "exponential"
|
|
123
|
+
? this.config.retryBaseInterval * Math.pow(2, entry.attempts - 1)
|
|
124
|
+
: this.config.retryBaseInterval;
|
|
125
|
+
entry.nextRetry = new Date(Date.now() + delay).toISOString();
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Remove a specific entry (e.g., after successful delivery).
|
|
131
|
+
*/
|
|
132
|
+
removeEntry(peerId: string, messageId: string): boolean {
|
|
133
|
+
const queue = this.queues.get(peerId);
|
|
134
|
+
if (!queue) return false;
|
|
135
|
+
|
|
136
|
+
const idx = queue.findIndex((e) => e.id === messageId);
|
|
137
|
+
if (idx === -1) return false;
|
|
138
|
+
|
|
139
|
+
queue.splice(idx, 1);
|
|
140
|
+
if (queue.length === 0) this.queues.delete(peerId);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Process tick: expire old messages past TTL.
|
|
146
|
+
*/
|
|
147
|
+
tick(): number {
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
let expired = 0;
|
|
150
|
+
|
|
151
|
+
for (const [peerId, queue] of this.queues.entries()) {
|
|
152
|
+
const before = queue.length;
|
|
153
|
+
const remaining = queue.filter((entry) => {
|
|
154
|
+
const age = now - new Date(entry.enqueuedAt).getTime();
|
|
155
|
+
return age < this.config.maxTTL;
|
|
156
|
+
});
|
|
157
|
+
expired += before - remaining.length;
|
|
158
|
+
|
|
159
|
+
if (remaining.length === 0) {
|
|
160
|
+
this.queues.delete(peerId);
|
|
161
|
+
} else {
|
|
162
|
+
this.queues.set(peerId, remaining);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (expired > 0) {
|
|
167
|
+
this.events.emit("queue.expired", { count: expired });
|
|
168
|
+
}
|
|
169
|
+
return expired;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Start periodic tick timer for TTL expiry.
|
|
174
|
+
*/
|
|
175
|
+
startTicking(intervalMs: number = 60_000): void {
|
|
176
|
+
this.stopTicking();
|
|
177
|
+
this.tickTimer = setInterval(() => this.tick(), intervalMs);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Stop periodic tick timer.
|
|
182
|
+
*/
|
|
183
|
+
stopTicking(): void {
|
|
184
|
+
if (this.tickTimer) {
|
|
185
|
+
clearInterval(this.tickTimer);
|
|
186
|
+
this.tickTimer = undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get queue depth for a peer.
|
|
192
|
+
*/
|
|
193
|
+
size(peerId: string): number {
|
|
194
|
+
return this.queues.get(peerId)?.length ?? 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get total queue depth across all peers.
|
|
199
|
+
*/
|
|
200
|
+
totalSize(): number {
|
|
201
|
+
let total = 0;
|
|
202
|
+
for (const queue of this.queues.values()) {
|
|
203
|
+
total += queue.length;
|
|
204
|
+
}
|
|
205
|
+
return total;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* List peers with queued messages.
|
|
210
|
+
*/
|
|
211
|
+
peers(): string[] {
|
|
212
|
+
return Array.from(this.queues.keys());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Clean up all state. Call on shutdown.
|
|
217
|
+
*/
|
|
218
|
+
destroy(): void {
|
|
219
|
+
this.stopTicking();
|
|
220
|
+
this.queues.clear();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { parseAddress, formatAddress, isRemoteAddress, isBroadcastAddress } from "./address.js";
|
|
2
|
+
export { ConnectionManager } from "./connection-manager.js";
|
|
3
|
+
export type { DeliveryResult } from "./connection-manager.js";
|
|
4
|
+
export { DeliveryQueue } from "./delivery-queue.js";
|
|
5
|
+
export { RoutingEngine } from "./routing-engine.js";
|
|
6
|
+
export { TrustManager } from "./trust.js";
|