@rivetkit/engine-runner 25.7.1-rc.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/src/mod.ts ADDED
@@ -0,0 +1,1354 @@
1
+ import WebSocket from "ws";
2
+ import { importWebSocket } from "./websocket.js";
3
+ import * as protocol from "@rivetkit/engine-runner-protocol";
4
+ import { unreachable, calculateBackoff } from "./utils";
5
+ import { Tunnel } from "./tunnel";
6
+ import { WebSocketTunnelAdapter } from "./websocket-tunnel-adapter";
7
+ import type { Logger } from "pino";
8
+ import { setLogger, logger } from "./log.js";
9
+
10
+ const KV_EXPIRE: number = 30_000;
11
+
12
+ export interface ActorInstance {
13
+ actorId: string;
14
+ generation: number;
15
+ config: ActorConfig;
16
+ requests: Set<string>; // Track active request IDs
17
+ webSockets: Set<string>; // Track active WebSocket IDs
18
+ }
19
+
20
+ export interface ActorConfig {
21
+ name: string;
22
+ key: string | null;
23
+ createTs: bigint;
24
+ input: Uint8Array | null;
25
+ }
26
+
27
+ export interface RunnerConfig {
28
+ logger?: Logger;
29
+ version: number;
30
+ endpoint: string;
31
+ pegboardEndpoint?: string;
32
+ pegboardRelayEndpoint?: string;
33
+ namespace: string;
34
+ totalSlots: number;
35
+ runnerName: string;
36
+ runnerKey: string;
37
+ prepopulateActorNames: Record<string, { metadata: Record<string, any> }>;
38
+ metadata?: Record<string, any>;
39
+ onConnected: () => void;
40
+ onDisconnected: () => void;
41
+ onShutdown: () => void;
42
+ fetch: (actorId: string, request: Request) => Promise<Response>;
43
+ websocket?: (actorId: string, ws: any, request: Request) => Promise<void>;
44
+ onActorStart: (
45
+ actorId: string,
46
+ generation: number,
47
+ config: ActorConfig,
48
+ ) => Promise<void>;
49
+ onActorStop: (actorId: string, generation: number) => Promise<void>;
50
+ noAutoShutdown?: boolean;
51
+ }
52
+
53
+ export interface KvListOptions {
54
+ reverse?: boolean;
55
+ limit?: number;
56
+ }
57
+
58
+ interface KvRequestEntry {
59
+ actorId: string;
60
+ data: protocol.KvRequestData;
61
+ resolve: (value: any) => void;
62
+ reject: (error: unknown) => void;
63
+ sent: boolean;
64
+ timestamp: number;
65
+ }
66
+
67
+ export class Runner {
68
+ #config: RunnerConfig;
69
+
70
+ get config(): RunnerConfig {
71
+ return this.#config;
72
+ }
73
+
74
+ #actors: Map<string, ActorInstance> = new Map();
75
+ #actorWebSockets: Map<string, Set<WebSocketTunnelAdapter>> = new Map();
76
+
77
+ // WebSocket
78
+ #pegboardWebSocket?: WebSocket;
79
+ runnerId?: string;
80
+ #lastCommandIdx: number = -1;
81
+ #pingLoop?: NodeJS.Timeout;
82
+ #nextEventIdx: bigint = 0n;
83
+ #started: boolean = false;
84
+ #shutdown: boolean = false;
85
+ #reconnectAttempt: number = 0;
86
+ #reconnectTimeout?: NodeJS.Timeout;
87
+
88
+ // Runner lost threshold management
89
+ #runnerLostThreshold?: number;
90
+ #runnerLostTimeout?: NodeJS.Timeout;
91
+
92
+ // Event storage for resending
93
+ #eventHistory: { event: protocol.EventWrapper; timestamp: number }[] = [];
94
+ #eventPruneInterval?: NodeJS.Timeout;
95
+
96
+ // Command acknowledgment
97
+ #ackInterval?: NodeJS.Timeout;
98
+
99
+ // KV operations
100
+ #nextRequestId: number = 0;
101
+ #kvRequests: Map<number, KvRequestEntry> = new Map();
102
+ #kvCleanupInterval?: NodeJS.Timeout;
103
+
104
+ // Tunnel for HTTP/WebSocket forwarding
105
+ #tunnel?: Tunnel;
106
+
107
+ constructor(config: RunnerConfig) {
108
+ this.#config = config;
109
+ if (this.#config.logger) setLogger(this.#config.logger);
110
+
111
+ // TODO(RVT-4986): Prune when server acks events
112
+ // Start pruning old events every minute
113
+ this.#eventPruneInterval = setInterval(() => {
114
+ this.#pruneOldEvents();
115
+ }, 60000); // Run every minute
116
+
117
+ // Start cleaning up old unsent KV requests every 15 seconds
118
+ this.#kvCleanupInterval = setInterval(() => {
119
+ this.#cleanupOldKvRequests();
120
+ }, 15000); // Run every 15 seconds
121
+ }
122
+
123
+ // MARK: Manage actors
124
+ sleepActor(actorId: string, generation?: number) {
125
+ const actor = this.getActor(actorId, generation);
126
+ if (!actor) return;
127
+
128
+ // Keep the actor instance in memory during sleep
129
+ this.#sendActorIntent(actorId, actor.generation, "sleep");
130
+
131
+ // NOTE: We do NOT remove the actor from this.#actors here
132
+ // The server will send a StopActor command if it wants to fully stop
133
+ }
134
+
135
+ async stopActor(actorId: string, generation?: number) {
136
+ const actor = this.#removeActor(actorId, generation);
137
+ if (!actor) return;
138
+
139
+ // Unregister actor from tunnel
140
+ if (this.#tunnel) {
141
+ this.#tunnel.unregisterActor(actor);
142
+ }
143
+
144
+ // If onActorStop times out, Pegboard will handle this timeout with ACTOR_STOP_THRESHOLD_DURATION_MS
145
+ try {
146
+ await this.#config.onActorStop(actorId, actor.generation);
147
+ } catch (err) {
148
+ console.error(`Error in onActorStop for actor ${actorId}:`, err);
149
+ }
150
+
151
+ this.#sendActorStateUpdate(actorId, actor.generation, "stopped");
152
+
153
+ this.#config.onActorStop(actorId, actor.generation).catch((err) => {
154
+ logger()?.error({
155
+ msg: "error in onactorstop for actor",
156
+ actorId,
157
+ err,
158
+ });
159
+ });
160
+ }
161
+
162
+ #stopAllActors() {
163
+ logger()?.info(
164
+ "stopping all actors due to runner lost threshold exceeded",
165
+ );
166
+
167
+ const actorIds = Array.from(this.#actors.keys());
168
+ for (const actorId of actorIds) {
169
+ this.stopActor(actorId);
170
+ }
171
+ }
172
+
173
+ getActor(actorId: string, generation?: number): ActorInstance | undefined {
174
+ const actor = this.#actors.get(actorId);
175
+ if (!actor) {
176
+ logger()?.error({ msg: "actor not found", actorId });
177
+ return undefined;
178
+ }
179
+ if (generation !== undefined && actor.generation !== generation) {
180
+ logger()?.error({
181
+ msg: "actor generation mismatch",
182
+ actorId,
183
+ generation,
184
+ });
185
+ return undefined;
186
+ }
187
+
188
+ return actor;
189
+ }
190
+
191
+ hasActor(actorId: string, generation?: number): boolean {
192
+ const actor = this.#actors.get(actorId);
193
+
194
+ return (
195
+ !!actor &&
196
+ (generation === undefined || actor.generation === generation)
197
+ );
198
+ }
199
+
200
+ #removeActor(
201
+ actorId: string,
202
+ generation?: number,
203
+ ): ActorInstance | undefined {
204
+ const actor = this.#actors.get(actorId);
205
+ if (!actor) {
206
+ logger()?.error({ msg: "actor not found", actorId });
207
+ return undefined;
208
+ }
209
+ if (generation !== undefined && actor.generation !== generation) {
210
+ logger()?.error({
211
+ msg: "actor generation mismatch",
212
+ actorId,
213
+ generation,
214
+ });
215
+ return undefined;
216
+ }
217
+
218
+ this.#actors.delete(actorId);
219
+
220
+ // Close all WebSocket connections for this actor
221
+ const actorWebSockets = this.#actorWebSockets.get(actorId);
222
+ if (actorWebSockets) {
223
+ for (const ws of actorWebSockets) {
224
+ try {
225
+ ws.close(1000, "Actor stopped");
226
+ } catch (err) {
227
+ logger()?.error({
228
+ msg: "error closing websocket for actor",
229
+ actorId,
230
+ err,
231
+ });
232
+ }
233
+ }
234
+ this.#actorWebSockets.delete(actorId);
235
+ }
236
+
237
+ return actor;
238
+ }
239
+
240
+ // MARK: Start
241
+ async start() {
242
+ if (this.#started) throw new Error("Cannot call runner.start twice");
243
+ this.#started = true;
244
+
245
+ logger()?.info("starting runner");
246
+
247
+ try {
248
+ // Connect tunnel first and wait for it to be ready before connecting runner WebSocket
249
+ // This prevents a race condition where the runner appears ready but can't accept network requests
250
+ await this.#openTunnelAndWait();
251
+ await this.#openPegboardWebSocket();
252
+ } catch (error) {
253
+ this.#started = false;
254
+ throw error;
255
+ }
256
+
257
+ if (!this.#config.noAutoShutdown) {
258
+ process.on("SIGTERM", this.shutdown.bind(this, false, true));
259
+ process.on("SIGINT", this.shutdown.bind(this, false, true));
260
+ }
261
+ }
262
+
263
+ // MARK: Shutdown
264
+ async shutdown(immediate: boolean, exit: boolean = false) {
265
+ logger()?.info({ msg: "starting shutdown...", immediate });
266
+ this.#shutdown = true;
267
+
268
+ // Clear reconnect timeout
269
+ if (this.#reconnectTimeout) {
270
+ clearTimeout(this.#reconnectTimeout);
271
+ this.#reconnectTimeout = undefined;
272
+ }
273
+
274
+ // Clear runner lost timeout
275
+ if (this.#runnerLostTimeout) {
276
+ clearTimeout(this.#runnerLostTimeout);
277
+ this.#runnerLostTimeout = undefined;
278
+ }
279
+
280
+ // Clear ping loop
281
+ if (this.#pingLoop) {
282
+ clearInterval(this.#pingLoop);
283
+ this.#pingLoop = undefined;
284
+ }
285
+
286
+ // Clear ack interval
287
+ if (this.#ackInterval) {
288
+ clearInterval(this.#ackInterval);
289
+ this.#ackInterval = undefined;
290
+ }
291
+
292
+ // Clear event prune interval
293
+ if (this.#eventPruneInterval) {
294
+ clearInterval(this.#eventPruneInterval);
295
+ this.#eventPruneInterval = undefined;
296
+ }
297
+
298
+ // Clear KV cleanup interval
299
+ if (this.#kvCleanupInterval) {
300
+ clearInterval(this.#kvCleanupInterval);
301
+ this.#kvCleanupInterval = undefined;
302
+ }
303
+
304
+ // Reject all KV requests
305
+ for (const request of this.#kvRequests.values()) {
306
+ request.reject(
307
+ new Error("WebSocket connection closed during shutdown"),
308
+ );
309
+ }
310
+ this.#kvRequests.clear();
311
+
312
+ // Close WebSocket
313
+ if (
314
+ this.#pegboardWebSocket &&
315
+ this.#pegboardWebSocket.readyState === WebSocket.OPEN
316
+ ) {
317
+ const pegboardWebSocket = this.#pegboardWebSocket;
318
+ if (immediate) {
319
+ // Stop immediately
320
+ pegboardWebSocket.close(1000, "Stopping");
321
+ } else {
322
+ // Wait for actors to shut down before stopping
323
+ try {
324
+ logger()?.info({
325
+ msg: "sending stopping message",
326
+ readyState: pegboardWebSocket.readyState,
327
+ });
328
+
329
+ // NOTE: We don't use #sendToServer here because that function checks if the runner is
330
+ // shut down
331
+ const encoded = protocol.encodeToServer({
332
+ tag: "ToServerStopping",
333
+ val: null,
334
+ });
335
+ if (
336
+ this.#pegboardWebSocket &&
337
+ this.#pegboardWebSocket.readyState === WebSocket.OPEN
338
+ ) {
339
+ this.#pegboardWebSocket.send(encoded);
340
+ } else {
341
+ logger()?.error(
342
+ "WebSocket not available or not open for sending data",
343
+ );
344
+ }
345
+
346
+ const closePromise = new Promise<void>((resolve) => {
347
+ if (!pegboardWebSocket)
348
+ throw new Error("missing pegboardWebSocket");
349
+
350
+ pegboardWebSocket.addEventListener("close", (ev) => {
351
+ logger()?.info({
352
+ msg: "connection closed",
353
+ code: ev.code,
354
+ reason: ev.reason.toString(),
355
+ });
356
+ resolve();
357
+ });
358
+ });
359
+
360
+ // TODO: Wait for all actors to stop before closing ws
361
+
362
+ logger()?.info("closing WebSocket");
363
+ pegboardWebSocket.close(1000, "Stopping");
364
+
365
+ await closePromise;
366
+
367
+ logger()?.info("websocket shutdown completed");
368
+ } catch (error) {
369
+ logger()?.error({
370
+ msg: "error during websocket shutdown:",
371
+ error,
372
+ });
373
+ pegboardWebSocket.close();
374
+ }
375
+ }
376
+ } else {
377
+ logger()?.warn("no runner WebSocket to shutdown or already closed");
378
+ }
379
+
380
+ // Close tunnel
381
+ if (this.#tunnel) {
382
+ this.#tunnel.shutdown();
383
+ logger()?.info("tunnel shutdown completed");
384
+ }
385
+
386
+ if (exit) process.exit(0);
387
+
388
+ this.#config.onShutdown();
389
+ }
390
+
391
+ // MARK: Networking
392
+ get pegboardUrl() {
393
+ const endpoint = this.#config.pegboardEndpoint || this.#config.endpoint;
394
+ const wsEndpoint = endpoint
395
+ .replace("http://", "ws://")
396
+ .replace("https://", "wss://");
397
+ return `${wsEndpoint}?protocol_version=1&namespace=${encodeURIComponent(this.#config.namespace)}&runner_key=${encodeURIComponent(this.#config.runnerKey)}`;
398
+ }
399
+
400
+ get pegboardTunnelUrl() {
401
+ const endpoint =
402
+ this.#config.pegboardRelayEndpoint ||
403
+ this.#config.pegboardEndpoint ||
404
+ this.#config.endpoint;
405
+ const wsEndpoint = endpoint
406
+ .replace("http://", "ws://")
407
+ .replace("https://", "wss://");
408
+ return `${wsEndpoint}?protocol_version=1&namespace=${encodeURIComponent(this.#config.namespace)}&runner_key=${this.#config.runnerKey}`;
409
+ }
410
+
411
+ async #openTunnelAndWait(): Promise<void> {
412
+ return new Promise((resolve, reject) => {
413
+ const url = this.pegboardTunnelUrl;
414
+ logger()?.info({ msg: "opening tunnel to:", url });
415
+ logger()?.info({
416
+ msg: "current runner id:",
417
+ runnerId: this.runnerId || "none",
418
+ });
419
+ logger()?.info({
420
+ msg: "active actors count:",
421
+ actors: this.#actors.size,
422
+ });
423
+
424
+ let connected = false;
425
+
426
+ this.#tunnel = new Tunnel(this, url, {
427
+ onConnected: () => {
428
+ if (!connected) {
429
+ connected = true;
430
+ logger()?.info("tunnel connected");
431
+ resolve();
432
+ }
433
+ },
434
+ onDisconnected: () => {
435
+ if (!connected) {
436
+ // First connection attempt failed
437
+ reject(new Error("Tunnel connection failed"));
438
+ }
439
+ // If already connected, tunnel will handle reconnection automatically
440
+ },
441
+ });
442
+ this.#tunnel.start();
443
+ });
444
+ }
445
+
446
+ // MARK: Runner protocol
447
+ async #openPegboardWebSocket() {
448
+ const WS = await importWebSocket();
449
+ const ws = new WS(this.pegboardUrl, {
450
+ headers: {
451
+ "x-rivet-target": "runner-ws",
452
+ },
453
+ }) as any as WebSocket;
454
+ this.#pegboardWebSocket = ws;
455
+
456
+ ws.addEventListener("open", () => {
457
+ logger()?.info("Connected");
458
+
459
+ // Reset reconnect attempt counter on successful connection
460
+ this.#reconnectAttempt = 0;
461
+
462
+ // Clear any pending reconnect timeout
463
+ if (this.#reconnectTimeout) {
464
+ clearTimeout(this.#reconnectTimeout);
465
+ this.#reconnectTimeout = undefined;
466
+ }
467
+
468
+ // Clear any pending runner lost timeout since we're reconnecting
469
+ if (this.#runnerLostTimeout) {
470
+ clearTimeout(this.#runnerLostTimeout);
471
+ this.#runnerLostTimeout = undefined;
472
+ }
473
+
474
+ // Send init message
475
+ const init: protocol.ToServerInit = {
476
+ name: this.#config.runnerName,
477
+ version: this.#config.version,
478
+ totalSlots: this.#config.totalSlots,
479
+ lastCommandIdx:
480
+ this.#lastCommandIdx >= 0
481
+ ? BigInt(this.#lastCommandIdx)
482
+ : null,
483
+ prepopulateActorNames: new Map(
484
+ Object.entries(this.#config.prepopulateActorNames).map(
485
+ ([name, data]) => [
486
+ name,
487
+ { metadata: JSON.stringify(data.metadata) },
488
+ ],
489
+ ),
490
+ ),
491
+ metadata: JSON.stringify(this.#config.metadata),
492
+ };
493
+
494
+ this.#sendToServer({
495
+ tag: "ToServerInit",
496
+ val: init,
497
+ });
498
+
499
+ // Process unsent KV requests
500
+ this.#processUnsentKvRequests();
501
+
502
+ // Start ping interval
503
+ const pingInterval = 1000;
504
+ const pingLoop = setInterval(() => {
505
+ if (ws.readyState === WebSocket.OPEN) {
506
+ this.#sendToServer({
507
+ tag: "ToServerPing",
508
+ val: {
509
+ ts: BigInt(Date.now()),
510
+ },
511
+ });
512
+ } else {
513
+ clearInterval(pingLoop);
514
+ logger()?.info("WebSocket not open, stopping ping loop");
515
+ }
516
+ }, pingInterval);
517
+ this.#pingLoop = pingLoop;
518
+
519
+ // Start command acknowledgment interval (5 minutes)
520
+ const ackInterval = 5 * 60 * 1000; // 5 minutes in milliseconds
521
+ const ackLoop = setInterval(() => {
522
+ if (ws.readyState === WebSocket.OPEN) {
523
+ this.#sendCommandAcknowledgment();
524
+ } else {
525
+ clearInterval(ackLoop);
526
+ logger()?.info("WebSocket not open, stopping ack loop");
527
+ }
528
+ }, ackInterval);
529
+ this.#ackInterval = ackLoop;
530
+ });
531
+
532
+ ws.addEventListener("message", async (ev) => {
533
+ let buf;
534
+ if (ev.data instanceof Blob) {
535
+ buf = new Uint8Array(await ev.data.arrayBuffer());
536
+ } else if (Buffer.isBuffer(ev.data)) {
537
+ buf = new Uint8Array(ev.data);
538
+ } else {
539
+ throw new Error("expected binary data, got " + typeof ev.data);
540
+ }
541
+
542
+ // Parse message
543
+ const message = protocol.decodeToClient(buf);
544
+
545
+ // Handle message
546
+ if (message.tag === "ToClientInit") {
547
+ const init = message.val;
548
+ const hadRunnerId = !!this.runnerId;
549
+ this.runnerId = init.runnerId;
550
+
551
+ // Store the runner lost threshold from metadata
552
+ this.#runnerLostThreshold = init.metadata?.runnerLostThreshold
553
+ ? Number(init.metadata.runnerLostThreshold)
554
+ : undefined;
555
+
556
+ logger()?.info({
557
+ msg: "received init",
558
+ runnerId: init.runnerId,
559
+ lastEventIdx: init.lastEventIdx,
560
+ runnerLostThreshold: this.#runnerLostThreshold,
561
+ });
562
+
563
+ // Resend events that haven't been acknowledged
564
+ this.#resendUnacknowledgedEvents(init.lastEventIdx);
565
+
566
+ this.#config.onConnected();
567
+ } else if (message.tag === "ToClientCommands") {
568
+ const commands = message.val;
569
+ this.#handleCommands(commands);
570
+ } else if (message.tag === "ToClientAckEvents") {
571
+ throw new Error("TODO");
572
+ } else if (message.tag === "ToClientKvResponse") {
573
+ const kvResponse = message.val;
574
+ this.#handleKvResponse(kvResponse);
575
+ }
576
+ });
577
+
578
+ ws.addEventListener("error", (ev) => {
579
+ logger()?.error("WebSocket error:", ev.error);
580
+ });
581
+
582
+ ws.addEventListener("close", (ev) => {
583
+ logger()?.info({
584
+ msg: "connection closed",
585
+ code: ev.code,
586
+ reason: ev.reason.toString(),
587
+ });
588
+
589
+ this.#config.onDisconnected();
590
+
591
+ // Clear ping loop on close
592
+ if (this.#pingLoop) {
593
+ clearInterval(this.#pingLoop);
594
+ this.#pingLoop = undefined;
595
+ }
596
+
597
+ // Clear ack interval on close
598
+ if (this.#ackInterval) {
599
+ clearInterval(this.#ackInterval);
600
+ this.#ackInterval = undefined;
601
+ }
602
+
603
+ if (!this.#shutdown) {
604
+ // Start runner lost timeout if we have a threshold and are not shutting down
605
+ if (
606
+ this.#runnerLostThreshold &&
607
+ this.#runnerLostThreshold > 0
608
+ ) {
609
+ logger()?.info({
610
+ msg: "starting runner lost timeout",
611
+ seconds: this.#runnerLostThreshold / 1000,
612
+ });
613
+ this.#runnerLostTimeout = setTimeout(() => {
614
+ this.#stopAllActors();
615
+ }, this.#runnerLostThreshold);
616
+ }
617
+
618
+ // Attempt to reconnect if not stopped
619
+ this.#scheduleReconnect();
620
+ }
621
+ });
622
+ }
623
+
624
+ #handleCommands(commands: protocol.ToClientCommands) {
625
+ logger()?.info({
626
+ msg: "received commands",
627
+ commandCount: commands.length,
628
+ });
629
+
630
+ for (const commandWrapper of commands) {
631
+ logger()?.info({ msg: "received command", commandWrapper });
632
+ if (commandWrapper.inner.tag === "CommandStartActor") {
633
+ this.#handleCommandStartActor(commandWrapper);
634
+ } else if (commandWrapper.inner.tag === "CommandStopActor") {
635
+ this.#handleCommandStopActor(commandWrapper);
636
+ }
637
+
638
+ this.#lastCommandIdx = Number(commandWrapper.index);
639
+ }
640
+ }
641
+
642
+ #handleCommandStartActor(commandWrapper: protocol.CommandWrapper) {
643
+ const startCommand = commandWrapper.inner
644
+ .val as protocol.CommandStartActor;
645
+
646
+ const actorId = startCommand.actorId;
647
+ const generation = startCommand.generation;
648
+ const config = startCommand.config;
649
+
650
+ const actorConfig: ActorConfig = {
651
+ name: config.name,
652
+ key: config.key,
653
+ createTs: config.createTs,
654
+ input: config.input ? new Uint8Array(config.input) : null,
655
+ };
656
+
657
+ const instance: ActorInstance = {
658
+ actorId,
659
+ generation,
660
+ config: actorConfig,
661
+ requests: new Set(),
662
+ webSockets: new Set(),
663
+ };
664
+
665
+ this.#actors.set(actorId, instance);
666
+
667
+ this.#sendActorStateUpdate(actorId, generation, "running");
668
+
669
+ // TODO: Add timeout to onActorStart
670
+ // Call onActorStart asynchronously and handle errors
671
+ this.#config
672
+ .onActorStart(actorId, generation, actorConfig)
673
+ .catch((err) => {
674
+ logger()?.error({
675
+ msg: "error in onactorstart for actor",
676
+ actorId,
677
+ err,
678
+ });
679
+
680
+ // TODO: Mark as crashed
681
+ // Send stopped state update if start failed
682
+ this.stopActor(actorId, generation);
683
+ });
684
+ }
685
+
686
+ #handleCommandStopActor(commandWrapper: protocol.CommandWrapper) {
687
+ const stopCommand = commandWrapper.inner
688
+ .val as protocol.CommandStopActor;
689
+
690
+ const actorId = stopCommand.actorId;
691
+ const generation = stopCommand.generation;
692
+
693
+ this.stopActor(actorId, generation);
694
+ }
695
+
696
+ #sendActorIntent(
697
+ actorId: string,
698
+ generation: number,
699
+ intentType: "sleep" | "stop",
700
+ ) {
701
+ if (this.#shutdown) {
702
+ logger()?.warn("Runner is shut down, cannot send actor intent");
703
+ return;
704
+ }
705
+ let actorIntent: protocol.ActorIntent;
706
+
707
+ if (intentType === "sleep") {
708
+ actorIntent = { tag: "ActorIntentSleep", val: null };
709
+ } else if (intentType === "stop") {
710
+ actorIntent = {
711
+ tag: "ActorIntentStop",
712
+ val: null,
713
+ };
714
+ } else {
715
+ unreachable(intentType);
716
+ }
717
+
718
+ const intentEvent: protocol.EventActorIntent = {
719
+ actorId,
720
+ generation,
721
+ intent: actorIntent,
722
+ };
723
+
724
+ const eventIndex = this.#nextEventIdx++;
725
+ const eventWrapper: protocol.EventWrapper = {
726
+ index: eventIndex,
727
+ inner: {
728
+ tag: "EventActorIntent",
729
+ val: intentEvent,
730
+ },
731
+ };
732
+
733
+ // Store event in history for potential resending
734
+ this.#eventHistory.push({
735
+ event: eventWrapper,
736
+ timestamp: Date.now(),
737
+ });
738
+
739
+ logger()?.info({
740
+ msg: "sending event to server",
741
+ index: eventWrapper.index,
742
+ tag: eventWrapper.inner.tag,
743
+ val: eventWrapper.inner.val,
744
+ });
745
+
746
+ this.#sendToServer({
747
+ tag: "ToServerEvents",
748
+ val: [eventWrapper],
749
+ });
750
+ }
751
+
752
+ #sendActorStateUpdate(
753
+ actorId: string,
754
+ generation: number,
755
+ stateType: "running" | "stopped",
756
+ ) {
757
+ if (this.#shutdown) {
758
+ logger()?.warn(
759
+ "Runner is shut down, cannot send actor state update",
760
+ );
761
+ return;
762
+ }
763
+ let actorState: protocol.ActorState;
764
+
765
+ if (stateType === "running") {
766
+ actorState = { tag: "ActorStateRunning", val: null };
767
+ } else if (stateType === "stopped") {
768
+ actorState = {
769
+ tag: "ActorStateStopped",
770
+ val: {
771
+ code: protocol.StopCode.Ok,
772
+ message: "hello",
773
+ },
774
+ };
775
+ } else {
776
+ unreachable(stateType);
777
+ }
778
+
779
+ const stateUpdateEvent: protocol.EventActorStateUpdate = {
780
+ actorId,
781
+ generation,
782
+ state: actorState,
783
+ };
784
+
785
+ const eventIndex = this.#nextEventIdx++;
786
+ const eventWrapper: protocol.EventWrapper = {
787
+ index: eventIndex,
788
+ inner: {
789
+ tag: "EventActorStateUpdate",
790
+ val: stateUpdateEvent,
791
+ },
792
+ };
793
+
794
+ // Store event in history for potential resending
795
+ this.#eventHistory.push({
796
+ event: eventWrapper,
797
+ timestamp: Date.now(),
798
+ });
799
+
800
+ logger()?.info({
801
+ msg: "sending event to server",
802
+ index: eventWrapper.index,
803
+ tag: eventWrapper.inner.tag,
804
+ val: eventWrapper.inner.val,
805
+ });
806
+
807
+ this.#sendToServer({
808
+ tag: "ToServerEvents",
809
+ val: [eventWrapper],
810
+ });
811
+ }
812
+
813
+ #sendCommandAcknowledgment() {
814
+ if (this.#shutdown) {
815
+ logger()?.warn(
816
+ "Runner is shut down, cannot send command acknowledgment",
817
+ );
818
+ return;
819
+ }
820
+
821
+ if (this.#lastCommandIdx < 0) {
822
+ // No commands received yet, nothing to acknowledge
823
+ return;
824
+ }
825
+
826
+ //logger()?.log("Sending command acknowledgment", this.#lastCommandIdx);
827
+
828
+ this.#sendToServer({
829
+ tag: "ToServerAckCommands",
830
+ val: {
831
+ lastCommandIdx: BigInt(this.#lastCommandIdx),
832
+ },
833
+ });
834
+ }
835
+
836
+ #handleKvResponse(response: protocol.ToClientKvResponse) {
837
+ const requestId = response.requestId;
838
+ const request = this.#kvRequests.get(requestId);
839
+
840
+ if (!request) {
841
+ const msg = "received kv response for unknown request id";
842
+ if (logger()) {
843
+ logger()?.error({ msg, requestId });
844
+ } else {
845
+ logger()?.error({ msg, requestId });
846
+ }
847
+ return;
848
+ }
849
+
850
+ this.#kvRequests.delete(requestId);
851
+
852
+ if (response.data.tag === "KvErrorResponse") {
853
+ request.reject(
854
+ new Error(response.data.val.message || "Unknown KV error"),
855
+ );
856
+ } else {
857
+ request.resolve(response.data.val);
858
+ }
859
+ }
860
+
861
+ #parseGetResponseSimple(
862
+ response: protocol.KvGetResponse,
863
+ requestedKeys: Uint8Array[],
864
+ ): (Uint8Array | null)[] {
865
+ // Parse the response keys and values
866
+ const responseKeys: Uint8Array[] = [];
867
+ const responseValues: Uint8Array[] = [];
868
+
869
+ for (const key of response.keys) {
870
+ responseKeys.push(new Uint8Array(key));
871
+ }
872
+
873
+ for (const value of response.values) {
874
+ responseValues.push(new Uint8Array(value));
875
+ }
876
+
877
+ // Map response back to requested key order
878
+ const result: (Uint8Array | null)[] = [];
879
+ for (const requestedKey of requestedKeys) {
880
+ let found = false;
881
+ for (let i = 0; i < responseKeys.length; i++) {
882
+ if (this.#keysEqual(requestedKey, responseKeys[i])) {
883
+ result.push(responseValues[i]);
884
+ found = true;
885
+ break;
886
+ }
887
+ }
888
+ if (!found) {
889
+ result.push(null);
890
+ }
891
+ }
892
+
893
+ return result;
894
+ }
895
+
896
+ #keysEqual(key1: Uint8Array, key2: Uint8Array): boolean {
897
+ if (key1.length !== key2.length) return false;
898
+ for (let i = 0; i < key1.length; i++) {
899
+ if (key1[i] !== key2[i]) return false;
900
+ }
901
+ return true;
902
+ }
903
+
904
+ //#parseGetResponse(response: protocol.KvGetResponse) {
905
+ // const keys: string[] = [];
906
+ // const values: Uint8Array[] = [];
907
+ // const metadata: { version: Uint8Array; createTs: bigint }[] = [];
908
+ //
909
+ // for (const key of response.keys) {
910
+ // keys.push(new TextDecoder().decode(key));
911
+ // }
912
+ //
913
+ // for (const value of response.values) {
914
+ // values.push(new Uint8Array(value));
915
+ // }
916
+ //
917
+ // for (const meta of response.metadata) {
918
+ // metadata.push({
919
+ // version: new Uint8Array(meta.version),
920
+ // createTs: meta.createTs,
921
+ // });
922
+ // }
923
+ //
924
+ // return { keys, values, metadata };
925
+ //}
926
+
927
+ #parseListResponseSimple(
928
+ response: protocol.KvListResponse,
929
+ ): [Uint8Array, Uint8Array][] {
930
+ const result: [Uint8Array, Uint8Array][] = [];
931
+
932
+ for (let i = 0; i < response.keys.length; i++) {
933
+ const key = response.keys[i];
934
+ const value = response.values[i];
935
+
936
+ if (key && value) {
937
+ const keyBytes = new Uint8Array(key);
938
+ const valueBytes = new Uint8Array(value);
939
+ result.push([keyBytes, valueBytes]);
940
+ }
941
+ }
942
+
943
+ return result;
944
+ }
945
+
946
+ //#parseListResponse(response: protocol.KvListResponse) {
947
+ // const keys: string[] = [];
948
+ // const values: Uint8Array[] = [];
949
+ // const metadata: { version: Uint8Array; createTs: bigint }[] = [];
950
+ //
951
+ // for (const key of response.keys) {
952
+ // keys.push(new TextDecoder().decode(key));
953
+ // }
954
+ //
955
+ // for (const value of response.values) {
956
+ // values.push(new Uint8Array(value));
957
+ // }
958
+ //
959
+ // for (const meta of response.metadata) {
960
+ // metadata.push({
961
+ // version: new Uint8Array(meta.version),
962
+ // createTs: meta.createTs,
963
+ // });
964
+ // }
965
+ //
966
+ // return { keys, values, metadata };
967
+ //}
968
+
969
+ // MARK: KV Operations
970
+ async kvGet(
971
+ actorId: string,
972
+ keys: Uint8Array[],
973
+ ): Promise<(Uint8Array | null)[]> {
974
+ const kvKeys: protocol.KvKey[] = keys.map(
975
+ (key) =>
976
+ key.buffer.slice(
977
+ key.byteOffset,
978
+ key.byteOffset + key.byteLength,
979
+ ) as ArrayBuffer,
980
+ );
981
+
982
+ const requestData: protocol.KvRequestData = {
983
+ tag: "KvGetRequest",
984
+ val: { keys: kvKeys },
985
+ };
986
+
987
+ const response = await this.#sendKvRequest(actorId, requestData);
988
+ return this.#parseGetResponseSimple(response, keys);
989
+ }
990
+
991
+ async kvListAll(
992
+ actorId: string,
993
+ options?: KvListOptions,
994
+ ): Promise<[Uint8Array, Uint8Array][]> {
995
+ const requestData: protocol.KvRequestData = {
996
+ tag: "KvListRequest",
997
+ val: {
998
+ query: { tag: "KvListAllQuery", val: null },
999
+ reverse: options?.reverse || null,
1000
+ limit:
1001
+ options?.limit !== undefined ? BigInt(options.limit) : null,
1002
+ },
1003
+ };
1004
+
1005
+ const response = await this.#sendKvRequest(actorId, requestData);
1006
+ return this.#parseListResponseSimple(response);
1007
+ }
1008
+
1009
+ async kvListRange(
1010
+ actorId: string,
1011
+ start: Uint8Array,
1012
+ end: Uint8Array,
1013
+ exclusive?: boolean,
1014
+ options?: KvListOptions,
1015
+ ): Promise<[Uint8Array, Uint8Array][]> {
1016
+ const startKey: protocol.KvKey = start.buffer.slice(
1017
+ start.byteOffset,
1018
+ start.byteOffset + start.byteLength,
1019
+ ) as ArrayBuffer;
1020
+ const endKey: protocol.KvKey = end.buffer.slice(
1021
+ end.byteOffset,
1022
+ end.byteOffset + end.byteLength,
1023
+ ) as ArrayBuffer;
1024
+
1025
+ const requestData: protocol.KvRequestData = {
1026
+ tag: "KvListRequest",
1027
+ val: {
1028
+ query: {
1029
+ tag: "KvListRangeQuery",
1030
+ val: {
1031
+ start: startKey,
1032
+ end: endKey,
1033
+ exclusive: exclusive || false,
1034
+ },
1035
+ },
1036
+ reverse: options?.reverse || null,
1037
+ limit:
1038
+ options?.limit !== undefined ? BigInt(options.limit) : null,
1039
+ },
1040
+ };
1041
+
1042
+ const response = await this.#sendKvRequest(actorId, requestData);
1043
+ return this.#parseListResponseSimple(response);
1044
+ }
1045
+
1046
+ async kvListPrefix(
1047
+ actorId: string,
1048
+ prefix: Uint8Array,
1049
+ options?: KvListOptions,
1050
+ ): Promise<[Uint8Array, Uint8Array][]> {
1051
+ const prefixKey: protocol.KvKey = prefix.buffer.slice(
1052
+ prefix.byteOffset,
1053
+ prefix.byteOffset + prefix.byteLength,
1054
+ ) as ArrayBuffer;
1055
+
1056
+ const requestData: protocol.KvRequestData = {
1057
+ tag: "KvListRequest",
1058
+ val: {
1059
+ query: {
1060
+ tag: "KvListPrefixQuery",
1061
+ val: { key: prefixKey },
1062
+ },
1063
+ reverse: options?.reverse || null,
1064
+ limit:
1065
+ options?.limit !== undefined ? BigInt(options.limit) : null,
1066
+ },
1067
+ };
1068
+
1069
+ const response = await this.#sendKvRequest(actorId, requestData);
1070
+ return this.#parseListResponseSimple(response);
1071
+ }
1072
+
1073
+ async kvPut(
1074
+ actorId: string,
1075
+ entries: [Uint8Array, Uint8Array][],
1076
+ ): Promise<void> {
1077
+ const keys: protocol.KvKey[] = entries.map(
1078
+ ([key, _value]) =>
1079
+ key.buffer.slice(
1080
+ key.byteOffset,
1081
+ key.byteOffset + key.byteLength,
1082
+ ) as ArrayBuffer,
1083
+ );
1084
+ const values: protocol.KvValue[] = entries.map(
1085
+ ([_key, value]) =>
1086
+ value.buffer.slice(
1087
+ value.byteOffset,
1088
+ value.byteOffset + value.byteLength,
1089
+ ) as ArrayBuffer,
1090
+ );
1091
+
1092
+ const requestData: protocol.KvRequestData = {
1093
+ tag: "KvPutRequest",
1094
+ val: { keys, values },
1095
+ };
1096
+
1097
+ await this.#sendKvRequest(actorId, requestData);
1098
+ }
1099
+
1100
+ async kvDelete(actorId: string, keys: Uint8Array[]): Promise<void> {
1101
+ const kvKeys: protocol.KvKey[] = keys.map(
1102
+ (key) =>
1103
+ key.buffer.slice(
1104
+ key.byteOffset,
1105
+ key.byteOffset + key.byteLength,
1106
+ ) as ArrayBuffer,
1107
+ );
1108
+
1109
+ const requestData: protocol.KvRequestData = {
1110
+ tag: "KvDeleteRequest",
1111
+ val: { keys: kvKeys },
1112
+ };
1113
+
1114
+ await this.#sendKvRequest(actorId, requestData);
1115
+ }
1116
+
1117
+ async kvDrop(actorId: string): Promise<void> {
1118
+ const requestData: protocol.KvRequestData = {
1119
+ tag: "KvDropRequest",
1120
+ val: null,
1121
+ };
1122
+
1123
+ await this.#sendKvRequest(actorId, requestData);
1124
+ }
1125
+
1126
+ // MARK: Alarm Operations
1127
+ setAlarm(actorId: string, alarmTs: number | null, generation?: number) {
1128
+ const actor = this.getActor(actorId, generation);
1129
+ if (!actor) return;
1130
+
1131
+ if (this.#shutdown) {
1132
+ console.warn("Runner is shut down, cannot set alarm");
1133
+ return;
1134
+ }
1135
+
1136
+ const alarmEvent: protocol.EventActorSetAlarm = {
1137
+ actorId,
1138
+ generation: actor.generation,
1139
+ alarmTs: alarmTs !== null ? BigInt(alarmTs) : null,
1140
+ };
1141
+
1142
+ const eventIndex = this.#nextEventIdx++;
1143
+ const eventWrapper: protocol.EventWrapper = {
1144
+ index: eventIndex,
1145
+ inner: {
1146
+ tag: "EventActorSetAlarm",
1147
+ val: alarmEvent,
1148
+ },
1149
+ };
1150
+
1151
+ // Store event in history for potential resending
1152
+ this.#eventHistory.push({
1153
+ event: eventWrapper,
1154
+ timestamp: Date.now(),
1155
+ });
1156
+
1157
+ this.#sendToServer({
1158
+ tag: "ToServerEvents",
1159
+ val: [eventWrapper],
1160
+ });
1161
+ }
1162
+
1163
+ clearAlarm(actorId: string, generation?: number) {
1164
+ this.setAlarm(actorId, null, generation);
1165
+ }
1166
+
1167
+ #sendKvRequest(
1168
+ actorId: string,
1169
+ requestData: protocol.KvRequestData,
1170
+ ): Promise<any> {
1171
+ return new Promise((resolve, reject) => {
1172
+ if (this.#shutdown) {
1173
+ reject(new Error("Runner is shut down"));
1174
+ return;
1175
+ }
1176
+
1177
+ const requestId = this.#nextRequestId++;
1178
+ const isConnected =
1179
+ this.#pegboardWebSocket &&
1180
+ this.#pegboardWebSocket.readyState === WebSocket.OPEN;
1181
+
1182
+ // Store the request
1183
+ const requestEntry = {
1184
+ actorId,
1185
+ data: requestData,
1186
+ resolve,
1187
+ reject,
1188
+ sent: false,
1189
+ timestamp: Date.now(),
1190
+ };
1191
+
1192
+ this.#kvRequests.set(requestId, requestEntry);
1193
+
1194
+ if (isConnected) {
1195
+ // Send immediately
1196
+ this.#sendSingleKvRequest(requestId);
1197
+ }
1198
+ });
1199
+ }
1200
+
1201
+ #sendSingleKvRequest(requestId: number) {
1202
+ const request = this.#kvRequests.get(requestId);
1203
+ if (!request || request.sent) return;
1204
+
1205
+ try {
1206
+ const kvRequest: protocol.ToServerKvRequest = {
1207
+ actorId: request.actorId,
1208
+ requestId,
1209
+ data: request.data,
1210
+ };
1211
+
1212
+ this.#sendToServer({
1213
+ tag: "ToServerKvRequest",
1214
+ val: kvRequest,
1215
+ });
1216
+
1217
+ // Mark as sent and update timestamp
1218
+ request.sent = true;
1219
+ request.timestamp = Date.now();
1220
+ } catch (error) {
1221
+ this.#kvRequests.delete(requestId);
1222
+ request.reject(error);
1223
+ }
1224
+ }
1225
+
1226
+ #processUnsentKvRequests() {
1227
+ if (
1228
+ !this.#pegboardWebSocket ||
1229
+ this.#pegboardWebSocket.readyState !== WebSocket.OPEN
1230
+ ) {
1231
+ return;
1232
+ }
1233
+
1234
+ let processedCount = 0;
1235
+ for (const [requestId, request] of this.#kvRequests.entries()) {
1236
+ if (!request.sent) {
1237
+ this.#sendSingleKvRequest(requestId);
1238
+ processedCount++;
1239
+ }
1240
+ }
1241
+
1242
+ if (processedCount > 0) {
1243
+ //logger()?.log(`Processed ${processedCount} queued KV requests`);
1244
+ }
1245
+ }
1246
+
1247
+ #sendToServer(message: protocol.ToServer) {
1248
+ if (this.#shutdown) {
1249
+ logger()?.warn(
1250
+ "Runner is shut down, cannot send message to server",
1251
+ );
1252
+ return;
1253
+ }
1254
+
1255
+ const encoded = protocol.encodeToServer(message);
1256
+ if (
1257
+ this.#pegboardWebSocket &&
1258
+ this.#pegboardWebSocket.readyState === WebSocket.OPEN
1259
+ ) {
1260
+ this.#pegboardWebSocket.send(encoded);
1261
+ } else {
1262
+ logger()?.error(
1263
+ "WebSocket not available or not open for sending data",
1264
+ );
1265
+ }
1266
+ }
1267
+
1268
+ #scheduleReconnect() {
1269
+ if (this.#shutdown) {
1270
+ //logger()?.log("Runner is shut down, not attempting reconnect");
1271
+ return;
1272
+ }
1273
+
1274
+ const delay = calculateBackoff(this.#reconnectAttempt, {
1275
+ initialDelay: 1000,
1276
+ maxDelay: 30000,
1277
+ multiplier: 2,
1278
+ jitter: true,
1279
+ });
1280
+
1281
+ //logger()?.log(
1282
+ // `Scheduling reconnect attempt ${this.#reconnectAttempt + 1} in ${delay}ms`,
1283
+ //);
1284
+
1285
+ this.#reconnectTimeout = setTimeout(async () => {
1286
+ if (!this.#shutdown) {
1287
+ this.#reconnectAttempt++;
1288
+ //logger()?.log(
1289
+ // `Attempting to reconnect (attempt ${this.#reconnectAttempt})...`,
1290
+ //);
1291
+ await this.#openPegboardWebSocket();
1292
+ }
1293
+ }, delay);
1294
+ }
1295
+
1296
+ #resendUnacknowledgedEvents(lastEventIdx: bigint) {
1297
+ const eventsToResend = this.#eventHistory.filter(
1298
+ (item) => item.event.index > lastEventIdx,
1299
+ );
1300
+
1301
+ if (eventsToResend.length === 0) return;
1302
+
1303
+ //logger()?.log(
1304
+ // `Resending ${eventsToResend.length} unacknowledged events from index ${Number(lastEventIdx) + 1}`,
1305
+ //);
1306
+
1307
+ // Resend events in batches
1308
+ const events = eventsToResend.map((item) => item.event);
1309
+ this.#sendToServer({
1310
+ tag: "ToServerEvents",
1311
+ val: events,
1312
+ });
1313
+ }
1314
+
1315
+ // TODO(RVT-4986): Prune when server acks events instead of based on old events
1316
+ #pruneOldEvents() {
1317
+ const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
1318
+ const originalLength = this.#eventHistory.length;
1319
+
1320
+ // Remove events older than 5 minutes
1321
+ this.#eventHistory = this.#eventHistory.filter(
1322
+ (item) => item.timestamp > fiveMinutesAgo,
1323
+ );
1324
+
1325
+ const prunedCount = originalLength - this.#eventHistory.length;
1326
+ if (prunedCount > 0) {
1327
+ //logger()?.log(`Pruned ${prunedCount} old events from history`);
1328
+ }
1329
+ }
1330
+
1331
+ #cleanupOldKvRequests() {
1332
+ const thirtySecondsAgo = Date.now() - KV_EXPIRE;
1333
+ const toDelete: number[] = [];
1334
+
1335
+ for (const [requestId, request] of this.#kvRequests.entries()) {
1336
+ if (request.timestamp < thirtySecondsAgo) {
1337
+ request.reject(
1338
+ new Error(
1339
+ "KV request timed out waiting for WebSocket connection",
1340
+ ),
1341
+ );
1342
+ toDelete.push(requestId);
1343
+ }
1344
+ }
1345
+
1346
+ for (const requestId of toDelete) {
1347
+ this.#kvRequests.delete(requestId);
1348
+ }
1349
+
1350
+ if (toDelete.length > 0) {
1351
+ //logger()?.log(`Cleaned up ${toDelete.length} expired KV requests`);
1352
+ }
1353
+ }
1354
+ }