@ewhauser/world-celld 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1808 @@
1
+ import { i as parse, n as rpcParse, r as rpcStringify, t as compact } from "./util-BvWlkqgV.js";
2
+ import { EventSchema, HookSchema, SPEC_VERSION_CURRENT, StepSchema, WorkflowRunSchema, isLegacySpecVersion, isTerminalStepStatus, isTerminalWorkflowRunStatus, requiresNewerWorld } from "@workflow/world";
3
+ import http from "node:http";
4
+ //#region src/testing/fake-cell.ts
5
+ function applyListOptions(keys, options) {
6
+ let result = keys.toSorted();
7
+ if (options.prefix !== void 0) {
8
+ const prefix = options.prefix;
9
+ result = result.filter((k) => k.startsWith(prefix));
10
+ }
11
+ if (options.start !== void 0) {
12
+ const bound = options.start;
13
+ result = result.filter((k) => k >= bound);
14
+ }
15
+ if (options.startAfter !== void 0) {
16
+ const bound = options.startAfter;
17
+ result = result.filter((k) => k > bound);
18
+ }
19
+ if (options.end !== void 0) {
20
+ const bound = options.end;
21
+ result = result.filter((k) => k < bound);
22
+ }
23
+ if (options.reverse) result = result.toReversed();
24
+ if (options.limit !== void 0) result = result.slice(0, options.limit);
25
+ return result;
26
+ }
27
+ /**
28
+ * Fake DurableObjectStorage over a Map. Transactions stage writes and commit
29
+ * on success (discard on throw), with reads seeing staged state — the
30
+ * semantics apply-event.ts relies on.
31
+ */
32
+ var FakeStorage = class {
33
+ clock;
34
+ data = /* @__PURE__ */ new Map();
35
+ alarmAt = null;
36
+ mutationFailure;
37
+ constructor(clock = () => Date.now()) {
38
+ this.clock = clock;
39
+ }
40
+ async get(key) {
41
+ return this.data.get(key);
42
+ }
43
+ async put(key, value) {
44
+ this.maybeFailMutation({
45
+ operation: "put",
46
+ key,
47
+ value
48
+ });
49
+ this.data.set(key, structuredClone(value));
50
+ }
51
+ async delete(key) {
52
+ this.maybeFailMutation({
53
+ operation: "delete",
54
+ key
55
+ });
56
+ return this.data.delete(key);
57
+ }
58
+ async deleteAll() {
59
+ this.data.clear();
60
+ }
61
+ async list(options = {}) {
62
+ const keys = applyListOptions(Array.from(this.data.keys()), options);
63
+ return new Map(keys.map((k) => [k, this.data.get(k)]));
64
+ }
65
+ async transaction(cb) {
66
+ const txn = new FakeTransaction(this);
67
+ const result = await cb(txn);
68
+ txn.commit();
69
+ return result;
70
+ }
71
+ async getAlarm() {
72
+ return this.alarmAt;
73
+ }
74
+ async setAlarm(at) {
75
+ this.alarmAt = typeof at === "number" ? at : at.getTime();
76
+ }
77
+ async deleteAlarm() {
78
+ this.alarmAt = null;
79
+ }
80
+ now() {
81
+ return this.clock();
82
+ }
83
+ /** Inject one matching write failure, including writes inside transactions. */
84
+ failNextMutation(predicate, error = /* @__PURE__ */ new Error("injected fake storage mutation failure")) {
85
+ this.mutationFailure = {
86
+ predicate,
87
+ error
88
+ };
89
+ }
90
+ /** @internal Shared with FakeTransaction so failure injection survives a transaction fix. */
91
+ maybeFailMutation(mutation) {
92
+ if (!this.mutationFailure?.predicate(mutation)) return;
93
+ const { error } = this.mutationFailure;
94
+ this.mutationFailure = void 0;
95
+ throw error;
96
+ }
97
+ };
98
+ var FakeTransaction = class {
99
+ storage;
100
+ staged = /* @__PURE__ */ new Map();
101
+ deleted = /* @__PURE__ */ new Set();
102
+ stagedAlarm;
103
+ constructor(storage) {
104
+ this.storage = storage;
105
+ }
106
+ async get(key) {
107
+ if (this.deleted.has(key)) return void 0;
108
+ if (this.staged.has(key)) return this.staged.get(key);
109
+ return this.storage.data.get(key);
110
+ }
111
+ async put(key, value) {
112
+ this.storage.maybeFailMutation({
113
+ operation: "put",
114
+ key,
115
+ value
116
+ });
117
+ this.deleted.delete(key);
118
+ this.staged.set(key, structuredClone(value));
119
+ }
120
+ async delete(key) {
121
+ this.storage.maybeFailMutation({
122
+ operation: "delete",
123
+ key
124
+ });
125
+ const existed = !this.deleted.has(key) && this.staged.has(key) || this.storage.data.has(key);
126
+ this.staged.delete(key);
127
+ this.deleted.add(key);
128
+ return existed;
129
+ }
130
+ async list(options = {}) {
131
+ const merged = new Map(this.storage.data);
132
+ for (const key of this.deleted) merged.delete(key);
133
+ for (const [key, value] of this.staged) merged.set(key, value);
134
+ const keys = applyListOptions(Array.from(merged.keys()), options);
135
+ return new Map(keys.map((k) => [k, merged.get(k)]));
136
+ }
137
+ async getAlarm() {
138
+ return this.stagedAlarm === void 0 ? this.storage.alarmAt : this.stagedAlarm;
139
+ }
140
+ async setAlarm(at) {
141
+ this.stagedAlarm = typeof at === "number" ? at : at.getTime();
142
+ }
143
+ async deleteAlarm() {
144
+ this.stagedAlarm = null;
145
+ }
146
+ commit() {
147
+ for (const key of this.deleted) this.storage.data.delete(key);
148
+ for (const [key, value] of this.staged) this.storage.data.set(key, value);
149
+ if (this.stagedAlarm !== void 0) this.storage.alarmAt = this.stagedAlarm;
150
+ }
151
+ };
152
+ /** celld abandons an alarm after six counted handler failures. */
153
+ const ALARM_FAILURE_LIMIT = 6;
154
+ /**
155
+ * A fake fleet: one namespace per binding, each lazily instantiating the real
156
+ * cell class with a fake ctx. `fireDueAlarms()` drives alarm dispatch
157
+ * deterministically (alarms never fire on their own), reproducing celld's
158
+ * retry ladder including the six-failure abandonment.
159
+ */
160
+ var FakeFleet = class {
161
+ classes;
162
+ cellEnv;
163
+ cells = /* @__PURE__ */ new Map();
164
+ now;
165
+ constructor(classes, cellEnv = {}, startTime = Date.now()) {
166
+ this.classes = classes;
167
+ this.cellEnv = cellEnv;
168
+ this.now = startTime;
169
+ }
170
+ namespace(bindingKey) {
171
+ return {
172
+ idFromName(name) {
173
+ return { toString: () => name };
174
+ },
175
+ get: (id) => this.cell(bindingKey, id.toString()).instance
176
+ };
177
+ }
178
+ cell(bindingKey, name) {
179
+ const key = `${bindingKey}\0${name}`;
180
+ let slot = this.cells.get(key);
181
+ if (!slot) {
182
+ const CellCtor = this.classes[bindingKey];
183
+ if (!CellCtor) throw new Error(`no cell class registered for binding: ${bindingKey}`);
184
+ const storage = new FakeStorage(() => this.now);
185
+ const pendingWaits = [];
186
+ slot = {
187
+ instance: new CellCtor({
188
+ storage,
189
+ id: {
190
+ toString: () => name,
191
+ name
192
+ },
193
+ waitUntil(promise) {
194
+ pendingWaits.push(promise);
195
+ },
196
+ async blockConcurrencyWhile(cb) {
197
+ return cb();
198
+ }
199
+ }, this.cellEnv),
200
+ storage,
201
+ pendingWaits,
202
+ alarmRetryCount: 0,
203
+ alarmAbandoned: false
204
+ };
205
+ this.cells.set(key, slot);
206
+ }
207
+ return slot;
208
+ }
209
+ advance(ms) {
210
+ this.now += ms;
211
+ }
212
+ /**
213
+ * Dispatch every alarm due at the virtual current time, awaiting waitUntil
214
+ * work. A throwing handler re-arms per celld's ladder (2s doubling) and
215
+ * abandons the alarm after the sixth counted failure — the cell keeps its
216
+ * state but has no timer.
217
+ */
218
+ async fireDueAlarms() {
219
+ for (const slot of this.cells.values()) {
220
+ const due = slot.storage.alarmAt;
221
+ if (due === null || due > this.now || slot.alarmAbandoned) continue;
222
+ if (typeof slot.instance.alarm !== "function") continue;
223
+ slot.storage.alarmAt = null;
224
+ try {
225
+ await slot.instance.alarm({ retryCount: slot.alarmRetryCount });
226
+ slot.alarmRetryCount = 0;
227
+ } catch {
228
+ slot.alarmRetryCount += 1;
229
+ if (slot.alarmRetryCount >= ALARM_FAILURE_LIMIT) slot.alarmAbandoned = true;
230
+ else if (slot.storage.alarmAt === null) slot.storage.alarmAt = this.now + 2e3 * 2 ** (slot.alarmRetryCount - 1);
231
+ }
232
+ await this.drainWaits(slot);
233
+ }
234
+ }
235
+ async drainWaits(slot) {
236
+ while (slot.pendingWaits.length > 0) {
237
+ const batch = slot.pendingWaits.splice(0);
238
+ await Promise.allSettled(batch);
239
+ }
240
+ }
241
+ /** Await all waitUntil work across the fleet (delivery fetches, etc.). */
242
+ async settle() {
243
+ for (const slot of this.cells.values()) await this.drainWaits(slot);
244
+ }
245
+ };
246
+ //#endregion
247
+ //#region src/worker/auth.ts
248
+ /**
249
+ * Bearer-token auth for the worker router.
250
+ *
251
+ * Fails closed: when the WORLD_SECRET var is not configured, every protected
252
+ * route returns 503 instead of forwarding unauthenticated traffic (the
253
+ * eve-ambient celld-worker pattern).
254
+ */
255
+ /**
256
+ * Constant-time string comparison via SHA-256 digests (digest-then-compare
257
+ * is length-independent). crypto.subtle is global in both workerd and Node.
258
+ */
259
+ async function timingSafeEqual(a, b) {
260
+ const enc = new TextEncoder();
261
+ const [da, db] = await Promise.all([crypto.subtle.digest("SHA-256", enc.encode(a)), crypto.subtle.digest("SHA-256", enc.encode(b))]);
262
+ const va = new Uint8Array(da);
263
+ const vb = new Uint8Array(db);
264
+ let diff = 0;
265
+ for (let i = 0; i < va.length; i++) diff |= va[i] ^ vb[i];
266
+ return diff === 0;
267
+ }
268
+ async function authenticate(request, secret) {
269
+ if (!secret) return {
270
+ ok: false,
271
+ response: Response.json({ error: {
272
+ name: "WorldNotConfigured",
273
+ message: "WORLD_SECRET is not configured"
274
+ } }, { status: 503 })
275
+ };
276
+ const header = request.headers.get("authorization") ?? "";
277
+ const token = header.startsWith("Bearer ") ? header.slice(7) : "";
278
+ if (!token || !await timingSafeEqual(token, secret)) return {
279
+ ok: false,
280
+ response: Response.json({ error: {
281
+ name: "Unauthorized",
282
+ message: "invalid or missing bearer token"
283
+ } }, { status: 401 })
284
+ };
285
+ return { ok: true };
286
+ }
287
+ //#endregion
288
+ //#region src/worker/router.ts
289
+ /**
290
+ * Worker-level HTTP router: the fleet-facing RPC surface of the celld world.
291
+ *
292
+ * Routes:
293
+ * GET /v1/health — liveness + spec version (unauthenticated)
294
+ * POST /v1/rpc/{binding}/{name}/{method} — DO RPC (bearer auth)
295
+ *
296
+ * The RPC body is an rpc-codec-encoded JSON array of arguments; the response
297
+ * body is the rpc-codec-encoded return value. Only whitelisted methods
298
+ * dispatch — everything else (including fetch/alarm) is unreachable.
299
+ */
300
+ const WORLD_NAME = "world-celld";
301
+ const WORLD_VERSION = "0.1.0";
302
+ const BINDINGS = {
303
+ runs: {
304
+ env: "WORKFLOW_DB",
305
+ methods: /* @__PURE__ */ new Set([
306
+ "applyEvent",
307
+ "getRun",
308
+ "getStep",
309
+ "getEvent",
310
+ "listEvents",
311
+ "listSteps",
312
+ "listHooks"
313
+ ])
314
+ },
315
+ streams: {
316
+ env: "WORKFLOW_STREAMS",
317
+ methods: /* @__PURE__ */ new Set([
318
+ "writeChunk",
319
+ "closeStream",
320
+ "getChunks",
321
+ "getInfo",
322
+ "registerStream",
323
+ "listStreams"
324
+ ])
325
+ },
326
+ index: {
327
+ env: "WORKFLOW_INDEX",
328
+ methods: /* @__PURE__ */ new Set([
329
+ "get",
330
+ "put",
331
+ "delete",
332
+ "list",
333
+ "reserveHookToken",
334
+ "finalizeHookIndexes",
335
+ "releaseHookToken",
336
+ "deleteHookIndexes"
337
+ ])
338
+ },
339
+ queue: {
340
+ env: "WORKFLOW_QUEUE",
341
+ methods: /* @__PURE__ */ new Set([
342
+ "enqueue",
343
+ "stats",
344
+ "listDeadLetters",
345
+ "redriveDeadLetter",
346
+ "purgeDeadLetters",
347
+ "rearmAlarm"
348
+ ])
349
+ }
350
+ };
351
+ /** Request body cap: oversize payloads get a clear 413 instead of an OOM. */
352
+ const MAX_BODY_BYTES = 33554432;
353
+ function errorResponse(status, name, message) {
354
+ return Response.json({ error: {
355
+ name,
356
+ message
357
+ } }, { status });
358
+ }
359
+ async function readBoundedBody(request) {
360
+ if (!request.body) return "";
361
+ const reader = request.body.getReader();
362
+ const decoder = new TextDecoder();
363
+ let total = 0;
364
+ let text = "";
365
+ try {
366
+ while (true) {
367
+ const { done, value } = await reader.read();
368
+ if (done) break;
369
+ total += value.byteLength;
370
+ if (total > MAX_BODY_BYTES) {
371
+ await reader.cancel("RPC body exceeds configured limit").catch(() => void 0);
372
+ return null;
373
+ }
374
+ text += decoder.decode(value, { stream: true });
375
+ }
376
+ return text + decoder.decode();
377
+ } finally {
378
+ reader.releaseLock();
379
+ }
380
+ }
381
+ function createRouter(env) {
382
+ return async (request) => {
383
+ const url = new URL(request.url);
384
+ const parts = url.pathname.split("/").filter(Boolean);
385
+ if (parts[0] !== "v1") return errorResponse(404, "NotFound", `unknown path: ${url.pathname}`);
386
+ if (parts[1] === "health" && parts.length === 2) return Response.json({
387
+ ok: true,
388
+ name: WORLD_NAME,
389
+ version: WORLD_VERSION,
390
+ specVersion: SPEC_VERSION_CURRENT
391
+ });
392
+ if (parts[1] !== "rpc") return errorResponse(404, "NotFound", `unknown path: ${url.pathname}`);
393
+ const auth = await authenticate(request, env.WORLD_SECRET);
394
+ if (!auth.ok) return auth.response;
395
+ if (request.method !== "POST" || parts.length !== 5) return errorResponse(404, "NotFound", "expected POST /v1/rpc/{binding}/{name}/{method}");
396
+ const [, , bindingKey, encodedName, method] = parts;
397
+ const binding = BINDINGS[bindingKey];
398
+ if (!binding) return errorResponse(404, "NotFound", `unknown binding: ${bindingKey}`);
399
+ if (!binding.methods.has(method)) return errorResponse(404, "NotFound", `unknown method: ${bindingKey}.${method}`);
400
+ if (Number(request.headers.get("content-length") ?? "0") > MAX_BODY_BYTES) return errorResponse(413, "PayloadTooLarge", `body exceeds ${MAX_BODY_BYTES} bytes`);
401
+ let args;
402
+ try {
403
+ const text = await readBoundedBody(request);
404
+ if (text === null) return errorResponse(413, "PayloadTooLarge", `body exceeds ${MAX_BODY_BYTES} bytes`);
405
+ const parsed = rpcParse(text);
406
+ if (!Array.isArray(parsed)) return errorResponse(400, "BadRequest", "body must be an argument array");
407
+ args = parsed;
408
+ } catch {
409
+ return errorResponse(400, "BadRequest", "malformed rpc body");
410
+ }
411
+ const name = decodeURIComponent(encodedName);
412
+ const namespace = env[binding.env];
413
+ if (!namespace) return errorResponse(500, "WorldMisconfigured", `missing binding: ${binding.env}`);
414
+ try {
415
+ const result = await namespace.get(namespace.idFromName(name))[method](...args);
416
+ return new Response(rpcStringify(result ?? null), {
417
+ status: 200,
418
+ headers: { "content-type": "application/json" }
419
+ });
420
+ } catch (error) {
421
+ const err = error;
422
+ return Response.json({ error: {
423
+ name: err?.name ?? "Error",
424
+ message: err?.message ?? String(error),
425
+ status: typeof err?.status === "number" ? err.status : void 0
426
+ } }, { status: 500 });
427
+ }
428
+ };
429
+ }
430
+ //#endregion
431
+ //#region src/worker/do-base.ts
432
+ /**
433
+ * Durable Object base class — Node variant.
434
+ *
435
+ * The cell classes extend `DurableObject` so celld's JS RPC works on their
436
+ * stubs. This module is what Node builds (tests, the `./testing` in-process
437
+ * fleet) resolve; the worker build swaps it for do-base-workerd.ts, which
438
+ * re-exports the real `cloudflare:workers` base (see tsdown.config.ts).
439
+ * Behaviorally identical at this layer: the base only assigns ctx/env.
440
+ */
441
+ var DurableObject = class {
442
+ ctx;
443
+ env;
444
+ constructor(ctx, env) {
445
+ this.ctx = ctx;
446
+ this.env = env;
447
+ }
448
+ };
449
+ //#endregion
450
+ //#region src/worker/durable-objects/IndexDO.ts
451
+ function sameOwner(left, right) {
452
+ return left.runId === right.runId && left.hookId === right.hookId;
453
+ }
454
+ function ownerFromHook(raw) {
455
+ const hook = parse(raw);
456
+ return {
457
+ runId: hook.runId,
458
+ hookId: hook.hookId
459
+ };
460
+ }
461
+ /**
462
+ * Global index cell, replacing the Workers KV namespace world-cloudflare
463
+ * uses for run listing and hook token/id lookup.
464
+ *
465
+ * A single cell (named `index`) serializes every read and write. Hook-token
466
+ * reservation, publication, and release use storage transactions so
467
+ * concurrent runs cannot both acquire the same token.
468
+ *
469
+ * The RPC surface matches the storage-layer KVNamespace interface bit-for-bit
470
+ * (including MockKVNamespace's pagination contract: cursor = last returned
471
+ * key, `list_complete` reflects whether further keys exist), so the vendored
472
+ * storage.ts works against it unchanged.
473
+ */
474
+ var IndexDO = class extends DurableObject {
475
+ async get(key) {
476
+ return await this.ctx.storage.get(key) ?? null;
477
+ }
478
+ async put(key, value) {
479
+ await this.ctx.storage.put(key, value);
480
+ }
481
+ async delete(key) {
482
+ await this.ctx.storage.delete(key);
483
+ }
484
+ async list(options) {
485
+ const prefix = options?.prefix ?? "";
486
+ const limit = options?.limit ?? 1e3;
487
+ const entries = await this.ctx.storage.list({
488
+ prefix,
489
+ ...options?.reverse ? {
490
+ reverse: true,
491
+ end: options.cursor
492
+ } : { startAfter: options?.cursor },
493
+ limit: limit + 1
494
+ });
495
+ const names = Array.from(entries.keys());
496
+ const page = names.slice(0, limit);
497
+ const listComplete = names.length <= limit;
498
+ return {
499
+ keys: page.map((name) => ({ name })),
500
+ list_complete: listComplete,
501
+ cursor: listComplete ? void 0 : page.at(-1)
502
+ };
503
+ }
504
+ async reserveHookToken(token, owner) {
505
+ return this.ctx.storage.transaction(async (txn) => {
506
+ const indexedHook = await txn.get(`hook:${token}`);
507
+ if (indexedHook !== void 0) return {
508
+ claimed: false,
509
+ holder: ownerFromHook(indexedHook)
510
+ };
511
+ const claimKey = `hookclaim:${token}`;
512
+ const claim = await txn.get(claimKey);
513
+ if (claim !== void 0 && !sameOwner(claim.owner, owner)) return {
514
+ claimed: false,
515
+ holder: claim.owner
516
+ };
517
+ await txn.put(claimKey, {
518
+ owner,
519
+ reservedAt: Date.now()
520
+ });
521
+ return { claimed: true };
522
+ });
523
+ }
524
+ async finalizeHookIndexes(token, hookId, serializedHook, owner) {
525
+ await this.ctx.storage.transaction(async (txn) => {
526
+ const indexedHook = await txn.get(`hook:${token}`);
527
+ if (indexedHook !== void 0 && !sameOwner(ownerFromHook(indexedHook), owner)) throw new Error(`Hook token ${token} is owned by another hook`);
528
+ const claim = await txn.get(`hookclaim:${token}`);
529
+ if (claim !== void 0 && !sameOwner(claim.owner, owner)) throw new Error(`Hook token ${token} is reserved by another hook`);
530
+ await txn.put(`hook:${token}`, serializedHook);
531
+ await txn.put(`hookid:${hookId}`, serializedHook);
532
+ if (claim !== void 0) await txn.delete(`hookclaim:${token}`);
533
+ });
534
+ }
535
+ async releaseHookToken(token, owner) {
536
+ await this.ctx.storage.transaction(async (txn) => {
537
+ const key = `hookclaim:${token}`;
538
+ const claim = await txn.get(key);
539
+ if (claim !== void 0 && sameOwner(claim.owner, owner)) await txn.delete(key);
540
+ });
541
+ }
542
+ async deleteHookIndexes(token, hookId, owner) {
543
+ await this.ctx.storage.transaction(async (txn) => {
544
+ const tokenKey = `hook:${token}`;
545
+ const idKey = `hookid:${hookId}`;
546
+ const claimKey = `hookclaim:${token}`;
547
+ const [byToken, byId, claim] = await Promise.all([
548
+ txn.get(tokenKey),
549
+ txn.get(idKey),
550
+ txn.get(claimKey)
551
+ ]);
552
+ if (byToken !== void 0 && sameOwner(ownerFromHook(byToken), owner)) await txn.delete(tokenKey);
553
+ if (byId !== void 0 && sameOwner(ownerFromHook(byId), owner)) await txn.delete(idKey);
554
+ if (claim !== void 0 && sameOwner(claim.owner, owner)) await txn.delete(claimKey);
555
+ });
556
+ }
557
+ };
558
+ //#endregion
559
+ //#region src/worker/durable-objects/QueueDO.ts
560
+ const PERMANENT_STATUSES = /* @__PURE__ */ new Set([
561
+ 404,
562
+ 409,
563
+ 410,
564
+ 422
565
+ ]);
566
+ const DEFAULT_MAX_ATTEMPTS = 5;
567
+ const DEFAULT_MAX_INFLIGHT = 5;
568
+ const DEFAULT_DELIVERY_TIMEOUT_MS = 3e5;
569
+ /** Grace period past the delivery timeout before a claim counts as lost. */
570
+ const INFLIGHT_GRACE_MS = 3e4;
571
+ /** Move overdue alarms to a new timestamp so celld observes a fresh edge. */
572
+ const MIN_ALARM_DELAY_MS = 1;
573
+ function pad(ms) {
574
+ return String(Math.max(0, Math.floor(ms))).padStart(13, "0");
575
+ }
576
+ function dueKey(atMs, messageId) {
577
+ return `due:${pad(atMs)}:${messageId}`;
578
+ }
579
+ function backoffSeconds(attempt) {
580
+ return Math.min(60, 2 ** attempt);
581
+ }
582
+ var QueueDO = class extends DurableObject {
583
+ #now() {
584
+ const clock = this.env?.clock;
585
+ return typeof clock === "function" ? clock() : Date.now();
586
+ }
587
+ #fetch() {
588
+ const impl = this.env?.fetch;
589
+ return typeof impl === "function" ? impl : fetch;
590
+ }
591
+ #intVar(name, fallback) {
592
+ const raw = this.env?.[name];
593
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
594
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
595
+ }
596
+ async enqueue(request) {
597
+ const storage = this.ctx.storage;
598
+ const now = this.#now();
599
+ return await storage.transaction(async (txn) => {
600
+ const pinned = await txn.get("cfg");
601
+ if (!pinned) await txn.put("cfg", {
602
+ queueShards: request.config.queueShards,
603
+ pinnedAt: now
604
+ });
605
+ else if (pinned.queueShards !== request.config.queueShards) return {
606
+ ok: false,
607
+ code: "CONFIG_MISMATCH",
608
+ message: `queue cell shard-count mismatch: pinned ${pinned.queueShards}, got ${request.config.queueShards} — changing queueShards requires draining the fleet`
609
+ };
610
+ if (request.idempotencyKey) {
611
+ const existing = await txn.get(`key:${request.idempotencyKey}`);
612
+ if (existing) {
613
+ if (await txn.get(`msg:${existing}`)) {
614
+ await this.#scheduleNextAlarm(txn, now);
615
+ return {
616
+ ok: true,
617
+ messageId: existing,
618
+ deduped: true
619
+ };
620
+ }
621
+ await txn.delete(`key:${request.idempotencyKey}`);
622
+ }
623
+ await txn.put(`key:${request.idempotencyKey}`, request.messageId);
624
+ }
625
+ const row = {
626
+ messageId: request.messageId,
627
+ queueName: request.queueName,
628
+ pathname: request.pathname,
629
+ body: request.body,
630
+ targetBaseUrl: request.config.targetBaseUrl,
631
+ idempotencyKey: request.idempotencyKey,
632
+ attempt: 0,
633
+ enqueuedAt: now
634
+ };
635
+ const dueAt = now + Math.max(0, request.delaySeconds ?? 0) * 1e3;
636
+ await txn.put(`msg:${row.messageId}`, row);
637
+ await txn.put(dueKey(dueAt, row.messageId), row.messageId);
638
+ await this.#armAlarmAtMost(txn, dueAt, now);
639
+ return {
640
+ ok: true,
641
+ messageId: row.messageId,
642
+ deduped: false
643
+ };
644
+ });
645
+ }
646
+ async alarm() {
647
+ let claimed = [];
648
+ let failure = null;
649
+ const attempt = async () => {
650
+ try {
651
+ claimed = await this.#claimPhase();
652
+ } catch (error) {
653
+ failure = error;
654
+ }
655
+ };
656
+ if (typeof this.ctx.blockConcurrencyWhile === "function") await this.ctx.blockConcurrencyWhile(attempt);
657
+ else await attempt();
658
+ if (failure !== null) throw failure;
659
+ for (const row of claimed) this.ctx.waitUntil(this.#deliver(row));
660
+ }
661
+ /**
662
+ * Storage-only: recover lost inflight claims, claim due messages up to the
663
+ * inflight cap, and re-arm the alarm. Never throws for data conditions.
664
+ */
665
+ async #claimPhase() {
666
+ const storage = this.ctx.storage;
667
+ const now = this.#now();
668
+ const maxInflight = this.#intVar("QUEUE_MAX_INFLIGHT", DEFAULT_MAX_INFLIGHT);
669
+ return await storage.transaction(async (txn) => {
670
+ const inflight = await txn.list({ prefix: "inflight:" });
671
+ let inflightCount = 0;
672
+ for (const [key, deadline] of inflight) if (deadline <= now) {
673
+ const messageId = key.slice(9);
674
+ await txn.delete(key);
675
+ if (await txn.get(`msg:${messageId}`)) await txn.put(dueKey(now, messageId), messageId);
676
+ } else inflightCount++;
677
+ const rows = [];
678
+ if (inflightCount < maxInflight) {
679
+ const due = await txn.list({
680
+ prefix: "due:",
681
+ end: `due:${pad(now + 1)}`,
682
+ limit: maxInflight - inflightCount
683
+ });
684
+ const timeoutMs = this.#intVar("QUEUE_DELIVERY_TIMEOUT_MS", DEFAULT_DELIVERY_TIMEOUT_MS);
685
+ for (const [key, messageId] of due) {
686
+ await txn.delete(key);
687
+ const row = await txn.get(`msg:${messageId}`);
688
+ if (!row) continue;
689
+ await txn.put(`inflight:${messageId}`, now + timeoutMs + INFLIGHT_GRACE_MS);
690
+ rows.push(row);
691
+ }
692
+ }
693
+ await this.#scheduleNextAlarm(txn, now);
694
+ return rows;
695
+ });
696
+ }
697
+ async #deliver(row) {
698
+ const storage = this.ctx.storage;
699
+ const timeoutMs = this.#intVar("QUEUE_DELIVERY_TIMEOUT_MS", DEFAULT_DELIVERY_TIMEOUT_MS);
700
+ const callbackSecret = this.env?.WORKFLOW_CALLBACK_SECRET;
701
+ const url = `${row.targetBaseUrl.replace(/\/$/, "")}/.well-known/workflow/v1/${row.pathname}`;
702
+ let response = null;
703
+ let transportError = null;
704
+ try {
705
+ response = await this.#fetch()(url, {
706
+ method: "POST",
707
+ headers: {
708
+ "content-type": "application/json",
709
+ "x-vqs-queue-name": row.queueName,
710
+ "x-vqs-message-id": row.messageId,
711
+ "x-vqs-message-attempt": String(row.attempt + 1),
712
+ ...callbackSecret ? { "x-workflow-callback-secret": callbackSecret } : {}
713
+ },
714
+ body: row.body,
715
+ signal: AbortSignal.timeout(timeoutMs)
716
+ });
717
+ } catch (error) {
718
+ transportError = error;
719
+ }
720
+ const now = this.#now();
721
+ if (response?.ok) await this.#ack(row);
722
+ else if (response && response.status === 503) {
723
+ let timeoutSeconds;
724
+ try {
725
+ timeoutSeconds = (await response.json())?.timeoutSeconds;
726
+ } catch {
727
+ timeoutSeconds = void 0;
728
+ }
729
+ if (typeof timeoutSeconds === "number") await storage.transaction(async (txn) => {
730
+ await txn.delete(`inflight:${row.messageId}`);
731
+ await txn.put(dueKey(now + timeoutSeconds * 1e3, row.messageId), row.messageId);
732
+ await this.#scheduleNextAlarm(txn, now);
733
+ });
734
+ else await this.#retry(row, "HTTP 503");
735
+ } else if (response && PERMANENT_STATUSES.has(response.status)) await this.#ack(row);
736
+ else {
737
+ const reason = response ? `HTTP ${response.status}` : `transport error: ${String(transportError)}`;
738
+ await this.#retry(row, reason);
739
+ }
740
+ }
741
+ async #ack(row) {
742
+ await this.ctx.storage.transaction(async (txn) => {
743
+ await txn.delete(`msg:${row.messageId}`);
744
+ await txn.delete(`inflight:${row.messageId}`);
745
+ if (row.idempotencyKey) {
746
+ if (await txn.get(`key:${row.idempotencyKey}`) === row.messageId) await txn.delete(`key:${row.idempotencyKey}`);
747
+ }
748
+ await this.#scheduleNextAlarm(txn, this.#now());
749
+ });
750
+ }
751
+ async #retry(row, reason) {
752
+ const storage = this.ctx.storage;
753
+ const now = this.#now();
754
+ const maxAttempts = this.#intVar("QUEUE_MAX_ATTEMPTS", DEFAULT_MAX_ATTEMPTS);
755
+ const attempt = row.attempt + 1;
756
+ await storage.transaction(async (txn) => {
757
+ await txn.delete(`inflight:${row.messageId}`);
758
+ if (attempt >= maxAttempts) {
759
+ const dead = {
760
+ ...row,
761
+ attempt,
762
+ lastError: reason,
763
+ failedAt: now
764
+ };
765
+ await txn.put(`dlq:${pad(now)}:${row.messageId}`, dead);
766
+ await txn.delete(`msg:${row.messageId}`);
767
+ if (row.idempotencyKey) {
768
+ if (await txn.get(`key:${row.idempotencyKey}`) === row.messageId) await txn.delete(`key:${row.idempotencyKey}`);
769
+ }
770
+ await this.#scheduleNextAlarm(txn, now);
771
+ return;
772
+ }
773
+ const updated = {
774
+ ...row,
775
+ attempt,
776
+ lastError: reason
777
+ };
778
+ await txn.put(`msg:${row.messageId}`, updated);
779
+ await txn.put(dueKey(now + backoffSeconds(attempt) * 1e3, row.messageId), row.messageId);
780
+ await this.#scheduleNextAlarm(txn, now);
781
+ });
782
+ }
783
+ /** Arm the alarm no later than `atMs` (durable backoff never throws). */
784
+ async #armAlarmAtMost(storage, atMs, now) {
785
+ const current = await storage.getAlarm();
786
+ if (current !== null && current <= now) {
787
+ await storage.setAlarm(now + MIN_ALARM_DELAY_MS);
788
+ return;
789
+ }
790
+ const target = atMs <= now ? now + MIN_ALARM_DELAY_MS : atMs;
791
+ if (current === null || target < current) await storage.setAlarm(target);
792
+ }
793
+ /** Recompute the alarm from the earliest due entry / inflight deadline. */
794
+ async #rearm() {
795
+ await this.#scheduleNextAlarm(this.ctx.storage, this.#now());
796
+ }
797
+ /** Keep the alarm index consistent with message state in the same transaction. */
798
+ async #scheduleNextAlarm(storage, now) {
799
+ let next = null;
800
+ const due = await storage.list({
801
+ prefix: "due:",
802
+ limit: 1
803
+ });
804
+ for (const key of due.keys()) next = Number.parseInt(key.slice(4, 17), 10);
805
+ const inflight = await storage.list({ prefix: "inflight:" });
806
+ for (const deadline of inflight.values()) if (next === null || deadline < next) next = deadline;
807
+ if (next !== null) await storage.setAlarm(next <= now ? now + MIN_ALARM_DELAY_MS : next);
808
+ else await storage.deleteAlarm();
809
+ }
810
+ async stats() {
811
+ const storage = this.ctx.storage;
812
+ const [due, inflight, dlq, alarmAt] = await Promise.all([
813
+ storage.list({ prefix: "due:" }),
814
+ storage.list({ prefix: "inflight:" }),
815
+ storage.list({ prefix: "dlq:" }),
816
+ storage.getAlarm()
817
+ ]);
818
+ return {
819
+ pending: due.size,
820
+ inflight: inflight.size,
821
+ deadLetters: dlq.size,
822
+ alarmAt
823
+ };
824
+ }
825
+ async listDeadLetters(params) {
826
+ const limit = params?.limit ?? 100;
827
+ const entries = await this.ctx.storage.list({
828
+ prefix: "dlq:",
829
+ startAfter: params?.cursor,
830
+ limit: limit + 1
831
+ });
832
+ const keys = Array.from(entries.keys());
833
+ const page = keys.slice(0, limit);
834
+ const hasMore = keys.length > limit;
835
+ return {
836
+ data: page.map((k) => entries.get(k)),
837
+ cursor: hasMore ? page.at(-1) : null,
838
+ hasMore
839
+ };
840
+ }
841
+ /** Move a dead letter back to the live queue (attempt count reset). */
842
+ async redriveDeadLetter(messageId) {
843
+ const storage = this.ctx.storage;
844
+ const now = this.#now();
845
+ return await storage.transaction(async (txn) => {
846
+ const dlq = await txn.list({ prefix: "dlq:" });
847
+ for (const [key, dead] of dlq) {
848
+ if (dead.messageId !== messageId) continue;
849
+ const { failedAt: _failedAt, ...rest } = dead;
850
+ const row = {
851
+ ...rest,
852
+ attempt: 0,
853
+ lastError: void 0
854
+ };
855
+ if (row.idempotencyKey) {
856
+ const dedupKey = `key:${row.idempotencyKey}`;
857
+ const holder = await txn.get(dedupKey);
858
+ if (holder && holder !== row.messageId && await txn.get(`msg:${holder}`)) return { ok: false };
859
+ if (holder && holder !== row.messageId) await txn.delete(dedupKey);
860
+ await txn.put(dedupKey, row.messageId);
861
+ }
862
+ await txn.put(`msg:${row.messageId}`, row);
863
+ await txn.put(dueKey(now, row.messageId), row.messageId);
864
+ await txn.delete(key);
865
+ await this.#armAlarmAtMost(txn, now, now);
866
+ return { ok: true };
867
+ }
868
+ return { ok: false };
869
+ });
870
+ }
871
+ async purgeDeadLetters() {
872
+ return await this.ctx.storage.transaction(async (txn) => {
873
+ const dlq = await txn.list({ prefix: "dlq:" });
874
+ for (const key of dlq.keys()) await txn.delete(key);
875
+ return { purged: dlq.size };
876
+ });
877
+ }
878
+ /**
879
+ * Recovery hatch for celld's six-failure alarm abandonment: derive the due
880
+ * time again and arm it.
881
+ */
882
+ async rearmAlarm() {
883
+ await this.#rearm();
884
+ return { alarmAt: await this.ctx.storage.getAlarm() };
885
+ }
886
+ };
887
+ //#endregion
888
+ //#region src/worker/durable-objects/StreamDO.ts
889
+ const META_KEY = "meta";
890
+ const CHUNK_KEY_PREFIX = "chunk:";
891
+ /** Registry keys used when this DO instance acts as a per-run stream index. */
892
+ const STREAM_REGISTRY_PREFIX = "stream:";
893
+ /**
894
+ * Zero-pad chunk indexes so `storage.list({ prefix })` returns chunks in
895
+ * write order. 12 digits comfortably exceeds any realistic chunk count.
896
+ */
897
+ function chunkKey(index) {
898
+ return `${CHUNK_KEY_PREFIX}${index.toString().padStart(12, "0")}`;
899
+ }
900
+ /**
901
+ * Durable Object backing workflow streams.
902
+ *
903
+ * Two roles, selected by the DO name:
904
+ * - `stream:<name>`: chunk storage for a single stream. A monotonic
905
+ * per-stream counter (maintained transactionally inside the DO) assigns
906
+ * each chunk its index; one chunk per storage key.
907
+ * - `run-streams:<runId>`: registry of stream names owned by a run,
908
+ * powering `listStreamsByRunId`.
909
+ */
910
+ var StreamDO = class extends DurableObject {
911
+ async getMeta() {
912
+ return await this.ctx.storage.get(META_KEY) ?? {
913
+ count: 0,
914
+ closed: false
915
+ };
916
+ }
917
+ /**
918
+ * Append a chunk. The index is allocated inside the transaction, so
919
+ * concurrent writers can never collide or skip.
920
+ */
921
+ async writeChunk(data) {
922
+ return await this.ctx.storage.transaction(async (txn) => {
923
+ const meta = await txn.get(META_KEY) ?? {
924
+ count: 0,
925
+ closed: false
926
+ };
927
+ if (meta.closed) throw new Error("Cannot write to a closed stream");
928
+ const index = meta.count;
929
+ await txn.put(chunkKey(index), data);
930
+ await txn.put(META_KEY, {
931
+ count: index + 1,
932
+ closed: false
933
+ });
934
+ return index;
935
+ });
936
+ }
937
+ /**
938
+ * Close the stream (idempotent). Readers observe `done: true` once all
939
+ * previously written chunks have been consumed.
940
+ */
941
+ async closeStream() {
942
+ await this.ctx.storage.transaction(async (txn) => {
943
+ const meta = await txn.get(META_KEY) ?? {
944
+ count: 0,
945
+ closed: false
946
+ };
947
+ await txn.put(META_KEY, {
948
+ ...meta,
949
+ closed: true
950
+ });
951
+ });
952
+ }
953
+ /**
954
+ * Read up to `limit` chunks starting at `startIndex` (0-based, inclusive).
955
+ */
956
+ async getChunks(params) {
957
+ const meta = await this.getMeta();
958
+ const start = Math.max(0, params.startIndex);
959
+ const entries = await this.ctx.storage.list({
960
+ prefix: CHUNK_KEY_PREFIX,
961
+ start: chunkKey(start),
962
+ limit: params.limit
963
+ });
964
+ return {
965
+ chunks: Array.from(entries.values()),
966
+ done: meta.closed,
967
+ tailIndex: meta.count - 1
968
+ };
969
+ }
970
+ /**
971
+ * Lightweight stream metadata: last chunk index and completion flag.
972
+ */
973
+ async getInfo() {
974
+ const meta = await this.getMeta();
975
+ return {
976
+ tailIndex: meta.count - 1,
977
+ done: meta.closed
978
+ };
979
+ }
980
+ /**
981
+ * Register a stream name against this per-run registry instance.
982
+ */
983
+ async registerStream(name) {
984
+ await this.ctx.storage.put(`${STREAM_REGISTRY_PREFIX}${name}`, true);
985
+ }
986
+ /**
987
+ * List stream names registered against this per-run registry instance.
988
+ */
989
+ async listStreams() {
990
+ const entries = await this.ctx.storage.list({ prefix: STREAM_REGISTRY_PREFIX });
991
+ return Array.from(entries.keys()).map((key) => key.slice(7));
992
+ }
993
+ };
994
+ //#endregion
995
+ //#region src/apply-event.ts
996
+ /** Storage key for the run entity. */
997
+ const RUN_KEY = "run";
998
+ /** Key prefixes for per-entity storage. */
999
+ const EVENT_KEY_PREFIX = "event:";
1000
+ const STEP_KEY_PREFIX = "step:";
1001
+ const HOOK_KEY_PREFIX = "hook:";
1002
+ const STEP_CREATED_KEY_PREFIX = "stepcreated:";
1003
+ const HOOK_CREATED_KEY_PREFIX = "hookcreated:";
1004
+ /**
1005
+ * Marker recording that a hook_created event was committed for a hookId.
1006
+ * Mirrors postgres' `workflow_events_entity_creation_unique` partial index:
1007
+ * it outlives hook disposal so a replayed hook_created after dispose is
1008
+ * rejected as a duplicate instead of resurrecting the hook.
1009
+ */
1010
+ const HOOK_EVENT_MARKER_PREFIX = "hookevent:";
1011
+ function failure(code, message, extra) {
1012
+ return {
1013
+ ok: false,
1014
+ code,
1015
+ message,
1016
+ ...extra
1017
+ };
1018
+ }
1019
+ function readStringProp(value, key) {
1020
+ if (value !== null && typeof value === "object") {
1021
+ const prop = value[key];
1022
+ if (typeof prop === "string") return prop;
1023
+ }
1024
+ }
1025
+ /**
1026
+ * Map a failure eventData.error (which may be a string, an Error-shaped
1027
+ * object, or anything else) to the structured error stored on entities.
1028
+ * Matches upstream: string errors keep their text, errorCode is preserved.
1029
+ */
1030
+ function toStructuredError(error, opts) {
1031
+ return {
1032
+ message: typeof error === "string" ? error : readStringProp(error, "message") ?? "Unknown error",
1033
+ stack: opts?.stack ?? readStringProp(error, "stack"),
1034
+ code: opts?.code ?? readStringProp(error, "code")
1035
+ };
1036
+ }
1037
+ /**
1038
+ * Shared cursor pagination over a key prefix. Fetches `limit + 1` entries to
1039
+ * detect `hasMore` without ever skipping the boundary entry (the cursor is
1040
+ * derived from the last *returned* item, not the peeked one).
1041
+ */
1042
+ async function listByPrefix(store, prefix, params, getId) {
1043
+ const { limit, cursor, sortOrder = "asc" } = params;
1044
+ const options = sortOrder === "desc" ? {
1045
+ prefix,
1046
+ reverse: true,
1047
+ limit: limit + 1,
1048
+ end: cursor ? `${prefix}${cursor}` : void 0
1049
+ } : {
1050
+ prefix,
1051
+ limit: limit + 1,
1052
+ startAfter: cursor ? `${prefix}${cursor}` : void 0
1053
+ };
1054
+ const entries = await store.list(options);
1055
+ const values = Array.from(entries.values());
1056
+ const hasMore = values.length > limit;
1057
+ const data = values.slice(0, limit);
1058
+ const last = data.at(-1);
1059
+ return {
1060
+ data,
1061
+ cursor: hasMore && last !== void 0 ? getId(last) : null,
1062
+ hasMore
1063
+ };
1064
+ }
1065
+ function creationIndexKey(prefix, createdAt, id) {
1066
+ return `${prefix}${createdAt.toISOString()}:${id}`;
1067
+ }
1068
+ /**
1069
+ * Cursor pagination over an explicit creation-time index. Entity ids are
1070
+ * values rather than key suffixes, so caller-supplied ids cannot disturb the
1071
+ * requested ordering.
1072
+ */
1073
+ async function listByCreationTime(store, indexPrefix, entityPrefix, params) {
1074
+ const { limit, cursor, sortOrder = "asc" } = params;
1075
+ const entries = await store.list(sortOrder === "desc" ? {
1076
+ prefix: indexPrefix,
1077
+ reverse: true,
1078
+ limit: limit + 1,
1079
+ end: cursor ? `${indexPrefix}${cursor}` : void 0
1080
+ } : {
1081
+ prefix: indexPrefix,
1082
+ limit: limit + 1,
1083
+ startAfter: cursor ? `${indexPrefix}${cursor}` : void 0
1084
+ });
1085
+ const page = Array.from(entries.entries()).slice(0, limit);
1086
+ const data = [];
1087
+ for (const [, id] of page) {
1088
+ const item = await store.get(`${entityPrefix}${id}`);
1089
+ if (item !== void 0) data.push(item);
1090
+ }
1091
+ const hasMore = entries.size > limit;
1092
+ const lastKey = page.at(-1)?.[0];
1093
+ return {
1094
+ data,
1095
+ cursor: hasMore && lastKey ? lastKey.slice(indexPrefix.length) : null,
1096
+ hasMore
1097
+ };
1098
+ }
1099
+ /** Delete all hook entities for the run, returning the released tokens. */
1100
+ async function releaseAllHooks(store) {
1101
+ const hooks = await store.list({ prefix: HOOK_KEY_PREFIX });
1102
+ const released = [];
1103
+ for (const hook of hooks.values()) {
1104
+ await store.delete(`${HOOK_KEY_PREFIX}${hook.hookId}`);
1105
+ await store.delete(creationIndexKey(HOOK_CREATED_KEY_PREFIX, hook.createdAt, hook.hookId));
1106
+ released.push({
1107
+ hookId: hook.hookId,
1108
+ token: hook.token
1109
+ });
1110
+ }
1111
+ return released;
1112
+ }
1113
+ async function applyEvent(store, ctx) {
1114
+ const { runId, data, now } = ctx;
1115
+ const specVersion = data.specVersion ?? SPEC_VERSION_CURRENT;
1116
+ /** Build and validate the stored event BEFORE anything is persisted. */
1117
+ const buildEvent = (record) => {
1118
+ const withMeta = {
1119
+ ...record,
1120
+ runId,
1121
+ eventId: ctx.nextEventId(),
1122
+ createdAt: now,
1123
+ specVersion
1124
+ };
1125
+ if (record.eventType === "run_started") delete withMeta.eventData;
1126
+ return EventSchema.parse(compact(withMeta));
1127
+ };
1128
+ const putEvent = async (event) => {
1129
+ await store.put(`${EVENT_KEY_PREFIX}${event.eventId}`, event);
1130
+ };
1131
+ let currentRun;
1132
+ const skipRunValidation = data.eventType === "run_created" || data.eventType === "step_completed" || data.eventType === "step_retrying";
1133
+ if (!skipRunValidation) currentRun = await store.get(RUN_KEY);
1134
+ let bootstrapped;
1135
+ if (data.eventType === "run_started" && !currentRun && data.eventData) {
1136
+ const { deploymentId, workflowName, input, executionContext } = data.eventData;
1137
+ if (deploymentId && workflowName && input !== void 0) {
1138
+ const createdRun = WorkflowRunSchema.parse(compact({
1139
+ runId,
1140
+ deploymentId,
1141
+ workflowName,
1142
+ specVersion,
1143
+ executionContext,
1144
+ input,
1145
+ status: "pending",
1146
+ output: void 0,
1147
+ error: void 0,
1148
+ startedAt: void 0,
1149
+ completedAt: void 0,
1150
+ createdAt: now,
1151
+ updatedAt: now
1152
+ }));
1153
+ const runCreatedEvent = buildEvent({
1154
+ eventType: "run_created",
1155
+ eventData: {
1156
+ deploymentId,
1157
+ workflowName,
1158
+ input,
1159
+ executionContext
1160
+ }
1161
+ });
1162
+ await store.put(RUN_KEY, createdRun);
1163
+ await putEvent(runCreatedEvent);
1164
+ currentRun = createdRun;
1165
+ bootstrapped = {
1166
+ workflowName,
1167
+ createdAt: now
1168
+ };
1169
+ }
1170
+ }
1171
+ if (currentRun) {
1172
+ if (requiresNewerWorld(currentRun.specVersion)) return failure("RUN_NOT_SUPPORTED", `Run "${runId}" requires a newer world (run specVersion ${currentRun.specVersion}, world ${SPEC_VERSION_CURRENT})`, { runSpecVersion: currentRun.specVersion });
1173
+ if (isLegacySpecVersion(currentRun.specVersion)) return failure("LEGACY_RUN_NOT_SUPPORTED", `Run "${runId}" was created with a legacy spec version (${currentRun.specVersion ?? "undefined"}) not supported by this world`);
1174
+ }
1175
+ if (currentRun && isTerminalWorkflowRunStatus(currentRun.status)) {
1176
+ if (data.eventType === "run_cancelled" && currentRun.status === "cancelled") {
1177
+ const event = buildEvent({ ...data });
1178
+ await putEvent(event);
1179
+ return {
1180
+ ok: true,
1181
+ event,
1182
+ run: currentRun,
1183
+ releasedHooks: []
1184
+ };
1185
+ }
1186
+ if (data.eventType === "run_started") return failure("RUN_EXPIRED", `Workflow run "${runId}" is already in terminal state "${currentRun.status}"`);
1187
+ if (data.eventType === "run_completed" || data.eventType === "run_failed" || data.eventType === "run_cancelled") return failure("ENTITY_CONFLICT", `Cannot transition run from terminal state "${currentRun.status}"`);
1188
+ if (data.eventType === "step_created" || data.eventType === "hook_created" || data.eventType === "wait_created") return failure("ENTITY_CONFLICT", `Cannot create new entities on run in terminal state "${currentRun.status}"`);
1189
+ }
1190
+ let validatedStep;
1191
+ if (data.eventType === "step_started" || data.eventType === "step_completed" || data.eventType === "step_failed" || data.eventType === "step_retrying") {
1192
+ validatedStep = await store.get(`${STEP_KEY_PREFIX}${data.correlationId}`);
1193
+ if (!validatedStep) return failure("STEP_NOT_FOUND", `Step "${data.correlationId}" not found`);
1194
+ if (isTerminalStepStatus(validatedStep.status)) return failure("ENTITY_CONFLICT", `Cannot modify step in terminal state "${validatedStep.status}"`);
1195
+ if (currentRun && isTerminalWorkflowRunStatus(currentRun.status) && validatedStep.status !== "running") return failure("RUN_EXPIRED", `Cannot modify non-running step on run in terminal state "${currentRun.status}"`);
1196
+ }
1197
+ if ((data.eventType === "hook_disposed" || data.eventType === "hook_received") && data.correlationId) {
1198
+ if (!await store.get(`hook:${data.correlationId}`)) return failure("HOOK_NOT_FOUND", `Hook "${data.correlationId}" not found`);
1199
+ }
1200
+ if (!skipRunValidation && data.eventType !== "run_started" && !currentRun) return failure("RUN_NOT_FOUND", `Workflow run "${runId}" not found`);
1201
+ switch (data.eventType) {
1202
+ case "run_created": {
1203
+ const existing = await store.get(RUN_KEY);
1204
+ if (existing) return {
1205
+ ok: true,
1206
+ run: existing,
1207
+ releasedHooks: []
1208
+ };
1209
+ const run = WorkflowRunSchema.parse(compact({
1210
+ runId,
1211
+ deploymentId: data.eventData.deploymentId,
1212
+ workflowName: data.eventData.workflowName,
1213
+ specVersion,
1214
+ executionContext: data.eventData.executionContext,
1215
+ input: data.eventData.input,
1216
+ status: "pending",
1217
+ output: void 0,
1218
+ error: void 0,
1219
+ startedAt: void 0,
1220
+ completedAt: void 0,
1221
+ createdAt: now,
1222
+ updatedAt: now
1223
+ }));
1224
+ const event = buildEvent({ ...data });
1225
+ await store.put(RUN_KEY, run);
1226
+ await putEvent(event);
1227
+ return {
1228
+ ok: true,
1229
+ event,
1230
+ run,
1231
+ releasedHooks: [],
1232
+ runCreated: {
1233
+ workflowName: data.eventData.workflowName,
1234
+ createdAt: now
1235
+ }
1236
+ };
1237
+ }
1238
+ case "run_started": {
1239
+ if (!currentRun) return failure("RUN_NOT_FOUND", `Workflow run "${runId}" not found`);
1240
+ if (currentRun.status === "running") return {
1241
+ ok: true,
1242
+ run: currentRun,
1243
+ releasedHooks: []
1244
+ };
1245
+ const run = WorkflowRunSchema.parse(compact({
1246
+ ...currentRun,
1247
+ status: "running",
1248
+ output: void 0,
1249
+ error: void 0,
1250
+ completedAt: void 0,
1251
+ startedAt: currentRun.startedAt ?? now,
1252
+ updatedAt: now
1253
+ }));
1254
+ const event = buildEvent({ ...data });
1255
+ await store.put(RUN_KEY, run);
1256
+ await putEvent(event);
1257
+ const allEvents = await listByPrefix(store, EVENT_KEY_PREFIX, {
1258
+ limit: 1e3,
1259
+ sortOrder: "asc"
1260
+ }, (e) => e.eventId);
1261
+ return {
1262
+ ok: true,
1263
+ event,
1264
+ run,
1265
+ releasedHooks: [],
1266
+ runCreated: bootstrapped,
1267
+ events: allEvents.data
1268
+ };
1269
+ }
1270
+ case "run_completed": {
1271
+ if (!currentRun) return failure("RUN_NOT_FOUND", `Workflow run "${runId}" not found`);
1272
+ const run = WorkflowRunSchema.parse(compact({
1273
+ ...currentRun,
1274
+ status: "completed",
1275
+ output: data.eventData.output,
1276
+ error: void 0,
1277
+ completedAt: now,
1278
+ updatedAt: now
1279
+ }));
1280
+ const event = buildEvent({ ...data });
1281
+ await store.put(RUN_KEY, run);
1282
+ await putEvent(event);
1283
+ return {
1284
+ ok: true,
1285
+ event,
1286
+ run,
1287
+ releasedHooks: await releaseAllHooks(store)
1288
+ };
1289
+ }
1290
+ case "run_failed": {
1291
+ if (!currentRun) return failure("RUN_NOT_FOUND", `Workflow run "${runId}" not found`);
1292
+ const run = WorkflowRunSchema.parse(compact({
1293
+ ...currentRun,
1294
+ status: "failed",
1295
+ output: void 0,
1296
+ error: toStructuredError(data.eventData.error, { code: data.eventData.errorCode ?? readStringProp(data.eventData.error, "code") }),
1297
+ completedAt: now,
1298
+ updatedAt: now
1299
+ }));
1300
+ const event = buildEvent({ ...data });
1301
+ await store.put(RUN_KEY, run);
1302
+ await putEvent(event);
1303
+ return {
1304
+ ok: true,
1305
+ event,
1306
+ run,
1307
+ releasedHooks: await releaseAllHooks(store)
1308
+ };
1309
+ }
1310
+ case "run_cancelled": {
1311
+ if (!currentRun) return failure("RUN_NOT_FOUND", `Workflow run "${runId}" not found`);
1312
+ const run = WorkflowRunSchema.parse(compact({
1313
+ ...currentRun,
1314
+ status: "cancelled",
1315
+ output: void 0,
1316
+ error: void 0,
1317
+ completedAt: now,
1318
+ updatedAt: now
1319
+ }));
1320
+ const event = buildEvent({ ...data });
1321
+ await store.put(RUN_KEY, run);
1322
+ await putEvent(event);
1323
+ return {
1324
+ ok: true,
1325
+ event,
1326
+ run,
1327
+ releasedHooks: await releaseAllHooks(store)
1328
+ };
1329
+ }
1330
+ case "step_created": {
1331
+ const stepKey = `${STEP_KEY_PREFIX}${data.correlationId}`;
1332
+ if (await store.get(stepKey)) return failure("ENTITY_CONFLICT", `Step "${data.correlationId}" already exists`);
1333
+ const step = StepSchema.parse(compact({
1334
+ runId,
1335
+ stepId: data.correlationId,
1336
+ stepName: data.eventData.stepName,
1337
+ status: "pending",
1338
+ input: data.eventData.input,
1339
+ output: void 0,
1340
+ error: void 0,
1341
+ attempt: 0,
1342
+ startedAt: void 0,
1343
+ completedAt: void 0,
1344
+ retryAfter: void 0,
1345
+ createdAt: now,
1346
+ updatedAt: now,
1347
+ specVersion
1348
+ }));
1349
+ const event = buildEvent({ ...data });
1350
+ await store.put(stepKey, step);
1351
+ await store.put(creationIndexKey(STEP_CREATED_KEY_PREFIX, step.createdAt, step.stepId), step.stepId);
1352
+ await putEvent(event);
1353
+ return {
1354
+ ok: true,
1355
+ event,
1356
+ step,
1357
+ releasedHooks: []
1358
+ };
1359
+ }
1360
+ case "step_started": {
1361
+ const step = validatedStep;
1362
+ if (!step) return failure("STEP_NOT_FOUND", `Step "${data.correlationId}" not found`);
1363
+ if (step.retryAfter && step.retryAfter.getTime() > now.getTime()) return failure("TOO_EARLY", `Cannot start step "${data.correlationId}": retryAfter timestamp has not been reached yet`, { retryAfterSeconds: Math.ceil((step.retryAfter.getTime() - now.getTime()) / 1e3) });
1364
+ const updated = StepSchema.parse(compact({
1365
+ ...step,
1366
+ status: "running",
1367
+ startedAt: step.startedAt ?? now,
1368
+ attempt: step.attempt + 1,
1369
+ retryAfter: void 0,
1370
+ updatedAt: now
1371
+ }));
1372
+ const event = buildEvent({ ...data });
1373
+ await store.put(`${STEP_KEY_PREFIX}${data.correlationId}`, updated);
1374
+ await putEvent(event);
1375
+ return {
1376
+ ok: true,
1377
+ event,
1378
+ step: updated,
1379
+ releasedHooks: []
1380
+ };
1381
+ }
1382
+ case "step_completed": {
1383
+ const step = validatedStep;
1384
+ if (!step) return failure("STEP_NOT_FOUND", `Step "${data.correlationId}" not found`);
1385
+ const updated = StepSchema.parse(compact({
1386
+ ...step,
1387
+ status: "completed",
1388
+ output: data.eventData.result,
1389
+ completedAt: now,
1390
+ updatedAt: now
1391
+ }));
1392
+ const event = buildEvent({ ...data });
1393
+ await store.put(`${STEP_KEY_PREFIX}${data.correlationId}`, updated);
1394
+ await putEvent(event);
1395
+ return {
1396
+ ok: true,
1397
+ event,
1398
+ step: updated,
1399
+ releasedHooks: []
1400
+ };
1401
+ }
1402
+ case "step_failed": {
1403
+ const step = validatedStep;
1404
+ if (!step) return failure("STEP_NOT_FOUND", `Step "${data.correlationId}" not found`);
1405
+ const updated = StepSchema.parse(compact({
1406
+ ...step,
1407
+ status: "failed",
1408
+ error: toStructuredError(data.eventData.error, { stack: data.eventData.stack }),
1409
+ completedAt: now,
1410
+ updatedAt: now
1411
+ }));
1412
+ const event = buildEvent({ ...data });
1413
+ await store.put(`${STEP_KEY_PREFIX}${data.correlationId}`, updated);
1414
+ await putEvent(event);
1415
+ return {
1416
+ ok: true,
1417
+ event,
1418
+ step: updated,
1419
+ releasedHooks: []
1420
+ };
1421
+ }
1422
+ case "step_retrying": {
1423
+ const step = validatedStep;
1424
+ if (!step) return failure("STEP_NOT_FOUND", `Step "${data.correlationId}" not found`);
1425
+ const updated = StepSchema.parse(compact({
1426
+ ...step,
1427
+ status: "pending",
1428
+ error: toStructuredError(data.eventData.error, { stack: data.eventData.stack }),
1429
+ retryAfter: data.eventData.retryAfter ? new Date(data.eventData.retryAfter) : void 0,
1430
+ updatedAt: now
1431
+ }));
1432
+ const event = buildEvent({ ...data });
1433
+ await store.put(`${STEP_KEY_PREFIX}${data.correlationId}`, updated);
1434
+ await putEvent(event);
1435
+ return {
1436
+ ok: true,
1437
+ event,
1438
+ step: updated,
1439
+ releasedHooks: []
1440
+ };
1441
+ }
1442
+ case "hook_created": {
1443
+ const hookId = data.correlationId;
1444
+ const token = data.eventData.token;
1445
+ const holder = ctx.tokenHolder ?? null;
1446
+ const markerKey = `${HOOK_EVENT_MARKER_PREFIX}${hookId}`;
1447
+ const hookKey = `${HOOK_KEY_PREFIX}${hookId}`;
1448
+ if (holder && (holder.runId !== runId || holder.hookId !== hookId)) {
1449
+ const event = buildEvent({
1450
+ eventType: "hook_conflict",
1451
+ correlationId: hookId,
1452
+ eventData: {
1453
+ token,
1454
+ conflictingRunId: holder.runId
1455
+ }
1456
+ });
1457
+ await putEvent(event);
1458
+ return {
1459
+ ok: true,
1460
+ event,
1461
+ releasedHooks: []
1462
+ };
1463
+ }
1464
+ if (await store.get(markerKey) !== void 0) {
1465
+ const existingHook = await store.get(hookKey);
1466
+ if (existingHook && !holder) return {
1467
+ ok: true,
1468
+ hook: existingHook,
1469
+ hookToIndex: existingHook,
1470
+ releasedHooks: []
1471
+ };
1472
+ return failure("ENTITY_CONFLICT", `Hook "${hookId}" already created`);
1473
+ }
1474
+ const hook = HookSchema.parse(compact({
1475
+ runId,
1476
+ hookId,
1477
+ token,
1478
+ metadata: data.eventData.metadata,
1479
+ ownerId: "",
1480
+ projectId: "",
1481
+ environment: "",
1482
+ createdAt: now,
1483
+ specVersion,
1484
+ isWebhook: data.eventData.isWebhook ?? false
1485
+ }));
1486
+ const event = buildEvent({ ...data });
1487
+ await store.put(hookKey, hook);
1488
+ await store.put(creationIndexKey(HOOK_CREATED_KEY_PREFIX, hook.createdAt, hook.hookId), hook.hookId);
1489
+ await store.put(markerKey, event.eventId);
1490
+ await putEvent(event);
1491
+ return {
1492
+ ok: true,
1493
+ event,
1494
+ hook,
1495
+ hookToIndex: hook,
1496
+ releasedHooks: []
1497
+ };
1498
+ }
1499
+ case "hook_disposed": {
1500
+ const hookKey = `${HOOK_KEY_PREFIX}${data.correlationId}`;
1501
+ const hook = await store.get(hookKey);
1502
+ if (!hook) return failure("HOOK_NOT_FOUND", `Hook "${data.correlationId}" not found`);
1503
+ await store.delete(hookKey);
1504
+ await store.delete(creationIndexKey(HOOK_CREATED_KEY_PREFIX, hook.createdAt, hook.hookId));
1505
+ const event = buildEvent({ ...data });
1506
+ await putEvent(event);
1507
+ return {
1508
+ ok: true,
1509
+ event,
1510
+ releasedHooks: [{
1511
+ hookId: hook.hookId,
1512
+ token: hook.token
1513
+ }]
1514
+ };
1515
+ }
1516
+ default: {
1517
+ const event = buildEvent({ ...data });
1518
+ await putEvent(event);
1519
+ return {
1520
+ ok: true,
1521
+ event,
1522
+ releasedHooks: []
1523
+ };
1524
+ }
1525
+ }
1526
+ }
1527
+ //#endregion
1528
+ //#region src/worker/durable-objects/WorkflowRunDO.ts
1529
+ /**
1530
+ * Current schema version for DO storage.
1531
+ * Increment when making breaking changes to the storage layout.
1532
+ * Migrations run lazily on first request after deploy.
1533
+ *
1534
+ * v2: one storage key per event ('event:<eventId>'), step ('step:<stepId>')
1535
+ * and hook ('hook:<hookId>') instead of single aggregate values. This package
1536
+ * is pre-production, so the migration simply drops the legacy aggregates.
1537
+ */
1538
+ const CURRENT_SCHEMA_VERSION = 3;
1539
+ /**
1540
+ * Idempotent migration functions keyed by target version. The schema version
1541
+ * is written only after the migration completes, so eviction safely retries
1542
+ * an interrupted migration.
1543
+ */
1544
+ const MIGRATIONS = {
1545
+ 2: async (storage) => {
1546
+ await storage.transaction(async (txn) => {
1547
+ await txn.delete("events");
1548
+ await txn.delete("steps");
1549
+ await txn.delete("hooks");
1550
+ });
1551
+ },
1552
+ 3: async (storage) => {
1553
+ await backfillCreationIndex(storage, STEP_KEY_PREFIX, STEP_CREATED_KEY_PREFIX, (step) => step.stepId);
1554
+ await backfillCreationIndex(storage, HOOK_KEY_PREFIX, HOOK_CREATED_KEY_PREFIX, (hook) => hook.hookId);
1555
+ }
1556
+ };
1557
+ async function backfillCreationIndex(storage, entityPrefix, indexPrefix, getId) {
1558
+ let startAfter;
1559
+ while (true) {
1560
+ const entities = await storage.list({
1561
+ prefix: entityPrefix,
1562
+ startAfter,
1563
+ limit: 100
1564
+ });
1565
+ if (entities.size === 0) return;
1566
+ for (const [key, entity] of entities) {
1567
+ const id = getId(entity);
1568
+ await storage.put(`${indexPrefix}${entity.createdAt.toISOString()}:${id}`, id);
1569
+ startAfter = key;
1570
+ }
1571
+ if (entities.size < 100) return;
1572
+ }
1573
+ }
1574
+ /**
1575
+ * Ensure the DO storage schema is up to date.
1576
+ * Runs any pending migrations sequentially inside transactions,
1577
+ * persisting the version after each successful migration.
1578
+ * Safe to call on every request -- it's a no-op when already current.
1579
+ */
1580
+ async function ensureSchemaUpToDate(storage) {
1581
+ const stored = await storage.get("schema_version") ?? 1;
1582
+ for (let v = stored + 1; v <= CURRENT_SCHEMA_VERSION; v++) {
1583
+ const migration = MIGRATIONS[v];
1584
+ if (migration) await migration(storage);
1585
+ await storage.put("schema_version", v);
1586
+ }
1587
+ }
1588
+ /**
1589
+ * Adapt DO storage (or a transaction) to the {@link EventStore} interface
1590
+ * shared with the test mocks. No casts: the methods line up structurally,
1591
+ * this just pins the subset we rely on.
1592
+ */
1593
+ function storeFrom(storage) {
1594
+ return {
1595
+ get: (key) => storage.get(key),
1596
+ put: (key, value) => storage.put(key, value),
1597
+ delete: (key) => storage.delete(key),
1598
+ list: (options) => storage.list(options)
1599
+ };
1600
+ }
1601
+ /**
1602
+ * Durable Object for managing a single workflow run's state.
1603
+ *
1604
+ * All event-sourced writes go through {@link applyEvent} inside a single
1605
+ * storage transaction: guard checks, the event append, and the entity
1606
+ * mutation are atomic even if the DO is evicted mid-operation.
1607
+ *
1608
+ * Instances named `claim:<queueName>:<idempotencyKey>` are used purely as
1609
+ * queue-message dedup claims (see claimInflight/releaseInflight).
1610
+ */
1611
+ var WorkflowRunDO = class extends DurableObject {
1612
+ schemaReady = null;
1613
+ /**
1614
+ * Lazily ensure schema migrations have run.
1615
+ * Memoized so it only runs once per DO instantiation.
1616
+ */
1617
+ ensureSchema() {
1618
+ if (!this.schemaReady) this.schemaReady = ensureSchemaUpToDate(this.ctx.storage);
1619
+ return this.schemaReady;
1620
+ }
1621
+ /**
1622
+ * Apply an event: validate, guard, append the event and mutate the entity
1623
+ * in ONE storage transaction. Guard failures are returned as structured
1624
+ * outcomes (not thrown) because typed errors do not survive the RPC
1625
+ * boundary with their `name` intact.
1626
+ */
1627
+ async applyEvent(request) {
1628
+ await this.ensureSchema();
1629
+ return await this.ctx.storage.transaction(async (txn) => {
1630
+ let eventSequence = await txn.get("event_sequence") ?? 0;
1631
+ const outcome = await applyEvent(storeFrom(txn), {
1632
+ ...request,
1633
+ nextEventId: () => `wevt_z${String(++eventSequence).padStart(20, "0")}`,
1634
+ now: /* @__PURE__ */ new Date()
1635
+ });
1636
+ if (outcome.ok) await txn.put("event_sequence", eventSequence);
1637
+ return outcome;
1638
+ });
1639
+ }
1640
+ /**
1641
+ * Get the workflow run
1642
+ */
1643
+ async getRun() {
1644
+ await this.ensureSchema();
1645
+ return await this.ctx.storage.get("run") ?? null;
1646
+ }
1647
+ /**
1648
+ * Get a specific step
1649
+ */
1650
+ async getStep(stepId) {
1651
+ await this.ensureSchema();
1652
+ return await this.ctx.storage.get(`step:${stepId}`) ?? null;
1653
+ }
1654
+ /**
1655
+ * List steps with real limit/cursor pagination (cursor = last stepId).
1656
+ */
1657
+ async listSteps(params) {
1658
+ await this.ensureSchema();
1659
+ return listByCreationTime(storeFrom(this.ctx.storage), STEP_CREATED_KEY_PREFIX, STEP_KEY_PREFIX, {
1660
+ limit: params?.limit ?? 20,
1661
+ cursor: params?.cursor,
1662
+ sortOrder: params?.sortOrder ?? "asc"
1663
+ });
1664
+ }
1665
+ /**
1666
+ * Get a specific event
1667
+ */
1668
+ async getEvent(eventId) {
1669
+ await this.ensureSchema();
1670
+ return await this.ctx.storage.get(`event:${eventId}`) ?? null;
1671
+ }
1672
+ /**
1673
+ * List events with real limit/cursor pagination. Events are keyed by their
1674
+ * monotonic ULID eventId, so storage order == event order.
1675
+ */
1676
+ async listEvents(params) {
1677
+ await this.ensureSchema();
1678
+ return listByPrefix(storeFrom(this.ctx.storage), EVENT_KEY_PREFIX, {
1679
+ limit: params?.limit ?? 100,
1680
+ cursor: params?.cursor,
1681
+ sortOrder: params?.sortOrder ?? "asc"
1682
+ }, (event) => event.eventId);
1683
+ }
1684
+ /**
1685
+ * List hooks with real limit/cursor pagination (cursor = last hookId).
1686
+ */
1687
+ async listHooks(params) {
1688
+ await this.ensureSchema();
1689
+ return listByCreationTime(storeFrom(this.ctx.storage), HOOK_CREATED_KEY_PREFIX, HOOK_KEY_PREFIX, {
1690
+ limit: params?.limit ?? 100,
1691
+ cursor: params?.cursor,
1692
+ sortOrder: params?.sortOrder ?? "asc"
1693
+ });
1694
+ }
1695
+ /**
1696
+ * Claim a queue-message dedup slot. Used on DO instances dedicated to a
1697
+ * single idempotency key (`claim:<queueName>:<idempotencyKey>`).
1698
+ *
1699
+ * - Unclaimed -> claim and proceed.
1700
+ * - Claimed by the SAME messageId -> redelivery of the same message
1701
+ * (previous attempt failed transiently) -> proceed.
1702
+ * - Claimed by a DIFFERENT messageId -> concurrent duplicate -> reject,
1703
+ * unless the claim is stale (holder crashed / went to the DLQ without
1704
+ * releasing), in which case the claim is stolen.
1705
+ */
1706
+ async claimInflight(params) {
1707
+ await this.ensureSchema();
1708
+ return await this.ctx.storage.transaction(async (txn) => {
1709
+ const existing = await txn.get("claim");
1710
+ const now = Date.now();
1711
+ if (existing && existing.messageId !== params.messageId && now - existing.claimedAt < params.staleMs) return { claimed: false };
1712
+ await txn.put("claim", {
1713
+ messageId: params.messageId,
1714
+ claimedAt: now
1715
+ });
1716
+ return { claimed: true };
1717
+ });
1718
+ }
1719
+ /**
1720
+ * Release a queue-message dedup claim after the message was fully handled.
1721
+ */
1722
+ async releaseInflight() {
1723
+ await this.ensureSchema();
1724
+ await this.ctx.storage.delete("claim");
1725
+ }
1726
+ };
1727
+ //#endregion
1728
+ //#region src/testing/http-harness.ts
1729
+ /**
1730
+ * Boots the real worker router over real cell classes (on FakeFleet state)
1731
+ * behind a node:http server — the full wire protocol without a celld binary.
1732
+ */
1733
+ async function startHarness(options = {}) {
1734
+ const fleet = new FakeFleet({
1735
+ runs: WorkflowRunDO,
1736
+ streams: StreamDO,
1737
+ index: IndexDO,
1738
+ queue: QueueDO,
1739
+ ...options.extraClasses
1740
+ }, options.cellEnv ?? {});
1741
+ const router = createRouter({
1742
+ WORKFLOW_DB: fleet.namespace("runs"),
1743
+ WORKFLOW_STREAMS: fleet.namespace("streams"),
1744
+ WORKFLOW_INDEX: fleet.namespace("index"),
1745
+ WORKFLOW_QUEUE: fleet.namespace("queue"),
1746
+ WORLD_SECRET: options.secret
1747
+ });
1748
+ const server = http.createServer(async (req, res) => {
1749
+ try {
1750
+ const chunks = [];
1751
+ for await (const chunk of req) chunks.push(chunk);
1752
+ const body = Buffer.concat(chunks);
1753
+ const request = new Request(`http://127.0.0.1${req.url ?? "/"}`, {
1754
+ method: req.method,
1755
+ headers: req.headers,
1756
+ body: req.method === "GET" || req.method === "HEAD" || body.length === 0 ? null : body
1757
+ });
1758
+ const response = await router(request);
1759
+ res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
1760
+ res.end(Buffer.from(await response.arrayBuffer()));
1761
+ } catch (error) {
1762
+ res.writeHead(500, { "content-type": "application/json" });
1763
+ res.end(JSON.stringify({ error: {
1764
+ name: "HarnessError",
1765
+ message: String(error)
1766
+ } }));
1767
+ }
1768
+ });
1769
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
1770
+ const { port } = server.address();
1771
+ return {
1772
+ url: `http://127.0.0.1:${port}`,
1773
+ fleet,
1774
+ close: () => new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve()))
1775
+ };
1776
+ }
1777
+ //#endregion
1778
+ //#region src/testing/index.ts
1779
+ /** Alarm-pump interval for the dev fleet (real time). */
1780
+ const PUMP_INTERVAL_MS = 25;
1781
+ async function startDevFleet(options = {}) {
1782
+ const harness = await startHarness(options);
1783
+ let pumping = false;
1784
+ const pump = setInterval(async () => {
1785
+ if (pumping) return;
1786
+ pumping = true;
1787
+ try {
1788
+ harness.fleet.now = Date.now();
1789
+ await harness.fleet.fireDueAlarms();
1790
+ } catch (error) {
1791
+ console.error("[world-celld dev fleet] alarm pump error:", error);
1792
+ } finally {
1793
+ pumping = false;
1794
+ }
1795
+ }, PUMP_INTERVAL_MS);
1796
+ pump.unref?.();
1797
+ return {
1798
+ ...harness,
1799
+ async stop() {
1800
+ clearInterval(pump);
1801
+ await harness.close();
1802
+ }
1803
+ };
1804
+ }
1805
+ //#endregion
1806
+ export { FakeFleet, FakeStorage, startDevFleet, startHarness };
1807
+
1808
+ //# sourceMappingURL=testing.js.map