@ctliz/agent-intercom-codex 0.11.0-connect.2
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/.codex-plugin/plugin.json +39 -0
- package/.mcp.json +10 -0
- package/COPYRIGHT +6 -0
- package/LICENSE +661 -0
- package/LICENSE_TRANSITION.md +25 -0
- package/README.md +572 -0
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/assets/logo-generated.png +0 -0
- package/assets/logo.svg +22 -0
- package/boss-control-outbox.ts +195 -0
- package/broker/access-credential.ts +76 -0
- package/broker/access-registry.ts +464 -0
- package/broker/audit.ts +60 -0
- package/broker/authorization.ts +54 -0
- package/broker/boss-adapter.ts +531 -0
- package/broker/boss-control-ledger.ts +306 -0
- package/broker/broker.ts +2327 -0
- package/broker/client.ts +1111 -0
- package/broker/framing.ts +87 -0
- package/broker/ownership.ts +61 -0
- package/broker/paths.ts +161 -0
- package/broker/spawn.ts +453 -0
- package/codex/app-server-client.ts +562 -0
- package/codex/boss-client.ts +34 -0
- package/codex/bridge-config.ts +282 -0
- package/codex/bridge-daemon.ts +913 -0
- package/codex/clipboard.ts +142 -0
- package/codex/coi.ts +802 -0
- package/codex/contact.ts +54 -0
- package/codex/mcp-protocol.ts +248 -0
- package/codex/runtime.ts +488 -0
- package/codex/server.ts +60 -0
- package/codex/team.ts +249 -0
- package/codex/tui-input.ts +174 -0
- package/config.ts +195 -0
- package/dist/bridge-daemon.mjs +3781 -0
- package/dist/broker.mjs +3012 -0
- package/dist/build-info.json +12 -0
- package/dist/codex-server.mjs +2890 -0
- package/dist/coi.mjs +4686 -0
- package/durable-json.ts +25 -0
- package/licenses/MIT-pi-intercom.txt +21 -0
- package/outbound-outbox.ts +116 -0
- package/package.json +89 -0
- package/protocol-v4/contract.ts +27 -0
- package/provider/protected-service.ts +162 -0
- package/provider/provider.mjs +34 -0
- package/scripts/build-info.mjs +58 -0
- package/scripts/build.mjs +55 -0
- package/skills/codex-intercom/SKILL.md +104 -0
- package/tsconfig.json +16 -0
- package/types.ts +225 -0
package/broker/client.ts
ADDED
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import net from "net";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { POLICY_SEMANTICS_HASH, POLICY_SEMANTICS_VERSION } from "@ctliz/agent-intercom-core";
|
|
5
|
+
import {
|
|
6
|
+
BOSS_RUN_FEATURE,
|
|
7
|
+
parseBrokerCapabilityAdvertisement,
|
|
8
|
+
type BossControlEnvelope,
|
|
9
|
+
} from "@ctliz/agent-intercom-core/boss";
|
|
10
|
+
import { writeMessage, createMessageReader } from "./framing.ts";
|
|
11
|
+
import { intercomScopeIdFromEnv, parseIntercomScopeId } from "../protocol-v4/contract.ts";
|
|
12
|
+
import { PersistentOutboundOutbox } from "../outbound-outbox.ts";
|
|
13
|
+
import { PersistentBossControlOutbox } from "../boss-control-outbox.ts";
|
|
14
|
+
import { loadRemoteAccessCredential, writeRemoteSessionCredential, type LoadedRemoteAccessCredential } from "./access-credential.ts";
|
|
15
|
+
import { parseBossControlAck, parseBossControlResult } from "./boss-control-ledger.ts";
|
|
16
|
+
import {
|
|
17
|
+
parseBossParticipantBindingMetadata,
|
|
18
|
+
parseBossParticipantRegistrationMetadata,
|
|
19
|
+
bossControlKind,
|
|
20
|
+
parseExactRegisteredFrame,
|
|
21
|
+
parseExactRegistrationFrame,
|
|
22
|
+
} from "./boss-adapter.ts";
|
|
23
|
+
import { types as nodeUtilTypes } from "node:util";
|
|
24
|
+
import {
|
|
25
|
+
getBrokerConnectTarget,
|
|
26
|
+
INTERCOM_PROTOCOL_NAME,
|
|
27
|
+
INTERCOM_PROTOCOL_VERSION,
|
|
28
|
+
type BrokerConnectTarget,
|
|
29
|
+
} from "./paths.ts";
|
|
30
|
+
import type {
|
|
31
|
+
AskCancellationReason,
|
|
32
|
+
DeliveryFailureCode,
|
|
33
|
+
SessionInfo,
|
|
34
|
+
Message,
|
|
35
|
+
Attachment,
|
|
36
|
+
SessionRegistration,
|
|
37
|
+
BossParticipantBindingMetadata,
|
|
38
|
+
BossParticipantRegistrationMetadata,
|
|
39
|
+
BossControlFailureCode,
|
|
40
|
+
} from "../types.ts";
|
|
41
|
+
|
|
42
|
+
export interface SendOptions {
|
|
43
|
+
text: string;
|
|
44
|
+
attachments?: Attachment[];
|
|
45
|
+
replyTo?: string;
|
|
46
|
+
expectsReply?: boolean;
|
|
47
|
+
messageId?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SendResult {
|
|
51
|
+
id: string;
|
|
52
|
+
accepted: boolean;
|
|
53
|
+
delivered: boolean;
|
|
54
|
+
deliveryId?: string;
|
|
55
|
+
code?: DeliveryFailureCode;
|
|
56
|
+
reason?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type BossControlSendResult = {
|
|
60
|
+
requestId: string;
|
|
61
|
+
messageId: string;
|
|
62
|
+
idempotencyKey: string;
|
|
63
|
+
status: "delivered";
|
|
64
|
+
delivered: true;
|
|
65
|
+
deliveryId: string;
|
|
66
|
+
} | {
|
|
67
|
+
requestId: string;
|
|
68
|
+
messageId: string;
|
|
69
|
+
idempotencyKey: string;
|
|
70
|
+
status: "rejected";
|
|
71
|
+
delivered: false;
|
|
72
|
+
deliveryId?: string;
|
|
73
|
+
code: BossControlFailureCode;
|
|
74
|
+
reason: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function toError(error: unknown): Error {
|
|
78
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function connectToBrokerTarget(target: BrokerConnectTarget): net.Socket {
|
|
82
|
+
return typeof target === "string"
|
|
83
|
+
? net.connect(target)
|
|
84
|
+
: net.connect({ host: target.host, port: target.port });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isAttachment(value: unknown): value is Attachment {
|
|
88
|
+
if (typeof value !== "object" || value === null) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const attachment = value as Record<string, unknown>;
|
|
93
|
+
|
|
94
|
+
if (
|
|
95
|
+
attachment.type !== "file"
|
|
96
|
+
&& attachment.type !== "snippet"
|
|
97
|
+
&& attachment.type !== "context"
|
|
98
|
+
) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (typeof attachment.name !== "string" || typeof attachment.content !== "string") {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return attachment.language === undefined || typeof attachment.language === "string";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isMessage(value: unknown): value is Message {
|
|
110
|
+
if (typeof value !== "object" || value === null) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const message = value as Record<string, unknown>;
|
|
115
|
+
|
|
116
|
+
if (typeof message.id !== "string" || typeof message.timestamp !== "number") {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (message.replyTo !== undefined && typeof message.replyTo !== "string") {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (message.expectsReply !== undefined && typeof message.expectsReply !== "boolean") {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof message.content !== "object" || message.content === null) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const content = message.content as Record<string, unknown>;
|
|
133
|
+
if (typeof content.text !== "string") {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return content.attachments === undefined
|
|
138
|
+
|| (Array.isArray(content.attachments) && content.attachments.every(isAttachment));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isSessionInfo(value: unknown): value is SessionInfo {
|
|
142
|
+
if (typeof value !== "object" || value === null) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const session = value as Record<string, unknown>;
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
typeof session.id !== "string"
|
|
150
|
+
|| typeof session.cwd !== "string"
|
|
151
|
+
|| typeof session.model !== "string"
|
|
152
|
+
|| typeof session.pid !== "number"
|
|
153
|
+
|| typeof session.startedAt !== "number"
|
|
154
|
+
|| typeof session.lastActivity !== "number"
|
|
155
|
+
) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (session.name !== undefined && typeof session.name !== "string") {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (session.status !== undefined && typeof session.status !== "string") {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (session.peerUid !== undefined && typeof session.peerUid !== "number") {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (session.trustedLocal !== undefined && typeof session.trustedLocal !== "boolean") return false;
|
|
172
|
+
if (session.origin !== undefined && session.origin !== "local" && session.origin !== "remote") return false;
|
|
173
|
+
if (session.remoteHostId !== undefined && typeof session.remoteHostId !== "string") return false;
|
|
174
|
+
if (session.parentSessionId !== undefined && typeof session.parentSessionId !== "string") return false;
|
|
175
|
+
if (session.rootSessionId !== undefined && typeof session.rootSessionId !== "string") return false;
|
|
176
|
+
if (session.generation !== undefined && (typeof session.generation !== "number" || !Number.isSafeInteger(session.generation))) return false;
|
|
177
|
+
if (session.canDelegate !== undefined && typeof session.canDelegate !== "boolean") return false;
|
|
178
|
+
for (const field of ["depth", "maxDepth", "maxChildren"] as const) {
|
|
179
|
+
if (session[field] !== undefined && (typeof session[field] !== "number" || !Number.isSafeInteger(session[field]))) return false;
|
|
180
|
+
}
|
|
181
|
+
if (session.boss !== undefined) {
|
|
182
|
+
try {
|
|
183
|
+
parseBossParticipantBindingMetadata(session.boss, session.id);
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isRemoteAccessMetadata(value: unknown): value is import("../types.ts").RemoteAccessMetadata {
|
|
192
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
193
|
+
const access = value as Record<string, unknown>;
|
|
194
|
+
return access.origin === "remote"
|
|
195
|
+
&& typeof access.remoteHostId === "string"
|
|
196
|
+
&& typeof access.parentSessionId === "string"
|
|
197
|
+
&& typeof access.rootSessionId === "string"
|
|
198
|
+
&& typeof access.generation === "number"
|
|
199
|
+
&& Number.isSafeInteger(access.generation)
|
|
200
|
+
&& access.generation > 0
|
|
201
|
+
&& typeof access.canDelegate === "boolean"
|
|
202
|
+
&& typeof access.depth === "number"
|
|
203
|
+
&& Number.isSafeInteger(access.depth)
|
|
204
|
+
&& typeof access.maxDepth === "number"
|
|
205
|
+
&& Number.isSafeInteger(access.maxDepth)
|
|
206
|
+
&& typeof access.maxChildren === "number"
|
|
207
|
+
&& Number.isSafeInteger(access.maxChildren)
|
|
208
|
+
&& (access.sessionCredential === undefined || typeof access.sessionCredential === "string");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface IntercomClientOptions {
|
|
212
|
+
/** Exact private registration scope captured for this client lifecycle. */
|
|
213
|
+
scopeId?: string;
|
|
214
|
+
/** Environment used only when scopeId is not explicitly supplied. */
|
|
215
|
+
env?: NodeJS.ProcessEnv;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export class IntercomClient extends EventEmitter {
|
|
219
|
+
private socket: net.Socket | null = null;
|
|
220
|
+
private readonly scopeId: string | undefined;
|
|
221
|
+
private _sessionId: string | null = null;
|
|
222
|
+
private pendingSends = new Map<string, {
|
|
223
|
+
accepted: boolean;
|
|
224
|
+
deliveryId?: string;
|
|
225
|
+
resolve: (r: SendResult) => void;
|
|
226
|
+
reject: (e: Error) => void;
|
|
227
|
+
}>();
|
|
228
|
+
private pendingLists = new Map<string, { resolve: (sessions: SessionInfo[]) => void; reject: (e: Error) => void }>();
|
|
229
|
+
private pendingAskControls = new Map<string, { resolve: (applied: boolean) => void; timeout: NodeJS.Timeout }>();
|
|
230
|
+
private pendingBossControls = new Map<string, {
|
|
231
|
+
messageId: string;
|
|
232
|
+
idempotencyKey: string;
|
|
233
|
+
deliveryId?: string;
|
|
234
|
+
resolve: (result: BossControlSendResult) => void;
|
|
235
|
+
reject: (error: Error) => void;
|
|
236
|
+
timeout: NodeJS.Timeout;
|
|
237
|
+
}>();
|
|
238
|
+
private outbox: PersistentOutboundOutbox | null = null;
|
|
239
|
+
private bossControlOutbox: PersistentBossControlOutbox | null = null;
|
|
240
|
+
private remoteAccessCredential: LoadedRemoteAccessCredential | undefined;
|
|
241
|
+
private requestedBossRegistration: BossParticipantRegistrationMetadata | undefined;
|
|
242
|
+
private _bossBinding: BossParticipantBindingMetadata | undefined;
|
|
243
|
+
private disconnecting = false;
|
|
244
|
+
private disconnectError: Error | null = null;
|
|
245
|
+
|
|
246
|
+
constructor(options: IntercomClientOptions = {}) {
|
|
247
|
+
super();
|
|
248
|
+
this.scopeId = options.scopeId === undefined
|
|
249
|
+
? intercomScopeIdFromEnv(options.env ?? process.env)
|
|
250
|
+
: parseIntercomScopeId(options.scopeId, "scopeId");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private failPending(error: Error): void {
|
|
254
|
+
for (const pending of this.pendingSends.values()) {
|
|
255
|
+
pending.reject(error);
|
|
256
|
+
}
|
|
257
|
+
this.pendingSends.clear();
|
|
258
|
+
for (const pending of this.pendingLists.values()) {
|
|
259
|
+
pending.reject(error);
|
|
260
|
+
}
|
|
261
|
+
this.pendingLists.clear();
|
|
262
|
+
for (const pending of this.pendingAskControls.values()) {
|
|
263
|
+
clearTimeout(pending.timeout);
|
|
264
|
+
pending.resolve(false);
|
|
265
|
+
}
|
|
266
|
+
this.pendingAskControls.clear();
|
|
267
|
+
for (const pending of this.pendingBossControls.values()) {
|
|
268
|
+
clearTimeout(pending.timeout);
|
|
269
|
+
pending.reject(error);
|
|
270
|
+
}
|
|
271
|
+
this.pendingBossControls.clear();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
get sessionId(): string | null {
|
|
275
|
+
return this._sessionId;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
get outboxSize(): number {
|
|
279
|
+
return this.outbox?.list().length ?? 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
get bossBinding(): BossParticipantBindingMetadata | undefined {
|
|
283
|
+
return this._bossBinding;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
get bossControlOutboxSize(): number {
|
|
287
|
+
return this.bossControlOutbox?.list().length ?? 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
isConnected(): boolean {
|
|
291
|
+
const socket = this.socket;
|
|
292
|
+
return Boolean(socket && this._sessionId && !this.disconnecting && !socket.destroyed && !socket.writableEnded && socket.writable);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private requireActiveSocket(): net.Socket {
|
|
296
|
+
if (this.disconnecting) {
|
|
297
|
+
throw new Error("Client disconnecting");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const socket = this.socket;
|
|
301
|
+
if (!socket || !this._sessionId) {
|
|
302
|
+
throw new Error("Not connected");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (socket.destroyed || socket.writableEnded || !socket.writable) {
|
|
306
|
+
throw new Error("Client disconnected");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return socket;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
connect(session: SessionRegistration, sessionId?: string): Promise<void> {
|
|
313
|
+
if (this.socket) {
|
|
314
|
+
return Promise.reject(new Error("Already connected"));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const canonicalSession = parseExactRegistrationFrame({
|
|
319
|
+
type: "register",
|
|
320
|
+
...(typeof session === "object" && session !== null && !nodeUtilTypes.isProxy(session)
|
|
321
|
+
&& Object.getOwnPropertyDescriptor(session, "boss") !== undefined
|
|
322
|
+
? { registrationKind: "boss" as const }
|
|
323
|
+
: {}),
|
|
324
|
+
protocol: INTERCOM_PROTOCOL_NAME,
|
|
325
|
+
version: INTERCOM_PROTOCOL_VERSION,
|
|
326
|
+
session,
|
|
327
|
+
}).session;
|
|
328
|
+
this.requestedBossRegistration = session.boss === undefined
|
|
329
|
+
? undefined
|
|
330
|
+
: parseBossParticipantRegistrationMetadata(session.boss);
|
|
331
|
+
if (canonicalSession !== session) throw new Error("Registration session identity changed during validation");
|
|
332
|
+
} catch (error) {
|
|
333
|
+
return Promise.reject(toError(error));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
let socket: net.Socket;
|
|
338
|
+
let target: BrokerConnectTarget;
|
|
339
|
+
try {
|
|
340
|
+
target = getBrokerConnectTarget();
|
|
341
|
+
this.remoteAccessCredential = loadRemoteAccessCredential();
|
|
342
|
+
socket = connectToBrokerTarget(target);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
reject(toError(error));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
this.socket = socket;
|
|
348
|
+
this.disconnectError = null;
|
|
349
|
+
let settled = false;
|
|
350
|
+
const timeout = setTimeout(() => {
|
|
351
|
+
if (!this._sessionId) {
|
|
352
|
+
cleanupConnectionAttempt();
|
|
353
|
+
cleanupSocketListeners();
|
|
354
|
+
if (this.socket === socket) {
|
|
355
|
+
this.socket = null;
|
|
356
|
+
}
|
|
357
|
+
socket.destroy();
|
|
358
|
+
reject(new Error("Connection timeout"));
|
|
359
|
+
}
|
|
360
|
+
}, 10000);
|
|
361
|
+
|
|
362
|
+
let connectionEstablished = false;
|
|
363
|
+
|
|
364
|
+
const onRegistered = () => {
|
|
365
|
+
settled = true;
|
|
366
|
+
connectionEstablished = true;
|
|
367
|
+
cleanupConnectionAttempt();
|
|
368
|
+
resolve();
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const onError = (err: Error) => {
|
|
372
|
+
settled = true;
|
|
373
|
+
cleanupConnectionAttempt();
|
|
374
|
+
cleanupSocketListeners();
|
|
375
|
+
if (this.socket === socket) {
|
|
376
|
+
this.socket = null;
|
|
377
|
+
}
|
|
378
|
+
socket.destroy();
|
|
379
|
+
reject(err);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const onClose = () => {
|
|
383
|
+
const wasConnecting = !settled && !this._sessionId;
|
|
384
|
+
const wasDisconnecting = this.disconnecting;
|
|
385
|
+
const disconnectError = this.disconnectError ?? new Error("Client disconnected");
|
|
386
|
+
this.disconnecting = false;
|
|
387
|
+
cleanupConnectionAttempt();
|
|
388
|
+
cleanupSocketListeners();
|
|
389
|
+
this.failPending(disconnectError);
|
|
390
|
+
if (this.socket === socket) {
|
|
391
|
+
this.socket = null;
|
|
392
|
+
}
|
|
393
|
+
this._sessionId = null;
|
|
394
|
+
this._bossBinding = undefined;
|
|
395
|
+
this.requestedBossRegistration = undefined;
|
|
396
|
+
this.disconnectError = null;
|
|
397
|
+
if (connectionEstablished && !wasDisconnecting) {
|
|
398
|
+
this.emit("disconnected", disconnectError);
|
|
399
|
+
}
|
|
400
|
+
if (wasConnecting) {
|
|
401
|
+
reject(new Error("Connection closed before registration"));
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const onSocketError = (err: Error) => {
|
|
406
|
+
if (connectionEstablished) {
|
|
407
|
+
this.disconnectError = err;
|
|
408
|
+
this.emit("error", err);
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const onReaderError = (error: Error) => {
|
|
413
|
+
const protocolError = new Error(`Intercom protocol error: ${error.message}`, { cause: error });
|
|
414
|
+
if (!connectionEstablished) {
|
|
415
|
+
onError(protocolError);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
this.disconnectError = protocolError;
|
|
419
|
+
this.emit("error", protocolError);
|
|
420
|
+
socket.destroy();
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const reader = createMessageReader((msg) => {
|
|
424
|
+
this.handleBrokerMessage(msg);
|
|
425
|
+
}, onReaderError);
|
|
426
|
+
|
|
427
|
+
const cleanupConnectionAttempt = () => {
|
|
428
|
+
this.off("_registered", onRegistered);
|
|
429
|
+
socket.off("error", onError);
|
|
430
|
+
clearTimeout(timeout);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const cleanupSocketListeners = () => {
|
|
434
|
+
socket.off("data", reader);
|
|
435
|
+
socket.off("error", onSocketError);
|
|
436
|
+
socket.off("close", onClose);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
socket.on("data", reader);
|
|
440
|
+
socket.on("error", onError);
|
|
441
|
+
socket.on("close", onClose);
|
|
442
|
+
|
|
443
|
+
socket.on("error", onSocketError);
|
|
444
|
+
this.once("_registered", onRegistered);
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
writeMessage(socket, {
|
|
448
|
+
type: "register",
|
|
449
|
+
...(session.boss === undefined ? {} : { registrationKind: "boss" as const }),
|
|
450
|
+
protocol: INTERCOM_PROTOCOL_NAME,
|
|
451
|
+
version: INTERCOM_PROTOCOL_VERSION,
|
|
452
|
+
session,
|
|
453
|
+
...(!this.remoteAccessCredential && sessionId ? { sessionId } : {}),
|
|
454
|
+
...(this.remoteAccessCredential ? { access: this.remoteAccessCredential.access } : {}),
|
|
455
|
+
...(this.scopeId ? { scopeId: this.scopeId } : {}),
|
|
456
|
+
...(typeof target === "string" ? {} : { stateId: target.stateId }),
|
|
457
|
+
});
|
|
458
|
+
} catch (error) {
|
|
459
|
+
cleanupConnectionAttempt();
|
|
460
|
+
cleanupSocketListeners();
|
|
461
|
+
if (this.socket === socket) {
|
|
462
|
+
this.socket = null;
|
|
463
|
+
}
|
|
464
|
+
socket.destroy();
|
|
465
|
+
reject(toError(error));
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private handleBrokerMessage(msg: unknown): void {
|
|
471
|
+
if (typeof msg !== "object" || msg === null || nodeUtilTypes.isProxy(msg)) {
|
|
472
|
+
throw new Error("Invalid broker message");
|
|
473
|
+
}
|
|
474
|
+
const typeDescriptor = Object.getOwnPropertyDescriptor(msg, "type");
|
|
475
|
+
if (
|
|
476
|
+
typeDescriptor === undefined
|
|
477
|
+
|| !typeDescriptor.enumerable
|
|
478
|
+
|| !Object.hasOwn(typeDescriptor, "value")
|
|
479
|
+
|| typeof typeDescriptor.value !== "string"
|
|
480
|
+
) throw new Error("Invalid broker message");
|
|
481
|
+
|
|
482
|
+
const brokerMessage = msg as { type: string } & Record<string, unknown>;
|
|
483
|
+
|
|
484
|
+
if (this._sessionId === null && brokerMessage.type !== "registered" && brokerMessage.type !== "error") {
|
|
485
|
+
throw new Error(`Received ${brokerMessage.type} before registered`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
switch (brokerMessage.type) {
|
|
489
|
+
case "registered": {
|
|
490
|
+
parseExactRegisteredFrame(
|
|
491
|
+
brokerMessage,
|
|
492
|
+
this.requestedBossRegistration === undefined
|
|
493
|
+
? this.remoteAccessCredential === undefined ? "ordinary-local" : "ordinary-remote"
|
|
494
|
+
: "boss",
|
|
495
|
+
);
|
|
496
|
+
if (
|
|
497
|
+
typeof brokerMessage.sessionId !== "string"
|
|
498
|
+
|| brokerMessage.protocol !== INTERCOM_PROTOCOL_NAME
|
|
499
|
+
|| brokerMessage.version !== INTERCOM_PROTOCOL_VERSION
|
|
500
|
+
) {
|
|
501
|
+
throw new Error("Invalid registered message");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (this._sessionId !== null) {
|
|
505
|
+
throw new Error("Received duplicate registered message");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (this.remoteAccessCredential) {
|
|
509
|
+
const contract = brokerMessage.remoteAccess;
|
|
510
|
+
const contractFields = typeof contract === "object" && contract !== null
|
|
511
|
+
? contract as Record<string, unknown>
|
|
512
|
+
: undefined;
|
|
513
|
+
if (
|
|
514
|
+
!contractFields
|
|
515
|
+
|| contractFields.feature !== "remote-access-v1"
|
|
516
|
+
|| contractFields.policySemanticsVersion !== POLICY_SEMANTICS_VERSION
|
|
517
|
+
|| contractFields.policySemanticsHash !== POLICY_SEMANTICS_HASH
|
|
518
|
+
) {
|
|
519
|
+
throw new Error("Remote Intercom policy contract is absent or incompatible");
|
|
520
|
+
}
|
|
521
|
+
if (!isRemoteAccessMetadata(brokerMessage.access)) {
|
|
522
|
+
throw new Error("Remote Intercom registration omitted broker-owned provenance");
|
|
523
|
+
}
|
|
524
|
+
if (this.remoteAccessCredential.enrollment) {
|
|
525
|
+
writeRemoteSessionCredential(this.remoteAccessCredential.path, brokerMessage.sessionId, brokerMessage.access);
|
|
526
|
+
} else {
|
|
527
|
+
const reconnect = this.remoteAccessCredential.access;
|
|
528
|
+
if (!("sessionId" in reconnect) || reconnect.sessionId !== brokerMessage.sessionId || reconnect.generation !== brokerMessage.access.generation) {
|
|
529
|
+
throw new Error("Remote Intercom reconnect identity or generation changed unexpectedly");
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
if (this.requestedBossRegistration !== undefined) {
|
|
536
|
+
if (brokerMessage.remoteAccess !== undefined || brokerMessage.access !== undefined) {
|
|
537
|
+
throw new Error("Boss registration returned folded remote-access metadata");
|
|
538
|
+
}
|
|
539
|
+
const advertisement = parseBrokerCapabilityAdvertisement(brokerMessage.capabilities);
|
|
540
|
+
if (!advertisement.features.some((feature) => feature.feature === BOSS_RUN_FEATURE)) {
|
|
541
|
+
throw new Error("Broker did not echo the required boss-run-v1 feature contract");
|
|
542
|
+
}
|
|
543
|
+
const binding = parseBossParticipantBindingMetadata(brokerMessage.boss, brokerMessage.sessionId);
|
|
544
|
+
const credential = this.requestedBossRegistration.credential;
|
|
545
|
+
if (
|
|
546
|
+
binding.featureContract.feature !== this.requestedBossRegistration.featureContract.feature
|
|
547
|
+
|| binding.binding.bossRunId !== credential.bossRunId
|
|
548
|
+
|| binding.binding.participantId !== credential.participantId
|
|
549
|
+
|| binding.binding.role !== credential.role
|
|
550
|
+
|| binding.binding.communicationProfile !== credential.communicationProfile
|
|
551
|
+
|| binding.binding.bindingEpoch !== credential.bindingEpoch
|
|
552
|
+
) {
|
|
553
|
+
throw new Error("Broker returned a Boss binding that does not match the authenticated registration request");
|
|
554
|
+
}
|
|
555
|
+
this._bossBinding = binding;
|
|
556
|
+
} else if (brokerMessage.boss !== undefined) {
|
|
557
|
+
throw new Error("Broker attached unsolicited Boss binding metadata to an ordinary registration");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
this._sessionId = brokerMessage.sessionId;
|
|
561
|
+
this.outbox = new PersistentOutboundOutbox(brokerMessage.sessionId);
|
|
562
|
+
this.bossControlOutbox = this._bossBinding === undefined
|
|
563
|
+
? null
|
|
564
|
+
: new PersistentBossControlOutbox(brokerMessage.sessionId);
|
|
565
|
+
this.replayOutbox();
|
|
566
|
+
this.replayBossControlOutbox();
|
|
567
|
+
this.emit("_registered", { type: "registered", sessionId: brokerMessage.sessionId });
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
case "sessions": {
|
|
572
|
+
const { requestId, sessions } = brokerMessage;
|
|
573
|
+
if (typeof requestId !== "string" || !Array.isArray(sessions) || !sessions.every(isSessionInfo)) {
|
|
574
|
+
throw new Error("Invalid sessions message");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const pending = this.pendingLists.get(requestId);
|
|
578
|
+
if (!pending) {
|
|
579
|
+
// Late list responses can still arrive after the caller has already timed out.
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
this.pendingLists.delete(requestId);
|
|
584
|
+
pending.resolve(sessions);
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
case "message": {
|
|
589
|
+
const { deliveryId, from, message } = brokerMessage;
|
|
590
|
+
if (typeof deliveryId !== "string" || !isSessionInfo(from) || !isMessage(message)) {
|
|
591
|
+
throw new Error("Invalid message event");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
this.emit("message", from, message, deliveryId);
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
case "boss_control": {
|
|
599
|
+
const { deliveryId, from } = brokerMessage;
|
|
600
|
+
if (typeof deliveryId !== "string" || !isSessionInfo(from)) {
|
|
601
|
+
throw new Error("Invalid boss_control event");
|
|
602
|
+
}
|
|
603
|
+
// Keep every inbound envelope behind the adapter's recursive,
|
|
604
|
+
// proxy-zero-trap preflight before Core parses or reflects on it.
|
|
605
|
+
const envelope = bossControlKind(brokerMessage.envelope).envelope;
|
|
606
|
+
const source = from.boss === undefined
|
|
607
|
+
? undefined
|
|
608
|
+
: parseBossParticipantBindingMetadata(from.boss, from.id).binding;
|
|
609
|
+
if (
|
|
610
|
+
source === undefined
|
|
611
|
+
|| source.state !== "active"
|
|
612
|
+
|| source.bossRunId !== envelope.bossRunId
|
|
613
|
+
|| source.participantId !== envelope.participantId
|
|
614
|
+
|| source.bindingEpoch !== envelope.bindingEpoch
|
|
615
|
+
) throw new Error("Boss control event sender does not match its broker-owned binding");
|
|
616
|
+
this.emit("boss_control", from, envelope, deliveryId);
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
case "boss_control_result": {
|
|
621
|
+
const result = parseBossControlResult(brokerMessage);
|
|
622
|
+
const { requestId, messageId, idempotencyKey, deliveryId } = result;
|
|
623
|
+
const stored = this.bossControlOutbox?.find(idempotencyKey);
|
|
624
|
+
if (!stored || stored.envelope.messageId !== requestId) throw new Error("Boss control result does not match the durable outbox binding");
|
|
625
|
+
const pending = this.pendingBossControls.get(requestId);
|
|
626
|
+
if (pending && (pending.messageId !== messageId || pending.idempotencyKey !== idempotencyKey)) {
|
|
627
|
+
throw new Error("Boss control result correlation does not match the pending request");
|
|
628
|
+
}
|
|
629
|
+
this.bossControlOutbox!.removeCorrelated(idempotencyKey, messageId, deliveryId);
|
|
630
|
+
if (pending) {
|
|
631
|
+
clearTimeout(pending.timeout);
|
|
632
|
+
this.pendingBossControls.delete(requestId);
|
|
633
|
+
pending.resolve(result);
|
|
634
|
+
}
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
case "boss_control_ack": {
|
|
639
|
+
const { requestId, messageId, idempotencyKey, deliveryId } = parseBossControlAck(brokerMessage);
|
|
640
|
+
const pending = this.pendingBossControls.get(requestId);
|
|
641
|
+
if (pending && (pending.messageId !== messageId || pending.idempotencyKey !== idempotencyKey)) {
|
|
642
|
+
throw new Error("Boss control acknowledgement correlation does not match the pending request");
|
|
643
|
+
}
|
|
644
|
+
const transition = this.bossControlOutbox?.markAccepted(idempotencyKey, messageId, deliveryId);
|
|
645
|
+
if (transition === undefined) throw new Error("Boss control acknowledgement has no durable outbox");
|
|
646
|
+
// A terminal replay is deliberately ACK-first. The exact ACK is
|
|
647
|
+
// idempotent when this durable accepted binding survived reconnect;
|
|
648
|
+
// markAccepted still rejects any changed correlation or deliveryId.
|
|
649
|
+
if (pending?.deliveryId !== undefined && pending.deliveryId !== deliveryId) {
|
|
650
|
+
throw new Error("Boss control acknowledgement changed the pending deliveryId");
|
|
651
|
+
}
|
|
652
|
+
if (pending) pending.deliveryId = deliveryId;
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
case "delivery_accepted": {
|
|
657
|
+
const { deliveryId, messageId } = brokerMessage;
|
|
658
|
+
if (typeof deliveryId !== "string" || typeof messageId !== "string") {
|
|
659
|
+
throw new Error("Invalid delivery_accepted message");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const pending = this.pendingSends.get(messageId);
|
|
663
|
+
if (!pending) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
pending.accepted = true;
|
|
667
|
+
pending.deliveryId = deliveryId;
|
|
668
|
+
this.emit("delivery_accepted", messageId, deliveryId);
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
case "delivered": {
|
|
673
|
+
const { deliveryId, messageId } = brokerMessage;
|
|
674
|
+
if (typeof deliveryId !== "string" || typeof messageId !== "string") {
|
|
675
|
+
throw new Error("Invalid delivered message");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
this.outbox?.remove(messageId);
|
|
679
|
+
const pending = this.pendingSends.get(messageId);
|
|
680
|
+
if (!pending) {
|
|
681
|
+
this.emit("outbox_delivered", messageId, deliveryId);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
this.pendingSends.delete(messageId);
|
|
686
|
+
pending.resolve({ id: messageId, accepted: true, delivered: true, deliveryId });
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
case "delivery_failed": {
|
|
691
|
+
const { accepted, code, messageId, reason } = brokerMessage;
|
|
692
|
+
if (
|
|
693
|
+
typeof accepted !== "boolean"
|
|
694
|
+
|| typeof code !== "string"
|
|
695
|
+
|| typeof messageId !== "string"
|
|
696
|
+
|| typeof reason !== "string"
|
|
697
|
+
) {
|
|
698
|
+
throw new Error("Invalid delivery_failed message");
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
this.outbox?.remove(messageId);
|
|
702
|
+
const pending = this.pendingSends.get(messageId);
|
|
703
|
+
if (!pending) {
|
|
704
|
+
this.emit("outbox_failed", messageId, code, reason);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
this.pendingSends.delete(messageId);
|
|
709
|
+
pending.resolve({
|
|
710
|
+
id: messageId,
|
|
711
|
+
accepted,
|
|
712
|
+
delivered: false,
|
|
713
|
+
code: code as DeliveryFailureCode,
|
|
714
|
+
reason,
|
|
715
|
+
...(pending.deliveryId ? { deliveryId: pending.deliveryId } : {}),
|
|
716
|
+
});
|
|
717
|
+
break;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
case "ask_deferred": {
|
|
721
|
+
const { fromSessionId, messageId } = brokerMessage;
|
|
722
|
+
if (typeof fromSessionId !== "string" || typeof messageId !== "string") {
|
|
723
|
+
throw new Error("Invalid ask_deferred message");
|
|
724
|
+
}
|
|
725
|
+
this.emit("ask_deferred", messageId, fromSessionId);
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
case "ask_cancelled": {
|
|
730
|
+
const { fromSessionId, messageId, reason } = brokerMessage;
|
|
731
|
+
if (typeof fromSessionId !== "string" || typeof messageId !== "string" || typeof reason !== "string") {
|
|
732
|
+
throw new Error("Invalid ask_cancelled message");
|
|
733
|
+
}
|
|
734
|
+
this.emit("ask_cancelled", messageId, fromSessionId, reason as AskCancellationReason);
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
case "ask_control_result": {
|
|
739
|
+
const { action, applied, messageId, requestId } = brokerMessage;
|
|
740
|
+
if (
|
|
741
|
+
(action !== "defer" && action !== "cancel")
|
|
742
|
+
|| typeof applied !== "boolean"
|
|
743
|
+
|| typeof messageId !== "string"
|
|
744
|
+
|| typeof requestId !== "string"
|
|
745
|
+
) {
|
|
746
|
+
throw new Error("Invalid ask_control_result message");
|
|
747
|
+
}
|
|
748
|
+
const pending = this.pendingAskControls.get(requestId);
|
|
749
|
+
if (!pending) return;
|
|
750
|
+
clearTimeout(pending.timeout);
|
|
751
|
+
this.pendingAskControls.delete(requestId);
|
|
752
|
+
pending.resolve(applied);
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
case "session_joined": {
|
|
757
|
+
if (!isSessionInfo(brokerMessage.session)) {
|
|
758
|
+
throw new Error("Invalid session_joined message");
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
this.emit("session_joined", brokerMessage.session);
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
case "session_left": {
|
|
766
|
+
if (typeof brokerMessage.sessionId !== "string") {
|
|
767
|
+
throw new Error("Invalid session_left message");
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
this.emit("session_left", brokerMessage.sessionId);
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
case "presence_update": {
|
|
775
|
+
if (!isSessionInfo(brokerMessage.session)) {
|
|
776
|
+
throw new Error("Invalid presence_update message");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
this.emit("presence_update", brokerMessage.session);
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
case "error": {
|
|
784
|
+
if (typeof brokerMessage.code !== "string" || typeof brokerMessage.error !== "string") {
|
|
785
|
+
throw new Error("Invalid error message");
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (this._sessionId === null) {
|
|
789
|
+
const error = new Error(brokerMessage.error) as Error & { code?: string };
|
|
790
|
+
error.code = brokerMessage.code;
|
|
791
|
+
throw error;
|
|
792
|
+
}
|
|
793
|
+
const error = new Error(brokerMessage.error) as Error & { code?: string };
|
|
794
|
+
error.code = brokerMessage.code;
|
|
795
|
+
this.emit("error", error);
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
default:
|
|
800
|
+
throw new Error(`Unknown broker message type: ${brokerMessage.type}`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async disconnect(preserveAsks = false): Promise<void> {
|
|
805
|
+
const socket = this.socket;
|
|
806
|
+
if (!socket) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
this.disconnecting = true;
|
|
811
|
+
this.disconnectError = null;
|
|
812
|
+
this.failPending(new Error("Client disconnected"));
|
|
813
|
+
if (!preserveAsks) this.outbox?.clear();
|
|
814
|
+
|
|
815
|
+
await new Promise<void>((resolve) => {
|
|
816
|
+
let settled = false;
|
|
817
|
+
const finish = () => {
|
|
818
|
+
if (settled) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
settled = true;
|
|
822
|
+
clearTimeout(timeout);
|
|
823
|
+
socket.off("close", onClose);
|
|
824
|
+
socket.off("error", onError);
|
|
825
|
+
resolve();
|
|
826
|
+
};
|
|
827
|
+
const onClose = () => finish();
|
|
828
|
+
const onError = () => {
|
|
829
|
+
socket.destroy();
|
|
830
|
+
};
|
|
831
|
+
const timeout = setTimeout(() => {
|
|
832
|
+
socket.destroy();
|
|
833
|
+
}, 2000);
|
|
834
|
+
|
|
835
|
+
socket.once("close", onClose);
|
|
836
|
+
socket.once("error", onError);
|
|
837
|
+
|
|
838
|
+
try {
|
|
839
|
+
writeMessage(socket, { type: "unregister", ...(preserveAsks ? { preserveAsks: true } : {}) });
|
|
840
|
+
socket.end();
|
|
841
|
+
} catch {
|
|
842
|
+
// Disconnect should still finish even if the unregister write fails.
|
|
843
|
+
socket.destroy();
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
listSessions(): Promise<SessionInfo[]> {
|
|
849
|
+
let socket: net.Socket;
|
|
850
|
+
try {
|
|
851
|
+
socket = this.requireActiveSocket();
|
|
852
|
+
} catch (error) {
|
|
853
|
+
return Promise.reject(toError(error));
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
return new Promise((resolve, reject) => {
|
|
857
|
+
const requestId = randomUUID();
|
|
858
|
+
const wrappedResolve = (sessions: SessionInfo[]) => {
|
|
859
|
+
clearTimeout(timeout);
|
|
860
|
+
resolve(sessions);
|
|
861
|
+
};
|
|
862
|
+
const wrappedReject = (error: Error) => {
|
|
863
|
+
clearTimeout(timeout);
|
|
864
|
+
reject(error);
|
|
865
|
+
};
|
|
866
|
+
const timeout = setTimeout(() => {
|
|
867
|
+
if (this.pendingLists.has(requestId)) {
|
|
868
|
+
this.pendingLists.delete(requestId);
|
|
869
|
+
wrappedReject(new Error("List sessions timeout"));
|
|
870
|
+
}
|
|
871
|
+
}, 5000);
|
|
872
|
+
this.pendingLists.set(requestId, { resolve: wrappedResolve, reject: wrappedReject });
|
|
873
|
+
try {
|
|
874
|
+
writeMessage(socket, { type: "list", requestId });
|
|
875
|
+
} catch (error) {
|
|
876
|
+
clearTimeout(timeout);
|
|
877
|
+
this.pendingLists.delete(requestId);
|
|
878
|
+
reject(toError(error));
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
send(to: string, options: SendOptions): Promise<SendResult> {
|
|
884
|
+
let socket: net.Socket;
|
|
885
|
+
try {
|
|
886
|
+
socket = this.requireActiveSocket();
|
|
887
|
+
} catch (error) {
|
|
888
|
+
return Promise.reject(toError(error));
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const messageId = options.messageId ?? randomUUID();
|
|
892
|
+
if (this.pendingSends.has(messageId)) {
|
|
893
|
+
return Promise.resolve({
|
|
894
|
+
id: messageId,
|
|
895
|
+
accepted: false,
|
|
896
|
+
delivered: false,
|
|
897
|
+
code: "DUPLICATE_MESSAGE_ID",
|
|
898
|
+
reason: `Message ID ${messageId} is already pending`,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
const message: Message = {
|
|
902
|
+
id: messageId,
|
|
903
|
+
timestamp: Date.now(),
|
|
904
|
+
replyTo: options.replyTo,
|
|
905
|
+
expectsReply: options.expectsReply,
|
|
906
|
+
content: {
|
|
907
|
+
text: options.text,
|
|
908
|
+
attachments: options.attachments,
|
|
909
|
+
},
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
try {
|
|
913
|
+
this.outbox?.enqueue(to, message);
|
|
914
|
+
} catch (error) {
|
|
915
|
+
return Promise.reject(toError(error));
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
return new Promise((resolve, reject) => {
|
|
919
|
+
const wrappedResolve = (result: SendResult) => {
|
|
920
|
+
clearTimeout(timeout);
|
|
921
|
+
resolve(result);
|
|
922
|
+
};
|
|
923
|
+
const wrappedReject = (error: Error) => {
|
|
924
|
+
clearTimeout(timeout);
|
|
925
|
+
reject(error);
|
|
926
|
+
};
|
|
927
|
+
const timeout = setTimeout(() => {
|
|
928
|
+
if (this.pendingSends.has(messageId)) {
|
|
929
|
+
this.pendingSends.delete(messageId);
|
|
930
|
+
wrappedReject(new Error("Send timeout"));
|
|
931
|
+
}
|
|
932
|
+
}, 10000);
|
|
933
|
+
this.pendingSends.set(messageId, {
|
|
934
|
+
accepted: false,
|
|
935
|
+
resolve: wrappedResolve,
|
|
936
|
+
reject: wrappedReject,
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
try {
|
|
940
|
+
writeMessage(socket, { type: "send", to, message });
|
|
941
|
+
} catch (error) {
|
|
942
|
+
clearTimeout(timeout);
|
|
943
|
+
this.pendingSends.delete(messageId);
|
|
944
|
+
reject(toError(error));
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
sendBossControl(to: string, envelopeValue: unknown): Promise<BossControlSendResult> {
|
|
950
|
+
let socket: net.Socket;
|
|
951
|
+
try {
|
|
952
|
+
socket = this.requireActiveSocket();
|
|
953
|
+
} catch (error) {
|
|
954
|
+
return Promise.reject(toError(error));
|
|
955
|
+
}
|
|
956
|
+
let envelope: BossControlEnvelope;
|
|
957
|
+
try {
|
|
958
|
+
// Caller objects receive the same adapter-owned zero-trap boundary as
|
|
959
|
+
// broker-delivered envelopes; Core never sees a Proxy at any depth.
|
|
960
|
+
envelope = bossControlKind(envelopeValue).envelope;
|
|
961
|
+
const binding = this._bossBinding?.binding;
|
|
962
|
+
if (
|
|
963
|
+
binding === undefined
|
|
964
|
+
|| binding.state !== "active"
|
|
965
|
+
|| envelope.bossRunId !== binding.bossRunId
|
|
966
|
+
|| envelope.participantId !== binding.participantId
|
|
967
|
+
|| envelope.bindingEpoch !== binding.bindingEpoch
|
|
968
|
+
) throw new Error("Boss control envelope does not match this client's active participant binding");
|
|
969
|
+
} catch (error) {
|
|
970
|
+
return Promise.reject(toError(error));
|
|
971
|
+
}
|
|
972
|
+
const requestId = envelope.messageId;
|
|
973
|
+
if (this.pendingBossControls.has(requestId)) {
|
|
974
|
+
return Promise.resolve({
|
|
975
|
+
requestId,
|
|
976
|
+
messageId: envelope.messageId,
|
|
977
|
+
idempotencyKey: envelope.idempotencyKey,
|
|
978
|
+
status: "rejected",
|
|
979
|
+
delivered: false,
|
|
980
|
+
code: "INVALID_CONTROL",
|
|
981
|
+
reason: "Boss requestId is already pending",
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
try {
|
|
985
|
+
if (!this.bossControlOutbox) throw new Error("Durable Boss control outbox is unavailable");
|
|
986
|
+
this.bossControlOutbox.enqueue(to, envelope);
|
|
987
|
+
} catch (error) {
|
|
988
|
+
return Promise.reject(toError(error));
|
|
989
|
+
}
|
|
990
|
+
return new Promise((resolve, reject) => {
|
|
991
|
+
const timeout = setTimeout(() => {
|
|
992
|
+
if (!this.pendingBossControls.delete(requestId)) return;
|
|
993
|
+
reject(new Error("Boss control delivery timeout"));
|
|
994
|
+
}, 10000);
|
|
995
|
+
timeout.unref?.();
|
|
996
|
+
this.pendingBossControls.set(requestId, {
|
|
997
|
+
messageId: envelope.messageId,
|
|
998
|
+
idempotencyKey: envelope.idempotencyKey,
|
|
999
|
+
resolve,
|
|
1000
|
+
reject,
|
|
1001
|
+
timeout,
|
|
1002
|
+
});
|
|
1003
|
+
try {
|
|
1004
|
+
writeMessage(socket, { type: "boss_control", requestId, to, envelope });
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
clearTimeout(timeout);
|
|
1007
|
+
this.pendingBossControls.delete(requestId);
|
|
1008
|
+
reject(toError(error));
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
acknowledgeBossControl(deliveryId: string, messageId: string, idempotencyKey: string): boolean {
|
|
1014
|
+
return this.writeControlMessage({ type: "boss_control_received", deliveryId, messageId, idempotencyKey });
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
acknowledgeMessage(deliveryId: string): boolean {
|
|
1018
|
+
return this.writeControlMessage({ type: "message_received", deliveryId });
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
rejectMessage(deliveryId: string, reason: string): boolean {
|
|
1022
|
+
return this.writeControlMessage({ type: "message_rejected", deliveryId, code: "CONFLICTING_MESSAGE_ID", reason });
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
deferAsk(messageId: string): Promise<boolean> {
|
|
1026
|
+
return this.sendAskControl("defer", messageId);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
cancelAsk(messageId: string): Promise<boolean> {
|
|
1030
|
+
return this.sendAskControl("cancel", messageId);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
private sendAskControl(action: "defer" | "cancel", messageId: string): Promise<boolean> {
|
|
1034
|
+
const requestId = randomUUID();
|
|
1035
|
+
return new Promise((resolve) => {
|
|
1036
|
+
const timeout = setTimeout(() => {
|
|
1037
|
+
this.pendingAskControls.delete(requestId);
|
|
1038
|
+
resolve(false);
|
|
1039
|
+
}, 2000);
|
|
1040
|
+
timeout.unref?.();
|
|
1041
|
+
this.pendingAskControls.set(requestId, { resolve, timeout });
|
|
1042
|
+
if (!this.writeControlMessage({ type: action === "defer" ? "defer_ask" : "cancel_ask", requestId, messageId })) {
|
|
1043
|
+
clearTimeout(timeout);
|
|
1044
|
+
this.pendingAskControls.delete(requestId);
|
|
1045
|
+
resolve(false);
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
private writeControlMessage(message: Record<string, unknown>): boolean {
|
|
1051
|
+
if (this.disconnecting) {
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const socket = this.socket;
|
|
1056
|
+
if (!socket || !this._sessionId || socket.destroyed || socket.writableEnded || !socket.writable) {
|
|
1057
|
+
return false;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
try {
|
|
1061
|
+
writeMessage(socket, message);
|
|
1062
|
+
return true;
|
|
1063
|
+
} catch {
|
|
1064
|
+
// Control messages are best-effort; local cleanup must still proceed.
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private replayOutbox(): void {
|
|
1070
|
+
const socket = this.socket;
|
|
1071
|
+
if (!socket || !this._sessionId || socket.destroyed || socket.writableEnded || !socket.writable) return;
|
|
1072
|
+
for (const entry of this.outbox?.list() ?? []) {
|
|
1073
|
+
if (this.pendingSends.has(entry.message.id)) continue;
|
|
1074
|
+
try {
|
|
1075
|
+
writeMessage(socket, { type: "send", to: entry.to, message: entry.message });
|
|
1076
|
+
} catch {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
private replayBossControlOutbox(): void {
|
|
1083
|
+
const socket = this.socket;
|
|
1084
|
+
if (!socket || socket.destroyed || !this._sessionId || !this.bossControlOutbox) return;
|
|
1085
|
+
for (const entry of this.bossControlOutbox.list()) {
|
|
1086
|
+
try {
|
|
1087
|
+
writeMessage(socket, {
|
|
1088
|
+
type: "boss_control",
|
|
1089
|
+
requestId: entry.envelope.messageId,
|
|
1090
|
+
to: entry.to,
|
|
1091
|
+
envelope: entry.envelope,
|
|
1092
|
+
});
|
|
1093
|
+
} catch {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
updatePresence(updates: { name?: string; status?: string; model?: string }): void {
|
|
1100
|
+
if (this.disconnecting) {
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const socket = this.socket;
|
|
1105
|
+
if (!socket || !this._sessionId || socket.destroyed || socket.writableEnded || !socket.writable) {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
writeMessage(socket, { type: "presence", ...updates });
|
|
1110
|
+
}
|
|
1111
|
+
}
|