@turingfocus/chat-gateway-tfrobot 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/attachment-uploader.d.ts +16 -0
- package/dist/attachment-uploader.d.ts.map +1 -0
- package/dist/attachment-uploader.js +63 -0
- package/dist/attachment-uploader.js.map +1 -0
- package/dist/dto.d.ts +14 -1
- package/dist/dto.d.ts.map +1 -1
- package/dist/dto.js +43 -6
- package/dist/dto.js.map +1 -1
- package/dist/gateway.d.ts +4 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +317 -19
- package/dist/gateway.js.map +1 -1
- package/dist/http.d.ts +6 -2
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +52 -40
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mapper.d.ts +1 -0
- package/dist/mapper.d.ts.map +1 -1
- package/dist/mapper.js +145 -8
- package/dist/mapper.js.map +1 -1
- package/dist/outbound-message.d.ts +7 -0
- package/dist/outbound-message.d.ts.map +1 -0
- package/dist/outbound-message.js +91 -0
- package/dist/outbound-message.js.map +1 -0
- package/dist/presentation.d.ts +5 -0
- package/dist/presentation.d.ts.map +1 -0
- package/dist/presentation.js +102 -0
- package/dist/presentation.js.map +1 -0
- package/dist/redaction.d.ts +4 -0
- package/dist/redaction.d.ts.map +1 -1
- package/dist/redaction.js +75 -24
- package/dist/redaction.js.map +1 -1
- package/dist/socket.d.ts +21 -3
- package/dist/socket.d.ts.map +1 -1
- package/dist/socket.js +317 -48
- package/dist/socket.js.map +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +29 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/socket.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { io } from "socket.io-client";
|
|
2
|
-
import { chatErrorSchema, chatUpdateSchema, createGatewayDeadlineExceededError, isGatewayDeadlineExceeded, } from "@turingfocus/chat-protocol";
|
|
2
|
+
import { chatErrorSchema, chatUpdateSchema, createGatewayDeadlineExceededError, getTimelineItemKey, isGatewayDeadlineExceeded, } from "@turingfocus/chat-protocol";
|
|
3
3
|
import { awaitBounded } from "./bounded.js";
|
|
4
4
|
import { chatErrorEventDtoSchema, eventDtoSchema, messageDtoSchema, socketProtocolErrorDtoSchema, stateChangedDtoSchema, statusDtoSchema, } from "./dto.js";
|
|
5
5
|
import { mapEventUpdate, mapMessageUpdate, mapRun, mapUnknownSocketEvent, } from "./mapper.js";
|
|
6
|
-
import { sanitizeCredentialError, sanitizeCredentialRaw, sanitizeCredentialText, } from "./redaction.js";
|
|
7
|
-
import { isValidTFRobotSession } from "./types.js";
|
|
6
|
+
import { sanitizeCredentialError, sanitizeCredentialPayload, sanitizeCredentialRaw, sanitizeCredentialText, TransportPayloadError, } from "./redaction.js";
|
|
7
|
+
import { isValidTFRobotSession, resolveTFRobotServerProfile } from "./types.js";
|
|
8
8
|
const KNOWN_EVENTS = new Set([
|
|
9
9
|
"chat_error",
|
|
10
10
|
"chat_event",
|
|
@@ -18,17 +18,21 @@ const KNOWN_EVENTS = new Set([
|
|
|
18
18
|
const MAX_TIMER_DELAY = 2_147_483_647;
|
|
19
19
|
const RECONNECT_AUTH_TIMEOUT_MS = 10_000;
|
|
20
20
|
const RECONNECT_JOIN_TIMEOUT_MS = 10_000;
|
|
21
|
-
const parseJoinAcknowledgement = (value, reconnect) => {
|
|
21
|
+
const parseJoinAcknowledgement = (value, reconnect, acceptEmpty) => {
|
|
22
22
|
if (value === undefined) {
|
|
23
23
|
return {
|
|
24
|
-
accepted: reconnect,
|
|
24
|
+
accepted: reconnect || acceptEmpty,
|
|
25
|
+
empty: true,
|
|
25
26
|
recoveryComplete: false,
|
|
26
|
-
...(reconnect
|
|
27
|
+
...(reconnect || acceptEmpty
|
|
28
|
+
? {}
|
|
29
|
+
: { rejectionCode: "validation" }),
|
|
27
30
|
};
|
|
28
31
|
}
|
|
29
32
|
if (value === null) {
|
|
30
33
|
return {
|
|
31
34
|
accepted: false,
|
|
35
|
+
empty: false,
|
|
32
36
|
recoveryComplete: false,
|
|
33
37
|
rejectionCode: "validation",
|
|
34
38
|
};
|
|
@@ -36,16 +40,18 @@ const parseJoinAcknowledgement = (value, reconnect) => {
|
|
|
36
40
|
if (value === false) {
|
|
37
41
|
return {
|
|
38
42
|
accepted: false,
|
|
43
|
+
empty: false,
|
|
39
44
|
recoveryComplete: false,
|
|
40
45
|
rejectionCode: "validation",
|
|
41
46
|
};
|
|
42
47
|
}
|
|
43
48
|
if (value === true) {
|
|
44
|
-
return { accepted: true, recoveryComplete: !reconnect };
|
|
49
|
+
return { accepted: true, empty: false, recoveryComplete: !reconnect };
|
|
45
50
|
}
|
|
46
51
|
if (typeof value !== "object") {
|
|
47
52
|
return {
|
|
48
53
|
accepted: false,
|
|
54
|
+
empty: false,
|
|
49
55
|
recoveryComplete: false,
|
|
50
56
|
rejectionCode: "validation",
|
|
51
57
|
};
|
|
@@ -120,6 +126,7 @@ const parseJoinAcknowledgement = (value, reconnect) => {
|
|
|
120
126
|
const authorizationRejected = statuses.some(({ normalized, numeric }) => numeric === 403 || normalized === "forbidden") || normalizedError === "forbidden";
|
|
121
127
|
return {
|
|
122
128
|
accepted,
|
|
129
|
+
empty: false,
|
|
123
130
|
...(authenticationRejected
|
|
124
131
|
? { rejectionCode: "authentication" }
|
|
125
132
|
: authorizationRejected
|
|
@@ -218,27 +225,65 @@ export const createSocketIoFactoryWith = (connect) => ({ getAuth, namespaceUrl,
|
|
|
218
225
|
},
|
|
219
226
|
});
|
|
220
227
|
export const createSocketIoFactory = createSocketIoFactoryWith(io);
|
|
228
|
+
const MAX_RECOVERY_IDENTITIES = 10_000;
|
|
229
|
+
const rememberBoundedSetValue = (values, value) => {
|
|
230
|
+
values.delete(value);
|
|
231
|
+
values.add(value);
|
|
232
|
+
while (values.size > MAX_RECOVERY_IDENTITIES) {
|
|
233
|
+
const oldest = values.values().next().value;
|
|
234
|
+
if (oldest === undefined)
|
|
235
|
+
break;
|
|
236
|
+
values.delete(oldest);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
const rememberBoundedMapValue = (values, key, value) => {
|
|
240
|
+
values.delete(key);
|
|
241
|
+
values.set(key, value);
|
|
242
|
+
while (values.size > MAX_RECOVERY_IDENTITIES) {
|
|
243
|
+
const oldest = values.keys().next().value;
|
|
244
|
+
if (oldest === undefined)
|
|
245
|
+
break;
|
|
246
|
+
values.delete(oldest);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
export const recoveryIdentityOfUpdate = (update) => {
|
|
250
|
+
if (update.kind === "timeline.upsert") {
|
|
251
|
+
return getTimelineItemKey(update.item);
|
|
252
|
+
}
|
|
253
|
+
if (update.kind === "event.transition.upsert") {
|
|
254
|
+
return `event:${update.event.id}:transition:${update.event.transition.id}`;
|
|
255
|
+
}
|
|
256
|
+
return undefined;
|
|
257
|
+
};
|
|
258
|
+
const recoveryIdentitiesOfSnapshot = (snapshot) => snapshot.timeline.flatMap((item) => item.kind === "agent-event"
|
|
259
|
+
? item.transitions.map((transition) => `event:${item.id}:transition:${transition.id}`)
|
|
260
|
+
: [getTimelineItemKey(item)]);
|
|
221
261
|
export class TFRobotSocketClient {
|
|
222
262
|
#disposed = false;
|
|
223
|
-
#establishmentAbort;
|
|
224
263
|
#establishing;
|
|
264
|
+
#pendingEstablishment;
|
|
225
265
|
#factory;
|
|
226
266
|
#namespaceUrl;
|
|
227
267
|
#now;
|
|
228
268
|
#options;
|
|
229
269
|
#path;
|
|
270
|
+
#profile;
|
|
271
|
+
#currentServerRestLoader;
|
|
230
272
|
#reconnectStatusLoader;
|
|
273
|
+
#recoveryCheckpoints = new Map();
|
|
231
274
|
#runs = new Map();
|
|
232
275
|
#subscriptions = new Set();
|
|
233
276
|
#subscriptionGeneration = 0;
|
|
234
|
-
constructor(options, reconnectStatusLoader) {
|
|
277
|
+
constructor(options, reconnectStatusLoader, currentServerRestLoader) {
|
|
235
278
|
this.#options = options;
|
|
236
279
|
this.#factory = options.socketFactory ?? createSocketIoFactory;
|
|
237
280
|
this.#path = options.socketPath ?? "/socket.io";
|
|
238
281
|
this.#now = options.now ?? Date.now;
|
|
282
|
+
this.#profile = resolveTFRobotServerProfile(options.serverProfile);
|
|
239
283
|
this.#namespaceUrl =
|
|
240
284
|
options.socketNamespaceUrl ?? this.#defaultNamespaceUrl(options.baseUrl);
|
|
241
285
|
this.#reconnectStatusLoader = reconnectStatusLoader;
|
|
286
|
+
this.#currentServerRestLoader = currentServerRestLoader;
|
|
242
287
|
}
|
|
243
288
|
async subscribe(conversationId, options, observer) {
|
|
244
289
|
if (this.#disposed) {
|
|
@@ -257,14 +302,25 @@ export class TFRobotSocketClient {
|
|
|
257
302
|
const subscriptionId = `tfrobot-subscription-${generation}`;
|
|
258
303
|
this.#cancelEstablishment();
|
|
259
304
|
const establishmentAbort = new AbortController();
|
|
260
|
-
|
|
261
|
-
|
|
305
|
+
const pendingEstablishment = {
|
|
306
|
+
abort: establishmentAbort,
|
|
307
|
+
conversationId,
|
|
308
|
+
generation,
|
|
309
|
+
};
|
|
310
|
+
this.#pendingEstablishment = pendingEstablishment;
|
|
311
|
+
const clearPendingEstablishment = () => {
|
|
312
|
+
if (this.#pendingEstablishment === pendingEstablishment) {
|
|
313
|
+
this.#pendingEstablishment = undefined;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const authOutcome = await awaitBounded(() => this.#getSocketSession(conversationId, "connect"), {
|
|
262
317
|
deadlineAt: options.deadlineAt,
|
|
263
318
|
now: this.#now,
|
|
264
319
|
signal: establishmentAbort.signal,
|
|
265
320
|
});
|
|
266
321
|
switch (authOutcome.kind) {
|
|
267
322
|
case "aborted": {
|
|
323
|
+
clearPendingEstablishment();
|
|
268
324
|
return {
|
|
269
325
|
ok: false,
|
|
270
326
|
error: this.#error("conflict", this.#disposed
|
|
@@ -273,12 +329,14 @@ export class TFRobotSocketClient {
|
|
|
273
329
|
};
|
|
274
330
|
}
|
|
275
331
|
case "deadline": {
|
|
332
|
+
clearPendingEstablishment();
|
|
276
333
|
return {
|
|
277
334
|
ok: false,
|
|
278
335
|
error: createGatewayDeadlineExceededError(),
|
|
279
336
|
};
|
|
280
337
|
}
|
|
281
338
|
case "error": {
|
|
339
|
+
clearPendingEstablishment();
|
|
282
340
|
return {
|
|
283
341
|
ok: false,
|
|
284
342
|
error: this.#error("authentication", "Unable to obtain a TFRobot Socket session", true, undefined),
|
|
@@ -289,14 +347,43 @@ export class TFRobotSocketClient {
|
|
|
289
347
|
}
|
|
290
348
|
}
|
|
291
349
|
if (this.#disposed || establishmentAbort.signal.aborted) {
|
|
350
|
+
clearPendingEstablishment();
|
|
292
351
|
return {
|
|
293
352
|
ok: false,
|
|
294
353
|
error: this.#error("conflict", "TFRobot Gateway was disposed during subscription", false, undefined),
|
|
295
354
|
};
|
|
296
355
|
}
|
|
297
|
-
const
|
|
356
|
+
const initialSession = authOutcome.value;
|
|
357
|
+
if (this.#profile.kind === "current-server") {
|
|
358
|
+
const preflight = await this.#currentServerRestLoader({
|
|
359
|
+
checkpoint: new Set(),
|
|
360
|
+
conversationId,
|
|
361
|
+
deadlineAt: options.deadlineAt,
|
|
362
|
+
limits: {
|
|
363
|
+
...this.#profile.rebase,
|
|
364
|
+
maxItems: this.#profile.rebase.pageSize,
|
|
365
|
+
maxPages: 1,
|
|
366
|
+
},
|
|
367
|
+
session: initialSession,
|
|
368
|
+
signal: establishmentAbort.signal,
|
|
369
|
+
});
|
|
370
|
+
if (this.#disposed || establishmentAbort.signal.aborted) {
|
|
371
|
+
clearPendingEstablishment();
|
|
372
|
+
return {
|
|
373
|
+
ok: false,
|
|
374
|
+
error: this.#error("conflict", "TFRobot subscription was replaced during REST preflight", false, undefined),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
if (!preflight.ok) {
|
|
378
|
+
clearPendingEstablishment();
|
|
379
|
+
return preflight;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const initialAuth = authOf(initialSession);
|
|
383
|
+
const credentialValues = new Set(Object.values(initialAuth));
|
|
298
384
|
const errorConversationId = () => sanitizeCredentialText(conversationId, credentialValues);
|
|
299
|
-
let firstAuth =
|
|
385
|
+
let firstAuth = initialAuth;
|
|
386
|
+
let currentServerSession = initialSession;
|
|
300
387
|
let authenticationFailure;
|
|
301
388
|
const connectionAbort = new AbortController();
|
|
302
389
|
let socket;
|
|
@@ -313,20 +400,22 @@ export class TFRobotSocketClient {
|
|
|
313
400
|
firstAuth = undefined;
|
|
314
401
|
return auth;
|
|
315
402
|
}
|
|
316
|
-
const
|
|
403
|
+
const reconnectSession = await awaitBounded(() => this.#getSocketSession(conversationId, "reconnect"), {
|
|
317
404
|
deadlineAt: this.#now() + RECONNECT_AUTH_TIMEOUT_MS,
|
|
318
405
|
now: this.#now,
|
|
319
406
|
signal: connectionAbort.signal,
|
|
320
407
|
});
|
|
321
|
-
if (
|
|
322
|
-
|
|
408
|
+
if (reconnectSession.kind === "value") {
|
|
409
|
+
currentServerSession = reconnectSession.value;
|
|
410
|
+
const reconnectAuth = authOf(reconnectSession.value);
|
|
411
|
+
for (const value of Object.values(reconnectAuth)) {
|
|
323
412
|
credentialValues.add(value);
|
|
324
413
|
}
|
|
325
|
-
return reconnectAuth
|
|
414
|
+
return reconnectAuth;
|
|
326
415
|
}
|
|
327
|
-
const reason =
|
|
328
|
-
?
|
|
329
|
-
: new Error(
|
|
416
|
+
const reason = reconnectSession.kind === "error"
|
|
417
|
+
? reconnectSession.reason
|
|
418
|
+
: new Error(reconnectSession.kind === "deadline"
|
|
330
419
|
? "TFRobot Socket session refresh exceeded its deadline"
|
|
331
420
|
: "TFRobot Socket session refresh was cancelled");
|
|
332
421
|
authenticationFailure = reason;
|
|
@@ -336,6 +425,7 @@ export class TFRobotSocketClient {
|
|
|
336
425
|
}
|
|
337
426
|
catch (reason) {
|
|
338
427
|
connectionAbort.abort();
|
|
428
|
+
clearPendingEstablishment();
|
|
339
429
|
return {
|
|
340
430
|
ok: false,
|
|
341
431
|
error: this.#transportError(reason, "Unable to create the TFRobot Socket transport", conversationId, credentialValues),
|
|
@@ -424,14 +514,7 @@ export class TFRobotSocketClient {
|
|
|
424
514
|
pendingNotifications.push({ kind: "error", error });
|
|
425
515
|
diagnose(error);
|
|
426
516
|
};
|
|
427
|
-
const sanitizePayload = (payload) =>
|
|
428
|
-
try {
|
|
429
|
-
return sanitizeCredentialRaw(payload, credentialValues);
|
|
430
|
-
}
|
|
431
|
-
catch {
|
|
432
|
-
return undefined;
|
|
433
|
-
}
|
|
434
|
-
};
|
|
517
|
+
const sanitizePayload = (payload) => sanitizeCredentialPayload(payload, credentialValues);
|
|
435
518
|
const next = (update) => {
|
|
436
519
|
if (!active.active)
|
|
437
520
|
return;
|
|
@@ -458,10 +541,20 @@ export class TFRobotSocketClient {
|
|
|
458
541
|
};
|
|
459
542
|
publishUpdate = next;
|
|
460
543
|
const dispatchRealtime = (action) => {
|
|
544
|
+
const guardedAction = () => {
|
|
545
|
+
try {
|
|
546
|
+
action();
|
|
547
|
+
}
|
|
548
|
+
catch (reason) {
|
|
549
|
+
if (!(reason instanceof TransportPayloadError))
|
|
550
|
+
throw reason;
|
|
551
|
+
report(this.#error("validation", reason.message, false, errorConversationId()));
|
|
552
|
+
}
|
|
553
|
+
};
|
|
461
554
|
if (active.acceptingEvents)
|
|
462
|
-
|
|
555
|
+
guardedAction();
|
|
463
556
|
else if (joinPending)
|
|
464
|
-
pendingRealtimeActions.push(
|
|
557
|
+
pendingRealtimeActions.push(guardedAction);
|
|
465
558
|
};
|
|
466
559
|
const flushRealtime = () => {
|
|
467
560
|
for (const action of pendingRealtimeActions.splice(0))
|
|
@@ -496,12 +589,18 @@ export class TFRobotSocketClient {
|
|
|
496
589
|
? {}
|
|
497
590
|
: {
|
|
498
591
|
recoveryComplete: recovery.complete,
|
|
592
|
+
...(recovery.assurance === undefined
|
|
593
|
+
? {}
|
|
594
|
+
: { recoveryAssurance: recovery.assurance }),
|
|
499
595
|
...(recovery.cursor === undefined
|
|
500
596
|
? {}
|
|
501
597
|
: { recoveryCursor: recovery.cursor }),
|
|
502
598
|
...(recovery.reason === undefined
|
|
503
599
|
? {}
|
|
504
600
|
: { recoveryReason: recovery.reason }),
|
|
601
|
+
...(recovery.source === undefined
|
|
602
|
+
? {}
|
|
603
|
+
: { recoverySource: recovery.source }),
|
|
505
604
|
}),
|
|
506
605
|
})).catch(() => undefined);
|
|
507
606
|
}
|
|
@@ -529,12 +628,80 @@ export class TFRobotSocketClient {
|
|
|
529
628
|
try {
|
|
530
629
|
update = map();
|
|
531
630
|
}
|
|
532
|
-
catch {
|
|
533
|
-
report(this.#error("validation",
|
|
631
|
+
catch (reason) {
|
|
632
|
+
report(this.#error("validation", reason instanceof TransportPayloadError
|
|
633
|
+
? reason.message
|
|
634
|
+
: invalidMessage, false, errorConversationId()));
|
|
534
635
|
return;
|
|
535
636
|
}
|
|
637
|
+
const recoveryIdentity = recoveryIdentityOfUpdate(update);
|
|
638
|
+
if (recoveryIdentity !== undefined) {
|
|
639
|
+
active.realtimeRevision += 1;
|
|
640
|
+
rememberBoundedMapValue(active.realtimeIdentityRevisions, recoveryIdentity, active.realtimeRevision);
|
|
641
|
+
const checkpoint = this.#recoveryCheckpoints.get(conversationId) ?? new Set();
|
|
642
|
+
rememberBoundedSetValue(checkpoint, recoveryIdentity);
|
|
643
|
+
this.#recoveryCheckpoints.set(conversationId, checkpoint);
|
|
644
|
+
}
|
|
536
645
|
next(update);
|
|
537
646
|
};
|
|
647
|
+
const rebaseCurrentServer = async (recoveryRevision, runRevision, session, checkpoint, realtimeRevision) => {
|
|
648
|
+
if (this.#profile.kind !== "current-server") {
|
|
649
|
+
return { kind: "failure" };
|
|
650
|
+
}
|
|
651
|
+
const result = await this.#currentServerRestLoader({
|
|
652
|
+
checkpoint,
|
|
653
|
+
conversationId,
|
|
654
|
+
deadlineAt: this.#now() + this.#profile.rebase.deadlineMs,
|
|
655
|
+
limits: this.#profile.rebase,
|
|
656
|
+
session,
|
|
657
|
+
signal: connectionAbort.signal,
|
|
658
|
+
});
|
|
659
|
+
if (!active.active ||
|
|
660
|
+
!this.#subscriptions.has(active) ||
|
|
661
|
+
!active.socket.connected ||
|
|
662
|
+
active.recoveryRevision !== recoveryRevision) {
|
|
663
|
+
return { kind: "failure" };
|
|
664
|
+
}
|
|
665
|
+
if (!result.ok) {
|
|
666
|
+
const error = sanitizeCredentialError(result.error, credentialValues);
|
|
667
|
+
if (error.code === "authentication" || error.code === "authorization") {
|
|
668
|
+
report(error, "authentication");
|
|
669
|
+
return { kind: "auth-required" };
|
|
670
|
+
}
|
|
671
|
+
report(error, "recovery");
|
|
672
|
+
return { kind: "failure" };
|
|
673
|
+
}
|
|
674
|
+
const remembered = this.#recoveryCheckpoints.get(conversationId) ?? new Set();
|
|
675
|
+
for (const update of result.value.updates) {
|
|
676
|
+
const identity = recoveryIdentityOfUpdate(update);
|
|
677
|
+
if (identity !== undefined &&
|
|
678
|
+
(active.realtimeIdentityRevisions.get(identity) ?? 0) >
|
|
679
|
+
realtimeRevision) {
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
next(update);
|
|
683
|
+
if (identity !== undefined)
|
|
684
|
+
rememberBoundedSetValue(remembered, identity);
|
|
685
|
+
}
|
|
686
|
+
this.#recoveryCheckpoints.set(conversationId, remembered);
|
|
687
|
+
if (active.runRevision === runRevision) {
|
|
688
|
+
const previous = this.#runs.get(conversationId);
|
|
689
|
+
this.#runs.set(conversationId, result.value.run);
|
|
690
|
+
if (!sameRun(previous, result.value.run)) {
|
|
691
|
+
next(chatUpdateSchema.parse({
|
|
692
|
+
kind: "run.replace",
|
|
693
|
+
conversationId: errorConversationId(),
|
|
694
|
+
run: result.value.run,
|
|
695
|
+
}));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const reason = result.value.checkpointReached
|
|
699
|
+
? "rest-rebase-reached-checkpoint"
|
|
700
|
+
: result.value.boundedBy === undefined
|
|
701
|
+
? "rest-rebase-history-exhausted"
|
|
702
|
+
: `rest-rebase-${result.value.boundedBy}`;
|
|
703
|
+
return { kind: "success", reason };
|
|
704
|
+
};
|
|
538
705
|
let settleEstablishment;
|
|
539
706
|
add("connect", () => {
|
|
540
707
|
if (!active.active)
|
|
@@ -563,6 +730,8 @@ export class TFRobotSocketClient {
|
|
|
563
730
|
const reconnect = subscriptionEstablished;
|
|
564
731
|
if (reconnect)
|
|
565
732
|
active.reconnectAttempt += 1;
|
|
733
|
+
const rebaseCheckpoint = new Set(this.#recoveryCheckpoints.get(conversationId) ?? []);
|
|
734
|
+
const rebaseRealtimeRevision = active.realtimeRevision;
|
|
566
735
|
const recoveryRevision = ++active.recoveryRevision;
|
|
567
736
|
const runRevision = active.runRevision;
|
|
568
737
|
const joinStartedAt = this.#now();
|
|
@@ -589,11 +758,12 @@ export class TFRobotSocketClient {
|
|
|
589
758
|
const rawAcknowledgement = acknowledgementArguments[0];
|
|
590
759
|
acknowledgement = parseJoinAcknowledgement(rawAcknowledgement === undefined || rawAcknowledgement === true
|
|
591
760
|
? rawAcknowledgement
|
|
592
|
-
: sanitizeCredentialRaw(rawAcknowledgement, credentialValues), reconnect);
|
|
761
|
+
: sanitizeCredentialRaw(rawAcknowledgement, credentialValues), reconnect, this.#profile.kind === "current-server");
|
|
593
762
|
}
|
|
594
763
|
catch {
|
|
595
764
|
acknowledgement = {
|
|
596
765
|
accepted: false,
|
|
766
|
+
empty: false,
|
|
597
767
|
recoveryComplete: false,
|
|
598
768
|
rejectionCode: "validation",
|
|
599
769
|
message: "Invalid TFRobot conversation subscription acknowledgement",
|
|
@@ -661,12 +831,24 @@ export class TFRobotSocketClient {
|
|
|
661
831
|
});
|
|
662
832
|
resolveError("connection");
|
|
663
833
|
resolveError("authentication");
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
:
|
|
669
|
-
|
|
834
|
+
if (this.#profile.kind === "current-server" &&
|
|
835
|
+
acknowledgement.empty) {
|
|
836
|
+
lifecycle("degraded", {
|
|
837
|
+
assurance: "best-effort",
|
|
838
|
+
complete: false,
|
|
839
|
+
reason: "initial-rest-preflight",
|
|
840
|
+
source: "rest-rebase",
|
|
841
|
+
}, this.#now() - joinStartedAt);
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
lifecycle("active", {
|
|
845
|
+
complete: true,
|
|
846
|
+
...(acknowledgement.cursor === undefined
|
|
847
|
+
? {}
|
|
848
|
+
: { cursor: acknowledgement.cursor }),
|
|
849
|
+
}, this.#now() - joinStartedAt);
|
|
850
|
+
}
|
|
851
|
+
currentServerSession = undefined;
|
|
670
852
|
active.acceptingEvents = true;
|
|
671
853
|
flushRealtime();
|
|
672
854
|
return;
|
|
@@ -684,6 +866,48 @@ export class TFRobotSocketClient {
|
|
|
684
866
|
}, joinLatencyMs);
|
|
685
867
|
active.acceptingEvents = true;
|
|
686
868
|
flushRealtime();
|
|
869
|
+
if (this.#profile.kind === "current-server" &&
|
|
870
|
+
!acknowledgement.recoveryComplete) {
|
|
871
|
+
const recoverySession = currentServerSession;
|
|
872
|
+
currentServerSession = undefined;
|
|
873
|
+
if (recoverySession === undefined) {
|
|
874
|
+
report(this.#error("authentication", "TFRobot reconnect did not provide a session for REST rebase", true, errorConversationId()), "recovery");
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
void rebaseCurrentServer(recoveryRevision, runRevision, recoverySession, rebaseCheckpoint, rebaseRealtimeRevision)
|
|
878
|
+
.then((outcome) => {
|
|
879
|
+
if (!active.active ||
|
|
880
|
+
!this.#subscriptions.has(active) ||
|
|
881
|
+
!active.socket.connected ||
|
|
882
|
+
active.recoveryRevision !== recoveryRevision) {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (outcome.kind === "auth-required") {
|
|
886
|
+
enterAuthRequired();
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (outcome.kind !== "success")
|
|
890
|
+
return;
|
|
891
|
+
resolveError("connection");
|
|
892
|
+
resolveError("recovery");
|
|
893
|
+
resolveError("authentication");
|
|
894
|
+
active.manualReconnectAttempted = false;
|
|
895
|
+
active.manualReconnectPending = false;
|
|
896
|
+
active.authRequired = false;
|
|
897
|
+
active.terminal = false;
|
|
898
|
+
lifecycle("degraded", {
|
|
899
|
+
assurance: "best-effort",
|
|
900
|
+
complete: false,
|
|
901
|
+
reason: outcome.reason,
|
|
902
|
+
source: "rest-rebase",
|
|
903
|
+
}, joinLatencyMs);
|
|
904
|
+
})
|
|
905
|
+
.catch(() => {
|
|
906
|
+
report(this.#error("unknown", "TFRobot REST rebase failed unexpectedly", true, errorConversationId()), "recovery");
|
|
907
|
+
});
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
currentServerSession = undefined;
|
|
687
911
|
void this.#reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, runRevision, next, report)
|
|
688
912
|
.then((outcome) => {
|
|
689
913
|
if (outcome === "auth-required") {
|
|
@@ -949,7 +1173,20 @@ export class TFRobotSocketClient {
|
|
|
949
1173
|
return;
|
|
950
1174
|
if (belongsToForeignConversation(payload, conversationId))
|
|
951
1175
|
return;
|
|
952
|
-
|
|
1176
|
+
// An unknown event needs only its name/time to remain visible. Its
|
|
1177
|
+
// optional payload must not turn a safe fallback into a stream error.
|
|
1178
|
+
let safePayload;
|
|
1179
|
+
if (payload !== undefined) {
|
|
1180
|
+
try {
|
|
1181
|
+
safePayload = sanitizePayload(payload);
|
|
1182
|
+
}
|
|
1183
|
+
catch (reason) {
|
|
1184
|
+
diagnose(this.#error("validation", reason instanceof TransportPayloadError
|
|
1185
|
+
? reason.message
|
|
1186
|
+
: "Unknown TFRobot Socket payload could not be retained", false, errorConversationId()));
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
mapAndNext("Unknown TFRobot Socket event could not be normalized", () => mapUnknownSocketEvent(sanitizeCredentialText(eventName, credentialValues), safePayload, errorConversationId(), this.#now()));
|
|
953
1190
|
});
|
|
954
1191
|
};
|
|
955
1192
|
if (setupFailure === undefined) {
|
|
@@ -987,6 +1224,7 @@ export class TFRobotSocketClient {
|
|
|
987
1224
|
// Adapter ownership is cleared even if an injected transport misbehaves.
|
|
988
1225
|
}
|
|
989
1226
|
firstAuth = undefined;
|
|
1227
|
+
currentServerSession = undefined;
|
|
990
1228
|
credentialValues.clear();
|
|
991
1229
|
pendingNotifications.length = 0;
|
|
992
1230
|
pendingRealtimeActions.length = 0;
|
|
@@ -994,6 +1232,7 @@ export class TFRobotSocketClient {
|
|
|
994
1232
|
if (setupFailure !== undefined) {
|
|
995
1233
|
const error = this.#transportError(setupFailure.reason, "Unable to configure the TFRobot Socket transport", conversationId, credentialValues);
|
|
996
1234
|
cleanup();
|
|
1235
|
+
clearPendingEstablishment();
|
|
997
1236
|
return {
|
|
998
1237
|
ok: false,
|
|
999
1238
|
error,
|
|
@@ -1016,6 +1255,8 @@ export class TFRobotSocketClient {
|
|
|
1016
1255
|
manualReconnectPending: false,
|
|
1017
1256
|
observer,
|
|
1018
1257
|
reconnectAttempt: 0,
|
|
1258
|
+
realtimeIdentityRevisions: new Map(),
|
|
1259
|
+
realtimeRevision: 0,
|
|
1019
1260
|
recoveryRevision: 0,
|
|
1020
1261
|
runRevision: 0,
|
|
1021
1262
|
socket,
|
|
@@ -1031,9 +1272,7 @@ export class TFRobotSocketClient {
|
|
|
1031
1272
|
return;
|
|
1032
1273
|
settled = true;
|
|
1033
1274
|
settleEstablishment = undefined;
|
|
1034
|
-
|
|
1035
|
-
this.#establishmentAbort = undefined;
|
|
1036
|
-
}
|
|
1275
|
+
clearPendingEstablishment();
|
|
1037
1276
|
if (this.#establishing === active)
|
|
1038
1277
|
this.#establishing = undefined;
|
|
1039
1278
|
clearTimeout(timeout);
|
|
@@ -1087,16 +1326,46 @@ export class TFRobotSocketClient {
|
|
|
1087
1326
|
subscription.cleanup();
|
|
1088
1327
|
}
|
|
1089
1328
|
this.#subscriptions.clear();
|
|
1329
|
+
this.#recoveryCheckpoints.clear();
|
|
1090
1330
|
this.#runs.clear();
|
|
1091
1331
|
}
|
|
1332
|
+
rememberSnapshot(snapshot) {
|
|
1333
|
+
if (this.#disposed)
|
|
1334
|
+
return;
|
|
1335
|
+
const conversationId = snapshot.conversation.id;
|
|
1336
|
+
this.#runs.set(conversationId, snapshot.run);
|
|
1337
|
+
const checkpoint = this.#recoveryCheckpoints.get(conversationId) ?? new Set();
|
|
1338
|
+
for (const identity of recoveryIdentitiesOfSnapshot(snapshot)) {
|
|
1339
|
+
rememberBoundedSetValue(checkpoint, identity);
|
|
1340
|
+
}
|
|
1341
|
+
this.#recoveryCheckpoints.set(conversationId, checkpoint);
|
|
1342
|
+
}
|
|
1092
1343
|
rememberRun(conversationId, run) {
|
|
1093
1344
|
if (this.#disposed)
|
|
1094
1345
|
return;
|
|
1095
1346
|
this.#runs.set(conversationId, run);
|
|
1096
1347
|
}
|
|
1348
|
+
forgetConversation(conversationId) {
|
|
1349
|
+
if (this.#disposed)
|
|
1350
|
+
return;
|
|
1351
|
+
if (this.#pendingEstablishment?.conversationId === conversationId ||
|
|
1352
|
+
this.#establishing?.conversationId === conversationId) {
|
|
1353
|
+
this.#cancelEstablishment();
|
|
1354
|
+
}
|
|
1355
|
+
for (const subscription of [...this.#subscriptions]) {
|
|
1356
|
+
if (subscription.conversationId !== conversationId)
|
|
1357
|
+
continue;
|
|
1358
|
+
subscription.active = false;
|
|
1359
|
+
subscription.recoveryRevision += 1;
|
|
1360
|
+
subscription.cleanup();
|
|
1361
|
+
this.#subscriptions.delete(subscription);
|
|
1362
|
+
}
|
|
1363
|
+
this.#recoveryCheckpoints.delete(conversationId);
|
|
1364
|
+
this.#runs.delete(conversationId);
|
|
1365
|
+
}
|
|
1097
1366
|
#cancelEstablishment() {
|
|
1098
|
-
this.#
|
|
1099
|
-
this.#
|
|
1367
|
+
this.#pendingEstablishment?.abort.abort();
|
|
1368
|
+
this.#pendingEstablishment = undefined;
|
|
1100
1369
|
this.#establishing?.cancelEstablishment();
|
|
1101
1370
|
this.#establishing = undefined;
|
|
1102
1371
|
}
|
|
@@ -1107,7 +1376,7 @@ export class TFRobotSocketClient {
|
|
|
1107
1376
|
url.hash = "";
|
|
1108
1377
|
return url.toString().replace(/\/$/u, "");
|
|
1109
1378
|
}
|
|
1110
|
-
async #
|
|
1379
|
+
async #getSocketSession(conversationId, purpose) {
|
|
1111
1380
|
const session = await this.#options.sessionProvider.getSession({
|
|
1112
1381
|
purpose,
|
|
1113
1382
|
operation: "subscribe",
|
|
@@ -1116,7 +1385,7 @@ export class TFRobotSocketClient {
|
|
|
1116
1385
|
if (!isValidTFRobotSession(session)) {
|
|
1117
1386
|
throw new TypeError("SessionProvider returned invalid TFRobot credentials");
|
|
1118
1387
|
}
|
|
1119
|
-
return
|
|
1388
|
+
return session;
|
|
1120
1389
|
}
|
|
1121
1390
|
async #reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, runRevision, next, report) {
|
|
1122
1391
|
const result = await this.#reconnectStatusLoader(conversationId);
|
|
@@ -1143,7 +1412,7 @@ export class TFRobotSocketClient {
|
|
|
1143
1412
|
}
|
|
1144
1413
|
try {
|
|
1145
1414
|
const updateConversationId = sanitizeCredentialText(conversationId, credentialValues);
|
|
1146
|
-
const safeStatus = statusDtoSchema.parse(
|
|
1415
|
+
const safeStatus = statusDtoSchema.parse(sanitizeCredentialPayload(result.value, credentialValues));
|
|
1147
1416
|
const run = mapRun(updateConversationId, safeStatus);
|
|
1148
1417
|
const previous = this.#runs.get(conversationId);
|
|
1149
1418
|
this.#runs.set(conversationId, run);
|