@selesai/code 0.13.39 → 0.13.40
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/dist/core/intercom-rpc.d.ts +135 -0
- package/dist/core/intercom-rpc.js +64 -0
- package/dist/core/release-rpc.d.ts +137 -0
- package/dist/core/release-rpc.js +77 -0
- package/dist/core/resource-loader.d.ts +3 -0
- package/dist/core/resource-loader.js +3 -0
- package/dist/core/subagent-rpc.d.ts +110 -0
- package/dist/core/subagent-rpc.js +60 -0
- package/dist/extensions/agent-browser.ts +9 -0
- package/dist/extensions/pi-intercom/README.md +15 -0
- package/dist/extensions/pi-intercom/broker/broker.ts +277 -13
- package/dist/extensions/pi-intercom/broker/client.ts +6 -2
- package/dist/extensions/pi-intercom/broker/mailbox-persistence.test.ts +523 -0
- package/dist/extensions/pi-intercom/broker/paths.test.ts +4 -4
- package/dist/extensions/pi-intercom/broker/paths.ts +4 -2
- package/dist/extensions/pi-intercom/broker/spawn.test.ts +1 -3
- package/dist/extensions/pi-intercom/extension-api.ts +10 -0
- package/dist/extensions/pi-intercom/index.ts +188 -8
- package/dist/extensions/pi-intercom/intercom.integration.test.ts +280 -0
- package/dist/extensions/pi-intercom/package.json +1 -1
- package/dist/extensions/pi-intercom/release-readiness.test.ts +116 -0
- package/dist/extensions/pi-intercom/release-readiness.ts +93 -0
- package/dist/extensions/pi-subagents/docs/extension-api.md +8 -0
- package/dist/extensions/pi-subagents/docs/observability.md +8 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +51 -1
- package/dist/extensions/pi-subagents/src/extension/observability.ts +164 -0
- package/dist/extensions/pi-subagents/src/extension/release-readiness.ts +367 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +8 -1
- package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +7 -1
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +1 -44
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +6 -0
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +13 -3
- package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +269 -7
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +11 -10
- package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +15 -3
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +64 -0
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +69 -6
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -1
- package/dist/extensions/pi-subagents/src/shared/owner-record.ts +107 -0
- package/dist/extensions/pi-subagents/src/shared/process-identity.ts +87 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +9 -0
- package/dist/extensions/pi-subagents/test/integration/async-attention-clearing.test.ts +191 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +63 -0
- package/dist/extensions/pi-subagents/test/integration/stop-all-background.test.ts +142 -0
- package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +8 -0
- package/dist/extensions/pi-subagents/test/unit/observability.test.ts +198 -0
- package/dist/extensions/pi-subagents/test/unit/process-identity.test.ts +75 -0
- package/dist/extensions/pi-subagents/test/unit/release-readiness.test.ts +715 -0
- package/dist/extensions/pi-subagents/test/unit/result-takeover.test.ts +332 -0
- package/dist/extensions/tokenin-onboarding.ts +4 -2
- package/dist/index.d.ts +6 -1
- package/dist/index.js +7 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +1 -1
- package/dist/modes/index.d.ts +1 -1
- package/dist/modes/rpc/intercom-bridge.d.ts +14 -0
- package/dist/modes/rpc/intercom-bridge.js +75 -0
- package/dist/modes/rpc/intercom-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/intercom-bridge.test.js +65 -0
- package/dist/modes/rpc/release-bridge.d.ts +35 -0
- package/dist/modes/rpc/release-bridge.js +159 -0
- package/dist/modes/rpc/release-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/release-bridge.test.js +347 -0
- package/dist/modes/rpc/rpc-client.d.ts +34 -10
- package/dist/modes/rpc/rpc-client.js +45 -2
- package/dist/modes/rpc/rpc-intercom-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-intercom-transport.test.js +85 -0
- package/dist/modes/rpc/rpc-mode.d.ts +1 -13
- package/dist/modes/rpc/rpc-mode.js +239 -8
- package/dist/modes/rpc/rpc-release-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-release-transport.test.js +329 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.js +60 -0
- package/dist/modes/rpc/rpc-types.d.ts +12 -2
- package/dist/modes/rpc/rpc-types.js +0 -6
- package/dist/modes/rpc/subagent-bridge.d.ts +23 -0
- package/dist/modes/rpc/subagent-bridge.js +93 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.d.ts +1 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.js +13 -0
- package/docs/rpc-subagent.schema.json +491 -0
- package/docs/rpc.md +210 -1
- package/package.json +2 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import net from "net";
|
|
2
|
-
import { chmodSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
2
|
+
import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
|
-
import { createHash, randomUUID } from "crypto";
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from "crypto";
|
|
5
5
|
import { writeMessage, createMessageReader } from "./framing.ts";
|
|
6
|
-
import { isMessage, isMessageReceipt, isSessionId, isSessionRegistration } from "./protocol.ts";
|
|
6
|
+
import { isMessage, isMessageReceipt, isSessionId, isSessionInfo, isSessionRegistration } from "./protocol.ts";
|
|
7
7
|
import {
|
|
8
8
|
ensureIntercomRuntimeDir,
|
|
9
9
|
getBrokerListenTarget,
|
|
@@ -28,6 +28,10 @@ const LISTEN_TARGET = getBrokerListenTarget();
|
|
|
28
28
|
const PID_PATH = join(INTERCOM_DIR, "broker.pid");
|
|
29
29
|
const PORT_PATH = getBrokerPortFilePath(INTERCOM_DIR);
|
|
30
30
|
const PENDING_ASKS_DIR = join(INTERCOM_DIR, "pending-asks");
|
|
31
|
+
const MAILBOX_DIR = join(INTERCOM_DIR, "mailbox");
|
|
32
|
+
const DISCONNECTED_SESSIONS_DIR = join(INTERCOM_DIR, "disconnected-sessions");
|
|
33
|
+
const PERSISTED_RECORD_VERSION = 1;
|
|
34
|
+
const MAX_PERSISTED_ID_FILENAME_CHARS = 100;
|
|
31
35
|
const BROKER_STATE_ID = randomUUID();
|
|
32
36
|
const MAX_SESSIONS = 128;
|
|
33
37
|
const MAX_UNREGISTERED_CONNECTIONS = 32;
|
|
@@ -199,12 +203,113 @@ function ensurePendingAskRecordDir(): void {
|
|
|
199
203
|
}
|
|
200
204
|
}
|
|
201
205
|
|
|
206
|
+
function sha256Hex(value: string): string {
|
|
207
|
+
return createHash("sha256").update(value).digest("hex");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// One file per queued message. The sender key is part of the name because
|
|
211
|
+
// message ids are only unique per sender (delivery records key on both).
|
|
212
|
+
function mailboxFilePath(scopeId: string | undefined, fromKey: string, messageId: string): string {
|
|
213
|
+
const scopePart = scopeId ? sha256Hex(scopeId).slice(0, 16) : "global";
|
|
214
|
+
const encodedId = encodeURIComponent(messageId);
|
|
215
|
+
const idPart = encodedId.length <= MAX_PERSISTED_ID_FILENAME_CHARS ? encodedId : sha256Hex(messageId);
|
|
216
|
+
return join(MAILBOX_DIR, `${scopePart}-${sha256Hex(fromKey).slice(0, 16)}-${idPart}.json`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function disconnectedSessionFilePath(sessionKey: string): string {
|
|
220
|
+
return join(DISCONNECTED_SESSIONS_DIR, `${sha256Hex(sessionKey)}.json`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function ensureRuntimeDir(dirPath: string): void {
|
|
224
|
+
mkdirSync(dirPath, { recursive: true, mode: INTERCOM_DIR_MODE });
|
|
225
|
+
if (process.platform !== "win32") {
|
|
226
|
+
chmodSync(dirPath, INTERCOM_DIR_MODE);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Temp file + rename so a crash mid-write never leaves a half-written record.
|
|
231
|
+
// Temp names end in .tmp (never .json) so loaders ignore and sweep them.
|
|
232
|
+
function writeFileAtomic(filePath: string, content: string): void {
|
|
233
|
+
const tempPath = `${filePath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
234
|
+
try {
|
|
235
|
+
writeFileSync(tempPath, content, { mode: INTERCOM_RUNTIME_FILE_MODE });
|
|
236
|
+
restrictIntercomRuntimeFile(tempPath);
|
|
237
|
+
renameSync(tempPath, filePath);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
try {
|
|
240
|
+
unlinkSync(tempPath);
|
|
241
|
+
} catch {
|
|
242
|
+
// The temp file may never have been created.
|
|
243
|
+
}
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isOptionalString(value: unknown): value is string | undefined {
|
|
249
|
+
return value === undefined || typeof value === "string";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parseMailboxRecord(value: unknown): MailboxMessage | null {
|
|
253
|
+
if (!isRecord(value) || value.version !== PERSISTED_RECORD_VERSION) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
if (
|
|
257
|
+
!isSessionInfo(value.from)
|
|
258
|
+
|| typeof value.fromKey !== "string"
|
|
259
|
+
|| !isOptionalString(value.fromScopeId)
|
|
260
|
+
|| !isSessionInfo(value.target)
|
|
261
|
+
|| typeof value.targetKey !== "string"
|
|
262
|
+
|| !isOptionalString(value.targetScopeId)
|
|
263
|
+
|| !isMessage(value.message)
|
|
264
|
+
|| !Number.isSafeInteger(value.queuedAt)
|
|
265
|
+
) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
// Blocking asks are never queued; a record claiming otherwise is not ours.
|
|
269
|
+
if (value.message.expectsReply) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
from: value.from,
|
|
274
|
+
fromKey: value.fromKey,
|
|
275
|
+
...(value.fromScopeId ? { fromScopeId: value.fromScopeId } : {}),
|
|
276
|
+
target: value.target,
|
|
277
|
+
targetKey: value.targetKey,
|
|
278
|
+
...(value.targetScopeId ? { targetScopeId: value.targetScopeId } : {}),
|
|
279
|
+
message: value.message,
|
|
280
|
+
queuedAt: value.queuedAt as number,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseDisconnectedSessionRecord(value: unknown): DisconnectedSession | null {
|
|
285
|
+
if (!isRecord(value) || value.version !== PERSISTED_RECORD_VERSION) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
if (
|
|
289
|
+
typeof value.key !== "string"
|
|
290
|
+
|| !isSessionInfo(value.info)
|
|
291
|
+
|| !isOptionalString(value.scopeId)
|
|
292
|
+
|| !Number.isSafeInteger(value.disconnectedAt)
|
|
293
|
+
) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
info: value.info,
|
|
298
|
+
key: value.key,
|
|
299
|
+
...(value.scopeId ? { scopeId: value.scopeId } : {}),
|
|
300
|
+
disconnectedAt: value.disconnectedAt as number,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
202
304
|
class IntercomBroker {
|
|
203
305
|
private sessions = new Map<string, ConnectedSession>();
|
|
204
306
|
private askEdges = new Map<string, AskEdge>();
|
|
205
307
|
private messageReceiptRoutes = new Map<string, MessageReceiptRoute>();
|
|
206
308
|
private disconnectedSessions = new Map<string, DisconnectedSession>();
|
|
207
309
|
private mailboxMessages: MailboxMessage[] = [];
|
|
310
|
+
// Where each queued message is persisted, so removal deletes the exact file
|
|
311
|
+
// it was loaded from or written to.
|
|
312
|
+
private mailboxFilePaths = new WeakMap<MailboxMessage, string>();
|
|
208
313
|
private deliveryRecords = new Map<string, DeliveryRecord>();
|
|
209
314
|
private connections = new Set<net.Socket>();
|
|
210
315
|
private unregisteredConnections = new Set<net.Socket>();
|
|
@@ -220,6 +325,8 @@ class IntercomBroker {
|
|
|
220
325
|
assertNoLiveBroker(PID_PATH);
|
|
221
326
|
ensurePendingAskRecordDir();
|
|
222
327
|
this.prunePendingAskRecords();
|
|
328
|
+
this.loadDisconnectedSessions();
|
|
329
|
+
this.loadMailboxMessages();
|
|
223
330
|
this.extensionStateManager = new ExtensionStateManager(INTERCOM_DIR);
|
|
224
331
|
if (typeof LISTEN_TARGET === "string" && process.platform !== "win32") {
|
|
225
332
|
try {
|
|
@@ -486,7 +593,7 @@ class IntercomBroker {
|
|
|
486
593
|
extensions,
|
|
487
594
|
};
|
|
488
595
|
this.sessions.set(key, connectedSession);
|
|
489
|
-
this.
|
|
596
|
+
this.forgetDisconnectedSession(key);
|
|
490
597
|
|
|
491
598
|
if (this.shutdownTimer) {
|
|
492
599
|
clearTimeout(this.shutdownTimer);
|
|
@@ -819,7 +926,8 @@ class IntercomBroker {
|
|
|
819
926
|
const sender = this.sessions.get(currentKey);
|
|
820
927
|
const queuedIndex = this.mailboxMessages.findIndex(entry => entry.message.id === clientMessage.messageId && entry.fromKey === currentKey);
|
|
821
928
|
if (queuedIndex >= 0 && sender?.socket === socket) {
|
|
822
|
-
this.mailboxMessages.splice(queuedIndex, 1);
|
|
929
|
+
const [cancelled] = this.mailboxMessages.splice(queuedIndex, 1);
|
|
930
|
+
this.removeMailboxFile(cancelled!);
|
|
823
931
|
this.updateDeliveryRecord(currentKey, clientMessage.messageId, "failed", "Sender cancelled the queued delivery", "E_DELIVERY_CANCELLED");
|
|
824
932
|
const edge = this.askEdges.get(clientMessage.messageId);
|
|
825
933
|
if (edge?.from === currentKey) {
|
|
@@ -976,23 +1084,156 @@ class IntercomBroker {
|
|
|
976
1084
|
}
|
|
977
1085
|
|
|
978
1086
|
private rememberDisconnectedSession(session: ConnectedSession, now = Date.now()): void {
|
|
979
|
-
|
|
1087
|
+
const remembered: DisconnectedSession = {
|
|
980
1088
|
info: { ...session.info },
|
|
981
1089
|
key: session.key,
|
|
982
1090
|
...(session.scopeId ? { scopeId: session.scopeId } : {}),
|
|
983
1091
|
disconnectedAt: now,
|
|
984
|
-
}
|
|
1092
|
+
};
|
|
1093
|
+
this.disconnectedSessions.set(session.key, remembered);
|
|
1094
|
+
this.persistDisconnectedSession(remembered);
|
|
985
1095
|
this.pruneDisconnectedSessions(now);
|
|
986
1096
|
}
|
|
987
1097
|
|
|
1098
|
+
private forgetDisconnectedSession(sessionKey: string): void {
|
|
1099
|
+
if (this.disconnectedSessions.delete(sessionKey)) {
|
|
1100
|
+
this.removePersistedFile(disconnectedSessionFilePath(sessionKey), "disconnected session");
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
988
1104
|
private pruneDisconnectedSessions(now = Date.now()): void {
|
|
989
1105
|
for (const [sessionId, session] of this.disconnectedSessions) {
|
|
990
1106
|
if (now - session.disconnectedAt > DISCONNECTED_SESSION_RETENTION_MS) {
|
|
991
|
-
this.
|
|
1107
|
+
this.forgetDisconnectedSession(sessionId);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Persistence is best effort: a full disk or bad permissions must never break
|
|
1113
|
+
// live delivery, so failures are logged and the broker keeps going in memory.
|
|
1114
|
+
private persistDisconnectedSession(session: DisconnectedSession): void {
|
|
1115
|
+
try {
|
|
1116
|
+
mkdirSync(DISCONNECTED_SESSIONS_DIR, { recursive: true, mode: INTERCOM_DIR_MODE });
|
|
1117
|
+
const record = { version: PERSISTED_RECORD_VERSION, ...session };
|
|
1118
|
+
writeFileAtomic(disconnectedSessionFilePath(session.key), `${JSON.stringify(record, null, 2)}\n`);
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
console.error("Failed to persist disconnected intercom session:", error);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
private persistMailboxMessage(entry: MailboxMessage): void {
|
|
1125
|
+
try {
|
|
1126
|
+
mkdirSync(MAILBOX_DIR, { recursive: true, mode: INTERCOM_DIR_MODE });
|
|
1127
|
+
const filePath = mailboxFilePath(entry.fromScopeId, entry.fromKey, entry.message.id);
|
|
1128
|
+
const record = { version: PERSISTED_RECORD_VERSION, ...entry };
|
|
1129
|
+
writeFileAtomic(filePath, `${JSON.stringify(record, null, 2)}\n`);
|
|
1130
|
+
this.mailboxFilePaths.set(entry, filePath);
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
console.error("Failed to persist intercom mailbox message:", error);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** Call at every place an entry leaves `mailboxMessages` (never on shutdown). */
|
|
1137
|
+
private removeMailboxFile(entry: MailboxMessage): void {
|
|
1138
|
+
const filePath = this.mailboxFilePaths.get(entry) ?? mailboxFilePath(entry.fromScopeId, entry.fromKey, entry.message.id);
|
|
1139
|
+
this.mailboxFilePaths.delete(entry);
|
|
1140
|
+
this.removePersistedFile(filePath, "mailbox message");
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
private removePersistedFile(filePath: string, label: string): void {
|
|
1144
|
+
try {
|
|
1145
|
+
unlinkSync(filePath);
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
if (!isRecord(error) || error.code !== "ENOENT") {
|
|
1148
|
+
console.error(`Failed to remove persisted intercom ${label}:`, error);
|
|
992
1149
|
}
|
|
993
1150
|
}
|
|
994
1151
|
}
|
|
995
1152
|
|
|
1153
|
+
/**
|
|
1154
|
+
* Reads every `*.json` record in `dirPath`, deleting corrupt, invalid and
|
|
1155
|
+
* (via `isExpired`) stale files. Leftover `*.tmp` files from an interrupted
|
|
1156
|
+
* atomic write are swept. Never throws: an unreadable directory just means
|
|
1157
|
+
* nothing is restored.
|
|
1158
|
+
*/
|
|
1159
|
+
private loadPersistedRecords<T>(
|
|
1160
|
+
dirPath: string,
|
|
1161
|
+
label: string,
|
|
1162
|
+
parse: (value: unknown) => T | null,
|
|
1163
|
+
isExpired: (record: T) => boolean,
|
|
1164
|
+
): Array<{ record: T; filePath: string }> {
|
|
1165
|
+
const loaded: Array<{ record: T; filePath: string }> = [];
|
|
1166
|
+
try {
|
|
1167
|
+
ensureRuntimeDir(dirPath);
|
|
1168
|
+
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
1169
|
+
if (!entry.isFile()) {
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
const filePath = join(dirPath, entry.name);
|
|
1173
|
+
if (entry.name.endsWith(".tmp")) {
|
|
1174
|
+
this.removePersistedFile(filePath, label);
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
if (!entry.name.endsWith(".json")) {
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
let parsed: unknown;
|
|
1181
|
+
try {
|
|
1182
|
+
parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
1183
|
+
} catch {
|
|
1184
|
+
this.removePersistedFile(filePath, label);
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
const record = parse(parsed);
|
|
1188
|
+
if (!record || isExpired(record)) {
|
|
1189
|
+
this.removePersistedFile(filePath, label);
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
loaded.push({ record, filePath });
|
|
1193
|
+
}
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
console.error(`Failed to load persisted intercom ${label}s:`, error);
|
|
1196
|
+
}
|
|
1197
|
+
return loaded;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
private loadDisconnectedSessions(now = Date.now()): void {
|
|
1201
|
+
const loaded = this.loadPersistedRecords(
|
|
1202
|
+
DISCONNECTED_SESSIONS_DIR,
|
|
1203
|
+
"disconnected session",
|
|
1204
|
+
parseDisconnectedSessionRecord,
|
|
1205
|
+
(session) => now - session.disconnectedAt > DISCONNECTED_SESSION_RETENTION_MS,
|
|
1206
|
+
);
|
|
1207
|
+
for (const { record, filePath } of loaded) {
|
|
1208
|
+
const existing = this.disconnectedSessions.get(record.key);
|
|
1209
|
+
if (existing && existing.disconnectedAt >= record.disconnectedAt) {
|
|
1210
|
+
this.removePersistedFile(filePath, "disconnected session");
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
this.disconnectedSessions.set(record.key, record);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
private loadMailboxMessages(now = Date.now()): void {
|
|
1218
|
+
const loaded = this.loadPersistedRecords(
|
|
1219
|
+
MAILBOX_DIR,
|
|
1220
|
+
"mailbox message",
|
|
1221
|
+
parseMailboxRecord,
|
|
1222
|
+
(entry) => now - entry.queuedAt > MAILBOX_MESSAGE_RETENTION_MS,
|
|
1223
|
+
);
|
|
1224
|
+
// Array.prototype.sort is stable, so equal timestamps keep directory order.
|
|
1225
|
+
loaded.sort((a, b) => a.record.queuedAt - b.record.queuedAt);
|
|
1226
|
+
// Same bound as live queueing: keep the newest, drop the oldest.
|
|
1227
|
+
const overflow = Math.max(0, loaded.length - MAX_MAILBOX_MESSAGES);
|
|
1228
|
+
for (const { filePath } of loaded.slice(0, overflow)) {
|
|
1229
|
+
this.removePersistedFile(filePath, "mailbox message");
|
|
1230
|
+
}
|
|
1231
|
+
for (const { record, filePath } of loaded.slice(overflow)) {
|
|
1232
|
+
this.mailboxFilePaths.set(record, filePath);
|
|
1233
|
+
this.mailboxMessages.push(record);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
996
1237
|
private pruneMailboxMessages(now = Date.now()): void {
|
|
997
1238
|
for (let index = this.mailboxMessages.length - 1; index >= 0; index -= 1) {
|
|
998
1239
|
const entry = this.mailboxMessages[index]!;
|
|
@@ -1004,6 +1245,7 @@ class IntercomBroker {
|
|
|
1004
1245
|
this.messageReceiptRoutes.delete(entry.message.id);
|
|
1005
1246
|
this.updateDeliveryRecord(entry.fromKey, entry.message.id, "failed", "Mailbox delivery expired", "E_DELIVERY_EXPIRED");
|
|
1006
1247
|
this.mailboxMessages.splice(index, 1);
|
|
1248
|
+
this.removeMailboxFile(entry);
|
|
1007
1249
|
}
|
|
1008
1250
|
}
|
|
1009
1251
|
}
|
|
@@ -1013,6 +1255,7 @@ class IntercomBroker {
|
|
|
1013
1255
|
while (this.mailboxMessages.length >= MAX_MAILBOX_MESSAGES) {
|
|
1014
1256
|
const evicted = this.mailboxMessages.shift();
|
|
1015
1257
|
if (!evicted) break;
|
|
1258
|
+
this.removeMailboxFile(evicted);
|
|
1016
1259
|
if (evicted.message.expectsReply) {
|
|
1017
1260
|
this.askEdges.delete(evicted.message.id);
|
|
1018
1261
|
this.removePendingAskRecord(evicted.message.id, evicted.fromScopeId);
|
|
@@ -1020,7 +1263,7 @@ class IntercomBroker {
|
|
|
1020
1263
|
this.messageReceiptRoutes.delete(evicted.message.id);
|
|
1021
1264
|
this.updateDeliveryRecord(evicted.fromKey, evicted.message.id, "failed", "Mailbox capacity evicted the delivery", "E_DELIVERY_EVICTED");
|
|
1022
1265
|
}
|
|
1023
|
-
|
|
1266
|
+
const queued: MailboxMessage = {
|
|
1024
1267
|
from: { ...from.info },
|
|
1025
1268
|
fromKey: from.key,
|
|
1026
1269
|
...(from.scopeId ? { fromScopeId: from.scopeId } : {}),
|
|
@@ -1029,7 +1272,9 @@ class IntercomBroker {
|
|
|
1029
1272
|
...(target.scopeId ? { targetScopeId: target.scopeId } : {}),
|
|
1030
1273
|
message: { ...message, brokerReceivedAt },
|
|
1031
1274
|
queuedAt: brokerReceivedAt,
|
|
1032
|
-
}
|
|
1275
|
+
};
|
|
1276
|
+
this.mailboxMessages.push(queued);
|
|
1277
|
+
this.persistMailboxMessage(queued);
|
|
1033
1278
|
}
|
|
1034
1279
|
|
|
1035
1280
|
private writeDeliverySuccess(socket: net.Socket, messageId: string, delivery: "socket_delivered" | "queued"): void {
|
|
@@ -1141,6 +1386,9 @@ class IntercomBroker {
|
|
|
1141
1386
|
}
|
|
1142
1387
|
|
|
1143
1388
|
this.mailboxMessages.splice(index, 1);
|
|
1389
|
+
// Drop the file before the socket write: a failed write already loses the
|
|
1390
|
+
// in-memory entry, so keeping the file would only risk a duplicate later.
|
|
1391
|
+
this.removeMailboxFile(entry);
|
|
1144
1392
|
const edge = this.askEdges.get(entry.message.id);
|
|
1145
1393
|
if (edge?.to === entry.targetKey) {
|
|
1146
1394
|
edge.to = session.key;
|
|
@@ -1173,11 +1421,25 @@ class IntercomBroker {
|
|
|
1173
1421
|
}
|
|
1174
1422
|
}
|
|
1175
1423
|
|
|
1176
|
-
|
|
1424
|
+
/**
|
|
1425
|
+
* Intentionally NOT called on disconnect/unregister: edges age out via
|
|
1426
|
+
* `pruneAskEdges`, and a late reply to a disconnected asker is mailboxed on
|
|
1427
|
+
* purpose so a resumed asker (or target) still receives it.
|
|
1428
|
+
* `asker`: drop asks this session was waiting on. `target`: drop asks
|
|
1429
|
+
* addressed to this session.
|
|
1430
|
+
*/
|
|
1431
|
+
private clearAskEdgesForSession(sessionKey: string, role: "asker" | "target" | "either"): void {
|
|
1177
1432
|
for (const [messageId, edge] of this.askEdges) {
|
|
1178
|
-
|
|
1179
|
-
|
|
1433
|
+
const matches = (role !== "target" && edge.from === sessionKey) || (role !== "asker" && edge.to === sessionKey);
|
|
1434
|
+
if (!matches) {
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
this.askEdges.delete(messageId);
|
|
1438
|
+
try {
|
|
1180
1439
|
this.removePendingAskRecord(messageId, edge.scopeId);
|
|
1440
|
+
} catch (error) {
|
|
1441
|
+
// Disconnect cleanup must not throw out of a socket event handler.
|
|
1442
|
+
console.error("Failed to remove pending ask record:", error);
|
|
1181
1443
|
}
|
|
1182
1444
|
}
|
|
1183
1445
|
}
|
|
@@ -1694,6 +1956,8 @@ class IntercomBroker {
|
|
|
1694
1956
|
this.sessions.clear();
|
|
1695
1957
|
this.askEdges.clear();
|
|
1696
1958
|
this.messageReceiptRoutes.clear();
|
|
1959
|
+
// Only the in-memory copies are cleared. The persisted mailbox and
|
|
1960
|
+
// disconnected-session files are kept on purpose: the next broker reloads them.
|
|
1697
1961
|
this.disconnectedSessions.clear();
|
|
1698
1962
|
this.mailboxMessages.length = 0;
|
|
1699
1963
|
if (typeof LISTEN_TARGET === "string" && process.platform !== "win32") {
|
|
@@ -383,7 +383,9 @@ export class IntercomClient extends EventEmitter {
|
|
|
383
383
|
}
|
|
384
384
|
|
|
385
385
|
this.pendingSends.delete(messageId);
|
|
386
|
-
|
|
386
|
+
const result: SendResult = { id: messageId, delivered: true, delivery: delivery as "socket_delivered" | "queued" | undefined ?? "socket_delivered", retryable: retryable as boolean | undefined ?? false, outcomeKnown: outcomeKnown as boolean | undefined ?? true, ...(typeof brokerMessage.code === "string" ? { code: brokerMessage.code } : {}) };
|
|
387
|
+
pending.resolve(result);
|
|
388
|
+
this.emit("broker_message", { type: "delivered", messageId, delivery: result.delivery, retryable: result.retryable, outcomeKnown: result.outcomeKnown, ...(result.code ? { code: result.code } : {}) });
|
|
387
389
|
break;
|
|
388
390
|
}
|
|
389
391
|
|
|
@@ -400,7 +402,9 @@ export class IntercomClient extends EventEmitter {
|
|
|
400
402
|
}
|
|
401
403
|
|
|
402
404
|
this.pendingSends.delete(messageId);
|
|
403
|
-
|
|
405
|
+
const result: SendResult = { id: messageId, delivered: false, reason, delivery: delivery as "failed" | "unknown" | undefined ?? "failed", retryable: retryable as boolean | undefined ?? false, outcomeKnown: outcomeKnown as boolean | undefined ?? true, ...(typeof brokerMessage.code === "string" ? { code: brokerMessage.code } : {}) };
|
|
406
|
+
pending.resolve(result);
|
|
407
|
+
this.emit("broker_message", { type: "delivery_failed", messageId, reason, delivery: result.delivery, retryable: result.retryable, outcomeKnown: result.outcomeKnown, ...(result.code ? { code: result.code } : {}) });
|
|
404
408
|
break;
|
|
405
409
|
}
|
|
406
410
|
|