@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,7 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { IntercomClient } from "./broker/client.js";
|
|
3
|
-
import {
|
|
4
|
-
import { Fault,
|
|
3
|
+
import { resolveSessionProjectId } from "./session-targets.js";
|
|
4
|
+
import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
|
|
5
|
+
import { Mailbox } from "./bridge-mailbox.js";
|
|
6
|
+
import { resolveBridgeTarget, bridgeSessionList } from "./bridge-target.js";
|
|
7
|
+
import { ControlReplies } from "./bridge-control.js";
|
|
5
8
|
import { WaitingAsk } from "./bridge-ask.js";
|
|
6
9
|
const MAX_REQUESTS = 8;
|
|
7
10
|
const IDLE_TIMEOUT = 5 * 60 * 1000;
|
|
@@ -17,9 +20,10 @@ export class Session {
|
|
|
17
20
|
listTimeoutMs: LIST_TIMEOUT,
|
|
18
21
|
});
|
|
19
22
|
requests = new Set();
|
|
20
|
-
inbox
|
|
21
|
-
|
|
23
|
+
inbox;
|
|
24
|
+
replying = new Set();
|
|
22
25
|
pending;
|
|
26
|
+
controls = new ControlReplies();
|
|
23
27
|
closed = false;
|
|
24
28
|
connecting;
|
|
25
29
|
disconnecting;
|
|
@@ -30,6 +34,7 @@ export class Session {
|
|
|
30
34
|
this.owner = owner;
|
|
31
35
|
this.identity = identity;
|
|
32
36
|
this.expires = expires;
|
|
37
|
+
this.inbox = new Mailbox(owner.stateDir, identity.fingerprint256, owner.notifications);
|
|
33
38
|
this.client.on("error", () => this.close(new Fault(503, "broker_unavailable", "Broker connection failed.")));
|
|
34
39
|
this.client.on("disconnected", () => this.close(new Fault(503, "broker_disconnected", "Broker disconnected.")));
|
|
35
40
|
this.client.on("_registered", () => {
|
|
@@ -49,6 +54,9 @@ export class Session {
|
|
|
49
54
|
if (this.closed) {
|
|
50
55
|
throw this.closeReason;
|
|
51
56
|
}
|
|
57
|
+
this.authorize();
|
|
58
|
+
}
|
|
59
|
+
authorize() {
|
|
52
60
|
if (!this.owner.allowed(this.identity)) {
|
|
53
61
|
throw new Fault(403, "revoked", "Identity is no longer authorized.");
|
|
54
62
|
}
|
|
@@ -102,6 +110,7 @@ export class Session {
|
|
|
102
110
|
this.close(fault);
|
|
103
111
|
throw fault;
|
|
104
112
|
}
|
|
113
|
+
this.presence();
|
|
105
114
|
log("registered", this.fields());
|
|
106
115
|
}
|
|
107
116
|
async connect(signal) {
|
|
@@ -136,42 +145,33 @@ export class Session {
|
|
|
136
145
|
// The original request receives the connection failure; teardown must still close a late socket.
|
|
137
146
|
}
|
|
138
147
|
await this.disconnect();
|
|
148
|
+
this.owner.lost(this.identity.fingerprint256, this.inbox.close());
|
|
139
149
|
this.owner.retired(this.identity.fingerprint256, this.generation);
|
|
140
150
|
log("session_closed", { ...this.fields(), result: this.closeReason.code });
|
|
141
151
|
}
|
|
142
|
-
close(
|
|
152
|
+
close(fault) {
|
|
143
153
|
if (this.closed) {
|
|
144
154
|
return;
|
|
145
155
|
}
|
|
146
156
|
this.closed = true;
|
|
147
|
-
this.closeReason =
|
|
157
|
+
this.closeReason = fault;
|
|
148
158
|
clearTimeout(this.lease);
|
|
149
|
-
this.pending?.reject(
|
|
159
|
+
this.pending?.reject(fault);
|
|
160
|
+
this.controls.close(fault);
|
|
150
161
|
for (const controller of this.requests) {
|
|
151
|
-
controller.abort(
|
|
162
|
+
controller.abort(fault);
|
|
152
163
|
}
|
|
153
|
-
this.owner.lost(this.identity.fingerprint256, this.inbox.size);
|
|
154
|
-
this.inbox.clear();
|
|
155
|
-
this.inboxBytes = 0;
|
|
156
164
|
// Retain the generation until acquisition/registration AND final disconnect settle.
|
|
157
|
-
this.startRetirement();
|
|
158
|
-
}
|
|
159
|
-
startRetirement() {
|
|
160
165
|
this.retirement = this.retire().catch((caught) => this.cleanupFailed(caught));
|
|
161
166
|
}
|
|
162
167
|
async settled() {
|
|
163
168
|
await this.retirement;
|
|
164
169
|
}
|
|
165
170
|
peerLeft(id) {
|
|
171
|
+
this.controls.peerLeft(id);
|
|
166
172
|
if (this.pending?.peer === id && !this.pending.settled) {
|
|
167
173
|
this.pending.reject(new Fault(409, "peer_offline", "Ask recipient disconnected.", { id: this.pending.id }));
|
|
168
174
|
}
|
|
169
|
-
for (const [messageId, entry] of this.inbox) {
|
|
170
|
-
if (entry.from.id === id && entry.message.expectsReply === true) {
|
|
171
|
-
this.remove(messageId);
|
|
172
|
-
this.owner.lost(this.identity.fingerprint256);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
175
|
this.presence();
|
|
176
176
|
}
|
|
177
177
|
receive(from, message) {
|
|
@@ -187,48 +187,60 @@ export class Session {
|
|
|
187
187
|
: new Fault(403, "revoked", "Identity is no longer authorized."));
|
|
188
188
|
return;
|
|
189
189
|
}
|
|
190
|
-
|
|
191
|
-
const pending = this.pending;
|
|
192
|
-
if (pending?.matches(from.id, message.replyTo) === true) {
|
|
193
|
-
pending.resolve({ from, message });
|
|
190
|
+
if (this.controls.receive(from.id, message)) {
|
|
194
191
|
return;
|
|
195
192
|
}
|
|
196
|
-
if (this.inbox.
|
|
197
|
-
this.owner.lost(this.identity.fingerprint256);
|
|
193
|
+
if (!this.inbox.persistent && this.resolveReply(from, message)) {
|
|
198
194
|
return;
|
|
199
195
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
196
|
+
this.storeReceive(from, message);
|
|
197
|
+
}
|
|
198
|
+
storeReceive(from, message) {
|
|
199
|
+
let result;
|
|
200
|
+
try {
|
|
201
|
+
result = this.inbox.add({ from, message });
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
203
204
|
log("inbox_overflow", {
|
|
204
205
|
...this.fields(),
|
|
205
206
|
peer: from.id,
|
|
206
207
|
messageId: message.id,
|
|
207
|
-
result: "
|
|
208
|
+
result: "storage_failed",
|
|
209
|
+
lossRecorded: false,
|
|
208
210
|
});
|
|
211
|
+
this.close(new Fault(507, "mailbox_storage_failed", "Mailbox storage failed; inspect the metadata audit for unrecorded losses."));
|
|
209
212
|
return;
|
|
210
213
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
+
if (result === "lost" || (result === "duplicate" && !this.inbox.persistent)) {
|
|
215
|
+
this.owner.lost(this.identity.fingerprint256);
|
|
216
|
+
}
|
|
217
|
+
this.recordReceive(from.id, message.id, result);
|
|
218
|
+
if (result !== "lost") {
|
|
219
|
+
this.resolveReply(from, message);
|
|
220
|
+
}
|
|
214
221
|
this.presence();
|
|
215
222
|
}
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
this.inbox.delete(id);
|
|
223
|
+
resolveReply(from, message) {
|
|
224
|
+
const pending = this.pending;
|
|
225
|
+
if (pending?.matches(from.id, message.replyTo) !== true) {
|
|
226
|
+
return false;
|
|
221
227
|
}
|
|
228
|
+
pending.resolve({ from, message });
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
recordReceive(peer, messageId, result) {
|
|
232
|
+
log(result === "lost" ? "inbox_overflow" : "received", {
|
|
233
|
+
...this.fields(),
|
|
234
|
+
peer,
|
|
235
|
+
messageId,
|
|
236
|
+
result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
|
|
237
|
+
});
|
|
222
238
|
}
|
|
223
239
|
presence() {
|
|
224
240
|
if (this.closed) {
|
|
225
241
|
return;
|
|
226
242
|
}
|
|
227
|
-
|
|
228
|
-
this.client.updatePresence({
|
|
229
|
-
pendingAsks,
|
|
230
|
-
acceptsAsks: this.inbox.size < INBOX_COUNT && this.inboxBytes < INBOX_BYTES,
|
|
231
|
-
});
|
|
243
|
+
this.client.updatePresence(this.inbox.presence());
|
|
232
244
|
}
|
|
233
245
|
async peers(signal) {
|
|
234
246
|
this.guard(signal);
|
|
@@ -250,20 +262,16 @@ export class Session {
|
|
|
250
262
|
await this.connect(signal);
|
|
251
263
|
return this.peers(signal);
|
|
252
264
|
}
|
|
265
|
+
async nativeControl(operation, certificate, context) {
|
|
266
|
+
await this.connect(context.signal);
|
|
267
|
+
const peer = await this.target(operation.to, context.signal);
|
|
268
|
+
return this.controls.request(this.client, { ...operation, peer: peer.id, certificate }, { ...context, guard: () => this.guard(context.signal) });
|
|
269
|
+
}
|
|
253
270
|
async target(to, signal) {
|
|
254
|
-
|
|
255
|
-
if (resolution.status === "none") {
|
|
256
|
-
throw new Fault(404, "peer_offline", "Peer is not connected.");
|
|
257
|
-
}
|
|
258
|
-
if (resolution.status !== "found" || !resolution.target) {
|
|
259
|
-
throw new Fault(409, "ambiguous_target", "Use an unambiguous peer name or session ID (prefixes need at least 8 characters).");
|
|
260
|
-
}
|
|
261
|
-
if (resolution.target.id === this.client.sessionId) {
|
|
262
|
-
throw new Fault(400, "self_target", "Choose another peer.");
|
|
263
|
-
}
|
|
264
|
-
return resolution.target;
|
|
271
|
+
return resolveBridgeTarget(await this.peers(signal), to, this.client.sessionId);
|
|
265
272
|
}
|
|
266
273
|
async send(peer, message, context, options = {}) {
|
|
274
|
+
await this.connect(context.signal);
|
|
267
275
|
const { signal, requestId } = context;
|
|
268
276
|
context.onPeer(peer);
|
|
269
277
|
this.guard(signal);
|
|
@@ -337,30 +345,19 @@ export class Session {
|
|
|
337
345
|
}
|
|
338
346
|
}
|
|
339
347
|
ack(ids) {
|
|
340
|
-
const
|
|
341
|
-
const retained = [];
|
|
342
|
-
for (const id of ids) {
|
|
343
|
-
const entry = this.inbox.get(id);
|
|
344
|
-
if (entry?.message.expectsReply === true) {
|
|
345
|
-
retained.push(id);
|
|
346
|
-
}
|
|
347
|
-
else if (entry) {
|
|
348
|
-
this.remove(id);
|
|
349
|
-
acked.push(id);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
348
|
+
const result = this.inbox.ack(ids);
|
|
352
349
|
this.presence();
|
|
353
|
-
return
|
|
350
|
+
return result;
|
|
354
351
|
}
|
|
355
352
|
async answer(replyTo, message, context) {
|
|
356
353
|
const entry = this.inbox.get(replyTo);
|
|
357
354
|
if (entry?.message.expectsReply !== true) {
|
|
358
355
|
throw new Fault(404, "unknown_ask", "replyTo must identify an unanswered inbound ask.");
|
|
359
356
|
}
|
|
360
|
-
if (
|
|
357
|
+
if (this.replying.has(replyTo)) {
|
|
361
358
|
throw new Fault(409, "reply_in_progress", "This ask already has a reply in flight.");
|
|
362
359
|
}
|
|
363
|
-
|
|
360
|
+
this.replying.add(replyTo);
|
|
364
361
|
const peer = entry.from.id;
|
|
365
362
|
context.onPeer(peer);
|
|
366
363
|
try {
|
|
@@ -369,16 +366,16 @@ export class Session {
|
|
|
369
366
|
throw new Fault(409, "peer_offline", "Original ask sender is disconnected.");
|
|
370
367
|
}
|
|
371
368
|
this.guard(context.signal);
|
|
372
|
-
if (this.inbox.get(replyTo)
|
|
369
|
+
if (!this.inbox.get(replyTo)) {
|
|
373
370
|
throw new Fault(409, "ask_retired", "Inbound ask is no longer replyable.");
|
|
374
371
|
}
|
|
375
372
|
const data = { ...(await this.send(peer, message, context, { replyTo })) };
|
|
376
|
-
this.remove(replyTo);
|
|
373
|
+
this.inbox.remove(replyTo);
|
|
377
374
|
this.presence();
|
|
378
375
|
return data;
|
|
379
376
|
}
|
|
380
377
|
finally {
|
|
381
|
-
|
|
378
|
+
this.replying.delete(replyTo);
|
|
382
379
|
}
|
|
383
380
|
}
|
|
384
381
|
async execute(operation, context) {
|
|
@@ -386,6 +383,19 @@ export class Session {
|
|
|
386
383
|
if (operation.route === "POST /v1/ask") {
|
|
387
384
|
return this.ask(operation, context);
|
|
388
385
|
}
|
|
386
|
+
if (operation.route === "GET /v1/inbox" || operation.route === "POST /v1/ack") {
|
|
387
|
+
// Durable reads/acks remain available while the local broker is down.
|
|
388
|
+
if (!this.inbox.persistent) {
|
|
389
|
+
await this.connect(context.signal);
|
|
390
|
+
}
|
|
391
|
+
if (operation.route === "POST /v1/ack") {
|
|
392
|
+
return this.ack(operation.ids);
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
...this.inbox.page(operation.after, operation.limit),
|
|
396
|
+
...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
389
399
|
await this.connect(context.signal);
|
|
390
400
|
switch (operation.route) {
|
|
391
401
|
case "POST /v1/register":
|
|
@@ -394,21 +404,9 @@ export class Session {
|
|
|
394
404
|
const sessions = await this.peers(context.signal);
|
|
395
405
|
return {
|
|
396
406
|
sessionId: this.client.sessionId,
|
|
397
|
-
sessions: sessions
|
|
398
|
-
return Object.assign(entry, { target: formatSessionTarget(entry, sessions) });
|
|
399
|
-
}),
|
|
407
|
+
sessions: bridgeSessionList(sessions),
|
|
400
408
|
};
|
|
401
409
|
}
|
|
402
|
-
case "GET /v1/inbox": {
|
|
403
|
-
const lostMessages = this.owner.losses(this.identity.fingerprint256);
|
|
404
|
-
return {
|
|
405
|
-
messages: [...this.inbox.values()].map(({ from, message }) => ({ from, message })),
|
|
406
|
-
overflow: lostMessages > 0,
|
|
407
|
-
lostMessages,
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
case "POST /v1/ack":
|
|
411
|
-
return this.ack(operation.ids);
|
|
412
410
|
case "POST /v1/send":
|
|
413
411
|
return {
|
|
414
412
|
...(await this.send((await this.target(operation.to, context.signal)).id, operation.message, context)),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { resolveSessionTarget, formatSessionTarget, nativePeerId } from "./session-targets.js";
|
|
2
|
+
import { Fault } from "./bridge-protocol.js";
|
|
3
|
+
export function bridgeSessionList(peers) {
|
|
4
|
+
return peers.map(({ topics: _topics, subscriptions: _subscriptions, ...entry }) => ({
|
|
5
|
+
...entry,
|
|
6
|
+
target: formatSessionTarget(entry, peers),
|
|
7
|
+
}));
|
|
8
|
+
}
|
|
9
|
+
export function resolveBridgeTarget(peers, to, self) {
|
|
10
|
+
const exactNative = peers.find((peer) => peer.id === nativePeerId(to));
|
|
11
|
+
const resolution = resolveSessionTarget(peers, exactNative?.id ?? to);
|
|
12
|
+
if (resolution.status === "none") {
|
|
13
|
+
throw new Fault(404, "peer_offline", "Peer is not connected.");
|
|
14
|
+
}
|
|
15
|
+
if (resolution.status !== "found" || !resolution.target) {
|
|
16
|
+
throw new Fault(409, "ambiguous_target", "Use an unambiguous peer name or session ID (prefixes need at least 8 characters).");
|
|
17
|
+
}
|
|
18
|
+
if (resolution.target.id === self) {
|
|
19
|
+
throw new Fault(400, "self_target", "Choose another peer.");
|
|
20
|
+
}
|
|
21
|
+
return resolution.target;
|
|
22
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { log } from "./bridge-protocol.js";
|
|
2
|
+
import { Mailbox } from "./bridge-mailbox.js";
|
|
3
|
+
const POLL_MS = 15000;
|
|
4
|
+
const FALLBACK_AFTER_MS = 120000;
|
|
5
|
+
const MAX_BACKOFF_MS = 300000;
|
|
6
|
+
export class WakeDelivery {
|
|
7
|
+
controller = new AbortController();
|
|
8
|
+
timer;
|
|
9
|
+
running;
|
|
10
|
+
backoff = POLL_MS;
|
|
11
|
+
name;
|
|
12
|
+
mailbox;
|
|
13
|
+
policy;
|
|
14
|
+
authorized;
|
|
15
|
+
constructor(name, mailbox, policy, authorized) {
|
|
16
|
+
this.name = name;
|
|
17
|
+
this.mailbox = mailbox;
|
|
18
|
+
this.policy = policy;
|
|
19
|
+
this.authorized = authorized;
|
|
20
|
+
}
|
|
21
|
+
start() {
|
|
22
|
+
if (!this.running && !this.controller.signal.aborted) {
|
|
23
|
+
this.schedule(0);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
schedule(delay) {
|
|
27
|
+
clearTimeout(this.timer);
|
|
28
|
+
this.timer = setTimeout(() => {
|
|
29
|
+
this.running = this.deliver()
|
|
30
|
+
.catch(() => {
|
|
31
|
+
log("wake_delivery", { name: this.name, result: "storage_unavailable" });
|
|
32
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
33
|
+
})
|
|
34
|
+
.finally(() => {
|
|
35
|
+
this.running = undefined;
|
|
36
|
+
if (!this.controller.signal.aborted) {
|
|
37
|
+
this.schedule(this.backoff);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}, delay);
|
|
41
|
+
}
|
|
42
|
+
async deliver() {
|
|
43
|
+
if (!this.authorized()) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const entries = this.mailbox.pendingNotifications().messages;
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.acknowledged) {
|
|
49
|
+
// The box's durable worker owns primary wake/replay after acknowledgement.
|
|
50
|
+
this.mailbox.notificationAccepted(entry.message.id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const next = entries.find((entry) => !entry.acknowledged && Date.now() - entry.receivedAt >= FALLBACK_AFTER_MS);
|
|
54
|
+
if (next) {
|
|
55
|
+
// One fallback wakes the box to drain the remaining mailbox; never flood a reset VM.
|
|
56
|
+
await this.post(next);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.backoff = POLL_MS;
|
|
60
|
+
}
|
|
61
|
+
async post(entry) {
|
|
62
|
+
if (!this.authorized()) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const fields = { name: this.name, messageId: entry.message.id };
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(this.policy.url, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
redirect: "error",
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `Bearer ${this.policy.key}`,
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
from: entry.from.name ?? entry.from.id,
|
|
76
|
+
from_id: entry.from.id,
|
|
77
|
+
to: this.name,
|
|
78
|
+
message_id: entry.message.id,
|
|
79
|
+
ask_id: entry.message.expectsReply === true ? entry.message.id : null,
|
|
80
|
+
text: entry.message.content.text,
|
|
81
|
+
}),
|
|
82
|
+
signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10000)]),
|
|
83
|
+
});
|
|
84
|
+
await response.body?.cancel();
|
|
85
|
+
if (response.ok && !this.controller.signal.aborted && this.authorized()) {
|
|
86
|
+
// HTTP acceptance cannot prove durable intake; the same ID remains pending until inbox ACK.
|
|
87
|
+
this.backoff = MAX_BACKOFF_MS;
|
|
88
|
+
log("wake_delivery", { ...fields, result: "accepted" });
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
92
|
+
log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
97
|
+
if (!this.controller.signal.aborted) {
|
|
98
|
+
log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async stop() {
|
|
103
|
+
this.controller.abort();
|
|
104
|
+
clearTimeout(this.timer);
|
|
105
|
+
await this.running;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export class WakeDeliveries {
|
|
109
|
+
stateDir;
|
|
110
|
+
allowed;
|
|
111
|
+
deliveries = new Map();
|
|
112
|
+
cleanup = new Set();
|
|
113
|
+
constructor(stateDir, allowed) {
|
|
114
|
+
this.stateDir = stateDir;
|
|
115
|
+
this.allowed = allowed;
|
|
116
|
+
}
|
|
117
|
+
session(identity, expires) {
|
|
118
|
+
if (!identity.wake ||
|
|
119
|
+
this.stateDir === undefined ||
|
|
120
|
+
this.deliveries.has(identity.fingerprint256)) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const mailbox = new Mailbox(this.stateDir, identity.fingerprint256, true);
|
|
124
|
+
const worker = new WakeDelivery(identity.name, mailbox, identity.wake, () => this.allowed(identity) && expires > Date.now());
|
|
125
|
+
this.deliveries.set(identity.fingerprint256, { identity, mailbox, worker });
|
|
126
|
+
worker.start();
|
|
127
|
+
}
|
|
128
|
+
drop(fp) {
|
|
129
|
+
const delivery = this.deliveries.get(fp);
|
|
130
|
+
if (!delivery) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.deliveries.delete(fp);
|
|
134
|
+
const cleanup = delivery.worker
|
|
135
|
+
.stop()
|
|
136
|
+
.then(() => delivery.mailbox.close())
|
|
137
|
+
.catch(() => {
|
|
138
|
+
log("wake_delivery", { name: delivery.identity.name, result: "cleanup_failed" });
|
|
139
|
+
})
|
|
140
|
+
.finally(() => {
|
|
141
|
+
this.cleanup.delete(cleanup);
|
|
142
|
+
});
|
|
143
|
+
this.cleanup.add(cleanup);
|
|
144
|
+
}
|
|
145
|
+
reload() {
|
|
146
|
+
for (const [fp, delivery] of this.deliveries) {
|
|
147
|
+
if (!this.allowed(delivery.identity)) {
|
|
148
|
+
this.drop(fp);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async stop() {
|
|
153
|
+
for (const fp of this.deliveries.keys()) {
|
|
154
|
+
this.drop(fp);
|
|
155
|
+
}
|
|
156
|
+
await Promise.all(this.cleanup);
|
|
157
|
+
}
|
|
158
|
+
}
|