@fitchmultz/pi-subagents 0.52.18 → 0.52.20
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 +50 -0
- package/README.md +4 -0
- package/dist/agent/host.js +12 -0
- package/dist/agent/index.js +70 -56
- package/dist/agent/launch.js +12 -3
- package/dist/agent/notifications.js +5 -5
- package/dist/agent/parking.js +26 -15
- package/dist/agent/runner.js +18 -16
- package/dist/agent/tool-schemas.js +2 -0
- package/dist/agent/usage.js +42 -3
- package/dist/agent/view.js +13 -13
- package/dist/agent/wake.js +17 -15
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/bridge-presence.js +30 -18
- package/dist/pi-intercom/bridge-wake.js +60 -37
- package/dist/pi-intercom/broker/broker.js +2 -2
- package/dist/pi-intercom/broker/client-last.js +6 -18
- package/dist/pi-intercom/broker/client.js +16 -50
- package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
- package/dist/pi-intercom/broker/pending-requests.js +26 -0
- package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
- package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
- package/dist/pi-intercom/broker/snapshots.d.ts +25 -7
- package/dist/pi-intercom/broker/snapshots.js +56 -25
- package/dist/pi-intercom/broker/spawn-claim.js +89 -0
- package/dist/pi-intercom/broker/spawn.js +31 -78
- package/dist/pi-intercom/lifecycle.js +26 -26
- package/dist/shared/post-exit-stdio-guard.js +44 -37
- package/docs/development.md +25 -0
- package/docs/intercom.md +13 -0
- package/docs/reference.md +16 -5
- package/package.json +1 -1
- package/scripts/build.mjs +41 -14
- package/skills/pi-subagents/SKILL.md +8 -4
- package/src/agent/contracts.ts +17 -0
- package/src/agent/host.ts +19 -0
- package/src/agent/index.ts +91 -62
- package/src/agent/launch.ts +16 -3
- package/src/agent/notifications.ts +5 -5
- package/src/agent/parking.ts +39 -17
- package/src/agent/runner.ts +20 -21
- package/src/agent/tool-schemas.ts +2 -0
- package/src/agent/usage.ts +48 -3
- package/src/agent/view.ts +15 -13
- package/src/agent/wake.ts +19 -15
- package/src/pi-intercom/bridge-presence.ts +37 -18
- package/src/pi-intercom/bridge-wake.ts +70 -38
- package/src/pi-intercom/broker/broker.ts +2 -1
- package/src/pi-intercom/broker/client-last.ts +14 -25
- package/src/pi-intercom/broker/client.ts +36 -65
- package/src/pi-intercom/broker/pending-requests.ts +37 -0
- package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
- package/src/pi-intercom/broker/snapshots.ts +82 -37
- package/src/pi-intercom/broker/spawn-claim.ts +98 -0
- package/src/pi-intercom/broker/spawn.ts +42 -82
- package/src/pi-intercom/lifecycle.ts +38 -27
- package/src/shared/post-exit-stdio-guard.ts +48 -40
|
@@ -15,6 +15,12 @@ interface Candidate {
|
|
|
15
15
|
readonly entry: PendingNotification;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
type DeliveryPhase =
|
|
19
|
+
| { readonly kind: "idle" }
|
|
20
|
+
| { readonly kind: "scheduled"; readonly timer: NodeJS.Timeout }
|
|
21
|
+
| { readonly kind: "running" | "running-dirty"; readonly task: Promise<void> }
|
|
22
|
+
| { readonly kind: "stopped"; readonly task: Promise<void> };
|
|
23
|
+
|
|
18
24
|
function actionable(entry: PendingNotification): boolean {
|
|
19
25
|
const message = entry.message;
|
|
20
26
|
if (entry.acknowledged || message.passive === true || message.delivery === "passive") {
|
|
@@ -39,9 +45,7 @@ export class WakeDeliveries {
|
|
|
39
45
|
private readonly allowed: (identity: Identity) => boolean;
|
|
40
46
|
private readonly deliveries = new Map<string, Delivery>();
|
|
41
47
|
private readonly controller = new AbortController();
|
|
42
|
-
private
|
|
43
|
-
private running?: Promise<void> | undefined;
|
|
44
|
-
private dirty = false;
|
|
48
|
+
private phase: DeliveryPhase = { kind: "idle" };
|
|
45
49
|
private storageBackoff = 15000;
|
|
46
50
|
|
|
47
51
|
constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
|
|
@@ -83,10 +87,17 @@ export class WakeDeliveries {
|
|
|
83
87
|
} catch {
|
|
84
88
|
// The scheduled storage recovery fails closed; never let an event bypass durable admission.
|
|
85
89
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
const phase = this.phase;
|
|
91
|
+
switch (phase.kind) {
|
|
92
|
+
case "running":
|
|
93
|
+
this.phase = { kind: "running-dirty", task: phase.task };
|
|
94
|
+
return;
|
|
95
|
+
case "running-dirty":
|
|
96
|
+
case "stopped":
|
|
97
|
+
return;
|
|
98
|
+
case "idle":
|
|
99
|
+
case "scheduled":
|
|
100
|
+
this.schedule(Date.now());
|
|
90
101
|
}
|
|
91
102
|
}
|
|
92
103
|
|
|
@@ -101,31 +112,40 @@ export class WakeDeliveries {
|
|
|
101
112
|
}
|
|
102
113
|
|
|
103
114
|
private schedule(deadline: number): void {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
this.running = this.deliver()
|
|
110
|
-
.then((due) => {
|
|
111
|
-
next = due;
|
|
112
|
-
this.storageBackoff = 15000;
|
|
113
|
-
return;
|
|
114
|
-
})
|
|
115
|
-
.catch(() => {
|
|
116
|
-
log("wake_delivery", { result: "storage_unavailable" });
|
|
117
|
-
next = Date.now() + this.storageBackoff;
|
|
118
|
-
this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
|
|
119
|
-
})
|
|
120
|
-
.finally(() => {
|
|
121
|
-
this.running = undefined;
|
|
122
|
-
if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
|
|
123
|
-
this.schedule(this.dirty ? Date.now() : next);
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
},
|
|
115
|
+
if (this.phase.kind === "scheduled") {
|
|
116
|
+
clearTimeout(this.phase.timer);
|
|
117
|
+
}
|
|
118
|
+
const timer = setTimeout(
|
|
119
|
+
() => this.startDelivery(),
|
|
127
120
|
Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)),
|
|
128
121
|
);
|
|
122
|
+
this.phase = { kind: "scheduled", timer };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private startDelivery(): void {
|
|
126
|
+
let next = Infinity;
|
|
127
|
+
const task = this.deliver()
|
|
128
|
+
.then((due) => {
|
|
129
|
+
next = due;
|
|
130
|
+
this.storageBackoff = 15000;
|
|
131
|
+
return;
|
|
132
|
+
})
|
|
133
|
+
.catch(() => {
|
|
134
|
+
log("wake_delivery", { result: "storage_unavailable" });
|
|
135
|
+
next = Date.now() + this.storageBackoff;
|
|
136
|
+
this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
|
|
137
|
+
})
|
|
138
|
+
.finally(() => {
|
|
139
|
+
if (this.phase.kind === "stopped") {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const dirty = this.phase.kind === "running-dirty";
|
|
143
|
+
this.phase = { kind: "idle" };
|
|
144
|
+
if (dirty || Number.isFinite(next)) {
|
|
145
|
+
this.schedule(dirty ? Date.now() : next);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
this.phase = { kind: "running", task };
|
|
129
149
|
}
|
|
130
150
|
|
|
131
151
|
private authorized(delivery: Delivery): boolean {
|
|
@@ -267,14 +287,26 @@ export class WakeDeliveries {
|
|
|
267
287
|
this.changed(false);
|
|
268
288
|
}
|
|
269
289
|
|
|
270
|
-
|
|
271
|
-
this.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
for (const delivery of this.deliveries.values()) {
|
|
275
|
-
delivery.mailbox.close();
|
|
290
|
+
stop(): Promise<void> {
|
|
291
|
+
const phase = this.phase;
|
|
292
|
+
if (phase.kind === "stopped") {
|
|
293
|
+
return phase.task;
|
|
276
294
|
}
|
|
277
|
-
|
|
278
|
-
|
|
295
|
+
if (phase.kind === "scheduled") {
|
|
296
|
+
clearTimeout(phase.timer);
|
|
297
|
+
}
|
|
298
|
+
const running =
|
|
299
|
+
phase.kind === "running" || phase.kind === "running-dirty" ? phase.task : undefined;
|
|
300
|
+
const task = Promise.resolve(running).then(() => {
|
|
301
|
+
for (const delivery of this.deliveries.values()) {
|
|
302
|
+
delivery.mailbox.close();
|
|
303
|
+
}
|
|
304
|
+
this.deliveries.clear();
|
|
305
|
+
this.state?.close();
|
|
306
|
+
return;
|
|
307
|
+
});
|
|
308
|
+
this.phase = { kind: "stopped", task };
|
|
309
|
+
this.controller.abort();
|
|
310
|
+
return task;
|
|
279
311
|
}
|
|
280
312
|
}
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
snapshotFrames,
|
|
30
30
|
TopicSnapshot,
|
|
31
31
|
publicationMessage,
|
|
32
|
+
preparedPublication,
|
|
32
33
|
deliveryAcknowledgement,
|
|
33
34
|
shouldReplaceDelivery,
|
|
34
35
|
} from "./snapshots.ts";
|
|
@@ -245,7 +246,7 @@ class IntercomBroker {
|
|
|
245
246
|
? session.info
|
|
246
247
|
: session.topics.snapshot(session.info, prepared);
|
|
247
248
|
const from = messageSender(nextInfo);
|
|
248
|
-
const deliveries = this.planPublications(currentId, from, prepared
|
|
249
|
+
const deliveries = this.planPublications(currentId, from, preparedPublication(prepared));
|
|
249
250
|
// Validate every frame before committing state or delivering publications.
|
|
250
251
|
if (prepared.restore) {
|
|
251
252
|
session.topics.validateRestore(session.info, prepared, requestId);
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type SessionInfo,
|
|
19
19
|
} from "../types.ts";
|
|
20
20
|
import { writeMessage } from "./framing.ts";
|
|
21
|
+
import { PendingRequests } from "./pending-requests.ts";
|
|
21
22
|
|
|
22
23
|
/** A peer's read of this session or, for a main, of its exited `parked` child. */
|
|
23
24
|
export function lastRequest(msg: UnknownRecord): {
|
|
@@ -46,35 +47,25 @@ export class LastMessageRequests {
|
|
|
46
47
|
constructor(timeoutMs: number) {
|
|
47
48
|
this.timeoutMs = timeoutMs;
|
|
48
49
|
}
|
|
49
|
-
private readonly pending = new
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
readonly reject: (error: Readonly<Error>) => void;
|
|
54
|
-
readonly timer: NodeJS.Timeout;
|
|
55
|
-
}
|
|
56
|
-
>();
|
|
50
|
+
private readonly pending = new PendingRequests<{
|
|
51
|
+
readonly resolve: (result: NativeReadResult) => void;
|
|
52
|
+
readonly reject: (error: Readonly<Error>) => void;
|
|
53
|
+
}>();
|
|
57
54
|
|
|
58
55
|
fail(error: Readonly<Error>): void {
|
|
59
56
|
this.lastMessages = false;
|
|
60
57
|
this.receiptReads = false;
|
|
61
|
-
|
|
62
|
-
clearTimeout(pending.timer);
|
|
63
|
-
pending.reject(error);
|
|
64
|
-
}
|
|
65
|
-
this.pending.clear();
|
|
58
|
+
this.pending.fail(error);
|
|
66
59
|
}
|
|
67
60
|
|
|
68
61
|
receive(message: UnknownRecord): void {
|
|
69
62
|
if (typeof message.requestId !== "string" || !isNativeReadResult(message.result)) {
|
|
70
63
|
throw new Error("Invalid last-message response");
|
|
71
64
|
}
|
|
72
|
-
const pending = this.pending.
|
|
65
|
+
const pending = this.pending.take(message.requestId);
|
|
73
66
|
if (!pending) {
|
|
74
67
|
return;
|
|
75
68
|
}
|
|
76
|
-
clearTimeout(pending.timer);
|
|
77
|
-
this.pending.delete(message.requestId);
|
|
78
69
|
pending.resolve(message.result);
|
|
79
70
|
}
|
|
80
71
|
|
|
@@ -100,18 +91,16 @@ export class LastMessageRequests {
|
|
|
100
91
|
}
|
|
101
92
|
return new Promise((resolve, reject) => {
|
|
102
93
|
const requestId = randomUUID();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
94
|
+
this.pending.set(
|
|
95
|
+
requestId,
|
|
96
|
+
{ resolve, reject },
|
|
97
|
+
this.timeoutMs,
|
|
98
|
+
() => new Error("Last-message read timeout"),
|
|
99
|
+
);
|
|
109
100
|
try {
|
|
110
101
|
writeMessage(socket, { type: "last_request", requestId, to, query });
|
|
111
102
|
} catch (error) {
|
|
112
|
-
|
|
113
|
-
this.pending.delete(requestId);
|
|
114
|
-
reject(new Error(errorMessage(error)));
|
|
103
|
+
this.pending.take(requestId)?.reject(new Error(errorMessage(error)));
|
|
115
104
|
}
|
|
116
105
|
});
|
|
117
106
|
}
|
|
@@ -4,6 +4,7 @@ import { randomUUID } from "crypto";
|
|
|
4
4
|
import { writeMessage } from "./framing.ts";
|
|
5
5
|
import { BrokerConnection, connectBrokerSocket } from "./connection.ts";
|
|
6
6
|
import { SnapshotReader } from "./snapshot-reader.ts";
|
|
7
|
+
import { PendingRequests } from "./pending-requests.ts";
|
|
7
8
|
import { LastMessageRequests, lastRequest } from "./client-last.ts";
|
|
8
9
|
import { ParkRequests } from "./client-park.ts";
|
|
9
10
|
import { WakeMessages } from "./client-wake.ts";
|
|
@@ -70,18 +71,15 @@ export class IntercomClient extends EventEmitter {
|
|
|
70
71
|
readonly wakes = new WakeMessages(this, () => this.requireActiveSocket());
|
|
71
72
|
private guardedDeliverySupported = false;
|
|
72
73
|
private readonly lastRequests: LastMessageRequests;
|
|
73
|
-
private readonly pendingSends = new
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
>();
|
|
77
|
-
private readonly pendingLists = new
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
readonly reader: SnapshotReader;
|
|
83
|
-
}
|
|
84
|
-
>();
|
|
74
|
+
private readonly pendingSends = new PendingRequests<{
|
|
75
|
+
readonly resolve: (r: SendResult) => void;
|
|
76
|
+
readonly reject: (e: Readonly<Error>) => void;
|
|
77
|
+
}>();
|
|
78
|
+
private readonly pendingLists = new PendingRequests<{
|
|
79
|
+
readonly resolve: (snapshot: SessionSnapshot) => void;
|
|
80
|
+
readonly reject: (e: Readonly<Error>) => void;
|
|
81
|
+
readonly reader: SnapshotReader;
|
|
82
|
+
}>();
|
|
85
83
|
private connecting = false;
|
|
86
84
|
private disconnecting = false;
|
|
87
85
|
private readonly sendTimeoutMs: number;
|
|
@@ -96,14 +94,8 @@ export class IntercomClient extends EventEmitter {
|
|
|
96
94
|
|
|
97
95
|
private failPending(error: Readonly<Error>): void {
|
|
98
96
|
this.lastRequests.fail(error);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
this.pendingSends.clear();
|
|
103
|
-
for (const pending of this.pendingLists.values()) {
|
|
104
|
-
pending.reject(error);
|
|
105
|
-
}
|
|
106
|
-
this.pendingLists.clear();
|
|
97
|
+
this.pendingSends.fail(error);
|
|
98
|
+
this.pendingLists.fail(error);
|
|
107
99
|
}
|
|
108
100
|
|
|
109
101
|
get sessionId(): string | null {
|
|
@@ -301,12 +293,11 @@ export class IntercomClient extends EventEmitter {
|
|
|
301
293
|
if (type !== "delivered" && typeof reason !== "string") {
|
|
302
294
|
throw new Error(`Invalid ${errorMessage(type)} message`);
|
|
303
295
|
}
|
|
304
|
-
const pending = this.pendingSends.
|
|
296
|
+
const pending = this.pendingSends.take(messageId);
|
|
305
297
|
// Late responses are harmless after the caller has timed out.
|
|
306
298
|
if (!pending) {
|
|
307
299
|
return;
|
|
308
300
|
}
|
|
309
|
-
this.pendingSends.delete(messageId);
|
|
310
301
|
pending.resolve({
|
|
311
302
|
id: messageId,
|
|
312
303
|
accepted: type !== "delivery_failed",
|
|
@@ -330,14 +321,12 @@ export class IntercomClient extends EventEmitter {
|
|
|
330
321
|
return;
|
|
331
322
|
}
|
|
332
323
|
if (typeof error === "string") {
|
|
333
|
-
this.pendingLists.
|
|
334
|
-
pending.reject(new Error(error));
|
|
324
|
+
this.pendingLists.take(requestId)?.reject(new Error(error));
|
|
335
325
|
return;
|
|
336
326
|
}
|
|
337
327
|
pending.reader.append(sessions, receipts);
|
|
338
328
|
if (more !== true) {
|
|
339
|
-
this.pendingLists.
|
|
340
|
-
pending.resolve(pending.reader.finish());
|
|
329
|
+
this.pendingLists.take(requestId)?.resolve(pending.reader.finish());
|
|
341
330
|
}
|
|
342
331
|
}
|
|
343
332
|
|
|
@@ -396,8 +385,8 @@ export class IntercomClient extends EventEmitter {
|
|
|
396
385
|
|
|
397
386
|
return new Promise((resolve, reject) => {
|
|
398
387
|
const requestId = randomUUID();
|
|
388
|
+
const reader = new SnapshotReader();
|
|
399
389
|
const wrappedResolve = (snapshot: SessionSnapshot) => {
|
|
400
|
-
clearTimeout(timeout);
|
|
401
390
|
try {
|
|
402
391
|
// Commit confirmed subscriptions before handling a following message in the same read.
|
|
403
392
|
onAccepted?.(snapshot);
|
|
@@ -407,23 +396,19 @@ export class IntercomClient extends EventEmitter {
|
|
|
407
396
|
}
|
|
408
397
|
};
|
|
409
398
|
const wrappedReject = (error: Readonly<Error>) => {
|
|
410
|
-
|
|
399
|
+
reader.fail();
|
|
411
400
|
reject(error);
|
|
412
401
|
};
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
resolve: wrappedResolve,
|
|
424
|
-
reject: wrappedReject,
|
|
425
|
-
reader: new SnapshotReader(),
|
|
426
|
-
});
|
|
402
|
+
this.pendingLists.set(
|
|
403
|
+
requestId,
|
|
404
|
+
{
|
|
405
|
+
resolve: wrappedResolve,
|
|
406
|
+
reject: wrappedReject,
|
|
407
|
+
reader,
|
|
408
|
+
},
|
|
409
|
+
this.listTimeoutMs,
|
|
410
|
+
() => Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }),
|
|
411
|
+
);
|
|
427
412
|
try {
|
|
428
413
|
writeMessage(socket, {
|
|
429
414
|
type: "list",
|
|
@@ -432,9 +417,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
432
417
|
...(change ? { change } : {}),
|
|
433
418
|
});
|
|
434
419
|
} catch (error) {
|
|
435
|
-
|
|
436
|
-
this.pendingLists.delete(requestId);
|
|
437
|
-
reject(toError(error));
|
|
420
|
+
this.pendingLists.take(requestId)?.reject(toError(error));
|
|
438
421
|
}
|
|
439
422
|
});
|
|
440
423
|
}
|
|
@@ -456,7 +439,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
456
439
|
}
|
|
457
440
|
|
|
458
441
|
const messageId = options.messageId ?? (options.guard ? "" : randomUUID());
|
|
459
|
-
if (this.pendingSends.
|
|
442
|
+
if (this.pendingSends.get(messageId)) {
|
|
460
443
|
return Promise.reject(new Error("Message ID already has a pending send"));
|
|
461
444
|
}
|
|
462
445
|
const message: Message = {
|
|
@@ -482,22 +465,12 @@ export class IntercomClient extends EventEmitter {
|
|
|
482
465
|
return Promise.reject(new Error("Invalid message format"));
|
|
483
466
|
}
|
|
484
467
|
return new Promise((resolve, reject) => {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
resolve
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
reject(error);
|
|
492
|
-
};
|
|
493
|
-
const timeout = setTimeout(() => {
|
|
494
|
-
if (this.pendingSends.has(messageId)) {
|
|
495
|
-
this.pendingSends.delete(messageId);
|
|
496
|
-
wrappedReject(new Error("Send timeout"));
|
|
497
|
-
}
|
|
498
|
-
}, this.sendTimeoutMs);
|
|
499
|
-
timeout.unref();
|
|
500
|
-
this.pendingSends.set(messageId, { resolve: wrappedResolve, reject: wrappedReject });
|
|
468
|
+
this.pendingSends.set(
|
|
469
|
+
messageId,
|
|
470
|
+
{ resolve, reject },
|
|
471
|
+
this.sendTimeoutMs,
|
|
472
|
+
() => new Error("Send timeout"),
|
|
473
|
+
);
|
|
501
474
|
|
|
502
475
|
try {
|
|
503
476
|
writeMessage(socket, {
|
|
@@ -506,9 +479,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
506
479
|
message: this._topicsSupported ? compactTopicMessage(message) : message,
|
|
507
480
|
});
|
|
508
481
|
} catch (error) {
|
|
509
|
-
|
|
510
|
-
this.pendingSends.delete(messageId);
|
|
511
|
-
reject(toError(error));
|
|
482
|
+
this.pendingSends.take(messageId)?.reject(toError(error));
|
|
512
483
|
}
|
|
513
484
|
});
|
|
514
485
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
interface Request {
|
|
2
|
+
readonly reject: (error: Readonly<Error>) => void;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Map membership owns settlement; taking a request also releases its deadline. */
|
|
6
|
+
export class PendingRequests<T extends Request> {
|
|
7
|
+
private readonly pending = new Map<
|
|
8
|
+
string,
|
|
9
|
+
{ readonly value: T; readonly timer: NodeJS.Timeout }
|
|
10
|
+
>();
|
|
11
|
+
|
|
12
|
+
get(id: string): T | undefined {
|
|
13
|
+
return this.pending.get(id)?.value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
set(id: string, value: T, timeoutMs: number, timeoutError: () => Error): void {
|
|
17
|
+
const timer = setTimeout(() => this.take(id)?.reject(timeoutError()), timeoutMs);
|
|
18
|
+
timer.unref();
|
|
19
|
+
this.pending.set(id, { value, timer });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
take(id: string): T | undefined {
|
|
23
|
+
const pending = this.pending.get(id);
|
|
24
|
+
if (pending === undefined) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
this.pending.delete(id);
|
|
28
|
+
clearTimeout(pending.timer);
|
|
29
|
+
return pending.value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fail(error: Readonly<Error>): void {
|
|
33
|
+
for (const id of this.pending.keys()) {
|
|
34
|
+
this.take(id)?.reject(error);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -58,6 +58,14 @@ interface SessionParts {
|
|
|
58
58
|
readonly subscriptions: TopicSubscription[] | undefined;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
type SnapshotPhase =
|
|
62
|
+
| {
|
|
63
|
+
readonly kind: "open";
|
|
64
|
+
readonly sessions: Map<string, SessionParts>;
|
|
65
|
+
readonly receipts: Receipt[];
|
|
66
|
+
}
|
|
67
|
+
| { readonly kind: "finished" | "failed" };
|
|
68
|
+
|
|
61
69
|
function appendRows<T>(
|
|
62
70
|
previous: T[] | undefined,
|
|
63
71
|
rows: readonly T[] | undefined,
|
|
@@ -75,16 +83,28 @@ function appendRows<T>(
|
|
|
75
83
|
|
|
76
84
|
/** Multipart frames build a private cache; only a complete validated snapshot escapes. */
|
|
77
85
|
export class SnapshotReader {
|
|
78
|
-
private
|
|
79
|
-
|
|
86
|
+
private phase: SnapshotPhase = { kind: "open", sessions: new Map(), receipts: [] };
|
|
87
|
+
|
|
88
|
+
private open(): Extract<SnapshotPhase, { readonly kind: "open" }> {
|
|
89
|
+
if (this.phase.kind !== "open") {
|
|
90
|
+
throw new Error(`Session snapshot is ${this.phase.kind}`);
|
|
91
|
+
}
|
|
92
|
+
return this.phase;
|
|
93
|
+
}
|
|
80
94
|
|
|
81
|
-
|
|
95
|
+
fail(): void {
|
|
96
|
+
if (this.phase.kind === "open") {
|
|
97
|
+
this.phase = { kind: "failed" };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private addSession(state: Extract<SnapshotPhase, { readonly kind: "open" }>, row: unknown): void {
|
|
82
102
|
if (!isRecord(row) || typeof row.id !== "string") {
|
|
83
103
|
throw new Error("Invalid session snapshot row");
|
|
84
104
|
}
|
|
85
105
|
const topics = optionalArray(row.topics);
|
|
86
106
|
const subscriptions = optionalArray(row.subscriptions);
|
|
87
|
-
const previous =
|
|
107
|
+
const previous = state.sessions.get(row.id);
|
|
88
108
|
const info = normalizeSessionInfo({
|
|
89
109
|
...previous?.info,
|
|
90
110
|
...row,
|
|
@@ -95,7 +115,7 @@ export class SnapshotReader {
|
|
|
95
115
|
throw new Error("Invalid session snapshot");
|
|
96
116
|
}
|
|
97
117
|
const { topics: newTopics, subscriptions: newSubscriptions, ...identity } = info;
|
|
98
|
-
|
|
118
|
+
state.sessions.set(info.id, {
|
|
99
119
|
info: identity,
|
|
100
120
|
topics: appendRows(previous?.topics, newTopics, "topics" in row),
|
|
101
121
|
subscriptions: appendRows(previous?.subscriptions, newSubscriptions, "subscriptions" in row),
|
|
@@ -103,27 +123,35 @@ export class SnapshotReader {
|
|
|
103
123
|
}
|
|
104
124
|
|
|
105
125
|
append(sessions: readonly unknown[], receipts: unknown): void {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
126
|
+
const state = this.open();
|
|
127
|
+
try {
|
|
128
|
+
for (const row of sessions) {
|
|
129
|
+
this.addSession(state, row);
|
|
130
|
+
}
|
|
131
|
+
if (receipts === undefined) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!isUnknownArray(receipts)) {
|
|
135
|
+
throw new Error("Invalid topic delivery receipts");
|
|
136
|
+
}
|
|
137
|
+
state.receipts.push(...receipts.map(receipt));
|
|
138
|
+
} catch (error) {
|
|
139
|
+
this.fail();
|
|
140
|
+
throw error;
|
|
114
141
|
}
|
|
115
|
-
this.receipts.push(...receipts.map(receipt));
|
|
116
142
|
}
|
|
117
143
|
|
|
118
144
|
finish(): SessionSnapshot {
|
|
145
|
+
const state = this.open();
|
|
119
146
|
const sessions: SessionInfo[] = [];
|
|
120
|
-
for (const { info, topics, subscriptions } of
|
|
147
|
+
for (const { info, topics, subscriptions } of state.sessions.values()) {
|
|
121
148
|
sessions.push({
|
|
122
149
|
...info,
|
|
123
150
|
...(topics !== undefined ? { topics } : {}),
|
|
124
151
|
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
125
152
|
});
|
|
126
153
|
}
|
|
127
|
-
|
|
154
|
+
this.phase = { kind: "finished" };
|
|
155
|
+
return { sessions, receipts: state.receipts };
|
|
128
156
|
}
|
|
129
157
|
}
|