@dungle-scrubs/tether-client 0.1.1
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/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/approval-observer.d.ts +74 -0
- package/dist/approval-observer.js +163 -0
- package/dist/auth-token.d.ts +6 -0
- package/dist/auth-token.js +25 -0
- package/dist/effect-timing.d.ts +3 -0
- package/dist/effect-timing.js +9 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +12 -0
- package/dist/observability.d.ts +147 -0
- package/dist/observability.js +223 -0
- package/dist/participant-claimable-task-runner.d.ts +36 -0
- package/dist/participant-claimable-task-runner.js +92 -0
- package/dist/participant-runtime-client.d.ts +443 -0
- package/dist/participant-runtime-client.js +780 -0
- package/dist/participant-task-claim-flow.d.ts +51 -0
- package/dist/participant-task-claim-flow.js +248 -0
- package/dist/protocol.d.ts +2 -0
- package/dist/protocol.js +1 -0
- package/dist/session-event-stream-client.d.ts +133 -0
- package/dist/session-event-stream-client.js +333 -0
- package/dist/task-cancellation-registry.d.ts +48 -0
- package/dist/task-cancellation-registry.js +95 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.js +1 -0
- package/package.json +53 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Effect, Either } from "effect";
|
|
3
|
+
import WebSocket from "ws";
|
|
4
|
+
import { ModuleObservability, readModuleObservabilityOptions, } from "./observability.js";
|
|
5
|
+
import { resolveServiceAuthToken } from "./auth-token.js";
|
|
6
|
+
import { buildWsPublishMessage, buildWsTaskClaimMessage, buildWsTaskCompleteMessage, buildWsTaskFailMessage, buildWsTaskRefreshMessage, parseWebSocketServerEnvelope, webSocketOperation, } from "./protocol.js";
|
|
7
|
+
import { buildParticipantClaimableTaskLoop, } from "./participant-claimable-task-runner.js";
|
|
8
|
+
import { runParticipantTaskClaimFlow } from "./participant-task-claim-flow.js";
|
|
9
|
+
import { sleepUnrefEffect } from "./effect-timing.js";
|
|
10
|
+
const defaultReconnectBaseDelayMs = 100;
|
|
11
|
+
const defaultReconnectMaxDelayMs = 2_000;
|
|
12
|
+
const defaultCommandTimeoutMs = 15_000;
|
|
13
|
+
const defaultCursorPersistIntervalMs = 1_000;
|
|
14
|
+
const defaultCursorPersistEventCount = 50;
|
|
15
|
+
/**
|
|
16
|
+
* Error raised when participant runtime client configuration is invalid.
|
|
17
|
+
*/
|
|
18
|
+
export class ParticipantRuntimeClientConfigurationError extends Error {
|
|
19
|
+
/** Machine-readable configuration field that failed validation. */
|
|
20
|
+
field;
|
|
21
|
+
constructor(field, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "ParticipantRuntimeClientConfigurationError";
|
|
24
|
+
this.field = field;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Error raised when the server returns a correlated command error envelope.
|
|
29
|
+
*/
|
|
30
|
+
export class ParticipantRuntimeCommandError extends Error {
|
|
31
|
+
/** WebSocket command operation that rejected. */
|
|
32
|
+
op;
|
|
33
|
+
/** Number of pending commands observed when the rejection was handled. */
|
|
34
|
+
pendingCommandCount;
|
|
35
|
+
/** Correlation id attached to the command request. */
|
|
36
|
+
requestId;
|
|
37
|
+
/** Task id carried by task-scoped commands when present. */
|
|
38
|
+
taskId;
|
|
39
|
+
constructor(input) {
|
|
40
|
+
super(input.message);
|
|
41
|
+
this.name = "ParticipantRuntimeCommandError";
|
|
42
|
+
this.op = input.op;
|
|
43
|
+
this.pendingCommandCount = input.pendingCommandCount;
|
|
44
|
+
this.requestId = input.requestId;
|
|
45
|
+
if (input.taskId !== undefined) {
|
|
46
|
+
this.taskId = input.taskId;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Error raised when a correlated WebSocket command does not receive a response
|
|
52
|
+
* before the configured command timeout.
|
|
53
|
+
*/
|
|
54
|
+
export class ParticipantRuntimeCommandTimeoutError extends Error {
|
|
55
|
+
/** WebSocket command operation that timed out. */
|
|
56
|
+
op;
|
|
57
|
+
/** Number of pending commands observed when the timeout fired. */
|
|
58
|
+
pendingCommandCount;
|
|
59
|
+
/** Correlation id attached to the command request. */
|
|
60
|
+
requestId;
|
|
61
|
+
/** Task id carried by task-scoped commands when present. */
|
|
62
|
+
taskId;
|
|
63
|
+
/** Command response timeout in milliseconds. */
|
|
64
|
+
timeoutMs;
|
|
65
|
+
constructor(input) {
|
|
66
|
+
super(`Timed out waiting for ${input.op} command response`);
|
|
67
|
+
this.name = "ParticipantRuntimeCommandTimeoutError";
|
|
68
|
+
this.op = input.op;
|
|
69
|
+
this.pendingCommandCount = input.pendingCommandCount;
|
|
70
|
+
this.requestId = input.requestId;
|
|
71
|
+
if (input.taskId !== undefined) {
|
|
72
|
+
this.taskId = input.taskId;
|
|
73
|
+
}
|
|
74
|
+
this.timeoutMs = input.timeoutMs;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Runs a participant runtime using the reusable client boundary. Adapters call
|
|
79
|
+
* this when they only need to provide identity, capabilities, task selection,
|
|
80
|
+
* and execution policy.
|
|
81
|
+
*/
|
|
82
|
+
export async function runParticipantRuntime(input) {
|
|
83
|
+
const client = await ParticipantRuntimeClient.connect(input);
|
|
84
|
+
const unsubscribeError = installRunParticipantRuntimeErrorHandler(client, input);
|
|
85
|
+
let cleanup;
|
|
86
|
+
try {
|
|
87
|
+
await client.waitForReplayComplete();
|
|
88
|
+
const replayHookResult = await input.hooks?.onReplayComplete?.(client);
|
|
89
|
+
cleanup = typeof replayHookResult === "function" ? replayHookResult : undefined;
|
|
90
|
+
await client.runClaimableTasks({
|
|
91
|
+
claimRefreshMs: input.claimRefreshMs,
|
|
92
|
+
executor: input.executor,
|
|
93
|
+
once: input.once ?? false,
|
|
94
|
+
shouldClaimTask: input.shouldClaimTask ??
|
|
95
|
+
((task) => shouldClaimParticipantTask(task, input.workKinds, input.participantId)),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
try {
|
|
100
|
+
await cleanup?.();
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
unsubscribeError();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Checks whether a task is claimable by a participant with a work-kind allow
|
|
109
|
+
* list.
|
|
110
|
+
*/
|
|
111
|
+
export function shouldClaimParticipantTask(task, workKinds, participantId) {
|
|
112
|
+
if (!workKinds.includes(task.kind)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (task.completedAt || task.failedAt || task.cancelledAt) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return task.claimedBy === null || task.claimedBy === participantId;
|
|
119
|
+
}
|
|
120
|
+
/** Installs default or caller-supplied error handling for the high-level runner. */
|
|
121
|
+
function installRunParticipantRuntimeErrorHandler(client, input) {
|
|
122
|
+
if (input.hooks?.onError === null) {
|
|
123
|
+
return () => undefined;
|
|
124
|
+
}
|
|
125
|
+
const handler = input.hooks?.onError ?? ((error) => writeDefaultRuntimeError(error, client, input));
|
|
126
|
+
return client.onError(handler);
|
|
127
|
+
}
|
|
128
|
+
/** Writes one structured participant runtime error payload to stderr. */
|
|
129
|
+
function writeDefaultRuntimeError(error, client, input) {
|
|
130
|
+
const debug = client.debugInfo();
|
|
131
|
+
process.stderr.write(`${JSON.stringify({
|
|
132
|
+
errorMessage: error.message,
|
|
133
|
+
errorName: error.name,
|
|
134
|
+
instanceId: input.instanceId,
|
|
135
|
+
participantId: input.participantId,
|
|
136
|
+
reconnectFailureCount: debug.reconnectFailureCount,
|
|
137
|
+
runtimeKind: input.runtimeKind,
|
|
138
|
+
sessionId: input.sessionId,
|
|
139
|
+
type: "participant_runtime.error",
|
|
140
|
+
})}\n`);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Reusable WebSocket runtime client for external participants. It owns replay
|
|
144
|
+
* buffering, command correlation, task claiming, claim refresh, cancellation,
|
|
145
|
+
* and shutdown hooks so adapter workers only provide task-selection and
|
|
146
|
+
* execution policy.
|
|
147
|
+
*/
|
|
148
|
+
export class ParticipantRuntimeClient {
|
|
149
|
+
config;
|
|
150
|
+
static maxRecentEvents = 80;
|
|
151
|
+
closeHandlers = new Set();
|
|
152
|
+
closePromise = Promise.resolve();
|
|
153
|
+
eventBacklog = [];
|
|
154
|
+
eventHandlers = new Set();
|
|
155
|
+
errorHandlers = new Set();
|
|
156
|
+
lastObservedSeq;
|
|
157
|
+
lastHandledSeq;
|
|
158
|
+
lastPersistedSeq;
|
|
159
|
+
eventsSinceCursorPersist = 0;
|
|
160
|
+
cursorPersistTimer = null;
|
|
161
|
+
cursorPersistIntervalMs;
|
|
162
|
+
cursorPersistEventCount;
|
|
163
|
+
commandTimeoutMs;
|
|
164
|
+
observability = new ModuleObservability(readModuleObservabilityOptions("ParticipantRuntimeClient"));
|
|
165
|
+
pendingCommands = new Map();
|
|
166
|
+
recentEvents = [];
|
|
167
|
+
reconnectFailureCount = 0;
|
|
168
|
+
reconnectSuccessCount = 0;
|
|
169
|
+
replayComplete = Promise.resolve();
|
|
170
|
+
rejectReplayComplete = null;
|
|
171
|
+
resolveReplayComplete = null;
|
|
172
|
+
replayCompleteSettled = true;
|
|
173
|
+
socket = null;
|
|
174
|
+
stopped = false;
|
|
175
|
+
/**
|
|
176
|
+
* Attaches protocol message handling to an already-created WebSocket.
|
|
177
|
+
*/
|
|
178
|
+
constructor(config) {
|
|
179
|
+
this.config = config;
|
|
180
|
+
this.lastObservedSeq = config.afterSeq;
|
|
181
|
+
this.lastHandledSeq = config.afterSeq;
|
|
182
|
+
this.lastPersistedSeq = config.afterSeq;
|
|
183
|
+
this.commandTimeoutMs = resolveCommandTimeoutMs(config.commandTimeoutMs);
|
|
184
|
+
this.cursorPersistIntervalMs =
|
|
185
|
+
config.cursorPersist?.intervalMs ?? defaultCursorPersistIntervalMs;
|
|
186
|
+
this.cursorPersistEventCount =
|
|
187
|
+
config.cursorPersist?.eventCount ?? defaultCursorPersistEventCount;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Opens the WebSocket stream and returns a command-capable participant client.
|
|
191
|
+
* When a durable cursor is configured, the stream resumes from the higher of
|
|
192
|
+
* `afterSeq` and the persisted cursor so restarts replay only actual downtime.
|
|
193
|
+
*/
|
|
194
|
+
static async connect(config) {
|
|
195
|
+
const client = new ParticipantRuntimeClient(config);
|
|
196
|
+
const resumeSeq = await client.resolveInitialResumeSeq();
|
|
197
|
+
client.lastObservedSeq = resumeSeq;
|
|
198
|
+
client.lastHandledSeq = resumeSeq;
|
|
199
|
+
client.lastPersistedSeq = resumeSeq;
|
|
200
|
+
await client.open(resumeSeq);
|
|
201
|
+
return client;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Publishes one participant-originated event over the WebSocket command
|
|
205
|
+
* channel.
|
|
206
|
+
*/
|
|
207
|
+
async appendEvent(input) {
|
|
208
|
+
await this.observability.traceBoundary("appendEvent", {
|
|
209
|
+
eventId: input.eventId,
|
|
210
|
+
producerId: input.producerId,
|
|
211
|
+
sessionId: input.sessionId,
|
|
212
|
+
type: input.type,
|
|
213
|
+
}, async () => {
|
|
214
|
+
await this.sendCommand((requestId) => buildWsPublishMessage({
|
|
215
|
+
eventId: input.eventId,
|
|
216
|
+
payload: input.payload,
|
|
217
|
+
producerId: input.producerId,
|
|
218
|
+
requestId,
|
|
219
|
+
type: input.type,
|
|
220
|
+
}));
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Attempts to claim a task and returns null when another participant won.
|
|
225
|
+
*/
|
|
226
|
+
async claimTask(taskId) {
|
|
227
|
+
return this.observability.traceBoundary("claimTask", { taskId }, async () => {
|
|
228
|
+
const result = await this.sendCommand((requestId) => buildWsTaskClaimMessage({ requestId, taskId }));
|
|
229
|
+
return result.task ?? null;
|
|
230
|
+
}, (task) => ({ claimed: task !== null }));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Requests a graceful WebSocket close.
|
|
234
|
+
*/
|
|
235
|
+
close() {
|
|
236
|
+
this.stopped = true;
|
|
237
|
+
this.persistCursor();
|
|
238
|
+
this.socket?.close();
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Drops the current transport without stopping the client. Long-running task
|
|
242
|
+
* loops reconnect from the last observed event sequence.
|
|
243
|
+
*/
|
|
244
|
+
disconnect() {
|
|
245
|
+
this.socket?.terminate();
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Returns client-side queue, cursor, and pending-command counters.
|
|
249
|
+
*/
|
|
250
|
+
debugInfo() {
|
|
251
|
+
return {
|
|
252
|
+
...this.observability.debugInfo(),
|
|
253
|
+
eventBacklogSize: this.eventBacklog.length,
|
|
254
|
+
eventHandlerCount: this.eventHandlers.size,
|
|
255
|
+
lastObservedSeq: this.lastObservedSeq,
|
|
256
|
+
pendingCommandCount: this.pendingCommands.size,
|
|
257
|
+
reconnectFailureCount: this.reconnectFailureCount,
|
|
258
|
+
reconnectSuccessCount: this.reconnectSuccessCount,
|
|
259
|
+
socketReadyState: this.socket?.readyState ?? WebSocket.CLOSED,
|
|
260
|
+
stopped: this.stopped,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Completes the active task claim with a structured result payload.
|
|
265
|
+
*/
|
|
266
|
+
async completeTask(taskId, result) {
|
|
267
|
+
await this.observability.traceBoundary("completeTask", { taskId }, () => this.sendCommand((requestId) => buildWsTaskCompleteMessage({ requestId, result, taskId })));
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Fails the active task claim with a structured failure payload.
|
|
271
|
+
*/
|
|
272
|
+
async failTask(taskId, failure) {
|
|
273
|
+
await this.observability.traceBoundary("failTask", { taskId }, () => this.sendCommand((requestId) => buildWsTaskFailMessage({ failure, requestId, taskId })));
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Registers a close callback and returns an unsubscribe function.
|
|
277
|
+
*/
|
|
278
|
+
onClose(handler) {
|
|
279
|
+
this.closeHandlers.add(handler);
|
|
280
|
+
return () => {
|
|
281
|
+
this.closeHandlers.delete(handler);
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Registers an error callback and returns an unsubscribe function.
|
|
286
|
+
*/
|
|
287
|
+
onError(handler) {
|
|
288
|
+
this.errorHandlers.add(handler);
|
|
289
|
+
return () => {
|
|
290
|
+
this.errorHandlers.delete(handler);
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Registers an event callback and drains any events received before the
|
|
295
|
+
* handler was attached.
|
|
296
|
+
*/
|
|
297
|
+
onEvent(handler) {
|
|
298
|
+
this.eventHandlers.add(handler);
|
|
299
|
+
let drainedSeq = this.lastHandledSeq;
|
|
300
|
+
for (const event of this.eventBacklog.splice(0)) {
|
|
301
|
+
handler(event);
|
|
302
|
+
drainedSeq = Math.max(drainedSeq, event.seq);
|
|
303
|
+
}
|
|
304
|
+
this.markSeqHandled(drainedSeq);
|
|
305
|
+
return () => {
|
|
306
|
+
this.eventHandlers.delete(handler);
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Reopens the participant stream from the highest observed event sequence.
|
|
311
|
+
*/
|
|
312
|
+
async reconnect() {
|
|
313
|
+
this.socket?.close();
|
|
314
|
+
await this.waitForClose().catch(() => undefined);
|
|
315
|
+
await this.reconnectWithBackoff(0);
|
|
316
|
+
return this;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Refreshes the active task claim lease and returns null if the claim is no
|
|
320
|
+
* longer valid.
|
|
321
|
+
*/
|
|
322
|
+
async refreshTaskClaim(taskId) {
|
|
323
|
+
return this.observability.traceBoundary("refreshTaskClaim", { taskId }, async () => {
|
|
324
|
+
const result = await this.sendCommand((requestId) => buildWsTaskRefreshMessage({ requestId, taskId }));
|
|
325
|
+
return result.task ?? null;
|
|
326
|
+
}, (task) => ({ refreshed: task !== null }));
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Processes claimable tasks from replay and live events until either the
|
|
330
|
+
* one-shot replay work completes or the socket closes.
|
|
331
|
+
*/
|
|
332
|
+
async runClaimableTasks(options) {
|
|
333
|
+
await Effect.runPromise(buildParticipantClaimableTaskLoop({
|
|
334
|
+
close: () => this.close(),
|
|
335
|
+
isStopped: () => this.stopped,
|
|
336
|
+
onEvent: (handler) => this.onEvent(handler),
|
|
337
|
+
reconnectAfterClose: () => this.reconnectWithBackoff(),
|
|
338
|
+
runTaskClaimFlow: (input) => this.runTaskClaimFlow(input),
|
|
339
|
+
waitForClose: () => this.waitForClose(),
|
|
340
|
+
waitForReplayComplete: () => this.waitForReplayComplete(),
|
|
341
|
+
}, options));
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Runs the claim, progress, execution, output, and completion sequence for
|
|
345
|
+
* one task while respecting cancellation.
|
|
346
|
+
*/
|
|
347
|
+
async runTaskClaimFlow(input) {
|
|
348
|
+
await this.observability.traceBoundary("runTaskClaimFlow", {
|
|
349
|
+
participantId: this.config.participantId,
|
|
350
|
+
sessionId: this.config.sessionId,
|
|
351
|
+
taskId: input.task.taskId,
|
|
352
|
+
}, () => runParticipantTaskClaimFlow(this, {
|
|
353
|
+
instanceId: this.config.instanceId,
|
|
354
|
+
lastObservedSeq: this.lastObservedSeq,
|
|
355
|
+
participantId: this.config.participantId,
|
|
356
|
+
recentEvents: this.recentEvents,
|
|
357
|
+
sessionId: this.config.sessionId,
|
|
358
|
+
}, this.observability, input));
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Resolves when the current socket closes.
|
|
362
|
+
*/
|
|
363
|
+
async waitForClose() {
|
|
364
|
+
await this.closePromise;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Resolves once the server has finished replaying historical events.
|
|
368
|
+
*/
|
|
369
|
+
async waitForReplayComplete() {
|
|
370
|
+
await this.replayComplete;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Opens a WebSocket stream from the requested event cursor.
|
|
374
|
+
*/
|
|
375
|
+
async open(afterSeq) {
|
|
376
|
+
this.replayCompleteSettled = false;
|
|
377
|
+
const replayComplete = new Promise((resolve, reject) => {
|
|
378
|
+
this.rejectReplayComplete = reject;
|
|
379
|
+
this.resolveReplayComplete = resolve;
|
|
380
|
+
});
|
|
381
|
+
replayComplete.catch(() => undefined);
|
|
382
|
+
this.replayComplete = replayComplete;
|
|
383
|
+
const socket = new WebSocket(buildParticipantRuntimeStreamUrl({
|
|
384
|
+
...this.config,
|
|
385
|
+
afterSeq,
|
|
386
|
+
}));
|
|
387
|
+
this.socket = socket;
|
|
388
|
+
this.closePromise = new Promise((resolve) => {
|
|
389
|
+
socket.once("close", () => {
|
|
390
|
+
if (this.socket === socket) {
|
|
391
|
+
this.socket = null;
|
|
392
|
+
}
|
|
393
|
+
this.settleReplayCompleteError(new Error("WebSocket closed before replay completed"));
|
|
394
|
+
this.rejectPendingCommands(new Error("WebSocket closed"));
|
|
395
|
+
for (const handler of this.closeHandlers) {
|
|
396
|
+
handler();
|
|
397
|
+
}
|
|
398
|
+
resolve();
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
socket.on("error", (error) => {
|
|
402
|
+
this.settleReplayCompleteError(error);
|
|
403
|
+
this.rejectPendingCommands(error);
|
|
404
|
+
this.emitError(error);
|
|
405
|
+
});
|
|
406
|
+
socket.on("message", (data) => {
|
|
407
|
+
this.handleMessage(data);
|
|
408
|
+
});
|
|
409
|
+
await new Promise((resolve, reject) => {
|
|
410
|
+
socket.once("open", resolve);
|
|
411
|
+
socket.once("error", reject);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Reopens the WebSocket stream with bounded retry backoff.
|
|
416
|
+
*/
|
|
417
|
+
async reconnectWithBackoff(initialDelayMs = null) {
|
|
418
|
+
await Effect.runPromise(this.buildReconnectWithBackoff(initialDelayMs));
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Builds the bounded reconnect loop as an Effect program.
|
|
422
|
+
*/
|
|
423
|
+
buildReconnectWithBackoff(initialDelayMs = null) {
|
|
424
|
+
return Effect.gen(this, function* () {
|
|
425
|
+
let attempt = 0;
|
|
426
|
+
let delayMs = initialDelayMs ?? reconnectDelayMs(attempt, this.config);
|
|
427
|
+
while (!this.stopped) {
|
|
428
|
+
if (delayMs > 0) {
|
|
429
|
+
yield* sleepUnrefEffect(delayMs);
|
|
430
|
+
}
|
|
431
|
+
const opened = yield* Effect.either(Effect.tryPromise({
|
|
432
|
+
catch: (error) => error,
|
|
433
|
+
try: () => this.open(this.lastObservedSeq),
|
|
434
|
+
}));
|
|
435
|
+
if (Either.isRight(opened)) {
|
|
436
|
+
this.reconnectSuccessCount += 1;
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const error = opened.left;
|
|
440
|
+
this.reconnectFailureCount += 1;
|
|
441
|
+
this.emitError(error instanceof Error ? error : new Error("Reconnect failed"));
|
|
442
|
+
attempt += 1;
|
|
443
|
+
delayMs = reconnectDelayMs(attempt, this.config);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Returns the currently open socket or throws a connection-level error.
|
|
449
|
+
*/
|
|
450
|
+
requireOpenSocket() {
|
|
451
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
452
|
+
throw new Error("Participant runtime WebSocket is not open");
|
|
453
|
+
}
|
|
454
|
+
return this.socket;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Parses one server envelope and routes it to event handlers, pending command
|
|
458
|
+
* promises, or replay completion state.
|
|
459
|
+
*/
|
|
460
|
+
handleMessage(data) {
|
|
461
|
+
let parsed;
|
|
462
|
+
try {
|
|
463
|
+
parsed = JSON.parse(String(data));
|
|
464
|
+
const envelope = parseWebSocketServerEnvelope(parsed);
|
|
465
|
+
if (!envelope) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (envelope.op === webSocketOperation.event) {
|
|
469
|
+
this.lastObservedSeq = Math.max(this.lastObservedSeq, envelope.event.seq);
|
|
470
|
+
this.rememberEvent(envelope.event);
|
|
471
|
+
if (this.eventHandlers.size === 0) {
|
|
472
|
+
this.eventBacklog.push(envelope.event);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
for (const handler of this.eventHandlers) {
|
|
476
|
+
handler(envelope.event);
|
|
477
|
+
}
|
|
478
|
+
this.markSeqHandled(envelope.event.seq);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (envelope.op === webSocketOperation.commandResult) {
|
|
482
|
+
if (envelope.requestId) {
|
|
483
|
+
const pending = this.settlePendingCommand(envelope.requestId);
|
|
484
|
+
if (!pending) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
pending.resolve(envelope);
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (envelope.op === webSocketOperation.error) {
|
|
492
|
+
const error = new Error(envelope.error);
|
|
493
|
+
if (envelope.requestId) {
|
|
494
|
+
const pending = this.settlePendingCommand(envelope.requestId);
|
|
495
|
+
if (!pending) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const taskId = readStringField(envelope, "taskId") ?? pending.taskId;
|
|
499
|
+
pending.reject(new ParticipantRuntimeCommandError({
|
|
500
|
+
message: envelope.error,
|
|
501
|
+
op: readStringField(envelope, "command") ?? pending.op,
|
|
502
|
+
pendingCommandCount: this.pendingCommands.size,
|
|
503
|
+
requestId: envelope.requestId,
|
|
504
|
+
...(taskId !== undefined ? { taskId } : {}),
|
|
505
|
+
}));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
this.settleReplayCompleteError(error);
|
|
509
|
+
this.emitError(error);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (envelope.op === webSocketOperation.replayComplete) {
|
|
513
|
+
this.settleReplayComplete();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
this.emitError(error instanceof Error ? error : new Error("Failed to handle WebSocket message"));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Routes a connection-level or message-handling error to registered error
|
|
522
|
+
* handlers instead of throwing out of a ws event listener, which would
|
|
523
|
+
* surface as an unhandled exception.
|
|
524
|
+
*/
|
|
525
|
+
emitError(error) {
|
|
526
|
+
for (const handler of this.errorHandlers) {
|
|
527
|
+
handler(error);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/** Resolves the replay wait once, preserving later reconnect state. */
|
|
531
|
+
settleReplayComplete() {
|
|
532
|
+
if (this.replayCompleteSettled) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
this.replayCompleteSettled = true;
|
|
536
|
+
this.resolveReplayComplete?.();
|
|
537
|
+
this.resolveReplayComplete = null;
|
|
538
|
+
this.rejectReplayComplete = null;
|
|
539
|
+
}
|
|
540
|
+
/** Rejects the replay wait once when the socket fails before replay.complete. */
|
|
541
|
+
settleReplayCompleteError(error) {
|
|
542
|
+
if (this.replayCompleteSettled) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
this.replayCompleteSettled = true;
|
|
546
|
+
this.rejectReplayComplete?.(error);
|
|
547
|
+
this.resolveReplayComplete = null;
|
|
548
|
+
this.rejectReplayComplete = null;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Keeps a bounded in-memory window of replay/live events for executors that
|
|
552
|
+
* need conversation context without reimplementing stream replay.
|
|
553
|
+
*/
|
|
554
|
+
rememberEvent(event) {
|
|
555
|
+
this.recentEvents.push(event);
|
|
556
|
+
if (this.recentEvents.length > ParticipantRuntimeClient.maxRecentEvents) {
|
|
557
|
+
this.recentEvents.splice(0, this.recentEvents.length - ParticipantRuntimeClient.maxRecentEvents);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Resolves the startup resume sequence from the durable cursor, never below
|
|
562
|
+
* the configured `afterSeq` floor. Returns `afterSeq` when no cursor store is
|
|
563
|
+
* configured, preserving the fixed-window resume behavior.
|
|
564
|
+
*/
|
|
565
|
+
async resolveInitialResumeSeq() {
|
|
566
|
+
const cursorStore = this.config.cursorStore;
|
|
567
|
+
if (!cursorStore) {
|
|
568
|
+
return this.config.afterSeq;
|
|
569
|
+
}
|
|
570
|
+
const stored = await cursorStore.read();
|
|
571
|
+
return resolveResumeSeq(this.config.afterSeq, stored);
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Advances the handled cursor once an event's handlers have been invoked and
|
|
575
|
+
* schedules a durable persist. Only sequences that have actually been
|
|
576
|
+
* delivered to a handler reach the durable store, so a restart never resumes
|
|
577
|
+
* past an event that was merely received but never handled. No-op when the
|
|
578
|
+
* sequence has not advanced past the last handled value.
|
|
579
|
+
*/
|
|
580
|
+
markSeqHandled(seq) {
|
|
581
|
+
if (seq <= this.lastHandledSeq) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
this.lastHandledSeq = seq;
|
|
585
|
+
this.scheduleCursorPersist();
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Records that the handled cursor advanced and persists it under the
|
|
589
|
+
* configured throttle: immediately once enough events accumulate, otherwise
|
|
590
|
+
* on a trailing timer. No-op when no cursor store is configured.
|
|
591
|
+
*/
|
|
592
|
+
scheduleCursorPersist() {
|
|
593
|
+
if (!this.config.cursorStore) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
this.eventsSinceCursorPersist += 1;
|
|
597
|
+
if (this.eventsSinceCursorPersist >= this.cursorPersistEventCount) {
|
|
598
|
+
this.persistCursor();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (this.cursorPersistTimer === null) {
|
|
602
|
+
const timer = setTimeout(() => {
|
|
603
|
+
this.cursorPersistTimer = null;
|
|
604
|
+
this.persistCursor();
|
|
605
|
+
}, this.cursorPersistIntervalMs);
|
|
606
|
+
timer.unref?.();
|
|
607
|
+
this.cursorPersistTimer = timer;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Writes the highest handled event sequence to the durable cursor store,
|
|
612
|
+
* skipping when it has not advanced past the last persisted value. Only
|
|
613
|
+
* sequences confirmed as handled are persisted, never a value ahead of the
|
|
614
|
+
* events the runtime has actually processed.
|
|
615
|
+
*/
|
|
616
|
+
persistCursor() {
|
|
617
|
+
if (this.cursorPersistTimer !== null) {
|
|
618
|
+
clearTimeout(this.cursorPersistTimer);
|
|
619
|
+
this.cursorPersistTimer = null;
|
|
620
|
+
}
|
|
621
|
+
this.eventsSinceCursorPersist = 0;
|
|
622
|
+
const cursorStore = this.config.cursorStore;
|
|
623
|
+
if (!cursorStore) {
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const seq = this.lastHandledSeq;
|
|
627
|
+
if (seq <= this.lastPersistedSeq) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
this.lastPersistedSeq = seq;
|
|
631
|
+
try {
|
|
632
|
+
const result = cursorStore.write(seq);
|
|
633
|
+
if (result instanceof Promise) {
|
|
634
|
+
result.catch((error) => {
|
|
635
|
+
this.emitError(error instanceof Error ? error : new Error("Cursor store write failed"));
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
this.emitError(error instanceof Error ? error : new Error("Cursor store write failed"));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Rejects all pending command promises with a shared connection-level error.
|
|
645
|
+
*/
|
|
646
|
+
rejectPendingCommands(error) {
|
|
647
|
+
const pendingCommands = [...this.pendingCommands.values()];
|
|
648
|
+
this.pendingCommands.clear();
|
|
649
|
+
for (const pending of pendingCommands) {
|
|
650
|
+
clearTimeout(pending.timeout);
|
|
651
|
+
pending.reject(error);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Sends a command with a generated request id and waits for its matching
|
|
656
|
+
* command result or error envelope.
|
|
657
|
+
*/
|
|
658
|
+
async sendCommand(buildCommand) {
|
|
659
|
+
const result = await Effect.runPromise(Effect.either(this.buildCommandRequest(buildCommand)));
|
|
660
|
+
if (Either.isLeft(result)) {
|
|
661
|
+
throw result.left;
|
|
662
|
+
}
|
|
663
|
+
return result.right;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Builds one correlated WebSocket command request and guarantees pending
|
|
667
|
+
* command cleanup if the waiting Effect is interrupted.
|
|
668
|
+
*/
|
|
669
|
+
buildCommandRequest(buildCommand) {
|
|
670
|
+
const requestId = `req_${randomUUID()}`;
|
|
671
|
+
return Effect.async((resume) => {
|
|
672
|
+
const command = buildCommand(requestId);
|
|
673
|
+
const commandContext = readCommandContext(command, requestId);
|
|
674
|
+
const resolve = (result) => {
|
|
675
|
+
resume(Effect.succeed(result));
|
|
676
|
+
};
|
|
677
|
+
const reject = (error) => {
|
|
678
|
+
resume(Effect.fail(error));
|
|
679
|
+
};
|
|
680
|
+
const timeout = setTimeout(() => {
|
|
681
|
+
const pending = this.settlePendingCommand(requestId);
|
|
682
|
+
if (!pending) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
pending.reject(new ParticipantRuntimeCommandTimeoutError({
|
|
686
|
+
op: pending.op,
|
|
687
|
+
pendingCommandCount: this.pendingCommands.size,
|
|
688
|
+
requestId: pending.requestId,
|
|
689
|
+
...(pending.taskId !== undefined ? { taskId: pending.taskId } : {}),
|
|
690
|
+
timeoutMs: this.commandTimeoutMs,
|
|
691
|
+
}));
|
|
692
|
+
}, this.commandTimeoutMs);
|
|
693
|
+
timeout.unref?.();
|
|
694
|
+
this.pendingCommands.set(requestId, {
|
|
695
|
+
...commandContext,
|
|
696
|
+
reject,
|
|
697
|
+
resolve,
|
|
698
|
+
timeout,
|
|
699
|
+
});
|
|
700
|
+
try {
|
|
701
|
+
this.requireOpenSocket().send(JSON.stringify(command));
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
this.settlePendingCommand(requestId);
|
|
705
|
+
resume(Effect.fail(error instanceof Error ? error : new Error("Command send failed")));
|
|
706
|
+
}
|
|
707
|
+
return Effect.sync(() => {
|
|
708
|
+
this.settlePendingCommand(requestId);
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
/** Removes one pending command and clears its timer before settlement. */
|
|
713
|
+
settlePendingCommand(requestId) {
|
|
714
|
+
const pending = this.pendingCommands.get(requestId);
|
|
715
|
+
if (!pending) {
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
this.pendingCommands.delete(requestId);
|
|
719
|
+
clearTimeout(pending.timeout);
|
|
720
|
+
return pending;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Builds the WebSocket stream URL with participant identity and capabilities in
|
|
725
|
+
* query parameters.
|
|
726
|
+
*/
|
|
727
|
+
export function buildParticipantRuntimeStreamUrl(config) {
|
|
728
|
+
const url = new URL(`/sessions/${config.sessionId}/stream`, config.serviceUrl);
|
|
729
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
730
|
+
url.searchParams.set("after", String(config.afterSeq));
|
|
731
|
+
url.searchParams.set("capabilities", JSON.stringify(config.capabilities));
|
|
732
|
+
url.searchParams.set("displayName", config.displayName);
|
|
733
|
+
url.searchParams.set("instanceId", config.instanceId);
|
|
734
|
+
url.searchParams.set("participantId", config.participantId);
|
|
735
|
+
url.searchParams.set("runtimeKind", config.runtimeKind);
|
|
736
|
+
const authToken = resolveServiceAuthToken(config.authToken);
|
|
737
|
+
if (authToken) {
|
|
738
|
+
url.searchParams.set("access_token", authToken);
|
|
739
|
+
}
|
|
740
|
+
return url.toString();
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Computes bounded exponential reconnect backoff from client configuration.
|
|
744
|
+
*/
|
|
745
|
+
function reconnectDelayMs(attempt, config) {
|
|
746
|
+
const baseDelayMs = config.reconnect?.baseDelayMs ?? defaultReconnectBaseDelayMs;
|
|
747
|
+
const maxDelayMs = config.reconnect?.maxDelayMs ?? defaultReconnectMaxDelayMs;
|
|
748
|
+
return Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Resolves the durable resume sequence as the higher of the configured
|
|
752
|
+
* `afterSeq` floor and the persisted cursor, treating a missing or non-finite
|
|
753
|
+
* stored value as zero. The resume point is never below `afterSeq`.
|
|
754
|
+
*/
|
|
755
|
+
export function resolveResumeSeq(afterSeq, storedSeq) {
|
|
756
|
+
const normalized = typeof storedSeq === "number" && Number.isFinite(storedSeq) ? storedSeq : 0;
|
|
757
|
+
return Math.max(afterSeq, normalized);
|
|
758
|
+
}
|
|
759
|
+
/** Resolves and validates the finite command response timeout. */
|
|
760
|
+
export function resolveCommandTimeoutMs(commandTimeoutMs) {
|
|
761
|
+
const resolved = commandTimeoutMs ?? defaultCommandTimeoutMs;
|
|
762
|
+
if (!Number.isFinite(resolved) || resolved <= 0) {
|
|
763
|
+
throw new ParticipantRuntimeClientConfigurationError("commandTimeoutMs", "Participant runtime commandTimeoutMs must be a finite positive number");
|
|
764
|
+
}
|
|
765
|
+
return resolved;
|
|
766
|
+
}
|
|
767
|
+
/** Extracts bounded command metadata without retaining the full payload. */
|
|
768
|
+
function readCommandContext(command, requestId) {
|
|
769
|
+
const taskId = readStringField(command, "taskId");
|
|
770
|
+
return {
|
|
771
|
+
op: command.op,
|
|
772
|
+
requestId,
|
|
773
|
+
...(taskId !== undefined ? { taskId } : {}),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
/** Reads one string field from a passthrough protocol record. */
|
|
777
|
+
function readStringField(record, field) {
|
|
778
|
+
const value = record[field];
|
|
779
|
+
return typeof value === "string" ? value : undefined;
|
|
780
|
+
}
|