@turingfocus/chat-gateway-tfrobot 0.5.0 → 0.7.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/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 +3 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +268 -19
- package/dist/gateway.js.map +1 -1
- package/dist/http.d.ts +5 -2
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +30 -23
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/mapper.d.ts.map +1 -1
- package/dist/mapper.js +4 -2
- package/dist/mapper.js.map +1 -1
- package/dist/socket.d.ts +21 -3
- package/dist/socket.d.ts.map +1 -1
- package/dist/socket.js +924 -152
- package/dist/socket.js.map +1 -1
- package/dist/types.d.ts +49 -2
- 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
6
|
import { sanitizeCredentialError, sanitizeCredentialRaw, sanitizeCredentialText, } from "./redaction.js";
|
|
7
|
-
import { isValidTFRobotSession } from "./types.js";
|
|
7
|
+
import { isValidTFRobotSession, resolveTFRobotServerProfile } from "./types.js";
|
|
8
8
|
const KNOWN_EVENTS = new Set([
|
|
9
9
|
"chat_error",
|
|
10
10
|
"chat_event",
|
|
@@ -17,6 +17,130 @@ const KNOWN_EVENTS = new Set([
|
|
|
17
17
|
]);
|
|
18
18
|
const MAX_TIMER_DELAY = 2_147_483_647;
|
|
19
19
|
const RECONNECT_AUTH_TIMEOUT_MS = 10_000;
|
|
20
|
+
const RECONNECT_JOIN_TIMEOUT_MS = 10_000;
|
|
21
|
+
const parseJoinAcknowledgement = (value, reconnect, acceptEmpty) => {
|
|
22
|
+
if (value === undefined) {
|
|
23
|
+
return {
|
|
24
|
+
accepted: reconnect || acceptEmpty,
|
|
25
|
+
empty: true,
|
|
26
|
+
recoveryComplete: false,
|
|
27
|
+
...(reconnect || acceptEmpty
|
|
28
|
+
? {}
|
|
29
|
+
: { rejectionCode: "validation" }),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (value === null) {
|
|
33
|
+
return {
|
|
34
|
+
accepted: false,
|
|
35
|
+
empty: false,
|
|
36
|
+
recoveryComplete: false,
|
|
37
|
+
rejectionCode: "validation",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (value === false) {
|
|
41
|
+
return {
|
|
42
|
+
accepted: false,
|
|
43
|
+
empty: false,
|
|
44
|
+
recoveryComplete: false,
|
|
45
|
+
rejectionCode: "validation",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (value === true) {
|
|
49
|
+
return { accepted: true, empty: false, recoveryComplete: !reconnect };
|
|
50
|
+
}
|
|
51
|
+
if (typeof value !== "object") {
|
|
52
|
+
return {
|
|
53
|
+
accepted: false,
|
|
54
|
+
empty: false,
|
|
55
|
+
recoveryComplete: false,
|
|
56
|
+
rejectionCode: "validation",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const record = value;
|
|
60
|
+
const booleanAliases = ["ok", "accepted", "success"];
|
|
61
|
+
const hasInvalidBooleanAlias = booleanAliases.some((alias) => Object.hasOwn(record, alias) && typeof record[alias] !== "boolean");
|
|
62
|
+
const statusAliases = ["status", "statusCode", "code"];
|
|
63
|
+
const hasInvalidStatusAlias = statusAliases.some((alias) => Object.hasOwn(record, alias) && record[alias] === undefined);
|
|
64
|
+
const statuses = [record["status"], record["statusCode"], record["code"]]
|
|
65
|
+
.filter((status) => status !== undefined)
|
|
66
|
+
.map((status) => {
|
|
67
|
+
const numeric = typeof status === "number"
|
|
68
|
+
? status
|
|
69
|
+
: typeof status === "string" && /^\d{3}$/u.test(status)
|
|
70
|
+
? Number(status)
|
|
71
|
+
: undefined;
|
|
72
|
+
const normalized = typeof status === "string" ? status.trim().toLowerCase() : undefined;
|
|
73
|
+
const accepted = (numeric !== undefined && numeric >= 200 && numeric < 300) ||
|
|
74
|
+
normalized === "ok" ||
|
|
75
|
+
normalized === "success" ||
|
|
76
|
+
normalized === "accepted" ||
|
|
77
|
+
normalized === "joined";
|
|
78
|
+
const rejected = (numeric !== undefined && numeric >= 400) ||
|
|
79
|
+
normalized === "error" ||
|
|
80
|
+
normalized === "failed" ||
|
|
81
|
+
normalized === "forbidden" ||
|
|
82
|
+
normalized === "unauthorized" ||
|
|
83
|
+
normalized === "rejected";
|
|
84
|
+
return { accepted, normalized, numeric, rejected };
|
|
85
|
+
});
|
|
86
|
+
const hasUnknownStatus = statuses.some((status) => !status.accepted && !status.rejected);
|
|
87
|
+
const explicitlyAccepted = record["ok"] === true ||
|
|
88
|
+
record["accepted"] === true ||
|
|
89
|
+
record["success"] === true ||
|
|
90
|
+
statuses.some((status) => status.accepted);
|
|
91
|
+
const cursorAliases = ["cursor", "recoveryCursor"];
|
|
92
|
+
const cursorValues = cursorAliases
|
|
93
|
+
.filter((alias) => Object.hasOwn(record, alias))
|
|
94
|
+
.map((alias) => record[alias]);
|
|
95
|
+
const cursorInvalid = cursorValues.some((cursorValue) => typeof cursorValue !== "string");
|
|
96
|
+
const cursorConflict = cursorValues.length > 1 &&
|
|
97
|
+
cursorValues.some((cursorValue) => cursorValue !== cursorValues[0]);
|
|
98
|
+
const recoveryAliases = ["recovered", "recoveryComplete"];
|
|
99
|
+
const recoveryDeclarations = recoveryAliases
|
|
100
|
+
.filter((alias) => Object.hasOwn(record, alias))
|
|
101
|
+
.map((alias) => record[alias]);
|
|
102
|
+
const recoveryInvalid = recoveryDeclarations.some((declaration) => typeof declaration !== "boolean");
|
|
103
|
+
const recoveryConflict = recoveryDeclarations.includes(true) && recoveryDeclarations.includes(false);
|
|
104
|
+
const normalizedError = typeof record["error"] === "string"
|
|
105
|
+
? record["error"].trim().toLowerCase()
|
|
106
|
+
: undefined;
|
|
107
|
+
const explicitlyRejected = hasInvalidBooleanAlias ||
|
|
108
|
+
hasInvalidStatusAlias ||
|
|
109
|
+
record["ok"] === false ||
|
|
110
|
+
record["accepted"] === false ||
|
|
111
|
+
record["success"] === false ||
|
|
112
|
+
record["error"] !== undefined ||
|
|
113
|
+
cursorInvalid ||
|
|
114
|
+
cursorConflict ||
|
|
115
|
+
recoveryInvalid ||
|
|
116
|
+
recoveryConflict ||
|
|
117
|
+
(!reconnect && recoveryDeclarations.includes(false)) ||
|
|
118
|
+
hasUnknownStatus ||
|
|
119
|
+
statuses.some((status) => status.rejected);
|
|
120
|
+
const accepted = explicitlyAccepted && !explicitlyRejected;
|
|
121
|
+
const cursor = cursorValues[0];
|
|
122
|
+
const recoveryComplete = !reconnect ||
|
|
123
|
+
(recoveryDeclarations.length > 0 &&
|
|
124
|
+
recoveryDeclarations.every((declaration) => declaration === true));
|
|
125
|
+
const authenticationRejected = statuses.some(({ normalized, numeric }) => numeric === 401 || normalized === "unauthorized") || normalizedError === "unauthorized";
|
|
126
|
+
const authorizationRejected = statuses.some(({ normalized, numeric }) => numeric === 403 || normalized === "forbidden") || normalizedError === "forbidden";
|
|
127
|
+
return {
|
|
128
|
+
accepted,
|
|
129
|
+
empty: false,
|
|
130
|
+
...(authenticationRejected
|
|
131
|
+
? { rejectionCode: "authentication" }
|
|
132
|
+
: authorizationRejected
|
|
133
|
+
? { rejectionCode: "authorization" }
|
|
134
|
+
: accepted
|
|
135
|
+
? {}
|
|
136
|
+
: { rejectionCode: "validation" }),
|
|
137
|
+
recoveryComplete,
|
|
138
|
+
...(typeof cursor === "string" ? { cursor } : {}),
|
|
139
|
+
...(typeof record["message"] === "string"
|
|
140
|
+
? { message: record["message"] }
|
|
141
|
+
: {}),
|
|
142
|
+
};
|
|
143
|
+
};
|
|
20
144
|
const sameRun = (first, second) => first === second ||
|
|
21
145
|
(first != null &&
|
|
22
146
|
second != null &&
|
|
@@ -101,25 +225,65 @@ export const createSocketIoFactoryWith = (connect) => ({ getAuth, namespaceUrl,
|
|
|
101
225
|
},
|
|
102
226
|
});
|
|
103
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)]);
|
|
104
261
|
export class TFRobotSocketClient {
|
|
105
|
-
#active;
|
|
106
262
|
#disposed = false;
|
|
107
|
-
#
|
|
263
|
+
#establishing;
|
|
264
|
+
#pendingEstablishment;
|
|
108
265
|
#factory;
|
|
109
266
|
#namespaceUrl;
|
|
110
267
|
#now;
|
|
111
268
|
#options;
|
|
112
269
|
#path;
|
|
270
|
+
#profile;
|
|
271
|
+
#currentServerRestLoader;
|
|
113
272
|
#reconnectStatusLoader;
|
|
273
|
+
#recoveryCheckpoints = new Map();
|
|
114
274
|
#runs = new Map();
|
|
115
|
-
|
|
275
|
+
#subscriptions = new Set();
|
|
276
|
+
#subscriptionGeneration = 0;
|
|
277
|
+
constructor(options, reconnectStatusLoader, currentServerRestLoader) {
|
|
116
278
|
this.#options = options;
|
|
117
279
|
this.#factory = options.socketFactory ?? createSocketIoFactory;
|
|
118
280
|
this.#path = options.socketPath ?? "/socket.io";
|
|
119
281
|
this.#now = options.now ?? Date.now;
|
|
282
|
+
this.#profile = resolveTFRobotServerProfile(options.serverProfile);
|
|
120
283
|
this.#namespaceUrl =
|
|
121
284
|
options.socketNamespaceUrl ?? this.#defaultNamespaceUrl(options.baseUrl);
|
|
122
285
|
this.#reconnectStatusLoader = reconnectStatusLoader;
|
|
286
|
+
this.#currentServerRestLoader = currentServerRestLoader;
|
|
123
287
|
}
|
|
124
288
|
async subscribe(conversationId, options, observer) {
|
|
125
289
|
if (this.#disposed) {
|
|
@@ -134,19 +298,29 @@ export class TFRobotSocketClient {
|
|
|
134
298
|
error: createGatewayDeadlineExceededError(),
|
|
135
299
|
};
|
|
136
300
|
}
|
|
137
|
-
this.#
|
|
301
|
+
const generation = ++this.#subscriptionGeneration;
|
|
302
|
+
const subscriptionId = `tfrobot-subscription-${generation}`;
|
|
303
|
+
this.#cancelEstablishment();
|
|
138
304
|
const establishmentAbort = new AbortController();
|
|
139
|
-
|
|
140
|
-
|
|
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"), {
|
|
141
317
|
deadlineAt: options.deadlineAt,
|
|
142
318
|
now: this.#now,
|
|
143
319
|
signal: establishmentAbort.signal,
|
|
144
320
|
});
|
|
145
|
-
if (this.#establishmentAbort === establishmentAbort) {
|
|
146
|
-
this.#establishmentAbort = undefined;
|
|
147
|
-
}
|
|
148
321
|
switch (authOutcome.kind) {
|
|
149
322
|
case "aborted": {
|
|
323
|
+
clearPendingEstablishment();
|
|
150
324
|
return {
|
|
151
325
|
ok: false,
|
|
152
326
|
error: this.#error("conflict", this.#disposed
|
|
@@ -155,12 +329,14 @@ export class TFRobotSocketClient {
|
|
|
155
329
|
};
|
|
156
330
|
}
|
|
157
331
|
case "deadline": {
|
|
332
|
+
clearPendingEstablishment();
|
|
158
333
|
return {
|
|
159
334
|
ok: false,
|
|
160
335
|
error: createGatewayDeadlineExceededError(),
|
|
161
336
|
};
|
|
162
337
|
}
|
|
163
338
|
case "error": {
|
|
339
|
+
clearPendingEstablishment();
|
|
164
340
|
return {
|
|
165
341
|
ok: false,
|
|
166
342
|
error: this.#error("authentication", "Unable to obtain a TFRobot Socket session", true, undefined),
|
|
@@ -171,14 +347,43 @@ export class TFRobotSocketClient {
|
|
|
171
347
|
}
|
|
172
348
|
}
|
|
173
349
|
if (this.#disposed || establishmentAbort.signal.aborted) {
|
|
350
|
+
clearPendingEstablishment();
|
|
174
351
|
return {
|
|
175
352
|
ok: false,
|
|
176
353
|
error: this.#error("conflict", "TFRobot Gateway was disposed during subscription", false, undefined),
|
|
177
354
|
};
|
|
178
355
|
}
|
|
179
|
-
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));
|
|
180
384
|
const errorConversationId = () => sanitizeCredentialText(conversationId, credentialValues);
|
|
181
|
-
let firstAuth =
|
|
385
|
+
let firstAuth = initialAuth;
|
|
386
|
+
let currentServerSession = initialSession;
|
|
182
387
|
let authenticationFailure;
|
|
183
388
|
const connectionAbort = new AbortController();
|
|
184
389
|
let socket;
|
|
@@ -195,20 +400,22 @@ export class TFRobotSocketClient {
|
|
|
195
400
|
firstAuth = undefined;
|
|
196
401
|
return auth;
|
|
197
402
|
}
|
|
198
|
-
const
|
|
403
|
+
const reconnectSession = await awaitBounded(() => this.#getSocketSession(conversationId, "reconnect"), {
|
|
199
404
|
deadlineAt: this.#now() + RECONNECT_AUTH_TIMEOUT_MS,
|
|
200
405
|
now: this.#now,
|
|
201
406
|
signal: connectionAbort.signal,
|
|
202
407
|
});
|
|
203
|
-
if (
|
|
204
|
-
|
|
408
|
+
if (reconnectSession.kind === "value") {
|
|
409
|
+
currentServerSession = reconnectSession.value;
|
|
410
|
+
const reconnectAuth = authOf(reconnectSession.value);
|
|
411
|
+
for (const value of Object.values(reconnectAuth)) {
|
|
205
412
|
credentialValues.add(value);
|
|
206
413
|
}
|
|
207
|
-
return reconnectAuth
|
|
414
|
+
return reconnectAuth;
|
|
208
415
|
}
|
|
209
|
-
const reason =
|
|
210
|
-
?
|
|
211
|
-
: new Error(
|
|
416
|
+
const reason = reconnectSession.kind === "error"
|
|
417
|
+
? reconnectSession.reason
|
|
418
|
+
: new Error(reconnectSession.kind === "deadline"
|
|
212
419
|
? "TFRobot Socket session refresh exceeded its deadline"
|
|
213
420
|
: "TFRobot Socket session refresh was cancelled");
|
|
214
421
|
authenticationFailure = reason;
|
|
@@ -218,12 +425,27 @@ export class TFRobotSocketClient {
|
|
|
218
425
|
}
|
|
219
426
|
catch (reason) {
|
|
220
427
|
connectionAbort.abort();
|
|
428
|
+
clearPendingEstablishment();
|
|
221
429
|
return {
|
|
222
430
|
ok: false,
|
|
223
431
|
error: this.#transportError(reason, "Unable to create the TFRobot Socket transport", conversationId, credentialValues),
|
|
224
432
|
};
|
|
225
433
|
}
|
|
226
434
|
const listeners = new Map();
|
|
435
|
+
let joinTimeout;
|
|
436
|
+
const activeErrorIds = new Map();
|
|
437
|
+
const replaceableErrorSources = new Set([
|
|
438
|
+
"authentication",
|
|
439
|
+
"connection",
|
|
440
|
+
"recovery",
|
|
441
|
+
"subscription",
|
|
442
|
+
]);
|
|
443
|
+
let errorSequence = 0;
|
|
444
|
+
let publishUpdate = () => undefined;
|
|
445
|
+
const pendingNotifications = [];
|
|
446
|
+
const pendingRealtimeActions = [];
|
|
447
|
+
let joinPending = false;
|
|
448
|
+
let subscriptionEstablished = false;
|
|
227
449
|
let setupFailure;
|
|
228
450
|
const add = (eventName, listener) => {
|
|
229
451
|
if (setupFailure !== undefined)
|
|
@@ -244,15 +466,52 @@ export class TFRobotSocketClient {
|
|
|
244
466
|
// Diagnostics are observational and cannot break the chat stream.
|
|
245
467
|
}
|
|
246
468
|
};
|
|
247
|
-
const
|
|
248
|
-
if (!active.active)
|
|
249
|
-
return;
|
|
469
|
+
const notifyError = (error) => {
|
|
250
470
|
try {
|
|
251
471
|
observer.error?.(error);
|
|
252
472
|
}
|
|
253
473
|
catch {
|
|
254
474
|
// Host observers are isolated from the transport listener.
|
|
255
475
|
}
|
|
476
|
+
};
|
|
477
|
+
const resolveError = (source) => {
|
|
478
|
+
const errorId = activeErrorIds.get(source);
|
|
479
|
+
if (errorId === undefined || !active.active)
|
|
480
|
+
return;
|
|
481
|
+
activeErrorIds.delete(source);
|
|
482
|
+
publishUpdate(chatUpdateSchema.parse({
|
|
483
|
+
kind: "error.resolved",
|
|
484
|
+
conversationId: errorConversationId(),
|
|
485
|
+
errorId,
|
|
486
|
+
}));
|
|
487
|
+
};
|
|
488
|
+
const report = (error, source = "protocol") => {
|
|
489
|
+
if (!active.active)
|
|
490
|
+
return;
|
|
491
|
+
if (replaceableErrorSources.has(source))
|
|
492
|
+
resolveError(source);
|
|
493
|
+
const errorId = `${subscriptionId}:error:${++errorSequence}`;
|
|
494
|
+
if (replaceableErrorSources.has(source)) {
|
|
495
|
+
activeErrorIds.set(source, errorId);
|
|
496
|
+
}
|
|
497
|
+
const occurrenceError = error.conversationId === undefined
|
|
498
|
+
? error
|
|
499
|
+
: { ...error, conversationId: errorConversationId() };
|
|
500
|
+
publishUpdate(chatUpdateSchema.parse({
|
|
501
|
+
kind: "error.reported",
|
|
502
|
+
conversationId: errorConversationId(),
|
|
503
|
+
error: occurrenceError,
|
|
504
|
+
errorId,
|
|
505
|
+
source,
|
|
506
|
+
scope: source === "domain"
|
|
507
|
+
? { kind: "conversation", id: errorConversationId() }
|
|
508
|
+
: { kind: "subscription", id: subscriptionId },
|
|
509
|
+
generation,
|
|
510
|
+
}));
|
|
511
|
+
if (subscriptionEstablished)
|
|
512
|
+
notifyError(error);
|
|
513
|
+
else
|
|
514
|
+
pendingNotifications.push({ kind: "error", error });
|
|
256
515
|
diagnose(error);
|
|
257
516
|
};
|
|
258
517
|
const sanitizePayload = (payload) => {
|
|
@@ -276,6 +535,10 @@ export class TFRobotSocketClient {
|
|
|
276
535
|
updateConversationId !== errorConversationId()) {
|
|
277
536
|
return;
|
|
278
537
|
}
|
|
538
|
+
if (!subscriptionEstablished) {
|
|
539
|
+
pendingNotifications.push({ kind: "update", update });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
279
542
|
try {
|
|
280
543
|
observer.next(update);
|
|
281
544
|
}
|
|
@@ -283,6 +546,80 @@ export class TFRobotSocketClient {
|
|
|
283
546
|
diagnose(this.#error("unknown", "TFRobot Gateway observer rejected an update", false, errorConversationId()));
|
|
284
547
|
}
|
|
285
548
|
};
|
|
549
|
+
publishUpdate = next;
|
|
550
|
+
const dispatchRealtime = (action) => {
|
|
551
|
+
if (active.acceptingEvents)
|
|
552
|
+
action();
|
|
553
|
+
else if (joinPending)
|
|
554
|
+
pendingRealtimeActions.push(action);
|
|
555
|
+
};
|
|
556
|
+
const flushRealtime = () => {
|
|
557
|
+
for (const action of pendingRealtimeActions.splice(0))
|
|
558
|
+
action();
|
|
559
|
+
};
|
|
560
|
+
const lifecycle = (status, recovery, joinLatencyMs) => {
|
|
561
|
+
if (!active.active)
|
|
562
|
+
return;
|
|
563
|
+
const value = {
|
|
564
|
+
status,
|
|
565
|
+
generation,
|
|
566
|
+
reconnectAttempt: active.reconnectAttempt,
|
|
567
|
+
subscriptionId,
|
|
568
|
+
...(recovery === undefined ? {} : { recovery }),
|
|
569
|
+
};
|
|
570
|
+
const update = chatUpdateSchema.parse({
|
|
571
|
+
kind: "lifecycle.changed",
|
|
572
|
+
conversationId: errorConversationId(),
|
|
573
|
+
lifecycle: value,
|
|
574
|
+
});
|
|
575
|
+
next(update);
|
|
576
|
+
try {
|
|
577
|
+
void Promise.resolve(this.#options.onLifecycleDiagnostic?.({
|
|
578
|
+
kind: "socket.lifecycle",
|
|
579
|
+
conversationId: errorConversationId(),
|
|
580
|
+
subscriptionId,
|
|
581
|
+
generation,
|
|
582
|
+
status,
|
|
583
|
+
reconnectAttempt: active.reconnectAttempt,
|
|
584
|
+
...(joinLatencyMs === undefined ? {} : { joinLatencyMs }),
|
|
585
|
+
...(recovery === undefined
|
|
586
|
+
? {}
|
|
587
|
+
: {
|
|
588
|
+
recoveryComplete: recovery.complete,
|
|
589
|
+
...(recovery.assurance === undefined
|
|
590
|
+
? {}
|
|
591
|
+
: { recoveryAssurance: recovery.assurance }),
|
|
592
|
+
...(recovery.cursor === undefined
|
|
593
|
+
? {}
|
|
594
|
+
: { recoveryCursor: recovery.cursor }),
|
|
595
|
+
...(recovery.reason === undefined
|
|
596
|
+
? {}
|
|
597
|
+
: { recoveryReason: recovery.reason }),
|
|
598
|
+
...(recovery.source === undefined
|
|
599
|
+
? {}
|
|
600
|
+
: { recoverySource: recovery.source }),
|
|
601
|
+
}),
|
|
602
|
+
})).catch(() => undefined);
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
// Lifecycle diagnostics are observational.
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
const enterAuthRequired = () => {
|
|
609
|
+
active.acceptingEvents = false;
|
|
610
|
+
active.authRequired = true;
|
|
611
|
+
active.terminal = true;
|
|
612
|
+
joinPending = false;
|
|
613
|
+
pendingRealtimeActions.length = 0;
|
|
614
|
+
active.recoveryRevision += 1;
|
|
615
|
+
lifecycle("auth-required");
|
|
616
|
+
try {
|
|
617
|
+
active.socket.disconnect();
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
// Authentication is already terminal for this subscription episode.
|
|
621
|
+
}
|
|
622
|
+
};
|
|
286
623
|
const mapAndNext = (invalidMessage, map) => {
|
|
287
624
|
let update;
|
|
288
625
|
try {
|
|
@@ -292,13 +629,87 @@ export class TFRobotSocketClient {
|
|
|
292
629
|
report(this.#error("validation", invalidMessage, false, errorConversationId()));
|
|
293
630
|
return;
|
|
294
631
|
}
|
|
632
|
+
const recoveryIdentity = recoveryIdentityOfUpdate(update);
|
|
633
|
+
if (recoveryIdentity !== undefined) {
|
|
634
|
+
active.realtimeRevision += 1;
|
|
635
|
+
rememberBoundedMapValue(active.realtimeIdentityRevisions, recoveryIdentity, active.realtimeRevision);
|
|
636
|
+
const checkpoint = this.#recoveryCheckpoints.get(conversationId) ?? new Set();
|
|
637
|
+
rememberBoundedSetValue(checkpoint, recoveryIdentity);
|
|
638
|
+
this.#recoveryCheckpoints.set(conversationId, checkpoint);
|
|
639
|
+
}
|
|
295
640
|
next(update);
|
|
296
641
|
};
|
|
642
|
+
const rebaseCurrentServer = async (recoveryRevision, runRevision, session, checkpoint, realtimeRevision) => {
|
|
643
|
+
if (this.#profile.kind !== "current-server") {
|
|
644
|
+
return { kind: "failure" };
|
|
645
|
+
}
|
|
646
|
+
const result = await this.#currentServerRestLoader({
|
|
647
|
+
checkpoint,
|
|
648
|
+
conversationId,
|
|
649
|
+
deadlineAt: this.#now() + this.#profile.rebase.deadlineMs,
|
|
650
|
+
limits: this.#profile.rebase,
|
|
651
|
+
session,
|
|
652
|
+
signal: connectionAbort.signal,
|
|
653
|
+
});
|
|
654
|
+
if (!active.active ||
|
|
655
|
+
!this.#subscriptions.has(active) ||
|
|
656
|
+
!active.socket.connected ||
|
|
657
|
+
active.recoveryRevision !== recoveryRevision) {
|
|
658
|
+
return { kind: "failure" };
|
|
659
|
+
}
|
|
660
|
+
if (!result.ok) {
|
|
661
|
+
const error = sanitizeCredentialError(result.error, credentialValues);
|
|
662
|
+
if (error.code === "authentication" || error.code === "authorization") {
|
|
663
|
+
report(error, "authentication");
|
|
664
|
+
return { kind: "auth-required" };
|
|
665
|
+
}
|
|
666
|
+
report(error, "recovery");
|
|
667
|
+
return { kind: "failure" };
|
|
668
|
+
}
|
|
669
|
+
const remembered = this.#recoveryCheckpoints.get(conversationId) ?? new Set();
|
|
670
|
+
for (const update of result.value.updates) {
|
|
671
|
+
const identity = recoveryIdentityOfUpdate(update);
|
|
672
|
+
if (identity !== undefined &&
|
|
673
|
+
(active.realtimeIdentityRevisions.get(identity) ?? 0) >
|
|
674
|
+
realtimeRevision) {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
next(update);
|
|
678
|
+
if (identity !== undefined)
|
|
679
|
+
rememberBoundedSetValue(remembered, identity);
|
|
680
|
+
}
|
|
681
|
+
this.#recoveryCheckpoints.set(conversationId, remembered);
|
|
682
|
+
if (active.runRevision === runRevision) {
|
|
683
|
+
const previous = this.#runs.get(conversationId);
|
|
684
|
+
this.#runs.set(conversationId, result.value.run);
|
|
685
|
+
if (!sameRun(previous, result.value.run)) {
|
|
686
|
+
next(chatUpdateSchema.parse({
|
|
687
|
+
kind: "run.replace",
|
|
688
|
+
conversationId: errorConversationId(),
|
|
689
|
+
run: result.value.run,
|
|
690
|
+
}));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const reason = result.value.checkpointReached
|
|
694
|
+
? "rest-rebase-reached-checkpoint"
|
|
695
|
+
: result.value.boundedBy === undefined
|
|
696
|
+
? "rest-rebase-history-exhausted"
|
|
697
|
+
: `rest-rebase-${result.value.boundedBy}`;
|
|
698
|
+
return { kind: "success", reason };
|
|
699
|
+
};
|
|
297
700
|
let settleEstablishment;
|
|
298
|
-
let connectedOnce = false;
|
|
299
701
|
add("connect", () => {
|
|
300
702
|
if (!active.active)
|
|
301
703
|
return;
|
|
704
|
+
if (active.terminal) {
|
|
705
|
+
try {
|
|
706
|
+
active.socket.disconnect();
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
// Terminal subscriptions never rejoin.
|
|
710
|
+
}
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
302
713
|
if (settleEstablishment !== undefined &&
|
|
303
714
|
isGatewayDeadlineExceeded(options, this.#now())) {
|
|
304
715
|
settleEstablishment({
|
|
@@ -307,13 +718,263 @@ export class TFRobotSocketClient {
|
|
|
307
718
|
});
|
|
308
719
|
return;
|
|
309
720
|
}
|
|
310
|
-
|
|
311
|
-
|
|
721
|
+
if (joinTimeout !== undefined) {
|
|
722
|
+
clearTimeout(joinTimeout);
|
|
723
|
+
joinTimeout = undefined;
|
|
724
|
+
}
|
|
725
|
+
const reconnect = subscriptionEstablished;
|
|
726
|
+
if (reconnect)
|
|
727
|
+
active.reconnectAttempt += 1;
|
|
728
|
+
const rebaseCheckpoint = new Set(this.#recoveryCheckpoints.get(conversationId) ?? []);
|
|
729
|
+
const rebaseRealtimeRevision = active.realtimeRevision;
|
|
312
730
|
const recoveryRevision = ++active.recoveryRevision;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
731
|
+
const runRevision = active.runRevision;
|
|
732
|
+
const joinStartedAt = this.#now();
|
|
733
|
+
active.acceptingEvents = false;
|
|
734
|
+
joinPending = true;
|
|
735
|
+
pendingRealtimeActions.length = 0;
|
|
736
|
+
lifecycle("joining");
|
|
737
|
+
let acknowledgementSettled = false;
|
|
738
|
+
const settleJoin = (...acknowledgementArguments) => {
|
|
739
|
+
if (!active.active ||
|
|
740
|
+
!this.#subscriptions.has(active) ||
|
|
741
|
+
active.recoveryRevision !== recoveryRevision) {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (acknowledgementSettled)
|
|
745
|
+
return;
|
|
746
|
+
acknowledgementSettled = true;
|
|
747
|
+
if (joinTimeout !== undefined) {
|
|
748
|
+
clearTimeout(joinTimeout);
|
|
749
|
+
joinTimeout = undefined;
|
|
750
|
+
}
|
|
751
|
+
let acknowledgement;
|
|
752
|
+
try {
|
|
753
|
+
const rawAcknowledgement = acknowledgementArguments[0];
|
|
754
|
+
acknowledgement = parseJoinAcknowledgement(rawAcknowledgement === undefined || rawAcknowledgement === true
|
|
755
|
+
? rawAcknowledgement
|
|
756
|
+
: sanitizeCredentialRaw(rawAcknowledgement, credentialValues), reconnect, this.#profile.kind === "current-server");
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
acknowledgement = {
|
|
760
|
+
accepted: false,
|
|
761
|
+
empty: false,
|
|
762
|
+
recoveryComplete: false,
|
|
763
|
+
rejectionCode: "validation",
|
|
764
|
+
message: "Invalid TFRobot conversation subscription acknowledgement",
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
if (!acknowledgement.accepted) {
|
|
768
|
+
joinPending = false;
|
|
769
|
+
pendingRealtimeActions.length = 0;
|
|
770
|
+
const rejectionCode = acknowledgement.rejectionCode ?? "validation";
|
|
771
|
+
const error = this.#error(rejectionCode, acknowledgement.message ??
|
|
772
|
+
"TFRobot conversation subscription was rejected", false, errorConversationId());
|
|
773
|
+
if (settleEstablishment !== undefined) {
|
|
774
|
+
lifecycle("subscription-failed");
|
|
775
|
+
if (rejectionCode === "authentication" ||
|
|
776
|
+
rejectionCode === "authorization") {
|
|
777
|
+
void this.#invalidateSession(error, "rejected");
|
|
778
|
+
}
|
|
779
|
+
settleEstablishment({ ok: false, error });
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
const authenticationRejected = rejectionCode === "authentication" ||
|
|
783
|
+
rejectionCode === "authorization";
|
|
784
|
+
if (authenticationRejected) {
|
|
785
|
+
enterAuthRequired();
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
active.acceptingEvents = false;
|
|
789
|
+
active.terminal = true;
|
|
790
|
+
active.recoveryRevision += 1;
|
|
791
|
+
try {
|
|
792
|
+
active.socket.disconnect();
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
// Subscription failure is already terminal for this episode.
|
|
796
|
+
}
|
|
797
|
+
lifecycle("subscription-failed");
|
|
798
|
+
}
|
|
799
|
+
report(error, authenticationRejected ? "authentication" : "subscription");
|
|
800
|
+
if (authenticationRejected) {
|
|
801
|
+
void this.#invalidateSession(error, "rejected");
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (!reconnect) {
|
|
807
|
+
joinPending = false;
|
|
808
|
+
subscriptionEstablished = true;
|
|
809
|
+
for (const notification of pendingNotifications.splice(0)) {
|
|
810
|
+
if (notification.kind === "update")
|
|
811
|
+
next(notification.update);
|
|
812
|
+
else
|
|
813
|
+
notifyError(notification.error);
|
|
814
|
+
}
|
|
815
|
+
settleEstablishment?.({
|
|
816
|
+
ok: true,
|
|
817
|
+
value: {
|
|
818
|
+
dispose: () => {
|
|
819
|
+
if (!active.active)
|
|
820
|
+
return;
|
|
821
|
+
active.active = false;
|
|
822
|
+
active.cleanup();
|
|
823
|
+
this.#subscriptions.delete(active);
|
|
824
|
+
},
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
resolveError("connection");
|
|
828
|
+
resolveError("authentication");
|
|
829
|
+
if (this.#profile.kind === "current-server" &&
|
|
830
|
+
acknowledgement.empty) {
|
|
831
|
+
lifecycle("degraded", {
|
|
832
|
+
assurance: "best-effort",
|
|
833
|
+
complete: false,
|
|
834
|
+
reason: "initial-rest-preflight",
|
|
835
|
+
source: "rest-rebase",
|
|
836
|
+
}, this.#now() - joinStartedAt);
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
lifecycle("active", {
|
|
840
|
+
complete: true,
|
|
841
|
+
...(acknowledgement.cursor === undefined
|
|
842
|
+
? {}
|
|
843
|
+
: { cursor: acknowledgement.cursor }),
|
|
844
|
+
}, this.#now() - joinStartedAt);
|
|
845
|
+
}
|
|
846
|
+
currentServerSession = undefined;
|
|
847
|
+
active.acceptingEvents = true;
|
|
848
|
+
flushRealtime();
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
joinPending = false;
|
|
852
|
+
const joinLatencyMs = this.#now() - joinStartedAt;
|
|
853
|
+
lifecycle("recovering", {
|
|
854
|
+
complete: false,
|
|
855
|
+
...(acknowledgement.cursor === undefined
|
|
856
|
+
? {}
|
|
857
|
+
: { cursor: acknowledgement.cursor }),
|
|
858
|
+
...(acknowledgement.recoveryComplete
|
|
859
|
+
? {}
|
|
860
|
+
: { reason: "server-replay-contract-unavailable" }),
|
|
861
|
+
}, joinLatencyMs);
|
|
862
|
+
active.acceptingEvents = true;
|
|
863
|
+
flushRealtime();
|
|
864
|
+
if (this.#profile.kind === "current-server" &&
|
|
865
|
+
!acknowledgement.recoveryComplete) {
|
|
866
|
+
const recoverySession = currentServerSession;
|
|
867
|
+
currentServerSession = undefined;
|
|
868
|
+
if (recoverySession === undefined) {
|
|
869
|
+
report(this.#error("authentication", "TFRobot reconnect did not provide a session for REST rebase", true, errorConversationId()), "recovery");
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
void rebaseCurrentServer(recoveryRevision, runRevision, recoverySession, rebaseCheckpoint, rebaseRealtimeRevision)
|
|
873
|
+
.then((outcome) => {
|
|
874
|
+
if (!active.active ||
|
|
875
|
+
!this.#subscriptions.has(active) ||
|
|
876
|
+
!active.socket.connected ||
|
|
877
|
+
active.recoveryRevision !== recoveryRevision) {
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (outcome.kind === "auth-required") {
|
|
881
|
+
enterAuthRequired();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (outcome.kind !== "success")
|
|
885
|
+
return;
|
|
886
|
+
resolveError("connection");
|
|
887
|
+
resolveError("recovery");
|
|
888
|
+
resolveError("authentication");
|
|
889
|
+
active.manualReconnectAttempted = false;
|
|
890
|
+
active.manualReconnectPending = false;
|
|
891
|
+
active.authRequired = false;
|
|
892
|
+
active.terminal = false;
|
|
893
|
+
lifecycle("degraded", {
|
|
894
|
+
assurance: "best-effort",
|
|
895
|
+
complete: false,
|
|
896
|
+
reason: outcome.reason,
|
|
897
|
+
source: "rest-rebase",
|
|
898
|
+
}, joinLatencyMs);
|
|
899
|
+
})
|
|
900
|
+
.catch(() => {
|
|
901
|
+
report(this.#error("unknown", "TFRobot REST rebase failed unexpectedly", true, errorConversationId()), "recovery");
|
|
902
|
+
});
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
currentServerSession = undefined;
|
|
906
|
+
void this.#reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, runRevision, next, report)
|
|
907
|
+
.then((outcome) => {
|
|
908
|
+
if (outcome === "auth-required") {
|
|
909
|
+
enterAuthRequired();
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if ((outcome !== "success" && outcome !== "superseded-by-realtime") ||
|
|
913
|
+
!acknowledgement.recoveryComplete) {
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
resolveError("connection");
|
|
917
|
+
resolveError("recovery");
|
|
918
|
+
resolveError("authentication");
|
|
919
|
+
active.manualReconnectAttempted = false;
|
|
920
|
+
active.manualReconnectPending = false;
|
|
921
|
+
active.authRequired = false;
|
|
922
|
+
active.terminal = false;
|
|
923
|
+
lifecycle("active", {
|
|
924
|
+
complete: true,
|
|
925
|
+
...(acknowledgement.cursor === undefined
|
|
926
|
+
? {}
|
|
927
|
+
: { cursor: acknowledgement.cursor }),
|
|
928
|
+
}, joinLatencyMs);
|
|
929
|
+
})
|
|
930
|
+
.catch(() => {
|
|
931
|
+
active.acceptingEvents = false;
|
|
932
|
+
active.terminal = true;
|
|
933
|
+
try {
|
|
934
|
+
active.socket.disconnect();
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
// Offline is already terminal for this subscription episode.
|
|
938
|
+
}
|
|
939
|
+
lifecycle("offline");
|
|
940
|
+
report(this.#error("unknown", "TFRobot reconnect reconciliation failed", true, errorConversationId()), "recovery");
|
|
316
941
|
});
|
|
942
|
+
};
|
|
943
|
+
const joinDeadlineAt = reconnect
|
|
944
|
+
? this.#now() + RECONNECT_JOIN_TIMEOUT_MS
|
|
945
|
+
: options.deadlineAt;
|
|
946
|
+
joinTimeout = setTimeout(() => {
|
|
947
|
+
joinTimeout = undefined;
|
|
948
|
+
if (!active.active || active.recoveryRevision !== recoveryRevision) {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
active.recoveryRevision += 1;
|
|
952
|
+
joinPending = false;
|
|
953
|
+
pendingRealtimeActions.length = 0;
|
|
954
|
+
const error = createGatewayDeadlineExceededError(errorConversationId());
|
|
955
|
+
if (settleEstablishment !== undefined) {
|
|
956
|
+
lifecycle("subscription-failed");
|
|
957
|
+
settleEstablishment({ ok: false, error });
|
|
958
|
+
}
|
|
959
|
+
else {
|
|
960
|
+
if (active.manualReconnectPending)
|
|
961
|
+
enterAuthRequired();
|
|
962
|
+
else {
|
|
963
|
+
active.acceptingEvents = false;
|
|
964
|
+
active.terminal = true;
|
|
965
|
+
lifecycle("subscription-failed");
|
|
966
|
+
}
|
|
967
|
+
report(error, active.manualReconnectPending ? "connection" : "subscription");
|
|
968
|
+
try {
|
|
969
|
+
active.socket.disconnect();
|
|
970
|
+
}
|
|
971
|
+
catch {
|
|
972
|
+
// The failed join attempt is already invalidated.
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}, Math.min(MAX_TIMER_DELAY, Math.max(0, joinDeadlineAt - this.#now())));
|
|
976
|
+
try {
|
|
977
|
+
socket.emit("join_conversation", { conversation_id: conversationId }, settleJoin);
|
|
317
978
|
}
|
|
318
979
|
catch (reason) {
|
|
319
980
|
const error = this.#transportError(reason, "Unable to join the TFRobot conversation", conversationId, credentialValues);
|
|
@@ -321,112 +982,97 @@ export class TFRobotSocketClient {
|
|
|
321
982
|
settleEstablishment({ ok: false, error });
|
|
322
983
|
}
|
|
323
984
|
else {
|
|
324
|
-
report(error);
|
|
985
|
+
report(error, "subscription");
|
|
325
986
|
active.active = false;
|
|
326
987
|
active.cleanup();
|
|
327
|
-
|
|
328
|
-
this.#active = undefined;
|
|
988
|
+
this.#subscriptions.delete(active);
|
|
329
989
|
}
|
|
330
990
|
return;
|
|
331
991
|
}
|
|
332
|
-
if (settleEstablishment !== undefined &&
|
|
333
|
-
isGatewayDeadlineExceeded(options, this.#now())) {
|
|
334
|
-
settleEstablishment({
|
|
335
|
-
ok: false,
|
|
336
|
-
error: createGatewayDeadlineExceededError(errorConversationId()),
|
|
337
|
-
});
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
settleEstablishment?.({
|
|
341
|
-
ok: true,
|
|
342
|
-
value: {
|
|
343
|
-
dispose: () => {
|
|
344
|
-
if (!active.active)
|
|
345
|
-
return;
|
|
346
|
-
active.active = false;
|
|
347
|
-
active.cleanup();
|
|
348
|
-
if (this.#active === active)
|
|
349
|
-
this.#active = undefined;
|
|
350
|
-
},
|
|
351
|
-
},
|
|
352
|
-
});
|
|
353
|
-
if (reconnect) {
|
|
354
|
-
void this.#reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, next, report).catch(() => {
|
|
355
|
-
report(this.#error("unknown", "TFRobot reconnect reconciliation failed", true, errorConversationId()));
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
992
|
});
|
|
359
993
|
add("chat_message", (payload) => {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
994
|
+
dispatchRealtime(() => {
|
|
995
|
+
if (belongsToForeignConversation(payload, conversationId))
|
|
996
|
+
return;
|
|
997
|
+
const parsed = messageDtoSchema.safeParse(sanitizePayload(payload));
|
|
998
|
+
if (!parsed.success) {
|
|
999
|
+
report(this.#error("validation", "Invalid TFRobot chat_message payload", false, errorConversationId()));
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
mapAndNext("TFRobot chat_message could not be normalized", () => mapMessageUpdate(parsed.data));
|
|
1003
|
+
});
|
|
368
1004
|
});
|
|
369
1005
|
add("chat_event", (payload) => {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
1006
|
+
dispatchRealtime(() => {
|
|
1007
|
+
if (belongsToForeignConversation(payload, conversationId))
|
|
1008
|
+
return;
|
|
1009
|
+
const parsed = eventDtoSchema.safeParse(sanitizePayload(payload));
|
|
1010
|
+
if (!parsed.success) {
|
|
1011
|
+
report(this.#error("validation", "Invalid TFRobot chat_event payload", false, errorConversationId()));
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
mapAndNext("TFRobot chat_event could not be normalized", () => mapEventUpdate(parsed.data));
|
|
1015
|
+
});
|
|
378
1016
|
});
|
|
379
1017
|
add("conversation_state_changed", (payload) => {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
targetConversationId !==
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
1018
|
+
dispatchRealtime(() => {
|
|
1019
|
+
if (belongsToForeignConversation(payload, conversationId))
|
|
1020
|
+
return;
|
|
1021
|
+
const parsed = stateChangedDtoSchema.safeParse(sanitizePayload(payload));
|
|
1022
|
+
if (!parsed.success) {
|
|
1023
|
+
report(this.#error("validation", "Invalid TFRobot conversation_state_changed payload", false, errorConversationId()));
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const targetConversationId = String(parsed.data.conversationId);
|
|
1027
|
+
if (targetConversationId !== conversationId &&
|
|
1028
|
+
targetConversationId !== errorConversationId()) {
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
active.runRevision += 1;
|
|
1032
|
+
mapAndNext("TFRobot run state could not be normalized", () => {
|
|
1033
|
+
const run = mapRun(targetConversationId, {
|
|
1034
|
+
working: parsed.data.state === "working",
|
|
1035
|
+
taskId: parsed.data.taskId,
|
|
1036
|
+
});
|
|
1037
|
+
this.#runs.set(targetConversationId, run);
|
|
1038
|
+
return chatUpdateSchema.parse({
|
|
1039
|
+
kind: "run.replace",
|
|
1040
|
+
conversationId: targetConversationId,
|
|
1041
|
+
run,
|
|
1042
|
+
});
|
|
403
1043
|
});
|
|
404
1044
|
});
|
|
405
1045
|
});
|
|
406
1046
|
add("chat_error", (payload) => {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
targetConversationId !==
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
1047
|
+
dispatchRealtime(() => {
|
|
1048
|
+
if (belongsToForeignConversation(payload, conversationId))
|
|
1049
|
+
return;
|
|
1050
|
+
const parsed = chatErrorEventDtoSchema.safeParse(sanitizePayload(payload));
|
|
1051
|
+
if (!parsed.success) {
|
|
1052
|
+
report(this.#error("validation", "Invalid TFRobot chat_error payload", false, errorConversationId()));
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
const targetConversationId = String(parsed.data.conversationId);
|
|
1056
|
+
if (targetConversationId !== conversationId &&
|
|
1057
|
+
targetConversationId !== errorConversationId()) {
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
report(this.#error("server", typeof parsed.data.error === "string"
|
|
1061
|
+
? parsed.data.error
|
|
1062
|
+
: "TFRobot run failed", false, targetConversationId, parsed.data, credentialValues), "domain");
|
|
1063
|
+
});
|
|
422
1064
|
});
|
|
423
1065
|
add("error", (payload) => {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
1066
|
+
dispatchRealtime(() => {
|
|
1067
|
+
const parsed = socketProtocolErrorDtoSchema.safeParse(sanitizePayload(payload));
|
|
1068
|
+
report(this.#error("validation", parsed.success && typeof parsed.data.message === "string"
|
|
1069
|
+
? parsed.data.message
|
|
1070
|
+
: "TFRobot Socket protocol error", false, conversationId, undefined, credentialValues));
|
|
1071
|
+
});
|
|
428
1072
|
});
|
|
429
1073
|
add("connect_error", (reason) => {
|
|
1074
|
+
if (!active.active || active.terminal)
|
|
1075
|
+
return;
|
|
430
1076
|
const injectedError = chatErrorSchema.safeParse(reason);
|
|
431
1077
|
const rejectionCode = socketAuthRejectionCode(reason);
|
|
432
1078
|
const error = authenticationFailure !== undefined
|
|
@@ -448,23 +1094,82 @@ export class TFRobotSocketClient {
|
|
|
448
1094
|
settleEstablishment({ ok: false, error });
|
|
449
1095
|
return;
|
|
450
1096
|
}
|
|
451
|
-
|
|
1097
|
+
if (active.manualReconnectPending) {
|
|
1098
|
+
enterAuthRequired();
|
|
1099
|
+
report(error, error.code === "authentication" || error.code === "authorization"
|
|
1100
|
+
? "authentication"
|
|
1101
|
+
: "connection");
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (error.code === "authentication" || error.code === "authorization") {
|
|
1105
|
+
enterAuthRequired();
|
|
1106
|
+
}
|
|
1107
|
+
report(error, error.code === "authentication" || error.code === "authorization"
|
|
1108
|
+
? "authentication"
|
|
1109
|
+
: "connection");
|
|
452
1110
|
});
|
|
453
1111
|
add("disconnect", (reason) => {
|
|
454
1112
|
if (reason === "io client disconnect")
|
|
455
1113
|
return;
|
|
1114
|
+
if (active.terminal)
|
|
1115
|
+
return;
|
|
1116
|
+
active.acceptingEvents = false;
|
|
1117
|
+
joinPending = false;
|
|
1118
|
+
pendingRealtimeActions.length = 0;
|
|
1119
|
+
if (joinTimeout !== undefined) {
|
|
1120
|
+
clearTimeout(joinTimeout);
|
|
1121
|
+
joinTimeout = undefined;
|
|
1122
|
+
}
|
|
456
1123
|
active.recoveryRevision += 1;
|
|
1124
|
+
lifecycle("reconnecting");
|
|
457
1125
|
const injectedError = chatErrorSchema.safeParse(reason);
|
|
458
1126
|
report(injectedError.success
|
|
459
1127
|
? sanitizeCredentialError(injectedError.data, credentialValues)
|
|
460
|
-
: this.#error("network", `TFRobot Socket disconnected: ${String(reason)}`, true, conversationId, undefined, credentialValues));
|
|
461
|
-
|
|
462
|
-
const anyListener = (eventName, payload) => {
|
|
463
|
-
if (KNOWN_EVENTS.has(eventName))
|
|
1128
|
+
: this.#error("network", `TFRobot Socket disconnected: ${String(reason)}`, true, conversationId, undefined, credentialValues), "connection");
|
|
1129
|
+
if (reason !== "io server disconnect")
|
|
464
1130
|
return;
|
|
465
|
-
if (
|
|
1131
|
+
if (active.manualReconnectAttempted) {
|
|
1132
|
+
enterAuthRequired();
|
|
466
1133
|
return;
|
|
467
|
-
|
|
1134
|
+
}
|
|
1135
|
+
active.manualReconnectAttempted = true;
|
|
1136
|
+
active.manualReconnectPending = true;
|
|
1137
|
+
const invalidationRevision = active.recoveryRevision;
|
|
1138
|
+
const invalidationError = this.#error("authentication", "TFRobot Socket session was invalidated by the server", true, errorConversationId());
|
|
1139
|
+
void awaitBounded(() => this.#invalidateSession(invalidationError, "unknown"), {
|
|
1140
|
+
deadlineAt: this.#now() + RECONNECT_AUTH_TIMEOUT_MS,
|
|
1141
|
+
now: this.#now,
|
|
1142
|
+
signal: connectionAbort.signal,
|
|
1143
|
+
}).then((outcome) => {
|
|
1144
|
+
if (!active.active ||
|
|
1145
|
+
active.socket.connected ||
|
|
1146
|
+
active.authRequired ||
|
|
1147
|
+
!active.manualReconnectPending ||
|
|
1148
|
+
active.recoveryRevision !== invalidationRevision) {
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
if (outcome.kind !== "value" || !outcome.value) {
|
|
1152
|
+
report(this.#error("authentication", "Unable to invalidate the TFRobot Socket session", false, errorConversationId()), "authentication");
|
|
1153
|
+
enterAuthRequired();
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
try {
|
|
1157
|
+
active.socket.connect();
|
|
1158
|
+
}
|
|
1159
|
+
catch (reconnectError) {
|
|
1160
|
+
report(this.#transportError(reconnectError, "Unable to reconnect the TFRobot Socket transport", conversationId, credentialValues), "authentication");
|
|
1161
|
+
enterAuthRequired();
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
});
|
|
1165
|
+
const anyListener = (eventName, payload) => {
|
|
1166
|
+
dispatchRealtime(() => {
|
|
1167
|
+
if (KNOWN_EVENTS.has(eventName))
|
|
1168
|
+
return;
|
|
1169
|
+
if (belongsToForeignConversation(payload, conversationId))
|
|
1170
|
+
return;
|
|
1171
|
+
mapAndNext("Unknown TFRobot Socket event could not be normalized", () => mapUnknownSocketEvent(sanitizeCredentialText(eventName, credentialValues), sanitizePayload(payload), errorConversationId(), this.#now()));
|
|
1172
|
+
});
|
|
468
1173
|
};
|
|
469
1174
|
if (setupFailure === undefined) {
|
|
470
1175
|
try {
|
|
@@ -476,6 +1181,10 @@ export class TFRobotSocketClient {
|
|
|
476
1181
|
}
|
|
477
1182
|
const cleanup = () => {
|
|
478
1183
|
connectionAbort.abort();
|
|
1184
|
+
if (joinTimeout !== undefined) {
|
|
1185
|
+
clearTimeout(joinTimeout);
|
|
1186
|
+
joinTimeout = undefined;
|
|
1187
|
+
}
|
|
479
1188
|
for (const [eventName, listener] of listeners) {
|
|
480
1189
|
try {
|
|
481
1190
|
socket.off(eventName, listener);
|
|
@@ -497,18 +1206,24 @@ export class TFRobotSocketClient {
|
|
|
497
1206
|
// Adapter ownership is cleared even if an injected transport misbehaves.
|
|
498
1207
|
}
|
|
499
1208
|
firstAuth = undefined;
|
|
1209
|
+
currentServerSession = undefined;
|
|
500
1210
|
credentialValues.clear();
|
|
1211
|
+
pendingNotifications.length = 0;
|
|
1212
|
+
pendingRealtimeActions.length = 0;
|
|
501
1213
|
};
|
|
502
1214
|
if (setupFailure !== undefined) {
|
|
503
1215
|
const error = this.#transportError(setupFailure.reason, "Unable to configure the TFRobot Socket transport", conversationId, credentialValues);
|
|
504
1216
|
cleanup();
|
|
1217
|
+
clearPendingEstablishment();
|
|
505
1218
|
return {
|
|
506
1219
|
ok: false,
|
|
507
1220
|
error,
|
|
508
1221
|
};
|
|
509
1222
|
}
|
|
510
1223
|
const active = {
|
|
1224
|
+
acceptingEvents: true,
|
|
511
1225
|
active: true,
|
|
1226
|
+
authRequired: false,
|
|
512
1227
|
cancelEstablishment: () => {
|
|
513
1228
|
settleEstablishment?.({
|
|
514
1229
|
ok: false,
|
|
@@ -517,11 +1232,21 @@ export class TFRobotSocketClient {
|
|
|
517
1232
|
},
|
|
518
1233
|
cleanup,
|
|
519
1234
|
conversationId,
|
|
1235
|
+
generation,
|
|
1236
|
+
manualReconnectAttempted: false,
|
|
1237
|
+
manualReconnectPending: false,
|
|
520
1238
|
observer,
|
|
1239
|
+
reconnectAttempt: 0,
|
|
1240
|
+
realtimeIdentityRevisions: new Map(),
|
|
1241
|
+
realtimeRevision: 0,
|
|
521
1242
|
recoveryRevision: 0,
|
|
1243
|
+
runRevision: 0,
|
|
522
1244
|
socket,
|
|
1245
|
+
subscriptionId,
|
|
1246
|
+
terminal: false,
|
|
523
1247
|
};
|
|
524
|
-
this.#active
|
|
1248
|
+
this.#subscriptions.add(active);
|
|
1249
|
+
this.#establishing = active;
|
|
525
1250
|
return new Promise((resolve) => {
|
|
526
1251
|
let settled = false;
|
|
527
1252
|
const settle = (result) => {
|
|
@@ -529,12 +1254,14 @@ export class TFRobotSocketClient {
|
|
|
529
1254
|
return;
|
|
530
1255
|
settled = true;
|
|
531
1256
|
settleEstablishment = undefined;
|
|
1257
|
+
clearPendingEstablishment();
|
|
1258
|
+
if (this.#establishing === active)
|
|
1259
|
+
this.#establishing = undefined;
|
|
532
1260
|
clearTimeout(timeout);
|
|
533
1261
|
if (!result.ok) {
|
|
534
1262
|
active.active = false;
|
|
535
1263
|
active.cleanup();
|
|
536
|
-
|
|
537
|
-
this.#active = undefined;
|
|
1264
|
+
this.#subscriptions.delete(active);
|
|
538
1265
|
}
|
|
539
1266
|
resolve(result);
|
|
540
1267
|
};
|
|
@@ -553,6 +1280,7 @@ export class TFRobotSocketClient {
|
|
|
553
1280
|
return;
|
|
554
1281
|
}
|
|
555
1282
|
try {
|
|
1283
|
+
lifecycle("connecting");
|
|
556
1284
|
socket.connect();
|
|
557
1285
|
}
|
|
558
1286
|
catch (reason) {
|
|
@@ -574,24 +1302,54 @@ export class TFRobotSocketClient {
|
|
|
574
1302
|
if (this.#disposed)
|
|
575
1303
|
return;
|
|
576
1304
|
this.#disposed = true;
|
|
577
|
-
this.#
|
|
1305
|
+
this.#cancelEstablishment();
|
|
1306
|
+
for (const subscription of this.#subscriptions) {
|
|
1307
|
+
subscription.active = false;
|
|
1308
|
+
subscription.cleanup();
|
|
1309
|
+
}
|
|
1310
|
+
this.#subscriptions.clear();
|
|
1311
|
+
this.#recoveryCheckpoints.clear();
|
|
578
1312
|
this.#runs.clear();
|
|
579
1313
|
}
|
|
1314
|
+
rememberSnapshot(snapshot) {
|
|
1315
|
+
if (this.#disposed)
|
|
1316
|
+
return;
|
|
1317
|
+
const conversationId = snapshot.conversation.id;
|
|
1318
|
+
this.#runs.set(conversationId, snapshot.run);
|
|
1319
|
+
const checkpoint = this.#recoveryCheckpoints.get(conversationId) ?? new Set();
|
|
1320
|
+
for (const identity of recoveryIdentitiesOfSnapshot(snapshot)) {
|
|
1321
|
+
rememberBoundedSetValue(checkpoint, identity);
|
|
1322
|
+
}
|
|
1323
|
+
this.#recoveryCheckpoints.set(conversationId, checkpoint);
|
|
1324
|
+
}
|
|
580
1325
|
rememberRun(conversationId, run) {
|
|
581
1326
|
if (this.#disposed)
|
|
582
1327
|
return;
|
|
583
1328
|
this.#runs.set(conversationId, run);
|
|
584
1329
|
}
|
|
585
|
-
|
|
586
|
-
this.#
|
|
587
|
-
this.#establishmentAbort = undefined;
|
|
588
|
-
const current = this.#active;
|
|
589
|
-
if (current === undefined)
|
|
1330
|
+
forgetConversation(conversationId) {
|
|
1331
|
+
if (this.#disposed)
|
|
590
1332
|
return;
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
1333
|
+
if (this.#pendingEstablishment?.conversationId === conversationId ||
|
|
1334
|
+
this.#establishing?.conversationId === conversationId) {
|
|
1335
|
+
this.#cancelEstablishment();
|
|
1336
|
+
}
|
|
1337
|
+
for (const subscription of [...this.#subscriptions]) {
|
|
1338
|
+
if (subscription.conversationId !== conversationId)
|
|
1339
|
+
continue;
|
|
1340
|
+
subscription.active = false;
|
|
1341
|
+
subscription.recoveryRevision += 1;
|
|
1342
|
+
subscription.cleanup();
|
|
1343
|
+
this.#subscriptions.delete(subscription);
|
|
1344
|
+
}
|
|
1345
|
+
this.#recoveryCheckpoints.delete(conversationId);
|
|
1346
|
+
this.#runs.delete(conversationId);
|
|
1347
|
+
}
|
|
1348
|
+
#cancelEstablishment() {
|
|
1349
|
+
this.#pendingEstablishment?.abort.abort();
|
|
1350
|
+
this.#pendingEstablishment = undefined;
|
|
1351
|
+
this.#establishing?.cancelEstablishment();
|
|
1352
|
+
this.#establishing = undefined;
|
|
595
1353
|
}
|
|
596
1354
|
#defaultNamespaceUrl(baseUrl) {
|
|
597
1355
|
const url = new URL(baseUrl);
|
|
@@ -600,7 +1358,7 @@ export class TFRobotSocketClient {
|
|
|
600
1358
|
url.hash = "";
|
|
601
1359
|
return url.toString().replace(/\/$/u, "");
|
|
602
1360
|
}
|
|
603
|
-
async #
|
|
1361
|
+
async #getSocketSession(conversationId, purpose) {
|
|
604
1362
|
const session = await this.#options.sessionProvider.getSession({
|
|
605
1363
|
purpose,
|
|
606
1364
|
operation: "subscribe",
|
|
@@ -609,19 +1367,30 @@ export class TFRobotSocketClient {
|
|
|
609
1367
|
if (!isValidTFRobotSession(session)) {
|
|
610
1368
|
throw new TypeError("SessionProvider returned invalid TFRobot credentials");
|
|
611
1369
|
}
|
|
612
|
-
return
|
|
1370
|
+
return session;
|
|
613
1371
|
}
|
|
614
|
-
async #reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, next, report) {
|
|
1372
|
+
async #reconcileRunAfterReconnect(active, conversationId, credentialValues, recoveryRevision, runRevision, next, report) {
|
|
615
1373
|
const result = await this.#reconnectStatusLoader(conversationId);
|
|
616
1374
|
if (!active.active ||
|
|
617
|
-
this.#active
|
|
1375
|
+
!this.#subscriptions.has(active) ||
|
|
618
1376
|
!active.socket.connected ||
|
|
619
1377
|
active.recoveryRevision !== recoveryRevision) {
|
|
620
|
-
return;
|
|
1378
|
+
return "failure";
|
|
621
1379
|
}
|
|
622
1380
|
if (!result.ok) {
|
|
623
|
-
|
|
624
|
-
|
|
1381
|
+
const error = sanitizeCredentialError(result.error, credentialValues);
|
|
1382
|
+
if (error.code === "authentication" || error.code === "authorization") {
|
|
1383
|
+
report(error, "authentication");
|
|
1384
|
+
return "auth-required";
|
|
1385
|
+
}
|
|
1386
|
+
if (active.runRevision !== runRevision) {
|
|
1387
|
+
return "superseded-by-realtime";
|
|
1388
|
+
}
|
|
1389
|
+
report(error, "recovery");
|
|
1390
|
+
return "failure";
|
|
1391
|
+
}
|
|
1392
|
+
if (active.runRevision !== runRevision) {
|
|
1393
|
+
return "superseded-by-realtime";
|
|
625
1394
|
}
|
|
626
1395
|
try {
|
|
627
1396
|
const updateConversationId = sanitizeCredentialText(conversationId, credentialValues);
|
|
@@ -629,16 +1398,18 @@ export class TFRobotSocketClient {
|
|
|
629
1398
|
const run = mapRun(updateConversationId, safeStatus);
|
|
630
1399
|
const previous = this.#runs.get(conversationId);
|
|
631
1400
|
this.#runs.set(conversationId, run);
|
|
632
|
-
if (sameRun(previous, run))
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
1401
|
+
if (!sameRun(previous, run)) {
|
|
1402
|
+
next(chatUpdateSchema.parse({
|
|
1403
|
+
kind: "run.replace",
|
|
1404
|
+
conversationId: updateConversationId,
|
|
1405
|
+
run,
|
|
1406
|
+
}));
|
|
1407
|
+
}
|
|
1408
|
+
return "success";
|
|
639
1409
|
}
|
|
640
1410
|
catch {
|
|
641
|
-
report(this.#error("validation", "Reconnected TFRobot run status could not be normalized", false, conversationId, undefined, credentialValues));
|
|
1411
|
+
report(this.#error("validation", "Reconnected TFRobot run status could not be normalized", false, conversationId, undefined, credentialValues), "recovery");
|
|
1412
|
+
return "failure";
|
|
642
1413
|
}
|
|
643
1414
|
}
|
|
644
1415
|
async #invalidateSession(error, reason) {
|
|
@@ -647,9 +1418,10 @@ export class TFRobotSocketClient {
|
|
|
647
1418
|
reason: reason ?? (error.code === "authorization" ? "forbidden" : "expired"),
|
|
648
1419
|
error,
|
|
649
1420
|
});
|
|
1421
|
+
return true;
|
|
650
1422
|
}
|
|
651
1423
|
catch {
|
|
652
|
-
|
|
1424
|
+
return false;
|
|
653
1425
|
}
|
|
654
1426
|
}
|
|
655
1427
|
#transportError(reason, fallback, conversationId, credentialValues = []) {
|