@rivet-dev/vercel-world 2.3.6

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.
Files changed (81) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/README.md +31 -0
  3. package/dist/actors/coordinator.d.ts +114 -0
  4. package/dist/actors/coordinator.d.ts.map +1 -0
  5. package/dist/actors/coordinator.js +232 -0
  6. package/dist/actors/coordinator.js.map +1 -0
  7. package/dist/actors/db.d.ts +8 -0
  8. package/dist/actors/db.d.ts.map +1 -0
  9. package/dist/actors/db.js +282 -0
  10. package/dist/actors/db.js.map +1 -0
  11. package/dist/actors/dispatcher.d.ts +84 -0
  12. package/dist/actors/dispatcher.d.ts.map +1 -0
  13. package/dist/actors/dispatcher.js +498 -0
  14. package/dist/actors/dispatcher.js.map +1 -0
  15. package/dist/actors/hook-token.d.ts +26 -0
  16. package/dist/actors/hook-token.d.ts.map +1 -0
  17. package/dist/actors/hook-token.js +151 -0
  18. package/dist/actors/hook-token.js.map +1 -0
  19. package/dist/actors/shared.d.ts +366 -0
  20. package/dist/actors/shared.d.ts.map +1 -0
  21. package/dist/actors/shared.js +112 -0
  22. package/dist/actors/shared.js.map +1 -0
  23. package/dist/actors/streams.d.ts +26 -0
  24. package/dist/actors/streams.d.ts.map +1 -0
  25. package/dist/actors/streams.js +127 -0
  26. package/dist/actors/streams.js.map +1 -0
  27. package/dist/actors/workflow-run.d.ts +890 -0
  28. package/dist/actors/workflow-run.d.ts.map +1 -0
  29. package/dist/actors/workflow-run.js +863 -0
  30. package/dist/actors/workflow-run.js.map +1 -0
  31. package/dist/actors.d.ts +2204 -0
  32. package/dist/actors.d.ts.map +1 -0
  33. package/dist/actors.js +16 -0
  34. package/dist/actors.js.map +1 -0
  35. package/dist/codec.d.ts +4 -0
  36. package/dist/codec.d.ts.map +1 -0
  37. package/dist/codec.js +27 -0
  38. package/dist/codec.js.map +1 -0
  39. package/dist/index.d.ts +843 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +553 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/runtime.d.ts +2 -0
  44. package/dist/runtime.d.ts.map +1 -0
  45. package/dist/runtime.js +24 -0
  46. package/dist/runtime.js.map +1 -0
  47. package/package.json +51 -0
  48. package/scripts/conformance/run.ts +278 -0
  49. package/scripts/integration/run.ts +935 -0
  50. package/src/actors/coordinator.ts +360 -0
  51. package/src/actors/db.ts +291 -0
  52. package/src/actors/dispatcher.ts +787 -0
  53. package/src/actors/hook-token.ts +239 -0
  54. package/src/actors/shared.ts +153 -0
  55. package/src/actors/streams.ts +215 -0
  56. package/src/actors/workflow-run.ts +1466 -0
  57. package/src/actors.ts +18 -0
  58. package/src/codec.ts +28 -0
  59. package/src/index.ts +768 -0
  60. package/src/runtime.ts +29 -0
  61. package/tests/conformance.test.ts +8 -0
  62. package/tests/helpers/db.ts +62 -0
  63. package/tests/helpers/dispatcher-driver.ts +71 -0
  64. package/tests/helpers/harness.ts +161 -0
  65. package/tests/integration/crash-restart.test.ts +145 -0
  66. package/tests/integration/dispatcher-loop.test.ts +144 -0
  67. package/tests/integration/hook-token.test.ts +160 -0
  68. package/tests/integration/hooks.test.ts +123 -0
  69. package/tests/integration/streams.test.ts +178 -0
  70. package/tests/integration/workflow-events.test.ts +326 -0
  71. package/tests/setup.ts +10 -0
  72. package/tests/unit/codec.test.ts +73 -0
  73. package/tests/unit/coordinator-record.test.ts +177 -0
  74. package/tests/unit/db-migrations.test.ts +65 -0
  75. package/tests/unit/dispatcher-queue.test.ts +274 -0
  76. package/tests/unit/logging.test.ts +49 -0
  77. package/tests/unit/readiness.test.ts +102 -0
  78. package/tests/unit/transaction.test.ts +76 -0
  79. package/tsconfig.build.json +13 -0
  80. package/tsconfig.json +12 -0
  81. package/vitest.config.ts +32 -0
