@fitchmultz/pi-subagents 0.48.0 → 0.49.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/CHANGELOG.md +20 -0
- package/agents/worker.md +2 -2
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-config.js +1 -1
- package/dist/pi-intercom/bridge-mailbox.js +3 -55
- package/dist/pi-intercom/bridge-presence.js +5 -1
- package/dist/pi-intercom/bridge-routes.js +25 -6
- package/dist/pi-intercom/bridge-session.js +60 -45
- package/dist/pi-intercom/bridge-storage.js +73 -0
- package/dist/pi-intercom/bridge-wake-owner.js +57 -0
- package/dist/pi-intercom/bridge-wake-state.js +293 -0
- package/dist/pi-intercom/bridge-wake.js +190 -99
- package/dist/pi-intercom/bridge.js +10 -2
- package/docs/intercom-bridge.md +56 -25
- package/package.json +1 -1
- package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
- package/scripts/intercom-remote.mjs +39 -5
- package/src/pi-intercom/bridge-config.ts +1 -1
- package/src/pi-intercom/bridge-mailbox.ts +3 -59
- package/src/pi-intercom/bridge-presence.ts +7 -1
- package/src/pi-intercom/bridge-routes.ts +38 -8
- package/src/pi-intercom/bridge-session.ts +64 -61
- package/src/pi-intercom/bridge-storage.ts +83 -0
- package/src/pi-intercom/bridge-wake-owner.ts +58 -0
- package/src/pi-intercom/bridge-wake-state.ts +321 -0
- package/src/pi-intercom/bridge-wake.ts +224 -130
- package/src/pi-intercom/bridge.ts +11 -2
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { privateDatabase, privateFile, stateDirectory } from "./bridge-storage.js";
|
|
4
|
+
import { readBridgeFile } from "./bridge-files.js";
|
|
5
|
+
import { isRecord, isUnknownArray } from "../shared/unknown.js";
|
|
6
|
+
import { object } from "./bridge-protocol.js";
|
|
7
|
+
import { WakeOwner, WAKE_OWNER_FILE, stoppedWakeOwner } from "./bridge-wake-owner.js";
|
|
8
|
+
const HOUR_MS = 3600000;
|
|
9
|
+
const LIMIT = 20;
|
|
10
|
+
const RETRY_MS = 30000;
|
|
11
|
+
const MAX_BACKOFF_MS = 300000;
|
|
12
|
+
const FILE = "wake-quota.sqlite";
|
|
13
|
+
const ATTEMPTS = `CREATE TABLE wake_attempts(
|
|
14
|
+
sequence INTEGER PRIMARY KEY, attemptedAt INTEGER NOT NULL,
|
|
15
|
+
settledAt INTEGER, owner TEXT,
|
|
16
|
+
CHECK(settledAt IS NOT NULL OR owner IS NOT NULL))`;
|
|
17
|
+
function integer(value) {
|
|
18
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
19
|
+
throw new Error("Invalid durable wake state.");
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
/** One helper ledger, independent of identity, certificate, URL and credential rotation. */
|
|
24
|
+
export class WakeState {
|
|
25
|
+
db;
|
|
26
|
+
path;
|
|
27
|
+
stateDir;
|
|
28
|
+
inode;
|
|
29
|
+
device;
|
|
30
|
+
owner;
|
|
31
|
+
finishing = false;
|
|
32
|
+
constructor(stateDir) {
|
|
33
|
+
this.stateDir = stateDir;
|
|
34
|
+
this.path = join(stateDir, FILE);
|
|
35
|
+
const { db, created } = privateDatabase(stateDir, FILE);
|
|
36
|
+
this.db = db;
|
|
37
|
+
const stat = lstatSync(this.path);
|
|
38
|
+
this.inode = stat.ino;
|
|
39
|
+
this.device = stat.dev;
|
|
40
|
+
try {
|
|
41
|
+
if (created) {
|
|
42
|
+
this.initialize();
|
|
43
|
+
}
|
|
44
|
+
this.verify();
|
|
45
|
+
this.migrate();
|
|
46
|
+
this.owner = this.createOwner();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
db.close();
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
verify() {
|
|
54
|
+
const db = this.db;
|
|
55
|
+
if (db.prepare("PRAGMA quick_check").get()?.quick_check !== "ok") {
|
|
56
|
+
throw new Error("Wake ledger is corrupt.");
|
|
57
|
+
}
|
|
58
|
+
const meta = db.prepare("SELECT version, clock FROM wake_meta WHERE singleton=1").get();
|
|
59
|
+
if (!meta || (meta.version !== 1 && meta.version !== 2)) {
|
|
60
|
+
throw new Error("Unsupported wake ledger.");
|
|
61
|
+
}
|
|
62
|
+
integer(meta.clock);
|
|
63
|
+
this.verifyAttempts(meta.version);
|
|
64
|
+
for (const row of db.prepare("SELECT due, backoff FROM wake_batches").all()) {
|
|
65
|
+
integer(row.due);
|
|
66
|
+
const backoff = integer(row.backoff);
|
|
67
|
+
if ((backoff !== 0 && backoff < RETRY_MS) || backoff > MAX_BACKOFF_MS) {
|
|
68
|
+
throw new Error("Invalid wake retry state.");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
createOwner() {
|
|
73
|
+
let owner;
|
|
74
|
+
try {
|
|
75
|
+
// Recovery holds this same ledger lock: it cannot reap a file before its owner locks it.
|
|
76
|
+
return this.transaction(() => {
|
|
77
|
+
owner = new WakeOwner(this.stateDir);
|
|
78
|
+
return owner;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
owner?.close();
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
verifyAttempts(version) {
|
|
87
|
+
for (const row of this.db.prepare("SELECT attemptedAt FROM wake_attempts").all()) {
|
|
88
|
+
integer(row.attemptedAt);
|
|
89
|
+
}
|
|
90
|
+
if (version !== 2) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
for (const row of this.db
|
|
94
|
+
.prepare("SELECT attemptedAt, settledAt, owner FROM wake_attempts")
|
|
95
|
+
.all()) {
|
|
96
|
+
if (row.settledAt !== null) {
|
|
97
|
+
if (integer(row.settledAt) < integer(row.attemptedAt)) {
|
|
98
|
+
throw new Error("Invalid wake completion time.");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else if (typeof row.owner !== "string" || !WAKE_OWNER_FILE.test(row.owner)) {
|
|
102
|
+
throw new Error("Invalid open wake attempt.");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
initialize() {
|
|
107
|
+
// Timestamped legacy outcomes omit crash-before-log attempts. Verified preflight may provide
|
|
108
|
+
// complete history plus a bounded unknown reservation; absent proof reserves the full hour.
|
|
109
|
+
const legacy = readdirSync(this.stateDir).some((name) => name !== FILE && (name.endsWith(".sqlite") || name.startsWith("presence-")));
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
const attempts = this.history(now) ?? (legacy ? Array(LIMIT).fill(now) : []);
|
|
112
|
+
this.db.exec(`BEGIN IMMEDIATE;
|
|
113
|
+
CREATE TABLE wake_meta(singleton INTEGER PRIMARY KEY CHECK(singleton=1), version INTEGER NOT NULL, clock INTEGER NOT NULL);
|
|
114
|
+
${ATTEMPTS};
|
|
115
|
+
CREATE TABLE wake_batches(bucket TEXT PRIMARY KEY, due INTEGER NOT NULL, backoff INTEGER NOT NULL);`);
|
|
116
|
+
this.db.prepare("INSERT INTO wake_meta VALUES(1,2,?)").run(now);
|
|
117
|
+
const insert = this.db.prepare("INSERT INTO wake_attempts(attemptedAt,settledAt) VALUES(?,?)");
|
|
118
|
+
for (const timestamp of attempts) {
|
|
119
|
+
insert.run(timestamp, timestamp);
|
|
120
|
+
}
|
|
121
|
+
this.db.exec("COMMIT");
|
|
122
|
+
}
|
|
123
|
+
migrate() {
|
|
124
|
+
this.transaction(() => {
|
|
125
|
+
if (this.db.prepare("SELECT version FROM wake_meta").get()?.version !== 1) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// V1 timestamps precede COMMIT/connect/TLS, so none prove when a POST finished.
|
|
129
|
+
// Stop all old writers before upgrading; the new CHECK also denies their old INSERT shape.
|
|
130
|
+
const now = Math.max(Date.now(), integer(this.db.prepare("SELECT clock FROM wake_meta").get()?.clock));
|
|
131
|
+
this.db.exec(`ALTER TABLE wake_attempts RENAME TO wake_attempts_v1; ${ATTEMPTS};`);
|
|
132
|
+
this.db
|
|
133
|
+
.prepare(`INSERT INTO wake_attempts(sequence,attemptedAt,settledAt)
|
|
134
|
+
SELECT sequence,attemptedAt,MAX(attemptedAt,?) FROM wake_attempts_v1`)
|
|
135
|
+
.run(now);
|
|
136
|
+
this.db.exec("DROP TABLE wake_attempts_v1");
|
|
137
|
+
this.db.prepare("UPDATE wake_meta SET version=2,clock=?").run(now);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
history(now) {
|
|
141
|
+
const path = join(this.stateDir, "wake-history.json");
|
|
142
|
+
let input;
|
|
143
|
+
try {
|
|
144
|
+
privateFile(path);
|
|
145
|
+
input = JSON.parse(readBridgeFile(path, true, 1024 * 1024).toString("utf8"));
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
const raw = object(input, ["coveredSince", "stoppedAt", "attempts", "unknownAttempts"]);
|
|
154
|
+
const coveredSince = integer(raw.coveredSince);
|
|
155
|
+
const stoppedAt = integer(raw.stoppedAt);
|
|
156
|
+
const unknownAttempts = integer(raw.unknownAttempts);
|
|
157
|
+
if (coveredSince > stoppedAt - HOUR_MS ||
|
|
158
|
+
stoppedAt > now ||
|
|
159
|
+
unknownAttempts > LIMIT ||
|
|
160
|
+
!isUnknownArray(raw.attempts) ||
|
|
161
|
+
raw.attempts.length > 10000) {
|
|
162
|
+
throw new Error("Invalid legacy wake history; never reset prior usage.");
|
|
163
|
+
}
|
|
164
|
+
const attempts = raw.attempts.map(integer);
|
|
165
|
+
if (attempts.some((timestamp) => timestamp < coveredSince || timestamp > stoppedAt)) {
|
|
166
|
+
throw new Error("Attempt outside attested history coverage.");
|
|
167
|
+
}
|
|
168
|
+
return attempts
|
|
169
|
+
.filter((timestamp) => timestamp > now - HOUR_MS)
|
|
170
|
+
.concat(Array(unknownAttempts).fill(now));
|
|
171
|
+
}
|
|
172
|
+
validate() {
|
|
173
|
+
stateDirectory(this.stateDir);
|
|
174
|
+
privateFile(this.path);
|
|
175
|
+
const stat = lstatSync(this.path);
|
|
176
|
+
if (stat.ino !== this.inode || stat.dev !== this.device) {
|
|
177
|
+
throw new Error("Wake ledger was replaced.");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
deadline(bucket) {
|
|
181
|
+
this.validate();
|
|
182
|
+
const existing = this.db.prepare("SELECT due FROM wake_batches WHERE bucket=?").get(bucket);
|
|
183
|
+
if (existing) {
|
|
184
|
+
return integer(existing.due);
|
|
185
|
+
}
|
|
186
|
+
// First observation occurs only after mailbox FULL commit, never at the pre-write receivedAt.
|
|
187
|
+
// Keep this grace durable too: new arrivals/reload/restart cannot restart an existing deadline.
|
|
188
|
+
this.db
|
|
189
|
+
.prepare("INSERT OR IGNORE INTO wake_batches VALUES(?,?,0)")
|
|
190
|
+
.run(bucket, Date.now() + 2000);
|
|
191
|
+
return integer(this.db.prepare("SELECT due FROM wake_batches WHERE bucket=?").get(bucket)?.due);
|
|
192
|
+
}
|
|
193
|
+
retire(bucket) {
|
|
194
|
+
this.validate();
|
|
195
|
+
this.db.prepare("DELETE FROM wake_batches WHERE bucket=?").run(bucket);
|
|
196
|
+
}
|
|
197
|
+
/** Persist quota and uncertainty BEFORE dispatch. Returning a deadline means no attempt admitted. */
|
|
198
|
+
reserve(bucket) {
|
|
199
|
+
this.retrySettlement();
|
|
200
|
+
return this.transaction(() => this.reservation(bucket));
|
|
201
|
+
}
|
|
202
|
+
retrySettlement() {
|
|
203
|
+
if (this.finishing) {
|
|
204
|
+
this.settle();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
transaction(action) {
|
|
208
|
+
this.validate();
|
|
209
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
210
|
+
try {
|
|
211
|
+
const result = action();
|
|
212
|
+
this.db.exec("COMMIT");
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
this.db.exec("ROLLBACK");
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** A failed settlement stays open and must be retried before this owner can dispatch again. */
|
|
221
|
+
settle() {
|
|
222
|
+
this.finishing = true;
|
|
223
|
+
this.transaction(() => this.finishOwner(this.owner.name));
|
|
224
|
+
this.finishing = false;
|
|
225
|
+
}
|
|
226
|
+
finishOwner(owner) {
|
|
227
|
+
const now = Math.max(Date.now(), integer(this.db.prepare("SELECT clock FROM wake_meta").get()?.clock));
|
|
228
|
+
this.db
|
|
229
|
+
.prepare(`UPDATE wake_attempts SET settledAt=MAX(attemptedAt,?)
|
|
230
|
+
WHERE owner=? AND settledAt IS NULL`)
|
|
231
|
+
.run(now, owner);
|
|
232
|
+
this.db.prepare("UPDATE wake_meta SET clock=?").run(now);
|
|
233
|
+
}
|
|
234
|
+
recover() {
|
|
235
|
+
const names = new Set(readdirSync(this.stateDir).filter((name) => WAKE_OWNER_FILE.test(name)));
|
|
236
|
+
for (const row of this.db
|
|
237
|
+
.prepare("SELECT DISTINCT owner FROM wake_attempts WHERE settledAt IS NULL")
|
|
238
|
+
.all()) {
|
|
239
|
+
if (typeof row.owner !== "string") {
|
|
240
|
+
throw new Error("Invalid open wake owner.");
|
|
241
|
+
}
|
|
242
|
+
names.add(row.owner);
|
|
243
|
+
}
|
|
244
|
+
for (const name of names) {
|
|
245
|
+
if (name !== this.owner.name && stoppedWakeOwner(this.stateDir, name)) {
|
|
246
|
+
// The owner cannot send again. Unknown outcomes retain a full hour from this observation.
|
|
247
|
+
this.finishOwner(name);
|
|
248
|
+
rmSync(join(this.stateDir, name), { force: true });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
reservation(bucket) {
|
|
253
|
+
this.recover();
|
|
254
|
+
const clock = integer(this.db.prepare("SELECT clock FROM wake_meta WHERE singleton=1").get()?.clock);
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
if (now < clock) {
|
|
257
|
+
return clock;
|
|
258
|
+
}
|
|
259
|
+
const row = this.db.prepare("SELECT due, backoff FROM wake_batches WHERE bucket=?").get(bucket);
|
|
260
|
+
if (row && integer(row.due) > now) {
|
|
261
|
+
return integer(row.due);
|
|
262
|
+
}
|
|
263
|
+
this.db.prepare("DELETE FROM wake_attempts WHERE settledAt<=?").run(now - HOUR_MS);
|
|
264
|
+
const attempts = this.db
|
|
265
|
+
.prepare("SELECT settledAt FROM wake_attempts ORDER BY settledAt")
|
|
266
|
+
.all();
|
|
267
|
+
if (attempts.length >= LIMIT) {
|
|
268
|
+
const oldest = attempts.find((attempt) => attempt.settledAt !== null);
|
|
269
|
+
// Only open/unknown attempts have no expiry yet; revisit on the existing recovery backoff.
|
|
270
|
+
return oldest ? integer(oldest.settledAt) + HOUR_MS : now + RETRY_MS;
|
|
271
|
+
}
|
|
272
|
+
const backoff = row
|
|
273
|
+
? Math.max(RETRY_MS, Math.min(integer(row.backoff) * 2, MAX_BACKOFF_MS))
|
|
274
|
+
: RETRY_MS;
|
|
275
|
+
this.db
|
|
276
|
+
.prepare("INSERT INTO wake_attempts(attemptedAt,owner) VALUES(?,?)")
|
|
277
|
+
.run(now, this.owner.name);
|
|
278
|
+
this.db.prepare("UPDATE wake_meta SET clock=? WHERE singleton=1").run(now);
|
|
279
|
+
this.db
|
|
280
|
+
.prepare("INSERT OR REPLACE INTO wake_batches VALUES(?,?,?)")
|
|
281
|
+
.run(bucket, now + backoff, backoff);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
close() {
|
|
285
|
+
try {
|
|
286
|
+
this.settle();
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
this.db.close();
|
|
290
|
+
this.owner.close();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -1,146 +1,232 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { log } from "./bridge-protocol.js";
|
|
2
3
|
import { Mailbox } from "./bridge-mailbox.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
4
|
+
import { WakeState } from "./bridge-wake-state.js";
|
|
5
|
+
function actionable(entry) {
|
|
6
|
+
const message = entry.message;
|
|
7
|
+
if (entry.acknowledged || message.passive === true || message.delivery === "passive") {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
return (message.expectsReply === true ||
|
|
11
|
+
!/^(?:ACK|DUP-ACK|FYI|NOTE|RECEIPT|NOWAKE)(?=$|[\s:])/.test(message.content.text));
|
|
12
|
+
}
|
|
13
|
+
function bucket(identity) {
|
|
14
|
+
return createHash("sha256")
|
|
15
|
+
.update(identity.wake?.url ?? "")
|
|
16
|
+
.digest("hex");
|
|
17
|
+
}
|
|
18
|
+
/** Mailbox commits and durable retry/quota deadlines drive wakes; there is no intake scan timer. */
|
|
19
|
+
export class WakeDeliveries {
|
|
20
|
+
stateDir;
|
|
21
|
+
state;
|
|
22
|
+
allowed;
|
|
23
|
+
deliveries = new Map();
|
|
7
24
|
controller = new AbortController();
|
|
8
25
|
timer;
|
|
9
26
|
running;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
this.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
dirty = false;
|
|
28
|
+
storageBackoff = 15000;
|
|
29
|
+
constructor(stateDir, allowed) {
|
|
30
|
+
this.stateDir = stateDir;
|
|
31
|
+
this.allowed = allowed;
|
|
32
|
+
// Initialize before any presence proof/mailbox is created, so genuinely fresh installs start now.
|
|
33
|
+
this.state = stateDir === undefined ? undefined : new WakeState(stateDir);
|
|
34
|
+
}
|
|
35
|
+
session(identity, expires) {
|
|
36
|
+
if (!identity.wake ||
|
|
37
|
+
this.stateDir === undefined ||
|
|
38
|
+
this.deliveries.has(identity.fingerprint256)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
this.deliveries.set(identity.fingerprint256, {
|
|
42
|
+
identity,
|
|
43
|
+
expires,
|
|
44
|
+
mailbox: new Mailbox(this.stateDir, identity.fingerprint256, true),
|
|
45
|
+
controller: new AbortController(),
|
|
46
|
+
});
|
|
47
|
+
// Startup restores every certificate proof synchronously before the scheduled reconciliation.
|
|
48
|
+
// An empty first fingerprint must not clear another retained fingerprint's durable cold batch.
|
|
49
|
+
this.changed(false);
|
|
50
|
+
}
|
|
51
|
+
changed(reconcile = true) {
|
|
52
|
+
if (this.controller.signal.aborted) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
// ACK may be followed by fresh mail before the event timer runs. Clear completed batches now
|
|
57
|
+
// so a new obligation does not inherit a completed batch's uncertainty/backoff.
|
|
58
|
+
if (reconcile) {
|
|
59
|
+
this.reconcile();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// The scheduled storage recovery fails closed; never let an event bypass durable admission.
|
|
64
|
+
}
|
|
65
|
+
if (this.running) {
|
|
66
|
+
this.dirty = true;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
this.schedule(Date.now());
|
|
70
|
+
}
|
|
20
71
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
72
|
+
reconcile() {
|
|
73
|
+
for (const [key, candidate] of this.groups()) {
|
|
74
|
+
if (candidate) {
|
|
75
|
+
this.state?.deadline(key);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
this.state?.retire(key);
|
|
79
|
+
}
|
|
24
80
|
}
|
|
25
81
|
}
|
|
26
|
-
schedule(
|
|
82
|
+
schedule(deadline) {
|
|
27
83
|
clearTimeout(this.timer);
|
|
28
84
|
this.timer = setTimeout(() => {
|
|
85
|
+
this.dirty = false;
|
|
86
|
+
let next = Infinity;
|
|
29
87
|
this.running = this.deliver()
|
|
88
|
+
.then((due) => {
|
|
89
|
+
next = due;
|
|
90
|
+
this.storageBackoff = 15000;
|
|
91
|
+
return;
|
|
92
|
+
})
|
|
30
93
|
.catch(() => {
|
|
31
|
-
log("wake_delivery", {
|
|
32
|
-
|
|
94
|
+
log("wake_delivery", { result: "storage_unavailable" });
|
|
95
|
+
next = Date.now() + this.storageBackoff;
|
|
96
|
+
this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
|
|
33
97
|
})
|
|
34
98
|
.finally(() => {
|
|
35
99
|
this.running = undefined;
|
|
36
|
-
if (!this.controller.signal.aborted) {
|
|
37
|
-
this.schedule(this.
|
|
100
|
+
if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
|
|
101
|
+
this.schedule(this.dirty ? Date.now() : next);
|
|
38
102
|
}
|
|
39
103
|
});
|
|
40
|
-
},
|
|
104
|
+
}, Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)));
|
|
105
|
+
}
|
|
106
|
+
authorized(delivery) {
|
|
107
|
+
return (!this.controller.signal.aborted &&
|
|
108
|
+
this.allowed(delivery.identity) &&
|
|
109
|
+
delivery.expires > Date.now());
|
|
110
|
+
}
|
|
111
|
+
pending(delivery) {
|
|
112
|
+
let after = 0;
|
|
113
|
+
let next;
|
|
114
|
+
let page;
|
|
115
|
+
do {
|
|
116
|
+
page = delivery.mailbox.pendingNotifications(after);
|
|
117
|
+
for (const entry of page.messages) {
|
|
118
|
+
if (!actionable(entry)) {
|
|
119
|
+
// Retire only notification intent. Payload/reply obligations require actual ACK/reply.
|
|
120
|
+
delivery.mailbox.notificationAccepted(entry.message.id);
|
|
121
|
+
}
|
|
122
|
+
else if (!next) {
|
|
123
|
+
next = entry;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
after = Number(page.nextCursor);
|
|
127
|
+
} while (page.hasMore);
|
|
128
|
+
return next;
|
|
129
|
+
}
|
|
130
|
+
groups() {
|
|
131
|
+
const groups = new Map();
|
|
132
|
+
for (const delivery of this.deliveries.values()) {
|
|
133
|
+
if (!this.authorized(delivery)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const key = bucket(delivery.identity);
|
|
137
|
+
const entry = this.pending(delivery);
|
|
138
|
+
const previous = groups.get(key);
|
|
139
|
+
if (!groups.has(key) ||
|
|
140
|
+
(entry && (!previous || entry.receivedAt < previous.entry.receivedAt))) {
|
|
141
|
+
groups.set(key, entry ? { delivery, entry } : undefined);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return groups;
|
|
41
145
|
}
|
|
42
146
|
async deliver() {
|
|
43
|
-
|
|
44
|
-
|
|
147
|
+
const state = this.state;
|
|
148
|
+
let next = Infinity;
|
|
149
|
+
if (!state) {
|
|
150
|
+
return next;
|
|
45
151
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
152
|
+
// ACK can remove the final candidate while its failed completion write still needs recovery.
|
|
153
|
+
state.retrySettlement();
|
|
154
|
+
for (const [key, candidate] of this.groups()) {
|
|
155
|
+
if (!candidate) {
|
|
156
|
+
state.retire(key);
|
|
157
|
+
continue;
|
|
51
158
|
}
|
|
159
|
+
const due = state.deadline(key);
|
|
160
|
+
if (due > Date.now()) {
|
|
161
|
+
next = Math.min(next, due);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const deferred = state.reserve(key);
|
|
165
|
+
if (deferred !== undefined) {
|
|
166
|
+
next = Math.min(next, deferred);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
// ponytail: two cold Macs can each admit one wake. Literal cross-Mac single admission
|
|
170
|
+
// requires a neutral quota authority governing every writer, not a URL hash or HTTP 2xx.
|
|
171
|
+
try {
|
|
172
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
173
|
+
await this.post(candidate);
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
// Expiry starts after response/error/abort, never before COMMIT or connect/TLS.
|
|
177
|
+
state.settle();
|
|
178
|
+
}
|
|
179
|
+
// Re-read ACKs and all groups after transport I/O; a snapshot must not wake already consumed mail.
|
|
180
|
+
return Date.now();
|
|
52
181
|
}
|
|
53
|
-
|
|
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;
|
|
182
|
+
return next;
|
|
60
183
|
}
|
|
61
|
-
async post(entry) {
|
|
62
|
-
|
|
184
|
+
async post({ delivery, entry }) {
|
|
185
|
+
const policy = delivery.identity.wake;
|
|
186
|
+
if (!policy || !this.authorized(delivery)) {
|
|
63
187
|
return;
|
|
64
188
|
}
|
|
65
|
-
const fields = { name:
|
|
189
|
+
const fields = { name: delivery.identity.name, messageId: entry.message.id };
|
|
66
190
|
try {
|
|
67
|
-
const response = await fetch(
|
|
191
|
+
const response = await fetch(policy.url, {
|
|
68
192
|
method: "POST",
|
|
69
193
|
redirect: "error",
|
|
70
|
-
headers: {
|
|
71
|
-
Authorization: `Bearer ${this.policy.key}`,
|
|
72
|
-
"Content-Type": "application/json",
|
|
73
|
-
},
|
|
194
|
+
headers: { Authorization: `Bearer ${policy.key}`, "Content-Type": "application/json" },
|
|
74
195
|
body: JSON.stringify({
|
|
75
196
|
from: entry.from.name ?? entry.from.id,
|
|
76
197
|
from_id: entry.from.id,
|
|
77
|
-
to:
|
|
198
|
+
to: delivery.identity.name,
|
|
78
199
|
message_id: entry.message.id,
|
|
79
200
|
ask_id: entry.message.expectsReply === true ? entry.message.id : null,
|
|
80
201
|
text: entry.message.content.text,
|
|
81
202
|
}),
|
|
82
|
-
signal: AbortSignal.any([
|
|
203
|
+
signal: AbortSignal.any([
|
|
204
|
+
this.controller.signal,
|
|
205
|
+
delivery.controller.signal,
|
|
206
|
+
AbortSignal.timeout(10000),
|
|
207
|
+
]),
|
|
83
208
|
});
|
|
84
209
|
await response.body?.cancel();
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
92
|
-
log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
|
|
93
|
-
}
|
|
210
|
+
// Acceptance does not consume mail or reset uncertainty: only real inbox ACK retires intent.
|
|
211
|
+
log("wake_delivery", {
|
|
212
|
+
...fields,
|
|
213
|
+
result: response.ok ? "accepted" : "http_rejected",
|
|
214
|
+
status: response.status,
|
|
215
|
+
});
|
|
94
216
|
}
|
|
95
217
|
catch {
|
|
96
|
-
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
97
218
|
if (!this.controller.signal.aborted) {
|
|
98
219
|
log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
|
|
99
220
|
}
|
|
100
221
|
}
|
|
101
222
|
}
|
|
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
223
|
drop(fp) {
|
|
129
224
|
const delivery = this.deliveries.get(fp);
|
|
130
|
-
if (!delivery) {
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
225
|
this.deliveries.delete(fp);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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);
|
|
226
|
+
delivery?.controller.abort();
|
|
227
|
+
delivery?.mailbox.close();
|
|
228
|
+
// Membership removal alone is not an ACK. Reload restores proof-backed replacements first.
|
|
229
|
+
this.changed(false);
|
|
144
230
|
}
|
|
145
231
|
reload() {
|
|
146
232
|
for (const [fp, delivery] of this.deliveries) {
|
|
@@ -148,11 +234,16 @@ export class WakeDeliveries {
|
|
|
148
234
|
this.drop(fp);
|
|
149
235
|
}
|
|
150
236
|
}
|
|
237
|
+
this.changed(false);
|
|
151
238
|
}
|
|
152
239
|
async stop() {
|
|
153
|
-
|
|
154
|
-
|
|
240
|
+
this.controller.abort();
|
|
241
|
+
clearTimeout(this.timer);
|
|
242
|
+
await this.running;
|
|
243
|
+
for (const delivery of this.deliveries.values()) {
|
|
244
|
+
delivery.mailbox.close();
|
|
155
245
|
}
|
|
156
|
-
|
|
246
|
+
this.deliveries.clear();
|
|
247
|
+
this.state?.close();
|
|
157
248
|
}
|
|
158
249
|
}
|