@fitchmultz/pi-subagents 0.46.2 → 0.48.0
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/CHANGELOG.md +25 -0
- package/README.md +1 -1
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-broadcast.js +60 -0
- package/dist/pi-intercom/bridge-catalog.js +372 -0
- package/dist/pi-intercom/bridge-config.js +47 -26
- package/dist/pi-intercom/bridge-control.js +104 -0
- package/dist/pi-intercom/bridge-files.js +47 -0
- package/dist/pi-intercom/bridge-groups.js +135 -0
- package/dist/pi-intercom/bridge-http.js +8 -1
- package/dist/pi-intercom/bridge-launch-journal.js +84 -0
- package/dist/pi-intercom/bridge-launch-policy.js +71 -5
- package/dist/pi-intercom/bridge-launch.js +186 -76
- package/dist/pi-intercom/bridge-mailbox.js +324 -0
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +100 -0
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +190 -17
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -85
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake.js +158 -0
- package/dist/pi-intercom/bridge.js +84 -44
- package/dist/pi-intercom/broker/client.js +1 -0
- package/dist/pi-intercom/connection.js +2 -3
- package/dist/pi-intercom/inbound.js +2 -4
- package/dist/pi-intercom/index.js +7 -1
- package/dist/pi-intercom/native-control-auth.js +80 -0
- package/dist/pi-intercom/native-control-protocol.js +111 -0
- package/dist/pi-intercom/native-control.js +130 -0
- package/dist/pi-intercom/saved-transcript.js +135 -0
- package/dist/pi-intercom/session-targets.js +3 -0
- package/dist/pi-intercom/transcript.js +175 -0
- package/docs/intercom-bridge.md +293 -161
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-credentials.mjs +54 -12
- package/scripts/intercom-remote.mjs +196 -31
- package/scripts/package-smoke.mjs +1 -1
- package/src/pi-intercom/bridge-broadcast.ts +70 -0
- package/src/pi-intercom/bridge-catalog.ts +489 -0
- package/src/pi-intercom/bridge-config.ts +64 -37
- package/src/pi-intercom/bridge-control.ts +179 -0
- package/src/pi-intercom/bridge-files.ts +59 -0
- package/src/pi-intercom/bridge-groups.ts +169 -0
- package/src/pi-intercom/bridge-http.ts +15 -1
- package/src/pi-intercom/bridge-launch-journal.ts +122 -0
- package/src/pi-intercom/bridge-launch-policy.ts +88 -7
- package/src/pi-intercom/bridge-launch.ts +296 -107
- package/src/pi-intercom/bridge-mailbox.ts +395 -0
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +128 -0
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +281 -32
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +106 -105
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake.ts +188 -0
- package/src/pi-intercom/bridge.ts +88 -54
- package/src/pi-intercom/broker/client.ts +3 -0
- package/src/pi-intercom/connection.ts +2 -3
- package/src/pi-intercom/inbound.ts +2 -4
- package/src/pi-intercom/index.ts +7 -1
- package/src/pi-intercom/native-control-auth.ts +89 -0
- package/src/pi-intercom/native-control-protocol.ts +180 -0
- package/src/pi-intercom/native-control.ts +160 -0
- package/src/pi-intercom/saved-transcript.ts +175 -0
- package/src/pi-intercom/session-targets.ts +4 -0
- package/src/pi-intercom/transcript.ts +226 -0
- package/src/pi-intercom/types.ts +2 -0
|
@@ -1,37 +1,31 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { IntercomClient } from "./broker/client.ts";
|
|
3
|
-
import {
|
|
4
|
-
formatSessionTarget,
|
|
5
|
-
resolveSessionProjectId,
|
|
6
|
-
resolveSessionTarget,
|
|
7
|
-
} from "./session-targets.ts";
|
|
3
|
+
import { resolveSessionProjectId } from "./session-targets.ts";
|
|
8
4
|
import type { Message, SendResult, SessionInfo } from "./types.ts";
|
|
9
5
|
import type { Identity } from "./bridge-config.ts";
|
|
10
|
-
import { Fault,
|
|
6
|
+
import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
|
|
7
|
+
import { Mailbox } from "./bridge-mailbox.ts";
|
|
11
8
|
import type { UnknownRecord } from "../shared/unknown.ts";
|
|
12
9
|
import type { MessagingOperation as Operation } from "./bridge-routes.ts";
|
|
10
|
+
import type { RequestContext } from "./bridge-http.ts";
|
|
11
|
+
import { resolveBridgeTarget, bridgeSessionList } from "./bridge-target.ts";
|
|
12
|
+
import { ControlReplies } from "./bridge-control.ts";
|
|
13
|
+
import type { NativeControlOperation } from "./native-control-protocol.ts";
|
|
13
14
|
|
|
14
|
-
import { WaitingAsk
|
|
15
|
+
import { WaitingAsk } from "./bridge-ask.ts";
|
|
15
16
|
const MAX_REQUESTS = 8;
|
|
16
17
|
const IDLE_TIMEOUT = 5 * 60 * 1000;
|
|
17
18
|
const SEND_TIMEOUT = 8000;
|
|
18
19
|
const LIST_TIMEOUT = 5000;
|
|
19
20
|
|
|
20
|
-
interface InboxEntry extends Envelope {
|
|
21
|
-
readonly bytes: number;
|
|
22
|
-
replying: boolean;
|
|
23
|
-
}
|
|
24
|
-
interface RequestContext {
|
|
25
|
-
readonly signal: AbortSignal;
|
|
26
|
-
readonly requestId: string;
|
|
27
|
-
readonly onPeer: (peer: string) => void;
|
|
28
|
-
}
|
|
29
21
|
interface SendOptions {
|
|
30
22
|
readonly messageId?: string;
|
|
31
23
|
readonly replyTo?: string;
|
|
32
24
|
readonly expectsReply?: boolean;
|
|
33
25
|
}
|
|
34
26
|
export interface SessionOwner {
|
|
27
|
+
readonly stateDir?: string;
|
|
28
|
+
readonly notifications?: boolean;
|
|
35
29
|
readonly allowed: (identity: Identity) => boolean;
|
|
36
30
|
readonly lost: (fp: string, count?: number) => void;
|
|
37
31
|
readonly retired: (fp: string, generation: string) => void;
|
|
@@ -48,9 +42,10 @@ export class Session {
|
|
|
48
42
|
listTimeoutMs: LIST_TIMEOUT,
|
|
49
43
|
});
|
|
50
44
|
private readonly requests = new Set<AbortController>();
|
|
51
|
-
private readonly inbox
|
|
52
|
-
private
|
|
45
|
+
private readonly inbox: Mailbox;
|
|
46
|
+
private readonly replying = new Set<string>();
|
|
53
47
|
private pending?: WaitingAsk;
|
|
48
|
+
private readonly controls = new ControlReplies();
|
|
54
49
|
private closed = false;
|
|
55
50
|
private connecting?: Promise<void>;
|
|
56
51
|
private disconnecting?: Promise<void>;
|
|
@@ -62,6 +57,7 @@ export class Session {
|
|
|
62
57
|
this.owner = owner;
|
|
63
58
|
this.identity = identity;
|
|
64
59
|
this.expires = expires;
|
|
60
|
+
this.inbox = new Mailbox(owner.stateDir, identity.fingerprint256, owner.notifications);
|
|
65
61
|
this.client.on("error", () =>
|
|
66
62
|
this.close(new Fault(503, "broker_unavailable", "Broker connection failed.")),
|
|
67
63
|
);
|
|
@@ -86,6 +82,9 @@ export class Session {
|
|
|
86
82
|
if (this.closed) {
|
|
87
83
|
throw this.closeReason;
|
|
88
84
|
}
|
|
85
|
+
this.authorize();
|
|
86
|
+
}
|
|
87
|
+
authorize(): void {
|
|
89
88
|
if (!this.owner.allowed(this.identity)) {
|
|
90
89
|
throw new Fault(403, "revoked", "Identity is no longer authorized.");
|
|
91
90
|
}
|
|
@@ -153,6 +152,7 @@ export class Session {
|
|
|
153
152
|
this.close(fault);
|
|
154
153
|
throw fault;
|
|
155
154
|
}
|
|
155
|
+
this.presence();
|
|
156
156
|
log("registered", this.fields());
|
|
157
157
|
}
|
|
158
158
|
private async connect(signal: AbortSignal): Promise<void> {
|
|
@@ -192,27 +192,23 @@ export class Session {
|
|
|
192
192
|
// The original request receives the connection failure; teardown must still close a late socket.
|
|
193
193
|
}
|
|
194
194
|
await this.disconnect();
|
|
195
|
+
this.owner.lost(this.identity.fingerprint256, this.inbox.close());
|
|
195
196
|
this.owner.retired(this.identity.fingerprint256, this.generation);
|
|
196
197
|
log("session_closed", { ...this.fields(), result: this.closeReason.code });
|
|
197
198
|
}
|
|
198
|
-
close(
|
|
199
|
+
close(fault: Readonly<Fault>): void {
|
|
199
200
|
if (this.closed) {
|
|
200
201
|
return;
|
|
201
202
|
}
|
|
202
203
|
this.closed = true;
|
|
203
|
-
this.closeReason =
|
|
204
|
+
this.closeReason = fault;
|
|
204
205
|
clearTimeout(this.lease);
|
|
205
|
-
this.pending?.reject(
|
|
206
|
+
this.pending?.reject(fault);
|
|
207
|
+
this.controls.close(fault);
|
|
206
208
|
for (const controller of this.requests) {
|
|
207
|
-
controller.abort(
|
|
209
|
+
controller.abort(fault);
|
|
208
210
|
}
|
|
209
|
-
this.owner.lost(this.identity.fingerprint256, this.inbox.size);
|
|
210
|
-
this.inbox.clear();
|
|
211
|
-
this.inboxBytes = 0;
|
|
212
211
|
// Retain the generation until acquisition/registration AND final disconnect settle.
|
|
213
|
-
this.startRetirement();
|
|
214
|
-
}
|
|
215
|
-
private startRetirement(): void {
|
|
216
212
|
this.retirement = this.retire().catch((caught: unknown) => this.cleanupFailed(caught));
|
|
217
213
|
}
|
|
218
214
|
async settled(): Promise<void> {
|
|
@@ -220,17 +216,12 @@ export class Session {
|
|
|
220
216
|
}
|
|
221
217
|
|
|
222
218
|
private peerLeft(id: string): void {
|
|
219
|
+
this.controls.peerLeft(id);
|
|
223
220
|
if (this.pending?.peer === id && !this.pending.settled) {
|
|
224
221
|
this.pending.reject(
|
|
225
222
|
new Fault(409, "peer_offline", "Ask recipient disconnected.", { id: this.pending.id }),
|
|
226
223
|
);
|
|
227
224
|
}
|
|
228
|
-
for (const [messageId, entry] of this.inbox) {
|
|
229
|
-
if (entry.from.id === id && entry.message.expectsReply === true) {
|
|
230
|
-
this.remove(messageId);
|
|
231
|
-
this.owner.lost(this.identity.fingerprint256);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
225
|
this.presence();
|
|
235
226
|
}
|
|
236
227
|
private receive(from: SessionInfo, message: Message): void {
|
|
@@ -247,50 +238,69 @@ export class Session {
|
|
|
247
238
|
);
|
|
248
239
|
return;
|
|
249
240
|
}
|
|
250
|
-
|
|
251
|
-
const pending = this.pending;
|
|
252
|
-
if (pending?.matches(from.id, message.replyTo) === true) {
|
|
253
|
-
pending.resolve({ from, message });
|
|
241
|
+
if (this.controls.receive(from.id, message)) {
|
|
254
242
|
return;
|
|
255
243
|
}
|
|
256
|
-
if (this.inbox.
|
|
257
|
-
this.owner.lost(this.identity.fingerprint256);
|
|
244
|
+
if (!this.inbox.persistent && this.resolveReply(from, message)) {
|
|
258
245
|
return;
|
|
259
246
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
247
|
+
this.storeReceive(from, message);
|
|
248
|
+
}
|
|
249
|
+
private storeReceive(from: SessionInfo, message: Message): void {
|
|
250
|
+
let result: "stored" | "duplicate" | "lost";
|
|
251
|
+
try {
|
|
252
|
+
result = this.inbox.add({ from, message });
|
|
253
|
+
} catch {
|
|
263
254
|
log("inbox_overflow", {
|
|
264
255
|
...this.fields(),
|
|
265
256
|
peer: from.id,
|
|
266
257
|
messageId: message.id,
|
|
267
|
-
result: "
|
|
258
|
+
result: "storage_failed",
|
|
259
|
+
lossRecorded: false,
|
|
268
260
|
});
|
|
261
|
+
this.close(
|
|
262
|
+
new Fault(
|
|
263
|
+
507,
|
|
264
|
+
"mailbox_storage_failed",
|
|
265
|
+
"Mailbox storage failed; inspect the metadata audit for unrecorded losses.",
|
|
266
|
+
),
|
|
267
|
+
);
|
|
269
268
|
return;
|
|
270
269
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
270
|
+
if (result === "lost" || (result === "duplicate" && !this.inbox.persistent)) {
|
|
271
|
+
this.owner.lost(this.identity.fingerprint256);
|
|
272
|
+
}
|
|
273
|
+
this.recordReceive(from.id, message.id, result);
|
|
274
|
+
if (result !== "lost") {
|
|
275
|
+
this.resolveReply(from, message);
|
|
276
|
+
}
|
|
274
277
|
this.presence();
|
|
275
278
|
}
|
|
276
|
-
private
|
|
277
|
-
const
|
|
278
|
-
if (
|
|
279
|
-
|
|
280
|
-
this.inbox.delete(id);
|
|
279
|
+
private resolveReply(from: SessionInfo, message: Message): boolean {
|
|
280
|
+
const pending = this.pending;
|
|
281
|
+
if (pending?.matches(from.id, message.replyTo) !== true) {
|
|
282
|
+
return false;
|
|
281
283
|
}
|
|
284
|
+
pending.resolve({ from, message });
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
private recordReceive(
|
|
288
|
+
peer: string,
|
|
289
|
+
messageId: string,
|
|
290
|
+
result: "stored" | "duplicate" | "lost",
|
|
291
|
+
): void {
|
|
292
|
+
log(result === "lost" ? "inbox_overflow" : "received", {
|
|
293
|
+
...this.fields(),
|
|
294
|
+
peer,
|
|
295
|
+
messageId,
|
|
296
|
+
result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
|
|
297
|
+
});
|
|
282
298
|
}
|
|
283
299
|
private presence(): void {
|
|
284
300
|
if (this.closed) {
|
|
285
301
|
return;
|
|
286
302
|
}
|
|
287
|
-
|
|
288
|
-
(entry) => entry.message.expectsReply === true,
|
|
289
|
-
).length;
|
|
290
|
-
this.client.updatePresence({
|
|
291
|
-
pendingAsks,
|
|
292
|
-
acceptsAsks: this.inbox.size < INBOX_COUNT && this.inboxBytes < INBOX_BYTES,
|
|
293
|
-
});
|
|
303
|
+
this.client.updatePresence(this.inbox.presence());
|
|
294
304
|
}
|
|
295
305
|
private async peers(signal: AbortSignal): Promise<SessionInfo[]> {
|
|
296
306
|
this.guard(signal);
|
|
@@ -311,29 +321,29 @@ export class Session {
|
|
|
311
321
|
await this.connect(signal);
|
|
312
322
|
return this.peers(signal);
|
|
313
323
|
}
|
|
324
|
+
async nativeControl(
|
|
325
|
+
operation: NativeControlOperation,
|
|
326
|
+
certificate: string,
|
|
327
|
+
context: RequestContext,
|
|
328
|
+
): Promise<UnknownRecord> {
|
|
329
|
+
await this.connect(context.signal);
|
|
330
|
+
const peer = await this.target(operation.to, context.signal);
|
|
331
|
+
return this.controls.request(
|
|
332
|
+
this.client,
|
|
333
|
+
{ ...operation, peer: peer.id, certificate },
|
|
334
|
+
{ ...context, guard: () => this.guard(context.signal) },
|
|
335
|
+
);
|
|
336
|
+
}
|
|
314
337
|
private async target(to: string, signal: AbortSignal): Promise<SessionInfo> {
|
|
315
|
-
|
|
316
|
-
if (resolution.status === "none") {
|
|
317
|
-
throw new Fault(404, "peer_offline", "Peer is not connected.");
|
|
318
|
-
}
|
|
319
|
-
if (resolution.status !== "found" || !resolution.target) {
|
|
320
|
-
throw new Fault(
|
|
321
|
-
409,
|
|
322
|
-
"ambiguous_target",
|
|
323
|
-
"Use an unambiguous peer name or session ID (prefixes need at least 8 characters).",
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
if (resolution.target.id === this.client.sessionId) {
|
|
327
|
-
throw new Fault(400, "self_target", "Choose another peer.");
|
|
328
|
-
}
|
|
329
|
-
return resolution.target;
|
|
338
|
+
return resolveBridgeTarget(await this.peers(signal), to, this.client.sessionId);
|
|
330
339
|
}
|
|
331
|
-
|
|
340
|
+
async send(
|
|
332
341
|
peer: string,
|
|
333
342
|
message: string,
|
|
334
343
|
context: RequestContext,
|
|
335
344
|
options: SendOptions = {},
|
|
336
345
|
): Promise<SendResult> {
|
|
346
|
+
await this.connect(context.signal);
|
|
337
347
|
const { signal, requestId } = context;
|
|
338
348
|
context.onPeer(peer);
|
|
339
349
|
this.guard(signal);
|
|
@@ -421,19 +431,9 @@ export class Session {
|
|
|
421
431
|
}
|
|
422
432
|
}
|
|
423
433
|
private ack(ids: readonly string[]): UnknownRecord {
|
|
424
|
-
const
|
|
425
|
-
const retained: string[] = [];
|
|
426
|
-
for (const id of ids) {
|
|
427
|
-
const entry = this.inbox.get(id);
|
|
428
|
-
if (entry?.message.expectsReply === true) {
|
|
429
|
-
retained.push(id);
|
|
430
|
-
} else if (entry) {
|
|
431
|
-
this.remove(id);
|
|
432
|
-
acked.push(id);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
434
|
+
const result = this.inbox.ack(ids);
|
|
435
435
|
this.presence();
|
|
436
|
-
return
|
|
436
|
+
return result;
|
|
437
437
|
}
|
|
438
438
|
private async answer(
|
|
439
439
|
replyTo: string,
|
|
@@ -444,10 +444,10 @@ export class Session {
|
|
|
444
444
|
if (entry?.message.expectsReply !== true) {
|
|
445
445
|
throw new Fault(404, "unknown_ask", "replyTo must identify an unanswered inbound ask.");
|
|
446
446
|
}
|
|
447
|
-
if (
|
|
447
|
+
if (this.replying.has(replyTo)) {
|
|
448
448
|
throw new Fault(409, "reply_in_progress", "This ask already has a reply in flight.");
|
|
449
449
|
}
|
|
450
|
-
|
|
450
|
+
this.replying.add(replyTo);
|
|
451
451
|
const peer = entry.from.id;
|
|
452
452
|
context.onPeer(peer);
|
|
453
453
|
try {
|
|
@@ -456,15 +456,15 @@ export class Session {
|
|
|
456
456
|
throw new Fault(409, "peer_offline", "Original ask sender is disconnected.");
|
|
457
457
|
}
|
|
458
458
|
this.guard(context.signal);
|
|
459
|
-
if (this.inbox.get(replyTo)
|
|
459
|
+
if (!this.inbox.get(replyTo)) {
|
|
460
460
|
throw new Fault(409, "ask_retired", "Inbound ask is no longer replyable.");
|
|
461
461
|
}
|
|
462
462
|
const data = { ...(await this.send(peer, message, context, { replyTo })) };
|
|
463
|
-
this.remove(replyTo);
|
|
463
|
+
this.inbox.remove(replyTo);
|
|
464
464
|
this.presence();
|
|
465
465
|
return data;
|
|
466
466
|
} finally {
|
|
467
|
-
|
|
467
|
+
this.replying.delete(replyTo);
|
|
468
468
|
}
|
|
469
469
|
}
|
|
470
470
|
async execute(operation: Operation, context: RequestContext): Promise<UnknownRecord> {
|
|
@@ -472,6 +472,19 @@ export class Session {
|
|
|
472
472
|
if (operation.route === "POST /v1/ask") {
|
|
473
473
|
return this.ask(operation, context);
|
|
474
474
|
}
|
|
475
|
+
if (operation.route === "GET /v1/inbox" || operation.route === "POST /v1/ack") {
|
|
476
|
+
// Durable reads/acks remain available while the local broker is down.
|
|
477
|
+
if (!this.inbox.persistent) {
|
|
478
|
+
await this.connect(context.signal);
|
|
479
|
+
}
|
|
480
|
+
if (operation.route === "POST /v1/ack") {
|
|
481
|
+
return this.ack(operation.ids);
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
...this.inbox.page(operation.after, operation.limit),
|
|
485
|
+
...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
475
488
|
await this.connect(context.signal);
|
|
476
489
|
switch (operation.route) {
|
|
477
490
|
case "POST /v1/register":
|
|
@@ -480,21 +493,9 @@ export class Session {
|
|
|
480
493
|
const sessions = await this.peers(context.signal);
|
|
481
494
|
return {
|
|
482
495
|
sessionId: this.client.sessionId,
|
|
483
|
-
sessions: sessions
|
|
484
|
-
return Object.assign(entry, { target: formatSessionTarget(entry, sessions) });
|
|
485
|
-
}),
|
|
486
|
-
};
|
|
487
|
-
}
|
|
488
|
-
case "GET /v1/inbox": {
|
|
489
|
-
const lostMessages = this.owner.losses(this.identity.fingerprint256);
|
|
490
|
-
return {
|
|
491
|
-
messages: [...this.inbox.values()].map(({ from, message }) => ({ from, message })),
|
|
492
|
-
overflow: lostMessages > 0,
|
|
493
|
-
lostMessages,
|
|
496
|
+
sessions: bridgeSessionList(sessions),
|
|
494
497
|
};
|
|
495
498
|
}
|
|
496
|
-
case "POST /v1/ack":
|
|
497
|
-
return this.ack(operation.ids);
|
|
498
499
|
case "POST /v1/send":
|
|
499
500
|
return {
|
|
500
501
|
...(await this.send(
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { resolveSessionTarget, formatSessionTarget, nativePeerId } from "./session-targets.ts";
|
|
2
|
+
import { Fault } from "./bridge-protocol.ts";
|
|
3
|
+
import type { SessionInfo } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export function bridgeSessionList(
|
|
6
|
+
peers: readonly SessionInfo[],
|
|
7
|
+
): readonly (SessionInfo & { readonly target: string })[] {
|
|
8
|
+
return peers.map(({ topics: _topics, subscriptions: _subscriptions, ...entry }) => ({
|
|
9
|
+
...entry,
|
|
10
|
+
target: formatSessionTarget(entry, peers),
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveBridgeTarget(
|
|
15
|
+
peers: readonly SessionInfo[],
|
|
16
|
+
to: string,
|
|
17
|
+
self: string | null,
|
|
18
|
+
): SessionInfo {
|
|
19
|
+
const exactNative = peers.find((peer) => peer.id === nativePeerId(to));
|
|
20
|
+
const resolution = resolveSessionTarget(peers, exactNative?.id ?? to);
|
|
21
|
+
if (resolution.status === "none") {
|
|
22
|
+
throw new Fault(404, "peer_offline", "Peer is not connected.");
|
|
23
|
+
}
|
|
24
|
+
if (resolution.status !== "found" || !resolution.target) {
|
|
25
|
+
throw new Fault(
|
|
26
|
+
409,
|
|
27
|
+
"ambiguous_target",
|
|
28
|
+
"Use an unambiguous peer name or session ID (prefixes need at least 8 characters).",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (resolution.target.id === self) {
|
|
32
|
+
throw new Fault(400, "self_target", "Choose another peer.");
|
|
33
|
+
}
|
|
34
|
+
return resolution.target;
|
|
35
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { log } from "./bridge-protocol.ts";
|
|
2
|
+
import { Mailbox, type PendingNotification } from "./bridge-mailbox.ts";
|
|
3
|
+
import type { Identity, WakePolicy } from "./bridge-config.ts";
|
|
4
|
+
|
|
5
|
+
const POLL_MS = 15000;
|
|
6
|
+
const FALLBACK_AFTER_MS = 120000;
|
|
7
|
+
const MAX_BACKOFF_MS = 300000;
|
|
8
|
+
|
|
9
|
+
export class WakeDelivery {
|
|
10
|
+
private readonly controller = new AbortController();
|
|
11
|
+
private timer?: NodeJS.Timeout;
|
|
12
|
+
private running?: Promise<void>;
|
|
13
|
+
private backoff = POLL_MS;
|
|
14
|
+
private readonly name: string;
|
|
15
|
+
private readonly mailbox: Pick<Mailbox, "pendingNotifications" | "notificationAccepted">;
|
|
16
|
+
private readonly policy: WakePolicy;
|
|
17
|
+
private readonly authorized: () => boolean;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
name: string,
|
|
21
|
+
mailbox: Pick<Mailbox, "pendingNotifications" | "notificationAccepted">,
|
|
22
|
+
policy: WakePolicy,
|
|
23
|
+
authorized: () => boolean,
|
|
24
|
+
) {
|
|
25
|
+
this.name = name;
|
|
26
|
+
this.mailbox = mailbox;
|
|
27
|
+
this.policy = policy;
|
|
28
|
+
this.authorized = authorized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
start(): void {
|
|
32
|
+
if (!this.running && !this.controller.signal.aborted) {
|
|
33
|
+
this.schedule(0);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private schedule(delay: number): void {
|
|
38
|
+
clearTimeout(this.timer);
|
|
39
|
+
this.timer = setTimeout(() => {
|
|
40
|
+
this.running = this.deliver()
|
|
41
|
+
.catch(() => {
|
|
42
|
+
log("wake_delivery", { name: this.name, result: "storage_unavailable" });
|
|
43
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
44
|
+
})
|
|
45
|
+
.finally(() => {
|
|
46
|
+
this.running = undefined;
|
|
47
|
+
if (!this.controller.signal.aborted) {
|
|
48
|
+
this.schedule(this.backoff);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}, delay);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private async deliver(): Promise<void> {
|
|
55
|
+
if (!this.authorized()) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const entries = this.mailbox.pendingNotifications().messages;
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (entry.acknowledged) {
|
|
61
|
+
// The box's durable worker owns primary wake/replay after acknowledgement.
|
|
62
|
+
this.mailbox.notificationAccepted(entry.message.id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const next = entries.find(
|
|
66
|
+
(entry) => !entry.acknowledged && Date.now() - entry.receivedAt >= FALLBACK_AFTER_MS,
|
|
67
|
+
);
|
|
68
|
+
if (next) {
|
|
69
|
+
// One fallback wakes the box to drain the remaining mailbox; never flood a reset VM.
|
|
70
|
+
await this.post(next);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.backoff = POLL_MS;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async post(entry: PendingNotification): Promise<void> {
|
|
77
|
+
if (!this.authorized()) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const fields = { name: this.name, messageId: entry.message.id };
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetch(this.policy.url, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
redirect: "error",
|
|
85
|
+
headers: {
|
|
86
|
+
Authorization: `Bearer ${this.policy.key}`,
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
from: entry.from.name ?? entry.from.id,
|
|
91
|
+
from_id: entry.from.id,
|
|
92
|
+
to: this.name,
|
|
93
|
+
message_id: entry.message.id,
|
|
94
|
+
ask_id: entry.message.expectsReply === true ? entry.message.id : null,
|
|
95
|
+
text: entry.message.content.text,
|
|
96
|
+
}),
|
|
97
|
+
signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10000)]),
|
|
98
|
+
});
|
|
99
|
+
await response.body?.cancel();
|
|
100
|
+
if (response.ok && !this.controller.signal.aborted && this.authorized()) {
|
|
101
|
+
// HTTP acceptance cannot prove durable intake; the same ID remains pending until inbox ACK.
|
|
102
|
+
this.backoff = MAX_BACKOFF_MS;
|
|
103
|
+
log("wake_delivery", { ...fields, result: "accepted" });
|
|
104
|
+
} else {
|
|
105
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
106
|
+
log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
110
|
+
if (!this.controller.signal.aborted) {
|
|
111
|
+
log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async stop(): Promise<void> {
|
|
117
|
+
this.controller.abort();
|
|
118
|
+
clearTimeout(this.timer);
|
|
119
|
+
await this.running;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class WakeDeliveries {
|
|
124
|
+
private readonly stateDir: string | undefined;
|
|
125
|
+
private readonly allowed: (identity: Identity) => boolean;
|
|
126
|
+
private readonly deliveries = new Map<
|
|
127
|
+
string,
|
|
128
|
+
{ readonly identity: Identity; readonly mailbox: Mailbox; readonly worker: WakeDelivery }
|
|
129
|
+
>();
|
|
130
|
+
private readonly cleanup = new Set<Promise<unknown>>();
|
|
131
|
+
|
|
132
|
+
constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
|
|
133
|
+
this.stateDir = stateDir;
|
|
134
|
+
this.allowed = allowed;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
session(identity: Identity, expires: number): void {
|
|
138
|
+
if (
|
|
139
|
+
!identity.wake ||
|
|
140
|
+
this.stateDir === undefined ||
|
|
141
|
+
this.deliveries.has(identity.fingerprint256)
|
|
142
|
+
) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const mailbox = new Mailbox(this.stateDir, identity.fingerprint256, true);
|
|
146
|
+
const worker = new WakeDelivery(
|
|
147
|
+
identity.name,
|
|
148
|
+
mailbox,
|
|
149
|
+
identity.wake,
|
|
150
|
+
() => this.allowed(identity) && expires > Date.now(),
|
|
151
|
+
);
|
|
152
|
+
this.deliveries.set(identity.fingerprint256, { identity, mailbox, worker });
|
|
153
|
+
worker.start();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
drop(fp: string): void {
|
|
157
|
+
const delivery = this.deliveries.get(fp);
|
|
158
|
+
if (!delivery) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.deliveries.delete(fp);
|
|
162
|
+
const cleanup = delivery.worker
|
|
163
|
+
.stop()
|
|
164
|
+
.then(() => delivery.mailbox.close())
|
|
165
|
+
.catch(() => {
|
|
166
|
+
log("wake_delivery", { name: delivery.identity.name, result: "cleanup_failed" });
|
|
167
|
+
})
|
|
168
|
+
.finally(() => {
|
|
169
|
+
this.cleanup.delete(cleanup);
|
|
170
|
+
});
|
|
171
|
+
this.cleanup.add(cleanup);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
reload(): void {
|
|
175
|
+
for (const [fp, delivery] of this.deliveries) {
|
|
176
|
+
if (!this.allowed(delivery.identity)) {
|
|
177
|
+
this.drop(fp);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async stop(): Promise<void> {
|
|
183
|
+
for (const fp of this.deliveries.keys()) {
|
|
184
|
+
this.drop(fp);
|
|
185
|
+
}
|
|
186
|
+
await Promise.all(this.cleanup);
|
|
187
|
+
}
|
|
188
|
+
}
|