@@ -0,0 +1,935 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { createServer, type Server } from "node:http";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { getEnginePath } from "@rivetkit/engine-cli";
9
+ import { SPEC_VERSION_CURRENT } from "@workflow/world";
10
+ import { createClient, type Client } from "rivetkit/client";
11
+ import { registry } from "../../src/actors.ts";
12
+ import { RivetClientWorld } from "../../src/index.ts";
13
+
14
+ const TOKEN = "dev";
15
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
16
+ const RUNTIME_PATH = join(ROOT, "src/runtime.ts");
17
+
18
+ type ManagedProcess = {
19
+ child: ChildProcess;
20
+ output: () => string;
21
+ };
22
+
23
+ type EngineProcess = ManagedProcess & {
24
+ endpoint: string;
25
+ dbRoot: string;
26
+ };
27
+
28
+ type Delivery = {
29
+ route: string;
30
+ body: unknown;
31
+ headers: Headers;
32
+ };
33
+
34
+ type MockWorkflowBehavior = {
35
+ failuresBeforeSuccess: Map<string, number>;
36
+ alwaysFail: Set<string>;
37
+ /** runId -> ms to hold the response open before replying (long-step). */
38
+ holdMs: Map<string, number>;
39
+ };
40
+
41
+ function log(message: string) {
42
+ console.log(`[integration] ${message}`);
43
+ }
44
+
45
+ function assert(condition: unknown, message: string): asserts condition {
46
+ if (!condition) throw new Error(message);
47
+ }
48
+
49
+ function sleep(ms: number) {
50
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
51
+ }
52
+
53
+ async function waitFor(
54
+ label: string,
55
+ predicate: () => Promise<boolean> | boolean,
56
+ timeoutMs: number,
57
+ intervalMs = 250,
58
+ ) {
59
+ const deadline = Date.now() + timeoutMs;
60
+ while (Date.now() < deadline) {
61
+ if (await predicate()) return;
62
+ await sleep(intervalMs);
63
+ }
64
+ throw new Error(`Timed out waiting for ${label}`);
65
+ }
66
+
67
+ function freePort(host = "127.0.0.1"): Promise<number> {
68
+ return new Promise((resolvePromise, reject) => {
69
+ const server = createServer();
70
+ server.listen(0, host, () => {
71
+ const address = server.address();
72
+ if (!address || typeof address === "string") {
73
+ server.close(() => reject(new Error("Could not allocate port")));
74
+ return;
75
+ }
76
+ server.close(() => resolvePromise(address.port));
77
+ });
78
+ server.on("error", reject);
79
+ });
80
+ }
81
+
82
+ function capture(child: ChildProcess): () => string {
83
+ let stdout = "";
84
+ let stderr = "";
85
+ child.stdout?.on("data", (chunk) => {
86
+ stdout += chunk.toString();
87
+ });
88
+ child.stderr?.on("data", (chunk) => {
89
+ stderr += chunk.toString();
90
+ });
91
+ return () => `${stdout}\n${stderr}`;
92
+ }
93
+
94
+ async function stopProcess(child: ChildProcess, name: string) {
95
+ if (child.exitCode !== null || child.signalCode !== null) return;
96
+ child.kill("SIGTERM");
97
+ const stopped = await new Promise<boolean>((resolvePromise) => {
98
+ const timeout = setTimeout(() => resolvePromise(false), 1500);
99
+ child.once("exit", () => {
100
+ clearTimeout(timeout);
101
+ resolvePromise(true);
102
+ });
103
+ });
104
+ if (!stopped) {
105
+ child.kill("SIGKILL");
106
+ await new Promise<void>((resolvePromise) =>
107
+ child.once("exit", () => resolvePromise()),
108
+ );
109
+ }
110
+ log(`stopped ${name}`);
111
+ }
112
+
113
+ async function startEngine(): Promise<EngineProcess> {
114
+ const host = "127.0.0.1";
115
+ const guardPort = await freePort(host);
116
+ const apiPeerPort = await freePort(host);
117
+ const metricsPort = await freePort(host);
118
+ const endpoint = `http://${host}:${guardPort}`;
119
+ const dbRoot = mkdtempSync(join(tmpdir(), "vercel-world-rivet-it-"));
120
+ const configPath = join(dbRoot, "config.json");
121
+
122
+ mkdirSync(join(dbRoot, "db"), { recursive: true });
123
+ writeFileSync(
124
+ configPath,
125
+ JSON.stringify({
126
+ topology: {
127
+ datacenter_label: 1,
128
+ datacenters: {
129
+ default: {
130
+ datacenter_label: 1,
131
+ is_leader: true,
132
+ public_url: endpoint,
133
+ peer_url: `http://${host}:${apiPeerPort}`,
134
+ },
135
+ },
136
+ },
137
+ }),
138
+ );
139
+
140
+ const child = spawn(getEnginePath(), ["start", "--config", configPath], {
141
+ env: {
142
+ ...process.env,
143
+ RIVET__GUARD__HOST: host,
144
+ RIVET__GUARD__PORT: String(guardPort),
145
+ RIVET__API_PEER__HOST: host,
146
+ RIVET__API_PEER__PORT: String(apiPeerPort),
147
+ RIVET__METRICS__HOST: host,
148
+ RIVET__METRICS__PORT: String(metricsPort),
149
+ RIVET__FILE_SYSTEM__PATH: join(dbRoot, "db"),
150
+ },
151
+ stdio: ["ignore", "pipe", "pipe"],
152
+ });
153
+ const output = capture(child);
154
+
155
+ await waitFor(
156
+ "engine health",
157
+ async () => {
158
+ if (child.exitCode !== null) {
159
+ throw new Error(`Engine exited early:\n${output()}`);
160
+ }
161
+ try {
162
+ return (await fetch(`${endpoint}/health`)).ok;
163
+ } catch {
164
+ return false;
165
+ }
166
+ },
167
+ 90_000,
168
+ 500,
169
+ );
170
+
171
+ log(`engine ready at ${endpoint}`);
172
+ return { child, endpoint, dbRoot, output };
173
+ }
174
+
175
+ async function createNamespace(endpoint: string, namespace: string) {
176
+ const response = await fetch(`${endpoint}/namespaces`, {
177
+ method: "POST",
178
+ headers: {
179
+ Authorization: `Bearer ${TOKEN}`,
180
+ "Content-Type": "application/json",
181
+ },
182
+ body: JSON.stringify({
183
+ name: namespace,
184
+ display_name: `Integration ${namespace}`,
185
+ }),
186
+ });
187
+ if (!response.ok) {
188
+ throw new Error(
189
+ `Failed to create namespace: ${response.status} ${await response.text()}`,
190
+ );
191
+ }
192
+ }
193
+
194
+ async function upsertRunnerConfig(
195
+ endpoint: string,
196
+ namespace: string,
197
+ poolName: string,
198
+ ) {
199
+ const datacentersResponse = await fetch(
200
+ `${endpoint}/datacenters?namespace=${encodeURIComponent(namespace)}`,
201
+ { headers: { Authorization: `Bearer ${TOKEN}` } },
202
+ );
203
+ if (!datacentersResponse.ok) {
204
+ throw new Error(
205
+ `Failed to list datacenters: ${datacentersResponse.status} ${await datacentersResponse.text()}`,
206
+ );
207
+ }
208
+ const datacenters = (await datacentersResponse.json()) as {
209
+ datacenters: Array<{ name: string }>;
210
+ };
211
+ const datacenter = datacenters.datacenters[0]?.name;
212
+ if (!datacenter) throw new Error("Engine returned no datacenters");
213
+
214
+ const deadline = Date.now() + 30_000;
215
+ while (Date.now() < deadline) {
216
+ const response = await fetch(
217
+ `${endpoint}/runner-configs/${encodeURIComponent(poolName)}?namespace=${encodeURIComponent(namespace)}`,
218
+ {
219
+ method: "PUT",
220
+ headers: {
221
+ Authorization: `Bearer ${TOKEN}`,
222
+ "Content-Type": "application/json",
223
+ },
224
+ body: JSON.stringify({
225
+ datacenters: {
226
+ [datacenter]: { normal: {} },
227
+ },
228
+ }),
229
+ },
230
+ );
231
+ if (response.ok) return;
232
+ const body = await response.text();
233
+ if (body.includes('"code":"not_found"')) {
234
+ await sleep(500);
235
+ continue;
236
+ }
237
+ throw new Error(
238
+ `Failed to upsert runner config: ${response.status} ${body}`,
239
+ );
240
+ }
241
+ throw new Error("Timed out upserting runner config");
242
+ }
243
+
244
+ function startRuntime(
245
+ endpoint: string,
246
+ namespace: string,
247
+ poolName: string,
248
+ ): ManagedProcess {
249
+ const child = spawn(process.execPath, ["--import", "tsx", RUNTIME_PATH], {
250
+ cwd: ROOT,
251
+ env: {
252
+ ...process.env,
253
+ RIVET_TOKEN: TOKEN,
254
+ RIVET_NAMESPACE: namespace,
255
+ RIVET_ENDPOINT: endpoint,
256
+ RIVET_POOL: poolName,
257
+ RIVET_WORLD_RIVET_MAX_DISPATCH_ATTEMPTS: "3",
258
+ RIVET_WORLD_RIVET_RETRY_DELAY_MS: "200",
259
+ RIVET_WORLD_RIVET_STALE_INFLIGHT_MS: "500",
260
+ RIVET_WORLD_RIVET_TESTING: "1",
261
+ },
262
+ stdio: ["ignore", "pipe", "pipe"],
263
+ });
264
+ return { child, output: capture(child) };
265
+ }
266
+
267
+ async function waitForEnvoy(
268
+ runtime: ManagedProcess,
269
+ endpoint: string,
270
+ namespace: string,
271
+ poolName: string,
272
+ ) {
273
+ await waitFor(
274
+ "runtime envoy registration",
275
+ async () => {
276
+ if (runtime.child.exitCode !== null) {
277
+ throw new Error(`Runtime exited early:\n${runtime.output()}`);
278
+ }
279
+ const response = await fetch(
280
+ `${endpoint}/envoys?namespace=${encodeURIComponent(namespace)}&name=${encodeURIComponent(poolName)}`,
281
+ { headers: { Authorization: `Bearer ${TOKEN}` } },
282
+ );
283
+ if (!response.ok) return false;
284
+ const body = (await response.json()) as {
285
+ envoys: Array<{ envoy_key: string }>;
286
+ };
287
+ return body.envoys.length > 0;
288
+ },
289
+ 30_000,
290
+ 500,
291
+ );
292
+ }
293
+
294
+ async function startMockWorkflowApp() {
295
+ const deliveries: Delivery[] = [];
296
+ const healthChecks = { flow: 0 };
297
+ const behavior: MockWorkflowBehavior = {
298
+ failuresBeforeSuccess: new Map(),
299
+ alwaysFail: new Set(),
300
+ holdMs: new Map(),
301
+ };
302
+ const server = createServer(async (req, res) => {
303
+ const path = req.url ?? "";
304
+ if (
305
+ req.method === "GET" &&
306
+ path === "/.well-known/workflow/v1/flow?__health"
307
+ ) {
308
+ healthChecks.flow++;
309
+ res.writeHead(200, { "content-type": "application/json" });
310
+ res.end(JSON.stringify({ healthy: true }));
311
+ return;
312
+ }
313
+ if (
314
+ req.method !== "POST" ||
315
+ !path.startsWith("/.well-known/workflow/v1/")
316
+ ) {
317
+ res.writeHead(404);
318
+ res.end("not found");
319
+ return;
320
+ }
321
+
322
+ const chunks: Buffer[] = [];
323
+ for await (const chunk of req) {
324
+ chunks.push(Buffer.from(chunk));
325
+ }
326
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
327
+ deliveries.push({
328
+ route: path.split("/").at(-1) ?? "",
329
+ body,
330
+ headers: new Headers(req.headers as Record<string, string>),
331
+ });
332
+ const runId =
333
+ typeof body === "object" && body !== null && "runId" in body
334
+ ? String((body as { runId: unknown }).runId)
335
+ : null;
336
+ if (runId && behavior.alwaysFail.has(runId)) {
337
+ res.writeHead(503, { "content-type": "application/json" });
338
+ res.end(JSON.stringify({ error: "forced failure" }));
339
+ return;
340
+ }
341
+ if (runId) {
342
+ const remaining = behavior.failuresBeforeSuccess.get(runId) ?? 0;
343
+ if (remaining > 0) {
344
+ behavior.failuresBeforeSuccess.set(runId, remaining - 1);
345
+ res.writeHead(503, { "content-type": "application/json" });
346
+ res.end(JSON.stringify({ error: "forced retry" }));
347
+ return;
348
+ }
349
+ }
350
+ // Long-step: hold the flow response open like a real >60s step body would,
351
+ // so we exercise the real dispatcher's c.keepAwake fetch hold.
352
+ const hold = runId ? (behavior.holdMs.get(runId) ?? 0) : 0;
353
+ const reply = () => {
354
+ res.writeHead(200, { "content-type": "application/json" });
355
+ res.end(JSON.stringify({ ok: true }));
356
+ };
357
+ if (hold > 0) setTimeout(reply, hold);
358
+ else reply();
359
+ });
360
+
361
+ const port = await freePort();
362
+ await new Promise<void>((resolvePromise) =>
363
+ server.listen(port, "127.0.0.1", () => resolvePromise()),
364
+ );
365
+
366
+ return {
367
+ url: `http://127.0.0.1:${port}`,
368
+ deliveries,
369
+ healthChecks,
370
+ behavior,
371
+ close: () =>
372
+ new Promise<void>((resolvePromise, reject) =>
373
+ (server as Server).close((error) =>
374
+ error ? reject(error) : resolvePromise(),
375
+ ),
376
+ ),
377
+ };
378
+ }
379
+
380
+ function deliveryCount(
381
+ deliveries: Delivery[],
382
+ predicate: (delivery: Delivery) => boolean,
383
+ ) {
384
+ return deliveries.filter(predicate).length;
385
+ }
386
+
387
+ async function readWithTimeout<T>(
388
+ promise: Promise<ReadableStreamReadResult<T>>,
389
+ label: string,
390
+ ) {
391
+ const timeout = sleep(10_000).then(() => {
392
+ throw new Error(`Timed out waiting for ${label}`);
393
+ });
394
+ return await Promise.race([promise, timeout]);
395
+ }
396
+
397
+ async function runChecks(
398
+ world: RivetClientWorld,
399
+ worldConfig: ConstructorParameters<typeof RivetClientWorld>[0],
400
+ deliveries: Delivery[],
401
+ healthChecks: { flow: number },
402
+ behavior: MockWorkflowBehavior,
403
+ ) {
404
+ const previousSecret = process.env.RIVET_WORKFLOW_SECRET;
405
+ process.env.RIVET_WORKFLOW_SECRET = "integration-secret";
406
+ let handledAuthMessage = false;
407
+ try {
408
+ const authHandler = world.createQueueHandler(
409
+ "__wkf_workflow_",
410
+ async () => {
411
+ handledAuthMessage = true;
412
+ },
413
+ );
414
+ const unauthenticated = await authHandler(
415
+ new Request("http://127.0.0.1/.well-known/workflow/v1/flow", {
416
+ method: "POST",
417
+ headers: {
418
+ "content-type": "application/json",
419
+ "x-vqs-queue-name": "__wkf_workflow_authProbe",
420
+ "x-vqs-message-id": "msg_auth_probe",
421
+ "x-vqs-message-attempt": "1",
422
+ },
423
+ body: JSON.stringify({ runId: "wrun_auth_probe" }),
424
+ }),
425
+ );
426
+ assert(
427
+ unauthenticated.status === 401,
428
+ "Expected queue handler to reject missing bearer token",
429
+ );
430
+ assert(
431
+ !handledAuthMessage,
432
+ "Expected unauthorized queue request to skip handler",
433
+ );
434
+ const authenticated = await authHandler(
435
+ new Request("http://127.0.0.1/.well-known/workflow/v1/flow", {
436
+ method: "POST",
437
+ headers: {
438
+ authorization: "Bearer integration-secret",
439
+ "content-type": "application/json",
440
+ "x-vqs-queue-name": "__wkf_workflow_authProbe",
441
+ "x-vqs-message-id": "msg_auth_probe",
442
+ "x-vqs-message-attempt": "1",
443
+ },
444
+ body: JSON.stringify({ runId: "wrun_auth_probe" }),
445
+ }),
446
+ );
447
+ assert(authenticated.ok, "Expected valid bearer token to be accepted");
448
+ assert(handledAuthMessage, "Expected authorized queue request to run handler");
449
+ log("queue handler auth check passed");
450
+ } finally {
451
+ if (previousSecret === undefined) {
452
+ delete process.env.RIVET_WORKFLOW_SECRET;
453
+ } else {
454
+ process.env.RIVET_WORKFLOW_SECRET = previousSecret;
455
+ }
456
+ }
457
+
458
+ const streamName = `stream-${randomUUID()}`;
459
+ const streamRunId = `wrun_stream_${randomUUID()}`;
460
+ const liveStream = await world.readFromStream(streamRunId, streamName, 0);
461
+ const reader = liveStream.getReader();
462
+ const firstRead = readWithTimeout(reader.read(), "live stream chunk");
463
+ await world.writeToStream(streamName, streamRunId, "hello-live-stream");
464
+ const firstChunk = await firstRead;
465
+ assert(!firstChunk.done, "Expected live stream to yield written chunk");
466
+ assert(
467
+ new TextDecoder().decode(firstChunk.value) === "hello-live-stream",
468
+ "Expected live stream chunk contents to match",
469
+ );
470
+ const doneRead = readWithTimeout(reader.read(), "live stream close");
471
+ await world.closeStream(streamName, streamRunId);
472
+ const done = await doneRead;
473
+ assert(done.done, "Expected live stream to close after closeStream");
474
+ log("event-driven stream check passed");
475
+
476
+ const ttlStream = `stream-ttl-${randomUUID()}`;
477
+ const ttlRunId = `wrun_stream_ttl_${randomUUID()}`;
478
+ await world.writeToStream(ttlStream, ttlRunId, "expires");
479
+ await world.closeStream(ttlStream, ttlRunId);
480
+ const expired = await world.expireClosedStreamForTesting(ttlRunId, ttlStream);
481
+ assert(
482
+ expired.deletedChunks >= 2,
483
+ "Expected closed stream TTL cleanup to delete data and EOF chunks",
484
+ );
485
+ log("stream TTL cleanup check passed");
486
+
487
+ const idempotencyRunId = `wrun_it_idem_${randomUUID()}`;
488
+ const idempotencyKey = `step-${randomUUID()}`;
489
+ const first = await world.queue(
490
+ "__wkf_workflow_idempotencyProbe",
491
+ { runId: idempotencyRunId },
492
+ { idempotencyKey },
493
+ );
494
+ const second = await world.queue(
495
+ "__wkf_workflow_idempotencyProbe",
496
+ { runId: idempotencyRunId },
497
+ { idempotencyKey },
498
+ );
499
+ assert(
500
+ first.messageId === second.messageId,
501
+ "Expected duplicate idempotencyKey enqueue to return existing message id",
502
+ );
503
+ await waitFor(
504
+ "idempotent queue delivery",
505
+ () =>
506
+ deliveryCount(
507
+ deliveries,
508
+ (delivery) => (delivery.body as { runId?: string }).runId === idempotencyRunId,
509
+ ) === 1,
510
+ 30_000,
511
+ );
512
+ await sleep(1_000);
513
+ assert(
514
+ deliveryCount(
515
+ deliveries,
516
+ (delivery) => (delivery.body as { runId?: string }).runId === idempotencyRunId,
517
+ ) === 1,
518
+ "Expected duplicate idempotencyKey enqueue to dispatch only once",
519
+ );
520
+ log("queue idempotency check passed");
521
+
522
+ const retryRunId = `wrun_it_retry_${randomUUID()}`;
523
+ behavior.failuresBeforeSuccess.set(retryRunId, 2);
524
+ await world.queue("__wkf_workflow_retryProbe", { runId: retryRunId });
525
+ await waitFor(
526
+ "retry queue delivery",
527
+ () =>
528
+ deliveryCount(
529
+ deliveries,
530
+ (delivery) => (delivery.body as { runId?: string }).runId === retryRunId,
531
+ ) === 3,
532
+ 30_000,
533
+ );
534
+ const retrySnapshot = await world.inspectDispatcherQueue(retryRunId);
535
+ const retriedRow = retrySnapshot.rows.find((row) => row.status === "done");
536
+ assert(retriedRow, "Expected retried dispatch row to finish");
537
+ assert(retriedRow.attempt === 3, "Expected dispatch to succeed on attempt 3");
538
+ log("queue retry check passed");
539
+
540
+ const failedRunId = `wrun_it_failed_${randomUUID()}`;
541
+ behavior.alwaysFail.add(failedRunId);
542
+ await world.queue("__wkf_workflow_deadLetterProbe", { runId: failedRunId });
543
+ await waitFor(
544
+ "dead-letter queue status",
545
+ async () => {
546
+ const snapshot = await world.inspectDispatcherQueue(failedRunId);
547
+ return snapshot.rows.some(
548
+ (row) => row.status === "failed" && row.attempt === 3,
549
+ );
550
+ },
551
+ 30_000,
552
+ );
553
+ assert(
554
+ deliveryCount(
555
+ deliveries,
556
+ (delivery) => (delivery.body as { runId?: string }).runId === failedRunId,
557
+ ) === 3,
558
+ "Expected dead-letter dispatch to stop at configured max attempts",
559
+ );
560
+ log("queue dead-letter check passed");
561
+
562
+ const staleRunId = `wrun_it_stale_${randomUUID()}`;
563
+ const staleQueued = await world.queue(
564
+ "__wkf_workflow_staleProbe",
565
+ { runId: staleRunId },
566
+ { delaySeconds: 60 },
567
+ );
568
+ await world.forceStaleDispatcherMessageForTesting(
569
+ staleRunId,
570
+ staleQueued.messageId,
571
+ 1_000,
572
+ );
573
+ await waitFor(
574
+ "stale inflight recovery delivery",
575
+ () =>
576
+ deliveryCount(
577
+ deliveries,
578
+ (delivery) => (delivery.body as { runId?: string }).runId === staleRunId,
579
+ ) === 1,
580
+ 30_000,
581
+ );
582
+ const staleSnapshot = await world.inspectDispatcherQueue(staleRunId);
583
+ const staleRow = staleSnapshot.rows.find(
584
+ (row) => row.messageId === staleQueued.messageId,
585
+ );
586
+ assert(staleRow?.status === "done", "Expected stale dispatch to recover");
587
+ assert(staleRow.attempt === 1, "Expected recovered stale dispatch to run once");
588
+ log("stale queue recovery check passed");
589
+
590
+ const created = await world.events.create(null, {
591
+ eventType: "run_created",
592
+ specVersion: SPEC_VERSION_CURRENT,
593
+ eventData: {
594
+ deploymentId: "rivet",
595
+ workflowName: "recoveryProbe",
596
+ input: { source: "integration" },
597
+ },
598
+ });
599
+ const runId = created.run?.runId;
600
+ assert(runId, "Expected run_created to return a run id");
601
+
602
+ await world.start();
603
+ await waitFor(
604
+ "per-run recovery delivery",
605
+ () =>
606
+ deliveryCount(
607
+ deliveries,
608
+ (delivery) => (delivery.body as { runId?: string }).runId === runId,
609
+ ) === 1,
610
+ 30_000,
611
+ );
612
+ await world.start();
613
+ await sleep(1_000);
614
+ assert(
615
+ deliveryCount(
616
+ deliveries,
617
+ (delivery) => (delivery.body as { runId?: string }).runId === runId,
618
+ ) === 1,
619
+ "Expected process-idempotent start() not to duplicate delivery",
620
+ );
621
+ log("world.start idempotency check passed");
622
+
623
+ const stepLookupRun = await world.events.create(null, {
624
+ eventType: "run_created",
625
+ specVersion: SPEC_VERSION_CURRENT,
626
+ eventData: {
627
+ deploymentId: "rivet",
628
+ workflowName: "globalStepLookupProbe",
629
+ input: { source: "integration" },
630
+ },
631
+ });
632
+ const stepLookupRunId = stepLookupRun.run?.runId;
633
+ assert(stepLookupRunId, "Expected step lookup run_created to return a run id");
634
+ const stepId = `step-${randomUUID()}`;
635
+ await world.events.create(stepLookupRunId, {
636
+ eventType: "step_created",
637
+ specVersion: SPEC_VERSION_CURRENT,
638
+ correlationId: stepId,
639
+ eventData: {
640
+ stepName: "global-step",
641
+ input: { source: "integration" },
642
+ },
643
+ });
644
+ const globalStep = await world.steps.get(undefined, stepId, {
645
+ resolveData: "none",
646
+ });
647
+ assert(
648
+ globalStep.stepId === stepId && globalStep.runId === stepLookupRunId,
649
+ "Expected steps.get without runId to resolve via correlation index",
650
+ );
651
+ log("global step lookup check passed");
652
+
653
+ const waitCreated = await world.events.create(null, {
654
+ eventType: "run_created",
655
+ specVersion: SPEC_VERSION_CURRENT,
656
+ eventData: {
657
+ deploymentId: "rivet",
658
+ workflowName: "waitWakeProbe",
659
+ input: { source: "integration" },
660
+ },
661
+ });
662
+ const waitRunId = waitCreated.run?.runId;
663
+ assert(waitRunId, "Expected wait probe run_created to return a run id");
664
+ await world.events.create(waitRunId, {
665
+ eventType: "wait_created",
666
+ specVersion: SPEC_VERSION_CURRENT,
667
+ correlationId: `wait-${randomUUID()}`,
668
+ eventData: {
669
+ resumeAt: new Date(Date.now() + 750),
670
+ },
671
+ });
672
+ await waitFor(
673
+ "scheduled wait wake delivery",
674
+ () =>
675
+ deliveryCount(
676
+ deliveries,
677
+ (delivery) => (delivery.body as { runId?: string }).runId === waitRunId,
678
+ ) === 1,
679
+ 30_000,
680
+ );
681
+ log("workflowRun wait wake check passed");
682
+
683
+ const token = `token-${randomUUID()}`;
684
+ const firstHookRun = await world.events.create(null, {
685
+ eventType: "run_created",
686
+ specVersion: SPEC_VERSION_CURRENT,
687
+ eventData: {
688
+ deploymentId: "rivet",
689
+ workflowName: "hookConflictProbe",
690
+ input: { owner: 1 },
691
+ },
692
+ });
693
+ const firstHookRunId = firstHookRun.run?.runId;
694
+ assert(firstHookRunId, "Expected first hook run to return a run id");
695
+ const firstHook = await world.events.create(firstHookRunId, {
696
+ eventType: "hook_created",
697
+ specVersion: SPEC_VERSION_CURRENT,
698
+ correlationId: `hook-${randomUUID()}`,
699
+ eventData: { token, metadata: { owner: 1 } },
700
+ });
701
+ assert(firstHook.hook?.token === token, "Expected first hook token claim");
702
+ const hookById = await world.hooks.get(firstHook.hook.hookId, {
703
+ resolveData: "none",
704
+ });
705
+ assert(
706
+ hookById.hookId === firstHook.hook.hookId &&
707
+ hookById.runId === firstHookRunId,
708
+ "Expected hooks.get(hookId) to route through coordinator hook index",
709
+ );
710
+ const globalHooks = await world.hooks.list({
711
+ resolveData: "none",
712
+ pagination: { limit: 100 },
713
+ });
714
+ assert(
715
+ globalHooks.data.some(
716
+ (hook) =>
717
+ hook.hookId === firstHook.hook?.hookId && hook.runId === firstHookRunId,
718
+ ),
719
+ "Expected hooks.list without runId to page through coordinator hook index",
720
+ );
721
+
722
+ const secondHookRun = await world.events.create(null, {
723
+ eventType: "run_created",
724
+ specVersion: SPEC_VERSION_CURRENT,
725
+ eventData: {
726
+ deploymentId: "rivet",
727
+ workflowName: "hookConflictProbe",
728
+ input: { owner: 2 },
729
+ },
730
+ });
731
+ const secondHookRunId = secondHookRun.run?.runId;
732
+ assert(secondHookRunId, "Expected second hook run to return a run id");
733
+ const conflict = await world.events.create(secondHookRunId, {
734
+ eventType: "hook_created",
735
+ specVersion: SPEC_VERSION_CURRENT,
736
+ correlationId: `hook-${randomUUID()}`,
737
+ eventData: { token, metadata: { owner: 2 } },
738
+ });
739
+ assert(
740
+ conflict.event?.eventType === "hook_conflict",
741
+ "Expected duplicate hook token to create hook_conflict event",
742
+ );
743
+ assert(
744
+ conflict.event.eventData?.conflictingRunId === firstHookRunId,
745
+ "Expected hook_conflict to include conflictingRunId",
746
+ );
747
+
748
+ await world.events.create(firstHookRunId, {
749
+ eventType: "run_completed",
750
+ specVersion: SPEC_VERSION_CURRENT,
751
+ eventData: { output: { ok: true } },
752
+ });
753
+ const thirdHookRun = await world.events.create(null, {
754
+ eventType: "run_created",
755
+ specVersion: SPEC_VERSION_CURRENT,
756
+ eventData: {
757
+ deploymentId: "rivet",
758
+ workflowName: "hookConflictProbe",
759
+ input: { owner: 3 },
760
+ },
761
+ });
762
+ const thirdHookRunId = thirdHookRun.run?.runId;
763
+ assert(thirdHookRunId, "Expected third hook run to return a run id");
764
+ const reclaimed = await world.events.create(thirdHookRunId, {
765
+ eventType: "hook_created",
766
+ specVersion: SPEC_VERSION_CURRENT,
767
+ correlationId: `hook-${randomUUID()}`,
768
+ eventData: { token, metadata: { owner: 3 } },
769
+ });
770
+ assert(
771
+ reclaimed.hook?.runId === thirdHookRunId,
772
+ "Expected terminal hook release to allow token reuse",
773
+ );
774
+ log("hook lookup/conflict/release check passed");
775
+
776
+ // Real long-step E2E: the app holds the flow response open for >60s, like a
777
+ // real long-running step body. The dispatcher must hold the outbound fetch
778
+ // (c.keepAwake) without a premature client-action timeout or stale-inflight
779
+ // redelivery, then complete the message exactly once.
780
+ const longRunId = `wrun_it_longstep_${randomUUID()}`;
781
+ const HOLD_MS = 62_000;
782
+ behavior.holdMs.set(longRunId, HOLD_MS);
783
+ const longQueued = await world.queue("__wkf_workflow_longStep", {
784
+ runId: longRunId,
785
+ });
786
+ const longStart = Date.now();
787
+ await waitFor(
788
+ "long-step single delivery",
789
+ () =>
790
+ deliveryCount(
791
+ deliveries,
792
+ (d) => (d.body as { runId?: string }).runId === longRunId,
793
+ ) >= 1,
794
+ 20_000,
795
+ );
796
+ // During the hold, the row must stay in-flight with NO premature retry.
797
+ await sleep(2_000);
798
+ const midSnapshot = await world.inspectDispatcherQueue(longRunId);
799
+ const midRow = midSnapshot.rows.find(
800
+ (r) => r.messageId === longQueued.messageId,
801
+ );
802
+ assert(midRow?.status === "inflight", "Expected long step to stay inflight");
803
+ assert(midRow.attempt === 1, "Expected no premature long-step retry");
804
+ await waitFor(
805
+ "long-step completion",
806
+ async () => {
807
+ const snap = await world.inspectDispatcherQueue(longRunId);
808
+ return snap.rows.some(
809
+ (r) => r.messageId === longQueued.messageId && r.status === "done",
810
+ );
811
+ },
812
+ HOLD_MS + 20_000,
813
+ );
814
+ const heldMs = Date.now() - longStart;
815
+ assert(heldMs >= 60_000, `Expected the step to hold >60s, held ${heldMs}ms`);
816
+ assert(
817
+ deliveryCount(
818
+ deliveries,
819
+ (d) => (d.body as { runId?: string }).runId === longRunId,
820
+ ) === 1,
821
+ "Expected the long step to be delivered exactly once (no premature retry)",
822
+ );
823
+ log(`real long-step (>60s through the dispatcher) check passed (${heldMs}ms)`);
824
+
825
+ // Recovery at scale: every active run owns its recovery alarm, so recovery
826
+ // does not depend on a World startup scan or a coordinator sweep.
827
+ const SCALE = 40;
828
+ const scaleRunIds: string[] = [];
829
+ for (let i = 0; i < SCALE; i++) {
830
+ const created = await world.events.create(null, {
831
+ eventType: "run_created",
832
+ specVersion: SPEC_VERSION_CURRENT,
833
+ eventData: {
834
+ deploymentId: "rivet",
835
+ workflowName: "scaleRecoveryProbe",
836
+ input: { i },
837
+ },
838
+ });
839
+ const id = created.run?.runId;
840
+ assert(id, "Expected scale run to return a run id");
841
+ scaleRunIds.push(id);
842
+ }
843
+ await waitFor(
844
+ "scale recovery re-enqueue",
845
+ () =>
846
+ scaleRunIds.every(
847
+ (id) =>
848
+ deliveryCount(
849
+ deliveries,
850
+ (d) => (d.body as { runId?: string }).runId === id,
851
+ ) >= 1,
852
+ ),
853
+ 60_000,
854
+ );
855
+ log(`recovery at scale (${SCALE} runs) check passed`);
856
+ }
857
+
858
+ async function run() {
859
+ const engine = await startEngine();
860
+ const namespace = `it-${randomUUID()}`;
861
+ const poolName = `it-${randomUUID()}`;
862
+ let runtime: ManagedProcess | undefined;
863
+ let mockApp: Awaited<ReturnType<typeof startMockWorkflowApp>> | undefined;
864
+ let world: RivetClientWorld | undefined;
865
+ let client: Client<typeof registry> | undefined;
866
+
867
+ try {
868
+ await createNamespace(engine.endpoint, namespace);
869
+ await upsertRunnerConfig(engine.endpoint, namespace, poolName);
870
+ runtime = startRuntime(engine.endpoint, namespace, poolName);
871
+ await waitForEnvoy(runtime, engine.endpoint, namespace, poolName);
872
+ log(`runtime ready for namespace ${namespace}`);
873
+
874
+ mockApp = await startMockWorkflowApp();
875
+ const app = mockApp;
876
+ client = createClient<typeof registry>({
877
+ endpoint: engine.endpoint,
878
+ namespace,
879
+ poolName,
880
+ token: TOKEN,
881
+ });
882
+ const worldConfig = {
883
+ client,
884
+ runtimeUrl: app.url,
885
+ };
886
+ world = new RivetClientWorld(worldConfig);
887
+ await runChecks(
888
+ world,
889
+ worldConfig,
890
+ app.deliveries,
891
+ app.healthChecks,
892
+ app.behavior,
893
+ );
894
+ const restartRunId = `wrun_it_runtime_restart_${randomUUID()}`;
895
+ await world.queue(
896
+ "__wkf_workflow_runtimeRestartProbe",
897
+ { runId: restartRunId },
898
+ { delaySeconds: 3 },
899
+ );
900
+ await stopProcess(runtime.child, "runtime");
901
+ runtime = undefined;
902
+ runtime = startRuntime(engine.endpoint, namespace, poolName);
903
+ await waitForEnvoy(runtime, engine.endpoint, namespace, poolName);
904
+ await waitFor(
905
+ "runtime restart delayed queue delivery",
906
+ () =>
907
+ deliveryCount(
908
+ app.deliveries,
909
+ (delivery) => (delivery.body as { runId?: string }).runId === restartRunId,
910
+ ) === 1,
911
+ 45_000,
912
+ );
913
+ log("runtime restart delayed queue recovery check passed");
914
+ log("integration checks passed");
915
+ } catch (error) {
916
+ if (runtime) {
917
+ console.error("=== RUNTIME STDIO ===");
918
+ console.error(runtime.output());
919
+ }
920
+ throw error;
921
+ } finally {
922
+ if (world) await world.close();
923
+ if (client) await client.dispose();
924
+ if (mockApp) await mockApp.close();
925
+ if (runtime) await stopProcess(runtime.child, "runtime");
926
+ await stopProcess(engine.child, "engine");
927
+ rmSync(engine.dbRoot, { force: true, recursive: true });
928
+ }
929
+ }
930
+
931
+ run().catch((error) => {
932
+ console.error("[integration] FAILED");
933
+ console.error(error);
934
+ process.exitCode = 1;
935
+ });