@dudousxd/nestjs-durable-core 0.0.0-beta-20260701163515

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,4726 @@
1
+ const __importMetaUrl = require('url').pathToFileURL(__filename).href;
2
+ "use strict";
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
21
+
22
+ // src/index.ts
23
+ var index_exports = {};
24
+ __export(index_exports, {
25
+ CodecStateStore: () => CodecStateStore,
26
+ ContinueAsNew: () => ContinueAsNew,
27
+ DURABLE_OPTIONS: () => DURABLE_OPTIONS,
28
+ DURABLE_OPTIONS_CANONICAL: () => DURABLE_OPTIONS_CANONICAL,
29
+ Entities: () => Entities,
30
+ EventAccumulators: () => EventAccumulators,
31
+ FatalError: () => FatalError,
32
+ GatherError: () => GatherError,
33
+ InMemoryAdmissionBackend: () => InMemoryAdmissionBackend,
34
+ InMemoryStateStore: () => InMemoryStateStore,
35
+ InMemoryTransport: () => InMemoryTransport,
36
+ NonDeterminismError: () => NonDeterminismError,
37
+ QueueController: () => QueueController,
38
+ RemoteStepError: () => RemoteStepError,
39
+ RemoteStepTimeout: () => RemoteStepTimeout,
40
+ RemoteWorkflowExecutor: () => RemoteWorkflowExecutor,
41
+ RemoteWorkflowTimeout: () => RemoteWorkflowTimeout,
42
+ STATE_STORE: () => STATE_STORE,
43
+ STATE_STORE_CANONICAL: () => STATE_STORE_CANONICAL,
44
+ SignalTimeoutError: () => SignalTimeoutError,
45
+ SingletonQueueFullError: () => SingletonQueueFullError,
46
+ TERMINAL_RUN_STATUSES: () => TERMINAL_RUN_STATUSES,
47
+ TRANSPORT: () => TRANSPORT,
48
+ TRANSPORT_CANONICAL: () => TRANSPORT_CANONICAL,
49
+ WORKFLOW_NAME_KEY: () => WORKFLOW_NAME_KEY,
50
+ WorkflowEngine: () => WorkflowEngine,
51
+ WorkflowSuspended: () => WorkflowSuspended,
52
+ applyAttributeQuery: () => applyAttributeQuery,
53
+ attributeColumnFor: () => attributeColumnFor,
54
+ attributeOperand: () => attributeOperand,
55
+ breakpointToken: () => breakpointToken,
56
+ collectMetrics: () => collectMetrics,
57
+ createStepLogger: () => createStepLogger,
58
+ eventMatchOf: () => eventMatchOf,
59
+ eventMatches: () => eventMatches,
60
+ eventPrefix: () => eventPrefix,
61
+ eventToken: () => eventToken,
62
+ matchesAttributes: () => matchesAttributes,
63
+ normalizeAttributeRows: () => normalizeAttributeRows,
64
+ parseDuration: () => parseDuration,
65
+ prevCronFireMs: () => prevCronFireMs,
66
+ remoteStep: () => remoteStep,
67
+ runSchedules: () => runSchedules,
68
+ runStepHandler: () => runStepHandler,
69
+ scheduledRunId: () => scheduledRunId,
70
+ sqlComparator: () => sqlComparator,
71
+ stepId: () => stepId,
72
+ tenantGroup: () => tenantGroup,
73
+ workflowName: () => workflowName
74
+ });
75
+ module.exports = __toCommonJS(index_exports);
76
+
77
+ // src/queue.ts
78
+ var QueueController = class {
79
+ static {
80
+ __name(this, "QueueController");
81
+ }
82
+ config;
83
+ clock;
84
+ inFlight = 0;
85
+ windowStart = 0;
86
+ windowCount = 0;
87
+ /** Registered waiters by id (only used when priority/fairness ordering is in play). */
88
+ waiters = /* @__PURE__ */ new Map();
89
+ /** Monotonic counter stamping each newly-seen waiter for a stable FIFO tiebreak. */
90
+ waiterSeq = 0;
91
+ /** Per-key "last served" order for round-robin fairness — higher = more recently served. */
92
+ keyServed = /* @__PURE__ */ new Map();
93
+ servedTick = 0;
94
+ constructor(config, clock) {
95
+ this.config = config;
96
+ this.clock = clock;
97
+ }
98
+ /** Whether this controller does any priority/fairness ordering (else it's a plain FIFO gate). */
99
+ get ordered() {
100
+ return this.config.fairness === "key" || this.config.order === "lifo";
101
+ }
102
+ /**
103
+ * Try to admit one unit of work. On `ok`, the caller has taken a concurrency slot and must call
104
+ * {@link release} when the step settles. On block, `retryAt` is when admission may next succeed.
105
+ *
106
+ * `item` carries optional priority/fairness hints; omitting it is the legacy FIFO path. When
107
+ * ordering is configured (or a priority is given), a blocked call REGISTERS as a waiter and is only
108
+ * admitted on a later retry once it is the rightful next under (priority desc, fairness, FIFO).
109
+ */
110
+ tryAdmit(item) {
111
+ const now = this.clock();
112
+ const rl = this.config.rateLimit;
113
+ if (rl) {
114
+ if (now - this.windowStart >= rl.periodMs) {
115
+ this.windowStart = now;
116
+ this.windowCount = 0;
117
+ }
118
+ if (this.windowCount >= rl.limit) return {
119
+ ok: false,
120
+ retryAt: this.windowStart + rl.periodMs
121
+ };
122
+ }
123
+ const usingOrder = this.ordered || item?.priority != null || item?.waiterId != null;
124
+ const waiterId = item?.waiterId;
125
+ const full = this.config.concurrency != null && this.inFlight >= this.config.concurrency;
126
+ if (full) {
127
+ if (usingOrder && waiterId != null) this.register(waiterId, item);
128
+ return {
129
+ ok: false,
130
+ retryAt: now + (this.config.retryMs ?? 1e3)
131
+ };
132
+ }
133
+ if (usingOrder && this.waiters.size > 0) {
134
+ if (waiterId != null) this.register(waiterId, item);
135
+ if (!this.isNext(waiterId, item)) {
136
+ return {
137
+ ok: false,
138
+ retryAt: now + (this.config.retryMs ?? 1e3)
139
+ };
140
+ }
141
+ }
142
+ if (rl) this.windowCount += 1;
143
+ this.inFlight += 1;
144
+ if (waiterId != null) this.waiters.delete(waiterId);
145
+ if (item?.key != null) this.keyServed.set(item.key, ++this.servedTick);
146
+ return {
147
+ ok: true
148
+ };
149
+ }
150
+ /** Release a concurrency slot taken by a successful {@link tryAdmit}. */
151
+ release() {
152
+ if (this.inFlight > 0) this.inFlight -= 1;
153
+ }
154
+ /** Track (or refresh) a blocked call as a waiter; its FIFO seq is stamped only on first sight. */
155
+ register(waiterId, item) {
156
+ const existing = this.waiters.get(waiterId);
157
+ if (existing) {
158
+ existing.priority = item?.priority ?? 0;
159
+ existing.key = item?.key;
160
+ return;
161
+ }
162
+ this.waiters.set(waiterId, {
163
+ priority: item?.priority ?? 0,
164
+ key: item?.key,
165
+ seq: this.waiterSeq++
166
+ });
167
+ }
168
+ /**
169
+ * Is the calling item the best candidate among all registered waiters? Ordering, best first:
170
+ * 1. higher `priority`;
171
+ * 2. fairness: with `fairness: 'key'`, the least-recently-served key wins (round-robin);
172
+ * 3. earliest registration (FIFO) — stable tiebreak.
173
+ * A bare item (no waiterId) is compared on its hints directly.
174
+ */
175
+ isNext(waiterId, item) {
176
+ const self = (waiterId != null ? this.waiters.get(waiterId) : void 0) ?? {
177
+ priority: item?.priority ?? 0,
178
+ key: item?.key,
179
+ seq: Number.POSITIVE_INFINITY
180
+ };
181
+ let best = self;
182
+ for (const w of this.waiters.values()) {
183
+ if (this.better(w, best)) best = w;
184
+ }
185
+ return best === self;
186
+ }
187
+ /** Strict "a should be admitted before b" comparison under the active policy. */
188
+ better(a, b) {
189
+ if (a.priority !== b.priority) return a.priority > b.priority;
190
+ if (this.config.fairness === "key") {
191
+ const sa = this.servedOf(a.key);
192
+ const sb = this.servedOf(b.key);
193
+ if (sa !== sb) return sa < sb;
194
+ }
195
+ return this.config.order === "lifo" ? a.seq > b.seq : a.seq < b.seq;
196
+ }
197
+ /** Last-served tick for a key (0 = never served, so an unseen key is admitted before a busy one). */
198
+ servedOf(key) {
199
+ if (key == null) return 0;
200
+ return this.keyServed.get(key) ?? 0;
201
+ }
202
+ };
203
+
204
+ // src/admission.ts
205
+ var InMemoryAdmissionBackend = class {
206
+ static {
207
+ __name(this, "InMemoryAdmissionBackend");
208
+ }
209
+ clock;
210
+ controllers = /* @__PURE__ */ new Map();
211
+ constructor(clock) {
212
+ this.clock = clock;
213
+ }
214
+ register(config) {
215
+ this.controllers.set(config.name, new QueueController(config, this.clock));
216
+ }
217
+ handles(queue) {
218
+ return this.controllers.has(queue);
219
+ }
220
+ async tryAdmit(queue, item) {
221
+ const controller = this.controllers.get(queue);
222
+ if (!controller) return {
223
+ ok: true
224
+ };
225
+ return controller.tryAdmit(item);
226
+ }
227
+ async release(queue, _slotId) {
228
+ this.controllers.get(queue)?.release();
229
+ }
230
+ };
231
+
232
+ // src/duration.ts
233
+ var UNIT_MS = {
234
+ ms: 1,
235
+ s: 1e3,
236
+ sec: 1e3,
237
+ second: 1e3,
238
+ seconds: 1e3,
239
+ m: 6e4,
240
+ min: 6e4,
241
+ minute: 6e4,
242
+ minutes: 6e4,
243
+ h: 36e5,
244
+ hr: 36e5,
245
+ hour: 36e5,
246
+ hours: 36e5,
247
+ d: 864e5,
248
+ day: 864e5,
249
+ days: 864e5,
250
+ w: 6048e5,
251
+ week: 6048e5,
252
+ weeks: 6048e5
253
+ };
254
+ function parseDuration(duration) {
255
+ if (typeof duration === "number") return duration;
256
+ const match = duration.trim().match(/^(\d+(?:\.\d+)?)\s*([a-z]+)$/i);
257
+ if (!match) throw new Error(`invalid duration: ${duration}`);
258
+ const value = Number(match[1]);
259
+ const unit = (match[2] ?? "").toLowerCase();
260
+ const factor = UNIT_MS[unit];
261
+ if (factor === void 0) throw new Error(`unknown duration unit: ${unit}`);
262
+ return value * factor;
263
+ }
264
+ __name(parseDuration, "parseDuration");
265
+
266
+ // src/backoff.ts
267
+ function backoffDelay(attempt, options) {
268
+ const base = options?.backoffMs ?? 0;
269
+ if (base <= 0) return 0;
270
+ const raw = options?.backoff === "exp" ? base * 2 ** (attempt - 1) : base;
271
+ const capped = options?.backoffMaxMs ? Math.min(raw, options.backoffMaxMs) : raw;
272
+ return options?.jitter ? Math.round(capped * (0.5 + Math.random() * 0.5)) : capped;
273
+ }
274
+ __name(backoffDelay, "backoffDelay");
275
+
276
+ // src/step-logger.ts
277
+ function withDuration(start, now, data) {
278
+ if (data && typeof data === "object" && "durationMs" in data) {
279
+ return data;
280
+ }
281
+ const base = data && typeof data === "object" ? data : {};
282
+ return {
283
+ ...base,
284
+ durationMs: now() - start
285
+ };
286
+ }
287
+ __name(withDuration, "withDuration");
288
+ function createStepLogger(events, now) {
289
+ let currentSub;
290
+ const push = /* @__PURE__ */ __name((level, message, data) => events.push({
291
+ at: now(),
292
+ level,
293
+ message,
294
+ ...currentSub ? {
295
+ subId: currentSub.id
296
+ } : {},
297
+ ...data === void 0 ? {} : {
298
+ data
299
+ }
300
+ }), "push");
301
+ const subEvent = /* @__PURE__ */ __name((e) => events.push({
302
+ at: now(),
303
+ level: e.status === "failed" ? "error" : e.status === "skipped" ? "warn" : "info",
304
+ message: e.message ?? e.phase ?? e.name,
305
+ subId: e.id,
306
+ name: e.name,
307
+ ...e.group === void 0 ? {} : {
308
+ group: e.group
309
+ },
310
+ ...e.phase === void 0 ? {} : {
311
+ phase: e.phase
312
+ },
313
+ ...e.status === void 0 ? {} : {
314
+ status: e.status
315
+ },
316
+ ...e.data === void 0 ? {} : {
317
+ data: e.data
318
+ }
319
+ }), "subEvent");
320
+ const subProcess = /* @__PURE__ */ __name(async (name, body, opts) => {
321
+ const id = opts?.id ?? globalThis.crypto.randomUUID();
322
+ const group = opts?.group;
323
+ const start = now();
324
+ const prevSub = currentSub;
325
+ currentSub = {
326
+ id
327
+ };
328
+ let terminal = false;
329
+ const emit = /* @__PURE__ */ __name((status, message, data) => {
330
+ if (terminal) return;
331
+ terminal = true;
332
+ subEvent({
333
+ id,
334
+ name,
335
+ group,
336
+ status,
337
+ message,
338
+ data: withDuration(start, now, data)
339
+ });
340
+ }, "emit");
341
+ const handle = {
342
+ phase: /* @__PURE__ */ __name((phase, data) => {
343
+ if (!terminal) subEvent({
344
+ id,
345
+ name,
346
+ group,
347
+ phase,
348
+ data
349
+ });
350
+ return handle;
351
+ }, "phase"),
352
+ skip: /* @__PURE__ */ __name((reason, data) => emit("skipped", reason, data), "skip"),
353
+ fail: /* @__PURE__ */ __name((reason, data) => emit("failed", reason, data), "fail")
354
+ };
355
+ try {
356
+ const result = await body(handle);
357
+ emit("ok");
358
+ return result;
359
+ } catch (err) {
360
+ emit("failed", err instanceof Error ? err.message : String(err));
361
+ throw err;
362
+ } finally {
363
+ currentSub = prevSub;
364
+ }
365
+ }, "subProcess");
366
+ return {
367
+ debug: /* @__PURE__ */ __name((m, d) => push("debug", m, d), "debug"),
368
+ info: /* @__PURE__ */ __name((m, d) => push("info", m, d), "info"),
369
+ warn: /* @__PURE__ */ __name((m, d) => push("warn", m, d), "warn"),
370
+ error: /* @__PURE__ */ __name((m, d) => push("error", m, d), "error"),
371
+ sub: /* @__PURE__ */ __name((name, status, message, data) => events.push({
372
+ at: now(),
373
+ level: status === "failed" ? "error" : status === "skipped" ? "warn" : "info",
374
+ message: message ?? name,
375
+ name,
376
+ status,
377
+ ...data === void 0 ? {} : {
378
+ data
379
+ }
380
+ }), "sub"),
381
+ subEvent,
382
+ subProcess
383
+ };
384
+ }
385
+ __name(createStepLogger, "createStepLogger");
386
+
387
+ // src/protocol.ts
388
+ function stepId(runId, seq) {
389
+ return `${runId}:${seq}`;
390
+ }
391
+ __name(stepId, "stepId");
392
+ var breakpointToken = /* @__PURE__ */ __name((runId, seq) => `bp:${runId}:${seq}`, "breakpointToken");
393
+ async function runStepHandler(task, handler) {
394
+ const base = {
395
+ runId: task.runId,
396
+ seq: task.seq,
397
+ stepId: task.stepId,
398
+ startedAt: Date.now()
399
+ };
400
+ if (!handler) {
401
+ return {
402
+ ...base,
403
+ status: "failed",
404
+ error: {
405
+ message: `no handler for ${task.name}`,
406
+ retryable: false
407
+ }
408
+ };
409
+ }
410
+ const events = [];
411
+ const withEvents = /* @__PURE__ */ __name((result) => events.length > 0 ? {
412
+ ...result,
413
+ events
414
+ } : result, "withEvents");
415
+ try {
416
+ const output = await handler(task.input, createStepLogger(events, Date.now));
417
+ return withEvents({
418
+ ...base,
419
+ status: "completed",
420
+ output
421
+ });
422
+ } catch (err) {
423
+ const e = err;
424
+ return withEvents({
425
+ ...base,
426
+ status: "failed",
427
+ error: {
428
+ message: err instanceof Error ? err.message : String(err),
429
+ ...typeof e?.code === "string" ? {
430
+ code: e.code
431
+ } : {},
432
+ ...typeof e?.retryable === "boolean" ? {
433
+ retryable: e.retryable
434
+ } : {}
435
+ }
436
+ });
437
+ }
438
+ }
439
+ __name(runStepHandler, "runStepHandler");
440
+
441
+ // src/checkpoints.ts
442
+ function nonEmptyEvents(events) {
443
+ return events && events.length > 0 ? events : void 0;
444
+ }
445
+ __name(nonEmptyEvents, "nonEmptyEvents");
446
+ function stepCheckpoint(p) {
447
+ return {
448
+ runId: p.runId,
449
+ seq: p.seq,
450
+ name: p.name,
451
+ kind: p.kind,
452
+ stepId: stepId(p.runId, p.seq),
453
+ status: p.status,
454
+ input: p.input,
455
+ output: p.output,
456
+ error: p.error,
457
+ events: nonEmptyEvents(p.events),
458
+ attempts: p.attempts,
459
+ workerGroup: p.workerGroup,
460
+ wakeAt: p.wakeAt,
461
+ parallelGroup: p.parallelGroup,
462
+ enqueuedAt: p.enqueuedAt,
463
+ startedAt: p.startedAt,
464
+ finishedAt: p.finishedAt
465
+ };
466
+ }
467
+ __name(stepCheckpoint, "stepCheckpoint");
468
+ function instantCheckpoint(p) {
469
+ const at = /* @__PURE__ */ new Date();
470
+ return {
471
+ runId: p.runId,
472
+ seq: p.seq,
473
+ name: p.name,
474
+ kind: p.kind,
475
+ stepId: stepId(p.runId, p.seq),
476
+ status: p.status ?? "completed",
477
+ output: p.output,
478
+ wakeAt: p.wakeAt,
479
+ parallelGroup: p.parallelGroup,
480
+ attempts: 1,
481
+ enqueuedAt: at,
482
+ startedAt: at,
483
+ finishedAt: at
484
+ };
485
+ }
486
+ __name(instantCheckpoint, "instantCheckpoint");
487
+
488
+ // src/errors.ts
489
+ var FatalError = class extends Error {
490
+ static {
491
+ __name(this, "FatalError");
492
+ }
493
+ code;
494
+ constructor(message, code) {
495
+ super(message);
496
+ this.name = "FatalError";
497
+ this.code = code;
498
+ }
499
+ };
500
+ var SingletonQueueFullError = class extends Error {
501
+ static {
502
+ __name(this, "SingletonQueueFullError");
503
+ }
504
+ workflow;
505
+ key;
506
+ maxQueueDepth;
507
+ constructor(workflow, key, maxQueueDepth) {
508
+ super(`singleton queue for ${workflow} key "${key}" is full (maxQueueDepth=${maxQueueDepth}); retry later`);
509
+ this.name = "SingletonQueueFullError";
510
+ this.workflow = workflow;
511
+ this.key = key;
512
+ this.maxQueueDepth = maxQueueDepth;
513
+ }
514
+ };
515
+ var SignalTimeoutError = class extends Error {
516
+ static {
517
+ __name(this, "SignalTimeoutError");
518
+ }
519
+ token;
520
+ timeoutMs;
521
+ constructor(token, timeoutMs) {
522
+ super(`timed out after ${timeoutMs}ms waiting for signal "${token}"`);
523
+ this.name = "SignalTimeoutError";
524
+ this.token = token;
525
+ this.timeoutMs = timeoutMs;
526
+ }
527
+ };
528
+ var RemoteStepTimeout = class extends Error {
529
+ static {
530
+ __name(this, "RemoteStepTimeout");
531
+ }
532
+ stepId;
533
+ timeoutMs;
534
+ constructor(stepId2, timeoutMs) {
535
+ super(`remote step ${stepId2} produced no result/heartbeat within ${timeoutMs}ms`);
536
+ this.name = "RemoteStepTimeout";
537
+ this.stepId = stepId2;
538
+ this.timeoutMs = timeoutMs;
539
+ }
540
+ };
541
+ var RemoteWorkflowTimeout = class extends Error {
542
+ static {
543
+ __name(this, "RemoteWorkflowTimeout");
544
+ }
545
+ taskId;
546
+ timeoutMs;
547
+ constructor(taskId, timeoutMs) {
548
+ super(`remote workflow task ${taskId} produced no decision within ${timeoutMs}ms \u2014 presumed dropped; re-driving via recovery`);
549
+ this.name = "RemoteWorkflowTimeout";
550
+ this.taskId = taskId;
551
+ this.timeoutMs = timeoutMs;
552
+ }
553
+ };
554
+ var NonDeterminismError = class extends Error {
555
+ static {
556
+ __name(this, "NonDeterminismError");
557
+ }
558
+ runId;
559
+ seq;
560
+ constructor(runId, seq, expected, recorded) {
561
+ super(`non-determinism at ${runId}#${seq}: code expects "${expected}" but history recorded "${recorded}". The workflow changed under an in-flight run \u2014 register a new @Workflow version.`);
562
+ this.name = "NonDeterminismError";
563
+ this.runId = runId;
564
+ this.seq = seq;
565
+ }
566
+ };
567
+ var GatherError = class extends Error {
568
+ static {
569
+ __name(this, "GatherError");
570
+ }
571
+ failures;
572
+ constructor(failures, total) {
573
+ const ids = failures.map((f) => f.id).join(", ");
574
+ const denom = total ?? failures.length;
575
+ super(`ctx.all: ${failures.length} of ${denom} child(ren) failed: ${ids}`);
576
+ this.name = "GatherError";
577
+ this.failures = failures;
578
+ }
579
+ };
580
+ var WorkflowSuspended = class extends Error {
581
+ static {
582
+ __name(this, "WorkflowSuspended");
583
+ }
584
+ /** Epoch ms to auto-resume (durable sleep), or undefined when waiting on an external signal. */
585
+ wakeAt;
586
+ constructor(wakeAt) {
587
+ super("workflow suspended");
588
+ this.name = "WorkflowSuspended";
589
+ this.wakeAt = wakeAt;
590
+ }
591
+ };
592
+ var ContinueAsNew = class extends Error {
593
+ static {
594
+ __name(this, "ContinueAsNew");
595
+ }
596
+ input;
597
+ constructor(input) {
598
+ super("workflow continued as new"), this.input = input;
599
+ this.name = "ContinueAsNew";
600
+ }
601
+ };
602
+
603
+ // src/entities.ts
604
+ var ENTITY_WORKFLOW = "__entity";
605
+ var entityRunId = /* @__PURE__ */ __name((name, key) => `entity:${name}:${key}`, "entityRunId");
606
+ var Entities = class {
607
+ static {
608
+ __name(this, "Entities");
609
+ }
610
+ host;
611
+ configs = /* @__PURE__ */ new Map();
612
+ constructor(host) {
613
+ this.host = host;
614
+ host.register(ENTITY_WORKFLOW, "1", (ctx, input) => this.run(ctx, input));
615
+ }
616
+ register(name, config) {
617
+ this.configs.set(name, config);
618
+ }
619
+ /** Deliver an op to an entity's runner, starting it on first contact. `reply` (a signal token)
620
+ * receives the handler's result, for `ctx.callEntity`. */
621
+ dispatch(name, key, op, arg, reply) {
622
+ const runId = entityRunId(name, key);
623
+ return this.host.signalWithStart(ENTITY_WORKFLOW, {
624
+ name,
625
+ key
626
+ }, runId, {
627
+ token: runId,
628
+ payload: {
629
+ op,
630
+ arg,
631
+ reply
632
+ }
633
+ });
634
+ }
635
+ async signal(name, key, op, arg) {
636
+ await this.dispatch(name, key, op, arg);
637
+ }
638
+ getState(name, key) {
639
+ return this.host.getEvent(entityRunId(name, key), "state");
640
+ }
641
+ /** The built-in runner: one long-lived run per key, processing ops serially over state. */
642
+ async run(ctx, input) {
643
+ const { name, key } = input;
644
+ const config = this.configs.get(name);
645
+ if (!config) throw new FatalError(`entity "${name}" is not registered`);
646
+ const token = entityRunId(name, key);
647
+ let state = config.initialState();
648
+ for (let i = 0; ; i += 1) {
649
+ const msg = await ctx.waitForSignal(token);
650
+ const out = await ctx.step(`op:${i}`, async () => {
651
+ const handler = config.handlers[msg.op];
652
+ if (!handler) throw new FatalError(`entity "${name}" has no handler for op "${msg.op}"`);
653
+ const result = await handler(state, msg.arg);
654
+ return {
655
+ result,
656
+ state
657
+ };
658
+ });
659
+ state = out.state;
660
+ await ctx.setEvent("state", state);
661
+ if (msg.reply) {
662
+ await ctx.step(`reply:${i}`, async () => {
663
+ await this.host.signal(msg.reply, out.result);
664
+ });
665
+ }
666
+ }
667
+ }
668
+ };
669
+
670
+ // src/event-accumulators.ts
671
+ var accToken = /* @__PURE__ */ __name((target) => `__evtacc__:${target}`, "accToken");
672
+ var EventAccumulators = class {
673
+ static {
674
+ __name(this, "EventAccumulators");
675
+ }
676
+ host;
677
+ constructor(host) {
678
+ this.host = host;
679
+ host.register("__evt_debounce", "1", (ctx, input) => this.debounce(ctx, input));
680
+ host.register("__evt_batch", "1", (ctx, input) => this.batch(ctx, input));
681
+ }
682
+ /** Route an `onEvent` payload into its target's debounce/batch accumulator. */
683
+ route(target, config, payload) {
684
+ const [workflow, input] = config.mode === "debounce" ? [
685
+ "__evt_debounce",
686
+ {
687
+ target,
688
+ windowMs: config.windowMs
689
+ }
690
+ ] : [
691
+ "__evt_batch",
692
+ {
693
+ target,
694
+ maxSize: config.maxSize,
695
+ windowMs: config.windowMs
696
+ }
697
+ ];
698
+ return this.host.signalWithStart(workflow, input, accToken(target), {
699
+ token: accToken(target),
700
+ payload
701
+ });
702
+ }
703
+ /** Fire the target with the LAST payload once events have been quiet for `windowMs`. */
704
+ async debounce(ctx, input) {
705
+ const { target, windowMs } = input;
706
+ const token = accToken(target);
707
+ let last = await ctx.waitForSignal(token);
708
+ for (; ; ) {
709
+ try {
710
+ last = await ctx.waitForSignal(token, {
711
+ timeoutMs: windowMs
712
+ });
713
+ } catch (e) {
714
+ if (e instanceof SignalTimeoutError) break;
715
+ throw e;
716
+ }
717
+ }
718
+ await ctx.startChild(target, last);
719
+ return ctx.continueAsNew(input);
720
+ }
721
+ /** Fire the target with all payloads (`{ events }`) once `maxSize` or `windowMs` from the first. */
722
+ async batch(ctx, input) {
723
+ const { target, maxSize, windowMs } = input;
724
+ const token = accToken(target);
725
+ const events = [
726
+ await ctx.waitForSignal(token)
727
+ ];
728
+ const deadline = await ctx.now() + windowMs;
729
+ while (events.length < maxSize) {
730
+ const remaining = deadline - await ctx.now();
731
+ if (remaining <= 0) break;
732
+ try {
733
+ events.push(await ctx.waitForSignal(token, {
734
+ timeoutMs: remaining
735
+ }));
736
+ } catch (e) {
737
+ if (e instanceof SignalTimeoutError) break;
738
+ throw e;
739
+ }
740
+ }
741
+ await ctx.startChild(target, {
742
+ events
743
+ });
744
+ return ctx.continueAsNew(input);
745
+ }
746
+ };
747
+
748
+ // src/events.ts
749
+ var enc = /* @__PURE__ */ __name((s) => Buffer.from(s, "utf8").toString("base64"), "enc");
750
+ var dec = /* @__PURE__ */ __name((s) => Buffer.from(s, "base64").toString("utf8"), "dec");
751
+ function eventPrefix(name) {
752
+ return `event:${enc(name)}:`;
753
+ }
754
+ __name(eventPrefix, "eventPrefix");
755
+ function eventToken(name, match, runId, seq) {
756
+ return `${eventPrefix(name)}${enc(JSON.stringify(match ?? {}))}:${runId}#${seq}`;
757
+ }
758
+ __name(eventToken, "eventToken");
759
+ function eventMatchOf(token) {
760
+ try {
761
+ return JSON.parse(dec(token.split(":")[2] ?? ""));
762
+ } catch {
763
+ return {};
764
+ }
765
+ }
766
+ __name(eventMatchOf, "eventMatchOf");
767
+ function eventMatches(payload, match) {
768
+ const keys = Object.keys(match);
769
+ if (keys.length === 0) return true;
770
+ if (!payload || typeof payload !== "object") return false;
771
+ const p = payload;
772
+ return keys.every((k) => JSON.stringify(p[k]) === JSON.stringify(match[k]));
773
+ }
774
+ __name(eventMatches, "eventMatches");
775
+
776
+ // src/remote-workflow-executor.ts
777
+ var RemoteWorkflowExecutor = class {
778
+ static {
779
+ __name(this, "RemoteWorkflowExecutor");
780
+ }
781
+ transport;
782
+ group;
783
+ constructor(transport, group, _opts = {}) {
784
+ this.transport = transport;
785
+ this.group = group;
786
+ }
787
+ /**
788
+ * Enqueue one workflow turn under the engine-supplied `taskId`. Does NOT await the decision — the
789
+ * engine has already suspended the run on `taskId` and applies the decision durably via `onDecision`.
790
+ */
791
+ async dispatch(run, history, taskId, pendingSignals) {
792
+ if (!this.transport.dispatchWorkflowTask) {
793
+ throw new Error("transport does not support workflow tasks (dispatchWorkflowTask)");
794
+ }
795
+ const task = {
796
+ taskId,
797
+ runId: run.id,
798
+ workflow: run.workflow,
799
+ workflowVersion: run.workflowVersion,
800
+ input: run.input,
801
+ history,
802
+ ...pendingSignals ? {
803
+ pendingSignals
804
+ } : {},
805
+ group: this.group,
806
+ priority: run.priority,
807
+ attempt: 1
808
+ };
809
+ await this.transport.dispatchWorkflowTask(task);
810
+ }
811
+ };
812
+
813
+ // src/singleton-gate.ts
814
+ var SINGLETON_RETRY_MS = 1e3;
815
+ var SINGLETON_RETRY_JITTER_MS = 250;
816
+ var SingletonGate = class {
817
+ static {
818
+ __name(this, "SingletonGate");
819
+ }
820
+ deps;
821
+ constructor(deps) {
822
+ this.deps = deps;
823
+ }
824
+ /** The tag a singleton run carries, so the gate can find others sharing its key. */
825
+ tag(cfg, input) {
826
+ return `singleton:${cfg.key(input)}`;
827
+ }
828
+ /** Next wake time for a gated run: the base retry delay jittered to avoid a wakeup stampede. */
829
+ retryWakeAt() {
830
+ return this.deps.clock() + SINGLETON_RETRY_MS + Math.floor((Math.random() * 2 - 1) * SINGLETON_RETRY_JITTER_MS);
831
+ }
832
+ /**
833
+ * Reject a start that would grow the same-key backlog past `limit + maxQueueDepth` (counting
834
+ * `pending`/`running`/`suspended` runs sharing the key in one scan). No-op when no `maxQueueDepth`
835
+ * is configured.
836
+ */
837
+ async assertCapacity(workflow, cfg, input) {
838
+ if (cfg.maxQueueDepth == null) return;
839
+ const cap = (cfg.limit ?? 1) + cfg.maxQueueDepth;
840
+ const queued = await this.deps.store.listRuns({
841
+ tag: this.tag(cfg, input),
842
+ workflow,
843
+ statuses: [
844
+ "pending",
845
+ "running",
846
+ "suspended",
847
+ "cancelling"
848
+ ]
849
+ });
850
+ if (queued.length >= cap) {
851
+ throw new SingletonQueueFullError(workflow, cfg.key(input), cfg.maxQueueDepth);
852
+ }
853
+ }
854
+ /**
855
+ * Whether `run` may run now under its key: it's among the `limit` oldest in-flight (running or
856
+ * suspended) runs sharing the key, by `(createdAt, id)` order. A consistent store gives every
857
+ * instance the same ordering, so admission is race-free + FIFO.
858
+ */
859
+ async admit(run, cfg) {
860
+ const inflight = (await this.deps.store.listRuns({
861
+ tag: this.tag(cfg, run.input),
862
+ workflow: run.workflow,
863
+ statuses: [
864
+ "running",
865
+ "suspended",
866
+ "cancelling"
867
+ ]
868
+ })).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
869
+ const idx = inflight.findIndex((r) => r.id === run.id);
870
+ return idx >= 0 && idx < (cfg.limit ?? 1);
871
+ }
872
+ /**
873
+ * Notify-on-release: a singleton run settled (freeing a slot), so dispatch the oldest gated
874
+ * (`suspended` + same tag) waiters now instead of waiting for their ~1s retry timer. Each re-checks
875
+ * admission in the executor and runs only if it actually wins a slot, so FIFO/race-free guarantees
876
+ * hold; the durable timer remains the cross-instance/crash fallback.
877
+ */
878
+ async wakeNext(settled) {
879
+ const cfg = this.deps.configFor(settled);
880
+ if (!cfg) return;
881
+ const tag = settled.tags?.find((t) => t.startsWith("singleton:"));
882
+ if (!tag) return;
883
+ const gated = (await this.deps.store.listRuns({
884
+ tag,
885
+ workflow: settled.workflow,
886
+ statuses: [
887
+ "suspended"
888
+ ]
889
+ })).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
890
+ for (const next of gated.slice(0, cfg.limit ?? 1)) {
891
+ if (next.wakeAt != null) {
892
+ await this.deps.store.updateRun(next.id, {
893
+ wakeAt: void 0,
894
+ updatedAt: /* @__PURE__ */ new Date()
895
+ }).catch(() => void 0);
896
+ }
897
+ this.deps.dispatch(next.id);
898
+ }
899
+ }
900
+ };
901
+
902
+ // src/tenant-group.ts
903
+ function tenantGroup(baseGroup, tenant) {
904
+ return tenant !== void 0 && tenant !== "" && tenant !== "default" ? `${baseGroup}@${tenant}` : baseGroup;
905
+ }
906
+ __name(tenantGroup, "tenantGroup");
907
+
908
+ // src/transport-pool.ts
909
+ var TransportPool = class {
910
+ static {
911
+ __name(this, "TransportPool");
912
+ }
913
+ transports;
914
+ constructor(transports) {
915
+ this.transports = transports;
916
+ }
917
+ get size() {
918
+ return this.transports.length;
919
+ }
920
+ /**
921
+ * The primary (first) transport — the one `dispatch` prefers when no `preferId` is pinned. A
922
+ * {@link RemoteWorkflowExecutor} built by `engine.remote()` rides it (it needs a single
923
+ * `dispatchWorkflowTask`/`onDecision` Transport, not the whole failover pool). Throws if the pool
924
+ * is empty — there is nothing to dispatch a remote workflow over.
925
+ */
926
+ get primary() {
927
+ const first = this.transports[0];
928
+ if (!first) {
929
+ throw new Error("no transport configured \u2014 engine.remote() needs a transport (or transports)");
930
+ }
931
+ return first.transport;
932
+ }
933
+ /** Register the engine's result/heartbeat/step-event handlers on EVERY transport — a result can come
934
+ * back on whichever one delivered the task, so failover stays symmetric. `onStepEvent` is wired only
935
+ * on transports that carry streamed workflow step lifecycle (BullMQ); others simply skip it. */
936
+ bind(onResult, onHeartbeat, onStepEvent, onDecision) {
937
+ for (const { transport } of this.transports) {
938
+ transport.onResult(onResult);
939
+ transport.onHeartbeat(onHeartbeat);
940
+ if (onStepEvent && transport.onStepEvent) {
941
+ transport.onStepEvent(onStepEvent);
942
+ }
943
+ if (onDecision && transport.onDecision) {
944
+ transport.onDecision(onDecision);
945
+ }
946
+ }
947
+ }
948
+ /**
949
+ * Dispatch `task` over the pool: try the preferred (or first) transport, fail over to the next on
950
+ * a dispatch error, and stamp the task with the id of the transport that accepted it (so a worker
951
+ * replies on the matching one). Throws if every transport fails.
952
+ */
953
+ async dispatch(task, preferId) {
954
+ let lastErr;
955
+ for (const { id, transport } of this.ordered(preferId)) {
956
+ try {
957
+ await transport.dispatch({
958
+ ...task,
959
+ transport: id
960
+ });
961
+ return;
962
+ } catch (err) {
963
+ lastErr = err;
964
+ }
965
+ }
966
+ throw lastErr ?? new Error("no transport accepted the dispatch");
967
+ }
968
+ /** Worker-health for `group`, merged across every transport that can report it (a group's queue
969
+ * and its workers may live on any pinned transport). Undefined when no transport implements
970
+ * `groupHealth` (e.g. a pure in-process transport — nothing to introspect). */
971
+ async groupHealth(group) {
972
+ const reports = [];
973
+ for (const { transport } of this.transports) {
974
+ if (transport.groupHealth) reports.push(await transport.groupHealth(group));
975
+ }
976
+ if (reports.length === 0) return void 0;
977
+ return {
978
+ group,
979
+ depth: reports.reduce((sum, r) => sum + r.depth, 0),
980
+ liveWorkers: reports.flatMap((r) => r.liveWorkers)
981
+ };
982
+ }
983
+ /**
984
+ * Register a control-plane `start-run` consumer on every transport that carries the channel. Only
985
+ * control-plane-side transports implement {@link Transport.onStartRun}; a transport without it is
986
+ * skipped, so a plain worker/in-process pool is a no-op. Mirrors {@link bind}'s per-transport wiring.
987
+ */
988
+ onStartRun(handler) {
989
+ for (const { transport } of this.transports) {
990
+ transport.onStartRun?.(handler);
991
+ }
992
+ }
993
+ /** Distinct worker groups with a live heartbeat, merged across every transport that can report it. */
994
+ async listWorkerGroups() {
995
+ const groups = /* @__PURE__ */ new Set();
996
+ for (const { transport } of this.transports) {
997
+ if (transport.listWorkerGroups) {
998
+ for (const g of await transport.listWorkerGroups()) groups.add(g);
999
+ }
1000
+ }
1001
+ return [
1002
+ ...groups
1003
+ ];
1004
+ }
1005
+ /**
1006
+ * Propagate the engine's `namespace` to every transport that partitions by it — so the same
1007
+ * namespace that scopes the store also scopes each transport's queues/keys. A transport that
1008
+ * doesn't partition (no `useNamespace`) is skipped. Idempotent; a transport given an explicit
1009
+ * namespace at construction ignores this (see {@link Transport.useNamespace}).
1010
+ */
1011
+ useNamespace(namespace) {
1012
+ for (const { transport } of this.transports) {
1013
+ transport.useNamespace?.(namespace);
1014
+ }
1015
+ }
1016
+ /** Pinned `preferId` first, then the rest (failover order). */
1017
+ ordered(preferId) {
1018
+ if (!preferId) return this.transports;
1019
+ const pref = this.transports.filter((t) => t.id === preferId);
1020
+ if (pref.length === 0) throw new Error(`transport "${preferId}" is not registered`);
1021
+ return [
1022
+ ...pref,
1023
+ ...this.transports.filter((t) => t.id !== preferId)
1024
+ ];
1025
+ }
1026
+ };
1027
+
1028
+ // src/completion.ts
1029
+ function unwrapCompletion(payload, label) {
1030
+ const c = payload;
1031
+ if (c && typeof c === "object" && "ok" in c && c.ok === false) {
1032
+ throw new FatalError(`${label} failed: ${c.error}`);
1033
+ }
1034
+ return c?.value;
1035
+ }
1036
+ __name(unwrapCompletion, "unwrapCompletion");
1037
+
1038
+ // src/workflow-ref.ts
1039
+ var WORKFLOW_NAME_KEY = Symbol.for("nestjs-durable:workflow-name");
1040
+ function workflowName(ref) {
1041
+ if (typeof ref === "string") return ref;
1042
+ const name = ref[WORKFLOW_NAME_KEY];
1043
+ if (!name) {
1044
+ throw new Error(`workflow class ${ref.name} has no registered name \u2014 is it decorated with @Workflow({ name })?`);
1045
+ }
1046
+ return name;
1047
+ }
1048
+ __name(workflowName, "workflowName");
1049
+
1050
+ // src/workflow-ctx.ts
1051
+ function normalizeChildOptions(options) {
1052
+ return typeof options === "string" ? {
1053
+ childId: options
1054
+ } : options ?? {};
1055
+ }
1056
+ __name(normalizeChildOptions, "normalizeChildOptions");
1057
+ var Position = class Position2 {
1058
+ static {
1059
+ __name(this, "Position");
1060
+ }
1061
+ seq = -1;
1062
+ next() {
1063
+ this.seq += 1;
1064
+ return this.seq;
1065
+ }
1066
+ rewind() {
1067
+ this.seq -= 1;
1068
+ }
1069
+ };
1070
+ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1071
+ const { store, replay } = host;
1072
+ const pos = new Position();
1073
+ const readCheckpoint = /* @__PURE__ */ __name((seq) => {
1074
+ const hit = replay?.get(seq);
1075
+ if (hit !== void 0) return hit;
1076
+ return store.getCheckpoint(runId, seq);
1077
+ }, "readCheckpoint");
1078
+ const writeCheckpoint = /* @__PURE__ */ __name(async (cp) => {
1079
+ await store.saveCheckpoint(cp);
1080
+ replay?.set(cp.seq, cp);
1081
+ }, "writeCheckpoint");
1082
+ const step = /* @__PURE__ */ __name(async (name, fn, options) => {
1083
+ const current = pos.next();
1084
+ const existing = await readCheckpoint(current);
1085
+ if (existing && existing.name !== name) {
1086
+ throw new NonDeterminismError(runId, current, name, existing.name);
1087
+ }
1088
+ if (existing && existing.status === "completed") {
1089
+ if (options?.compensate) compensations.push({
1090
+ name,
1091
+ fn: options.compensate
1092
+ });
1093
+ return existing.output;
1094
+ }
1095
+ const maxAttempts = Math.max(1, options?.retries ?? 1);
1096
+ const startedAt = /* @__PURE__ */ new Date();
1097
+ await host.startStep({
1098
+ runId,
1099
+ seq: current,
1100
+ name,
1101
+ kind: "local",
1102
+ attempts: 1,
1103
+ enqueuedAt: startedAt,
1104
+ startedAt
1105
+ });
1106
+ for (let attempt = 1; ; attempt += 1) {
1107
+ const events = [];
1108
+ try {
1109
+ const invocation = {
1110
+ runId,
1111
+ workflow,
1112
+ stepName: name,
1113
+ seq: current,
1114
+ attempt
1115
+ };
1116
+ const body = /* @__PURE__ */ __name(() => fn(createStepLogger(events, host.clock)), "body");
1117
+ const output = host.interceptStep ? await host.interceptStep(invocation, body) : await body();
1118
+ await host.completeStep({
1119
+ runId,
1120
+ seq: current,
1121
+ name,
1122
+ kind: "local",
1123
+ output,
1124
+ events,
1125
+ attempts: attempt,
1126
+ enqueuedAt: startedAt,
1127
+ startedAt
1128
+ });
1129
+ if (options?.compensate) compensations.push({
1130
+ name,
1131
+ fn: options.compensate
1132
+ });
1133
+ return output;
1134
+ } catch (err) {
1135
+ if (err instanceof FatalError || attempt >= maxAttempts) {
1136
+ await host.failStep({
1137
+ runId,
1138
+ seq: current,
1139
+ name,
1140
+ kind: "local",
1141
+ error: {
1142
+ message: err instanceof Error ? err.message : String(err),
1143
+ stack: err instanceof Error ? err.stack : void 0
1144
+ },
1145
+ events,
1146
+ attempts: attempt,
1147
+ enqueuedAt: startedAt,
1148
+ startedAt
1149
+ });
1150
+ throw err;
1151
+ }
1152
+ const wait = backoffDelay(attempt, options);
1153
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
1154
+ }
1155
+ }
1156
+ }, "step");
1157
+ const transaction = /* @__PURE__ */ __name(async (name, fn) => {
1158
+ if (!store.transaction) {
1159
+ throw new Error("ctx.transaction needs a store that supports transactions (the SQL adapters do). Use ctx.step for non-transactional work.");
1160
+ }
1161
+ const current = pos.next();
1162
+ const existing = await readCheckpoint(current);
1163
+ if (existing && existing.name !== name) {
1164
+ throw new NonDeterminismError(runId, current, name, existing.name);
1165
+ }
1166
+ if (existing && existing.status === "completed") return existing.output;
1167
+ return store.transaction(async (tx) => {
1168
+ const output = await fn(tx.raw);
1169
+ const cp = instantCheckpoint({
1170
+ runId,
1171
+ seq: current,
1172
+ name,
1173
+ kind: "local",
1174
+ output
1175
+ });
1176
+ await tx.saveCheckpoint(cp);
1177
+ replay?.set(current, cp);
1178
+ return output;
1179
+ });
1180
+ }, "transaction");
1181
+ const suspendUntil = /* @__PURE__ */ __name(async (wakeAt) => {
1182
+ const current = pos.next();
1183
+ const now2 = host.clock();
1184
+ const existing = await readCheckpoint(current);
1185
+ if (existing) {
1186
+ if (now2 >= (existing.wakeAt ?? 0)) return;
1187
+ throw new WorkflowSuspended(existing.wakeAt ?? now2);
1188
+ }
1189
+ const at = wakeAt();
1190
+ await writeCheckpoint(instantCheckpoint({
1191
+ runId,
1192
+ seq: current,
1193
+ name: "sleep",
1194
+ kind: "sleep",
1195
+ wakeAt: at
1196
+ }));
1197
+ throw new WorkflowSuspended(at);
1198
+ }, "suspendUntil");
1199
+ const sleep = /* @__PURE__ */ __name((duration) => suspendUntil(() => host.clock() + parseDuration(duration)), "sleep");
1200
+ const sleepUntil = /* @__PURE__ */ __name((when) => suspendUntil(() => typeof when === "number" ? when : when.getTime()), "sleepUntil");
1201
+ const continueAsNew = /* @__PURE__ */ __name((input) => {
1202
+ throw new ContinueAsNew(input);
1203
+ }, "continueAsNew");
1204
+ const consumeBuffered = /* @__PURE__ */ __name(async (token, seq) => {
1205
+ const buffered = await store.takeBufferedSignal(token);
1206
+ if (!buffered) return null;
1207
+ await writeCheckpoint(instantCheckpoint({
1208
+ runId,
1209
+ seq,
1210
+ name: `signal:${token}`,
1211
+ kind: "signal",
1212
+ output: buffered.payload
1213
+ }));
1214
+ return {
1215
+ value: buffered.payload
1216
+ };
1217
+ }, "consumeBuffered");
1218
+ const waitForSignal = /* @__PURE__ */ __name(async (token, opts) => {
1219
+ if (opts?.timeoutMs == null) {
1220
+ const current = pos.next();
1221
+ const existing = await readCheckpoint(current);
1222
+ if (existing && existing.status === "completed") return existing.output;
1223
+ const buffered2 = await consumeBuffered(token, current);
1224
+ if (buffered2) return buffered2.value;
1225
+ await store.putSignalWaiter({
1226
+ token,
1227
+ runId,
1228
+ seq: current
1229
+ });
1230
+ throw new WorkflowSuspended();
1231
+ }
1232
+ const timeoutMs = opts.timeoutMs;
1233
+ const deadlineSeq = pos.next();
1234
+ const waitSeq = pos.next();
1235
+ const recorded = await readCheckpoint(deadlineSeq);
1236
+ const deadline = recorded?.wakeAt ?? host.clock() + timeoutMs;
1237
+ if (!recorded) {
1238
+ await writeCheckpoint(instantCheckpoint({
1239
+ runId,
1240
+ seq: deadlineSeq,
1241
+ name: `timeout:${token}`,
1242
+ kind: "sleep",
1243
+ wakeAt: deadline
1244
+ }));
1245
+ }
1246
+ const waited = await readCheckpoint(waitSeq);
1247
+ if (waited && waited.status === "completed") return waited.output;
1248
+ const buffered = await consumeBuffered(token, waitSeq);
1249
+ if (buffered) return buffered.value;
1250
+ if (host.clock() >= deadline) {
1251
+ await store.takeSignalWaiter(token).catch(() => void 0);
1252
+ throw new SignalTimeoutError(token, timeoutMs);
1253
+ }
1254
+ await store.putSignalWaiter({
1255
+ token,
1256
+ runId,
1257
+ seq: waitSeq
1258
+ });
1259
+ throw new WorkflowSuspended(deadline);
1260
+ }, "waitForSignal");
1261
+ const waitForEvent = /* @__PURE__ */ __name(async (name, opts) => {
1262
+ if (opts?.timeoutMs == null) {
1263
+ const current = pos.next();
1264
+ const token2 = eventToken(name, opts?.match, runId, current);
1265
+ const existing = await readCheckpoint(current);
1266
+ if (existing && existing.status === "completed") return existing.output;
1267
+ await store.putSignalWaiter({
1268
+ token: token2,
1269
+ runId,
1270
+ seq: current
1271
+ });
1272
+ throw new WorkflowSuspended();
1273
+ }
1274
+ const timeoutMs = opts.timeoutMs;
1275
+ const deadlineSeq = pos.next();
1276
+ const waitSeq = pos.next();
1277
+ const token = eventToken(name, opts.match, runId, waitSeq);
1278
+ const recorded = await readCheckpoint(deadlineSeq);
1279
+ const deadline = recorded?.wakeAt ?? host.clock() + timeoutMs;
1280
+ if (!recorded) {
1281
+ await writeCheckpoint(instantCheckpoint({
1282
+ runId,
1283
+ seq: deadlineSeq,
1284
+ name: `timeout:event:${name}`,
1285
+ kind: "sleep",
1286
+ wakeAt: deadline
1287
+ }));
1288
+ }
1289
+ const waited = await readCheckpoint(waitSeq);
1290
+ if (waited && waited.status === "completed") return waited.output;
1291
+ if (host.clock() >= deadline) {
1292
+ await store.takeSignalWaiter(token).catch(() => void 0);
1293
+ throw new SignalTimeoutError(`event:${name}`, timeoutMs);
1294
+ }
1295
+ await store.putSignalWaiter({
1296
+ token,
1297
+ runId,
1298
+ seq: waitSeq
1299
+ });
1300
+ throw new WorkflowSuspended(deadline);
1301
+ }, "waitForEvent");
1302
+ const task = /* @__PURE__ */ __name(async (name, dispatch, options) => {
1303
+ await step(`task:dispatch:${name}`, dispatch, options);
1304
+ return unwrapCompletion(await waitForSignal(`task:${runId}:${name}`), `task "${name}"`);
1305
+ }, "task");
1306
+ const child = /* @__PURE__ */ __name(async (workflow2, input, options) => {
1307
+ const { childId, priority } = normalizeChildOptions(options);
1308
+ const current = pos.next();
1309
+ const id = childId ?? `${runId}.child.${current}`;
1310
+ const existing = await readCheckpoint(current);
1311
+ if (existing && existing.status === "completed") {
1312
+ return unwrapCompletion(existing.output, `child "${id}"`);
1313
+ }
1314
+ await store.putSignalWaiter({
1315
+ token: `child:${id}`,
1316
+ runId,
1317
+ seq: current
1318
+ });
1319
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
1320
+ if (!existing) {
1321
+ await writeCheckpoint(instantCheckpoint({
1322
+ runId,
1323
+ seq: current,
1324
+ name: `signal:child:${id}`,
1325
+ kind: "signal",
1326
+ status: "running"
1327
+ }));
1328
+ }
1329
+ throw new WorkflowSuspended();
1330
+ }, "child");
1331
+ const all = /* @__PURE__ */ __name(async (workflow2, inputs, opts) => {
1332
+ if (inputs.length === 0) return [];
1333
+ const mode = opts?.mode ?? "waitAll";
1334
+ const name = workflowName(workflow2);
1335
+ const positions = inputs.map(() => pos.next());
1336
+ const group = `all:${positions[0]}`;
1337
+ const id = /* @__PURE__ */ __name((i) => `${runId}.all.${positions[0]}.${i}`, "id");
1338
+ const existing = await Promise.all(positions.map((seq) => readCheckpoint(seq)));
1339
+ if (mode === "failFast") {
1340
+ for (let i = 0; i < inputs.length; i += 1) {
1341
+ const cp = existing[i];
1342
+ const c = cp?.status === "completed" ? cp.output : null;
1343
+ if (c && c.ok === false) {
1344
+ throw new GatherError([
1345
+ {
1346
+ index: i,
1347
+ id: id(i),
1348
+ error: c.error ?? "unknown"
1349
+ }
1350
+ ], inputs.length);
1351
+ }
1352
+ }
1353
+ }
1354
+ let pending = false;
1355
+ for (let i = 0; i < inputs.length; i += 1) {
1356
+ const cp = existing[i];
1357
+ if (cp?.status === "completed") continue;
1358
+ pending = true;
1359
+ const seq = positions[i];
1360
+ const childId = id(i);
1361
+ await store.putSignalWaiter({
1362
+ token: `child:${childId}`,
1363
+ runId,
1364
+ seq,
1365
+ parallelGroup: group
1366
+ });
1367
+ if (!await store.getRun(childId)) host.startChild(name, inputs[i], childId);
1368
+ if (!cp) {
1369
+ await writeCheckpoint(instantCheckpoint({
1370
+ runId,
1371
+ seq,
1372
+ name: `signal:child:${childId}`,
1373
+ kind: "signal",
1374
+ status: "running",
1375
+ parallelGroup: group
1376
+ }));
1377
+ }
1378
+ }
1379
+ if (pending) throw new WorkflowSuspended();
1380
+ const outputs = [];
1381
+ const failures = [];
1382
+ for (let i = 0; i < inputs.length; i += 1) {
1383
+ const c = existing[i]?.output;
1384
+ if (c && typeof c === "object" && "ok" in c && c.ok === false) {
1385
+ failures.push({
1386
+ index: i,
1387
+ id: id(i),
1388
+ error: c.error ?? "unknown"
1389
+ });
1390
+ outputs.push(void 0);
1391
+ } else {
1392
+ outputs.push(c?.value);
1393
+ }
1394
+ }
1395
+ if (failures.length > 0) throw new GatherError(failures, inputs.length);
1396
+ return outputs;
1397
+ }, "all");
1398
+ const startChild = /* @__PURE__ */ __name(async (workflow2, input, options) => {
1399
+ const { childId, priority } = normalizeChildOptions(options);
1400
+ const current = pos.next();
1401
+ const id = childId ?? `${runId}.child.${current}`;
1402
+ const existing = await readCheckpoint(current);
1403
+ if (existing && existing.status === "completed") return existing.output;
1404
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
1405
+ await writeCheckpoint(instantCheckpoint({
1406
+ runId,
1407
+ seq: current,
1408
+ name: `spawn:${id}`,
1409
+ kind: "local",
1410
+ output: id
1411
+ }));
1412
+ return id;
1413
+ }, "startChild");
1414
+ const callEntity = /* @__PURE__ */ __name(async (name, key, op, arg) => {
1415
+ const current = pos.next();
1416
+ const existing = await readCheckpoint(current);
1417
+ if (existing && existing.status === "completed") return existing.output;
1418
+ const reply = `entityreply:${runId}:${current}`;
1419
+ await store.putSignalWaiter({
1420
+ token: reply,
1421
+ runId,
1422
+ seq: current
1423
+ });
1424
+ host.signalEntity?.(name, key, op, arg, reply);
1425
+ throw new WorkflowSuspended();
1426
+ }, "callEntity");
1427
+ const signalEntity = /* @__PURE__ */ __name(async (name, key, op, arg) => {
1428
+ const current = pos.next();
1429
+ const existing = await readCheckpoint(current);
1430
+ if (existing && existing.status === "completed") return;
1431
+ host.signalEntity?.(name, key, op, arg);
1432
+ await writeCheckpoint(instantCheckpoint({
1433
+ runId,
1434
+ seq: current,
1435
+ name: `entitysig:${name}:${key}`,
1436
+ kind: "local"
1437
+ }));
1438
+ }, "signalEntity");
1439
+ const breakpoint = /* @__PURE__ */ __name(async (label) => {
1440
+ const current = pos.next();
1441
+ const existing = await readCheckpoint(current);
1442
+ if (existing && existing.status === "completed") return;
1443
+ if (!existing) {
1444
+ await writeCheckpoint(instantCheckpoint({
1445
+ runId,
1446
+ seq: current,
1447
+ name: label ? `breakpoint:${label}` : "breakpoint",
1448
+ kind: "signal",
1449
+ status: "pending"
1450
+ }));
1451
+ await store.putSignalWaiter({
1452
+ token: breakpointToken(runId, current),
1453
+ runId,
1454
+ seq: current
1455
+ });
1456
+ }
1457
+ throw new WorkflowSuspended();
1458
+ }, "breakpoint");
1459
+ const patched = /* @__PURE__ */ __name(async (id) => {
1460
+ const marker = `patch:${id}`;
1461
+ const current = pos.next();
1462
+ const existing = await readCheckpoint(current);
1463
+ if (existing) {
1464
+ if (existing.name === marker) return true;
1465
+ if (existing.name.startsWith("patch:")) {
1466
+ throw new NonDeterminismError(runId, current, marker, existing.name);
1467
+ }
1468
+ pos.rewind();
1469
+ return false;
1470
+ }
1471
+ await writeCheckpoint(instantCheckpoint({
1472
+ runId,
1473
+ seq: current,
1474
+ name: marker,
1475
+ kind: "local",
1476
+ output: true
1477
+ }));
1478
+ return true;
1479
+ }, "patched");
1480
+ const onUpdate = /* @__PURE__ */ __name((name, opts) => waitForSignal(`update:${runId}:${name}`, opts), "onUpdate");
1481
+ const setEvent = /* @__PURE__ */ __name(async (key, value) => {
1482
+ const current = pos.next();
1483
+ const name = `event:${key}`;
1484
+ const existing = await readCheckpoint(current);
1485
+ if (existing && existing.name !== name) {
1486
+ throw new NonDeterminismError(runId, current, name, existing.name);
1487
+ }
1488
+ if (existing && existing.status === "completed") return;
1489
+ await writeCheckpoint(instantCheckpoint({
1490
+ runId,
1491
+ seq: current,
1492
+ name,
1493
+ kind: "local",
1494
+ output: value
1495
+ }));
1496
+ }, "setEvent");
1497
+ const webhook = /* @__PURE__ */ __name(() => {
1498
+ const current = pos.next();
1499
+ const token = `wh:${runId}:${current}`;
1500
+ const wait = /* @__PURE__ */ __name(async () => {
1501
+ const existing = await readCheckpoint(current);
1502
+ if (existing && existing.status === "completed") return existing.output;
1503
+ await store.putSignalWaiter({
1504
+ token,
1505
+ runId,
1506
+ seq: current
1507
+ });
1508
+ throw new WorkflowSuspended();
1509
+ }, "wait");
1510
+ return {
1511
+ token,
1512
+ url: host.webhookUrl?.(token),
1513
+ wait
1514
+ };
1515
+ }, "webhook");
1516
+ const now = /* @__PURE__ */ __name(() => step("now", async () => host.clock()), "now");
1517
+ const random = /* @__PURE__ */ __name(() => step("random", async () => Math.random()), "random");
1518
+ const uuid = /* @__PURE__ */ __name(() => step("uuid", async () => globalThis.crypto.randomUUID()), "uuid");
1519
+ const upsertSearchAttributes = /* @__PURE__ */ __name(async (attrs) => {
1520
+ const current = pos.next();
1521
+ const existing = await readCheckpoint(current);
1522
+ if (existing) {
1523
+ if (existing.name !== "searchAttributes") {
1524
+ throw new NonDeterminismError(runId, current, "searchAttributes", existing.name);
1525
+ }
1526
+ return;
1527
+ }
1528
+ await host.upsertSearchAttributes(runId, attrs);
1529
+ await writeCheckpoint(instantCheckpoint({
1530
+ runId,
1531
+ seq: current,
1532
+ name: "searchAttributes",
1533
+ kind: "local",
1534
+ output: attrs
1535
+ }));
1536
+ }, "upsertSearchAttributes");
1537
+ return {
1538
+ runId,
1539
+ step,
1540
+ upsertSearchAttributes,
1541
+ transaction,
1542
+ callEntity,
1543
+ signalEntity,
1544
+ sleep,
1545
+ sleepUntil,
1546
+ continueAsNew,
1547
+ waitForSignal,
1548
+ waitForEvent,
1549
+ task,
1550
+ child,
1551
+ all,
1552
+ startChild,
1553
+ breakpoint,
1554
+ webhook,
1555
+ setEvent,
1556
+ onUpdate,
1557
+ patched,
1558
+ now,
1559
+ random,
1560
+ uuid,
1561
+ call: /* @__PURE__ */ __name((remote, input, opts) => host.callRemote(runId, pos.next(), remote, input, opts?.queue, opts?.transport, {
1562
+ priority: opts?.priority,
1563
+ fairnessKey: opts?.fairnessKey
1564
+ }), "call")
1565
+ };
1566
+ }
1567
+ __name(createWorkflowCtx, "createWorkflowCtx");
1568
+
1569
+ // src/engine.ts
1570
+ function mergeTags(staticTags, runTags) {
1571
+ if (!staticTags?.length && !runTags?.length) return void 0;
1572
+ return [
1573
+ .../* @__PURE__ */ new Set([
1574
+ ...staticTags ?? [],
1575
+ ...runTags ?? []
1576
+ ])
1577
+ ];
1578
+ }
1579
+ __name(mergeTags, "mergeTags");
1580
+ var BREAKPOINT = "breakpoint";
1581
+ var isBreakpoint = /* @__PURE__ */ __name((cp) => cp.status === "pending" && cp.name.startsWith(BREAKPOINT), "isBreakpoint");
1582
+ var versionKey = /* @__PURE__ */ __name((name, version) => `${name}@${version}`, "versionKey");
1583
+ function nextContinuationId(runId) {
1584
+ const m = runId.match(/^(.*)~(\d+)$/);
1585
+ return m ? `${m[1]}~${Number(m[2]) + 1}` : `${runId}~1`;
1586
+ }
1587
+ __name(nextContinuationId, "nextContinuationId");
1588
+ function isNewerVersion(a, b) {
1589
+ const na = Number(a);
1590
+ const nb = Number(b);
1591
+ if (!Number.isNaN(na) && !Number.isNaN(nb)) return na > nb;
1592
+ return a.localeCompare(b, void 0, {
1593
+ numeric: true
1594
+ }) > 0;
1595
+ }
1596
+ __name(isNewerVersion, "isNewerVersion");
1597
+ var NamespaceMismatch = class NamespaceMismatch2 extends Error {
1598
+ static {
1599
+ __name(this, "NamespaceMismatch");
1600
+ }
1601
+ constructor() {
1602
+ super("namespace-mismatch");
1603
+ }
1604
+ };
1605
+ var WorkflowEngine = class {
1606
+ static {
1607
+ __name(this, "WorkflowEngine");
1608
+ }
1609
+ store;
1610
+ /** Ordered transport pool (dispatch + failover). Empty = no remote steps. */
1611
+ pool;
1612
+ controlPlane;
1613
+ clock;
1614
+ instanceId;
1615
+ namespace;
1616
+ leaseMs;
1617
+ maxRecoveryAttempts;
1618
+ remoteAdvanceSilenceMs;
1619
+ webhookUrl;
1620
+ traceparent;
1621
+ context;
1622
+ /** Establish the originating context ambiently around a local step body (see {@link WorkflowEngineDeps.rehydrate}). Default passthrough. */
1623
+ rehydrate;
1624
+ compensationRetries;
1625
+ /** Persist a `running` checkpoint at the start of a local step body (see {@link WorkflowEngineDeps.trackStepStart}). */
1626
+ trackStepStart;
1627
+ /** Where a freshly-started run executes — in-process by default (see {@link RunDispatcher}). */
1628
+ runDispatcher;
1629
+ /** Per-key serialization for singleton workflows (admission, back-pressure, notify-on-release). */
1630
+ singletons;
1631
+ /** Every registered workflow, keyed by `name@version` — so old versions stay runnable. */
1632
+ workflows = /* @__PURE__ */ new Map();
1633
+ /** The newest registered version per workflow name — used to `start` new runs. */
1634
+ latest = /* @__PURE__ */ new Map();
1635
+ /** Event name → workflow names started when that event is published (see `onEvent`). */
1636
+ eventTriggers = /* @__PURE__ */ new Map();
1637
+ /** Durable-entity subsystem (registers the `__entity` runner; see `registerEntity`). */
1638
+ entities;
1639
+ /** Event debounce/batch accumulators (register the `__evt_*` runners; see `accumulators.route`). */
1640
+ accumulators;
1641
+ /** In-flight remote steps awaiting a worker result, keyed by stepId. */
1642
+ pending = /* @__PURE__ */ new Map();
1643
+ /** "Reset the liveness timer" callbacks keyed by the beat's target — a stepId (in-flight remote step)
1644
+ * or a runId (in-flight remote workflow turn) — invoked when a matching heartbeat arrives. The two id
1645
+ * shapes don't collide in practice: a stepId is `${runId}:${seq}`, a run key is the bare runId. */
1646
+ heartbeatResets = /* @__PURE__ */ new Map();
1647
+ listeners = /* @__PURE__ */ new Set();
1648
+ /** Step interceptors (onion middleware around real local-step execution), first = outermost. */
1649
+ interceptors = [];
1650
+ /** Callbacks notified (on any instance) when a run is cancelled — for cooperative cancellation. */
1651
+ cancelListeners = /* @__PURE__ */ new Set();
1652
+ /** Callbacks notified when a run is enqueued elsewhere — for low-latency cross-pod dispatch. */
1653
+ enqueuedListeners = /* @__PURE__ */ new Set();
1654
+ /** Notified when a run is dead-lettered (moved to `dead`) — a hook for a DLQ handler. */
1655
+ deadListeners = /* @__PURE__ */ new Set();
1656
+ /** Validators gating `engine.update`, keyed by `<workflow>:<updateName>`. */
1657
+ updateValidators = /* @__PURE__ */ new Map();
1658
+ /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
1659
+ cancelRequested = /* @__PURE__ */ new Set();
1660
+ /** Flow-control admission backend for remote steps (see {@link registerQueue}). */
1661
+ admission;
1662
+ /** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
1663
+ queueWaiters = /* @__PURE__ */ new Map();
1664
+ /** Which queue a dispatched step took a slot from, by stepId — so the result can release it. */
1665
+ stepQueue = /* @__PURE__ */ new Map();
1666
+ /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
1667
+ inflight = /* @__PURE__ */ new Set();
1668
+ draining = false;
1669
+ /** When true, route an unregistered workflow to the live worker group of the same name. */
1670
+ remoteByConvention;
1671
+ constructor(deps) {
1672
+ this.store = deps.store;
1673
+ this.pool = new TransportPool(deps.transports ?? (deps.transport ? [
1674
+ {
1675
+ id: "default",
1676
+ transport: deps.transport
1677
+ }
1678
+ ] : []));
1679
+ this.controlPlane = deps.controlPlane;
1680
+ this.clock = deps.clock ?? Date.now;
1681
+ this.admission = deps.admission ?? new InMemoryAdmissionBackend(this.clock);
1682
+ this.admission.onFreed?.((queue) => this.wakeQueueWaiters(queue));
1683
+ this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
1684
+ this.namespace = deps.namespace;
1685
+ if (this.namespace !== void 0) {
1686
+ this.pool.useNamespace(this.namespace);
1687
+ }
1688
+ this.leaseMs = deps.leaseMs ?? 3e4;
1689
+ this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
1690
+ this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
1691
+ this.webhookUrl = deps.webhookUrl;
1692
+ this.traceparent = deps.traceparent;
1693
+ this.context = deps.context;
1694
+ this.rehydrate = deps.rehydrate ?? ((_carrier, fn) => fn());
1695
+ this.compensationRetries = Math.max(1, deps.compensationRetries ?? 1);
1696
+ this.trackStepStart = deps.trackStepStart ?? true;
1697
+ this.runDispatcher = deps.runDispatcher ?? {
1698
+ dispatch: /* @__PURE__ */ __name((runId) => queueMicrotask(() => void this.runOne(runId).catch(() => {
1699
+ })), "dispatch")
1700
+ };
1701
+ this.remoteByConvention = deps.remoteByConvention ?? false;
1702
+ this.singletons = new SingletonGate({
1703
+ store: this.store,
1704
+ clock: this.clock,
1705
+ dispatch: /* @__PURE__ */ __name((runId) => {
1706
+ this.runDispatcher.dispatch(runId);
1707
+ }, "dispatch"),
1708
+ configFor: /* @__PURE__ */ __name((run) => (this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? this.latest.get(run.workflow))?.singleton, "configFor")
1709
+ });
1710
+ this.pool.bind(
1711
+ async (result) => {
1712
+ const waiter = this.pending.get(result.stepId);
1713
+ if (waiter) {
1714
+ this.pending.delete(result.stepId);
1715
+ if (result.status === "completed") {
1716
+ waiter.resolve({
1717
+ output: result.output,
1718
+ startedAt: result.startedAt,
1719
+ events: result.events
1720
+ });
1721
+ } else {
1722
+ waiter.reject(new RemoteStepError(result.error));
1723
+ }
1724
+ return;
1725
+ }
1726
+ await this.completeRemoteResult(result);
1727
+ },
1728
+ // A heartbeat resets the liveness window for whatever it targets: an in-flight long STEP (keyed
1729
+ // by stepId, see callRemote) or — when stepId is absent — the in-flight workflow TURN (keyed by
1730
+ // runId). For an INLINE-advance turn the reset is an in-memory callback; for a DISPATCH-suspend
1731
+ // turn there is no in-memory waiter, so a run-scoped beat rearms the suspended turn's durable
1732
+ // re-drive deadline (`wakeAt`) instead, keeping a live worker's turn from being re-dispatched.
1733
+ async (beat) => {
1734
+ const reset = this.heartbeatResets.get(beat.stepId ?? beat.runId);
1735
+ if (reset) {
1736
+ reset();
1737
+ return;
1738
+ }
1739
+ if (beat.stepId === void 0) await this.rearmDecisionDeadline(beat.runId);
1740
+ },
1741
+ // A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
1742
+ // it's checkpointed live, not all-at-once when the long turn ends.
1743
+ async (event) => {
1744
+ await this.persistStepEvent(event);
1745
+ },
1746
+ // A workflow-turn DECISION delivered over the broker — applied durably (by run id) on whatever
1747
+ // instance consumes it, NOT correlated to an in-memory promise on the dispatcher. This is the
1748
+ // multi-instance fix: a point-to-point broker may hand the decision to a non-dispatching instance.
1749
+ async (decision) => {
1750
+ await this.completeRemoteDecision(decision);
1751
+ }
1752
+ );
1753
+ this.pool.onStartRun(async (message) => {
1754
+ await this.start(message.workflow, message.input, message.runId ?? globalThis.crypto.randomUUID(), {
1755
+ namespace: message.tenant,
1756
+ tags: message.tags
1757
+ });
1758
+ });
1759
+ this.controlPlane?.onControl((msg) => {
1760
+ if (msg.from === this.instanceId) return;
1761
+ if (msg.kind === "event") {
1762
+ this.deliver({
1763
+ ...msg.event,
1764
+ at: new Date(msg.event.at)
1765
+ });
1766
+ } else if (msg.kind === "cancel") {
1767
+ this.notifyCancelled(msg.runId);
1768
+ } else if (msg.kind === "enqueued") {
1769
+ for (const fn of this.enqueuedListeners) {
1770
+ try {
1771
+ fn(msg.runId);
1772
+ } catch {
1773
+ }
1774
+ }
1775
+ }
1776
+ });
1777
+ this.accumulators = new EventAccumulators(this);
1778
+ this.entities = new Entities(this);
1779
+ }
1780
+ /**
1781
+ * Register a **durable entity** (a virtual object): a keyed actor whose `handlers` run **serialized
1782
+ * per key** over **durable state**, exactly once. Drive it with `signalEntity` (fire) /
1783
+ * `ctx.callEntity` (call + await result) and read its state with `getEntityState`. See {@link Entities}.
1784
+ */
1785
+ registerEntity(name, config) {
1786
+ this.entities.register(name, config);
1787
+ }
1788
+ /** Send an operation to an entity (fire-and-forget). Ordered + exactly-once per key. */
1789
+ signalEntity(name, key, op, arg) {
1790
+ return this.entities.signal(name, key, op, arg);
1791
+ }
1792
+ /** Read an entity's current durable state (published after each op), or undefined if it has none yet. */
1793
+ getEntityState(name, key) {
1794
+ return this.entities.getState(name, key);
1795
+ }
1796
+ /**
1797
+ * Be notified when a run is enqueued on ANOTHER instance (via the control plane), so a worker can
1798
+ * pick it up at once — e.g. `engine.onEnqueued((runId) => engine.runOne(runId))`. Returns an
1799
+ * unsubscribe function. Only wire this on instances that should execute runs (workers).
1800
+ */
1801
+ onEnqueued(listener) {
1802
+ this.enqueuedListeners.add(listener);
1803
+ return () => this.enqueuedListeners.delete(listener);
1804
+ }
1805
+ /** Fire cooperative-cancellation listeners for `runId` (a worker bridge aborts in-flight work). */
1806
+ notifyCancelled(runId) {
1807
+ for (const fn of this.cancelListeners) {
1808
+ try {
1809
+ fn(runId);
1810
+ } catch {
1811
+ }
1812
+ }
1813
+ }
1814
+ /**
1815
+ * Register a workflow version. Register multiple versions of the same name to keep in-flight
1816
+ * runs working across a breaking change: old runs resume on the version they started on, new
1817
+ * runs start on the newest registered version.
1818
+ *
1819
+ * GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
1820
+ * this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
1821
+ * inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
1822
+ * body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
1823
+ * ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
1824
+ * engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
1825
+ * engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
1826
+ * inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
1827
+ * step toward routing every run through a group with no local/remote flag (Phase 3).
1828
+ */
1829
+ register(name, version, fn, opts) {
1830
+ if (opts?.group == null !== (opts?.executor == null)) {
1831
+ throw new Error(`workflow ${name}@${version}: register({ group, executor }) needs BOTH group and executor (or neither)`);
1832
+ }
1833
+ const registered = {
1834
+ name,
1835
+ version,
1836
+ fn,
1837
+ tags: opts?.tags,
1838
+ singleton: opts?.singleton,
1839
+ executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
1840
+ validateInput: opts?.validateInput,
1841
+ onEvent: opts?.onEvent,
1842
+ eventBatch: opts?.eventBatch,
1843
+ // A real, retained in-process body — so `workflowBody` can hand it to an in-app worker, and so
1844
+ // it is NOT confused with the throwing stub `registerRemote` installs for a body-less remote.
1845
+ hasBody: true
1846
+ };
1847
+ if (opts?.group != null && opts?.executor != null) {
1848
+ registered.remote = {
1849
+ group: opts.group,
1850
+ executor: opts.executor
1851
+ };
1852
+ }
1853
+ this.workflows.set(versionKey(name, version), registered);
1854
+ const current = this.latest.get(name);
1855
+ if (!current || isNewerVersion(version, current.version)) this.latest.set(name, registered);
1856
+ for (const event of opts?.onEvent ?? []) {
1857
+ const subscribers = this.eventTriggers.get(event) ?? /* @__PURE__ */ new Set();
1858
+ subscribers.add(name);
1859
+ this.eventTriggers.set(event, subscribers);
1860
+ }
1861
+ }
1862
+ /**
1863
+ * The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
1864
+ * own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
1865
+ * the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
1866
+ * it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
1867
+ * remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
1868
+ */
1869
+ workflowBody(name, version) {
1870
+ const reg = this.workflows.get(versionKey(name, version));
1871
+ return reg?.hasBody ? reg.fn : void 0;
1872
+ }
1873
+ /**
1874
+ * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
1875
+ * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
1876
+ * the run's history to `executor` (which dispatches a {@link WorkflowTask} to the worker) and applying
1877
+ * the {@link WorkflowDecision} the worker's replay returns. The worker never touches the store.
1878
+ */
1879
+ registerRemote(name, version, opts) {
1880
+ const registered = {
1881
+ name,
1882
+ version,
1883
+ // A remote workflow has no in-process body; execute() branches on `remote` before this is read.
1884
+ fn: /* @__PURE__ */ __name(() => {
1885
+ throw new Error(`workflow ${name} is remote \u2014 it has no in-process body`);
1886
+ }, "fn"),
1887
+ tags: opts.tags,
1888
+ singleton: opts.singleton,
1889
+ executionTimeoutMs: opts.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
1890
+ validateInput: opts.validateInput,
1891
+ remote: {
1892
+ group: opts.group,
1893
+ executor: opts.executor
1894
+ }
1895
+ };
1896
+ this.workflows.set(versionKey(name, version), registered);
1897
+ const current = this.latest.get(name);
1898
+ if (!current || isNewerVersion(version, current.version)) this.latest.set(name, registered);
1899
+ }
1900
+ /**
1901
+ * The convenience form of {@link registerRemote}: register a workflow whose body runs on a worker
1902
+ * `group` (e.g. the Python `durable-worker`, or a separate TS worker), without hand-building a
1903
+ * {@link RemoteWorkflowExecutor}. It wires the standard broker executor — a `RemoteWorkflowExecutor`
1904
+ * over the engine's own transport, dispatching to `opts.group` — for you, so you write
1905
+ * `engine.remote('processing', { group: 'processing' })` instead of
1906
+ * `engine.registerRemote('processing', '1', { group: 'processing', executor: new RemoteWorkflowExecutor(transport, 'processing') })`.
1907
+ *
1908
+ * Defaults the version to `'1'`. All the other {@link registerRemote} options
1909
+ * (`tags` / `singleton` / `executionTimeout` / `validateInput`) pass straight through. Reach for the
1910
+ * low-level {@link registerRemote} when you need a custom {@link WorkflowExecutor} (a different
1911
+ * transport, a fixed `timeoutMs`, a polyglot stub) — `remote()` is the standard wiring on the rails.
1912
+ */
1913
+ remote(name, opts) {
1914
+ const { group, version, ...rest } = opts;
1915
+ const executor = new RemoteWorkflowExecutor(this.pool.primary, group);
1916
+ this.registerRemote(name, version ?? "1", {
1917
+ group,
1918
+ executor,
1919
+ ...rest
1920
+ });
1921
+ }
1922
+ /**
1923
+ * Register a flow-control queue referenced by `ctx.call(step, input, { queue })`. Caps concurrent
1924
+ * in-flight steps and/or the admission rate; blocked calls re-suspend and retry, so the limit is
1925
+ * durable. Per engine instance (see {@link QueueConfig}). Registering the same name replaces it.
1926
+ */
1927
+ registerQueue(config) {
1928
+ this.admission.register(config);
1929
+ }
1930
+ /** Subscribe to lifecycle events. Returns an unsubscribe function. */
1931
+ subscribe(listener) {
1932
+ this.listeners.add(listener);
1933
+ return () => this.listeners.delete(listener);
1934
+ }
1935
+ /**
1936
+ * Register a {@link StepInterceptor} — onion middleware run around the real execution of every
1937
+ * local `ctx.step` (timing, logging, tracing, error enrichment, context propagation). First
1938
+ * registered is outermost; interceptors fire only when a step executes, never on replay. Returns
1939
+ * an unsubscribe function.
1940
+ */
1941
+ use(interceptor) {
1942
+ this.interceptors.push(interceptor);
1943
+ return () => {
1944
+ const i = this.interceptors.indexOf(interceptor);
1945
+ if (i >= 0) this.interceptors.splice(i, 1);
1946
+ };
1947
+ }
1948
+ /**
1949
+ * Fold the registered interceptors around a local step body (identity when there are none), then
1950
+ * run the whole thing inside the re-hydrated originating context. The carrier is read at execution
1951
+ * time from {@link context} — the SAME reader stamped on dispatched remote tasks — so a local step
1952
+ * sees the live tenant / user / trace ids ambiently (via the {@link rehydrate} bridge). Default
1953
+ * `rehydrate` is a passthrough, so this is byte-identical to a bare body call when unwired.
1954
+ */
1955
+ interceptStep(invocation, body) {
1956
+ const carrier = this.context?.();
1957
+ const run = /* @__PURE__ */ __name(() => {
1958
+ if (this.interceptors.length === 0) return body();
1959
+ const chain = this.interceptors.reduceRight((next, interceptor) => () => interceptor(invocation, next), body);
1960
+ return chain();
1961
+ }, "run");
1962
+ return this.rehydrate(carrier, run);
1963
+ }
1964
+ /**
1965
+ * Be notified when a run is cancelled — on ANY instance, via the transport control plane. A
1966
+ * worker bridge can use this for cooperative cancellation: abort the in-flight work for `runId`
1967
+ * instead of finishing it just to have the result discarded. Returns an unsubscribe function.
1968
+ */
1969
+ onCancel(listener) {
1970
+ this.cancelListeners.add(listener);
1971
+ return () => this.cancelListeners.delete(listener);
1972
+ }
1973
+ /**
1974
+ * Be notified when a run is **dead-lettered** — moved to `dead` after exceeding
1975
+ * `maxRecoveryAttempts`. The listener receives the dead run (status `dead`, with its error), so a
1976
+ * DLQ handler can do something other than just leaving it parked: alert, push to a real queue, or
1977
+ * start a dead-letter workflow (e.g. `engine.onDead((run) => engine.start('pipeline-dlq', run, ...))`).
1978
+ * Returns an unsubscribe function.
1979
+ */
1980
+ onDead(listener) {
1981
+ this.deadListeners.add(listener);
1982
+ return () => this.deadListeners.delete(listener);
1983
+ }
1984
+ notifyDead(run) {
1985
+ for (const fn of this.deadListeners) {
1986
+ try {
1987
+ fn(run);
1988
+ } catch {
1989
+ }
1990
+ }
1991
+ }
1992
+ /** Emit a locally-produced lifecycle event: deliver to subscribers AND broadcast it on the
1993
+ * control plane so other instances (e.g. a dashboard pod) can live-tail this run. */
1994
+ emit(event) {
1995
+ const full = {
1996
+ ...event,
1997
+ at: /* @__PURE__ */ new Date()
1998
+ };
1999
+ this.deliver(full);
2000
+ if (this.controlPlane) {
2001
+ void this.controlPlane.publishControl({
2002
+ kind: "event",
2003
+ event: full,
2004
+ from: this.instanceId
2005
+ }).catch(() => {
2006
+ });
2007
+ }
2008
+ }
2009
+ /** Deliver an event to local subscribers only (no re-broadcast) — used for both locally-produced
2010
+ * events and ones received from the control plane, so an event shows up once on every instance. */
2011
+ deliver(event) {
2012
+ for (const listener of this.listeners) {
2013
+ try {
2014
+ listener(event);
2015
+ } catch {
2016
+ }
2017
+ }
2018
+ }
2019
+ async start(workflow, input, runId, opts) {
2020
+ const name = workflowName(workflow);
2021
+ let registered = this.latest.get(name);
2022
+ if (!registered) {
2023
+ const ancestor = await this.findRemoteAncestor(runId);
2024
+ if (ancestor) registered = this.synthesizeRemoteChild(name, ancestor.version, ancestor);
2025
+ }
2026
+ if (!registered) {
2027
+ const conventionRun = {
2028
+ id: runId,
2029
+ workflow: name,
2030
+ workflowVersion: "1",
2031
+ status: "pending",
2032
+ // Stamp the namespace the REAL run will carry (:893) so convention routing resolves the same
2033
+ // tenant-suffixed group the run will dispatch to — an operator starting a tenant's run via the
2034
+ // `onStartRun` wire (opts.namespace = tenant) must find the live `<workflow>@<tenant>` group,
2035
+ // not the bare group. Without this the start-run entry path can't reach a suffixed-only worker.
2036
+ namespace: opts?.namespace ?? this.namespace,
2037
+ input,
2038
+ createdAt: /* @__PURE__ */ new Date(),
2039
+ updatedAt: /* @__PURE__ */ new Date()
2040
+ };
2041
+ registered = await this.resolveRemoteByConvention(conventionRun);
2042
+ }
2043
+ if (!registered) throw new Error(`workflow ${name} is not registered`);
2044
+ await registered.validateInput?.(input);
2045
+ const prior = await this.store.getRun(runId);
2046
+ if (prior) {
2047
+ return {
2048
+ runId,
2049
+ status: prior.status,
2050
+ output: prior.output,
2051
+ error: prior.error
2052
+ };
2053
+ }
2054
+ const now = /* @__PURE__ */ new Date();
2055
+ const tags = mergeTags(registered.tags, registered.singleton ? [
2056
+ ...opts?.tags ?? [],
2057
+ this.singletons.tag(registered.singleton, input)
2058
+ ] : opts?.tags);
2059
+ if (registered.singleton) {
2060
+ await this.singletons.assertCapacity(name, registered.singleton, input);
2061
+ }
2062
+ const run = {
2063
+ id: runId,
2064
+ workflow: name,
2065
+ workflowVersion: registered.version,
2066
+ status: "pending",
2067
+ namespace: opts?.namespace ?? this.namespace,
2068
+ input,
2069
+ tags,
2070
+ searchAttributes: opts?.searchAttributes,
2071
+ priority: opts?.priority,
2072
+ createdAt: now,
2073
+ updatedAt: now
2074
+ };
2075
+ await this.store.createRun(run);
2076
+ await this.runDispatcher.dispatch(runId);
2077
+ if (this.controlPlane) {
2078
+ void this.controlPlane.publishControl({
2079
+ kind: "enqueued",
2080
+ runId,
2081
+ from: this.instanceId
2082
+ }).catch(() => void 0);
2083
+ }
2084
+ return {
2085
+ runId,
2086
+ status: "pending"
2087
+ };
2088
+ }
2089
+ /** Read a run's current persisted state (or null if unknown). A thin pass-through to the store. */
2090
+ getRun(runId) {
2091
+ return this.store.getRun(runId);
2092
+ }
2093
+ async resume(runId) {
2094
+ const run = await this.store.getRun(runId);
2095
+ if (!run) throw new Error(`run ${runId} not found`);
2096
+ if (run.status === "cancelled" || run.status === "completed" || run.status === "dead") {
2097
+ return {
2098
+ runId,
2099
+ status: run.status,
2100
+ output: run.output,
2101
+ error: run.error
2102
+ };
2103
+ }
2104
+ if (this.namespace !== void 0 && run.namespace !== void 0 && run.namespace !== this.namespace) {
2105
+ await this.store.releaseRunLock(runId);
2106
+ throw new NamespaceMismatch();
2107
+ }
2108
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run) ?? await this.resolveRemoteByConvention(run);
2109
+ if (!registered) {
2110
+ throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered \u2014 keep the prior version deployed so in-flight runs can drain (skew protection)`);
2111
+ }
2112
+ return this.track(this.execute(run, registered.fn));
2113
+ }
2114
+ /**
2115
+ * The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
2116
+ * `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
2117
+ * flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
2118
+ * never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
2119
+ * Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
2120
+ * is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
2121
+ * no redundant registration per child name.
2122
+ *
2123
+ * RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
2124
+ * registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
2125
+ * registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
2126
+ * new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
2127
+ */
2128
+ async findInheritedRegistration(run) {
2129
+ const ancestor = await this.findRemoteAncestor(run.id);
2130
+ if (!ancestor) return void 0;
2131
+ return this.synthesizeRemoteChild(run.workflow, run.workflowVersion, ancestor);
2132
+ }
2133
+ /**
2134
+ * A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
2135
+ * group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
2136
+ * {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
2137
+ * is correct AND required — a second executor on the same group would race the worker for results.
2138
+ * Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
2139
+ * its identity stays its own.
2140
+ */
2141
+ synthesizeRemoteChild(name, version, ancestor) {
2142
+ const remote = ancestor.remote;
2143
+ if (!remote) throw new Error(`workflow ${ancestor.name} is not a remote workflow`);
2144
+ return {
2145
+ name,
2146
+ version,
2147
+ fn: /* @__PURE__ */ __name(() => {
2148
+ throw new Error(`workflow ${name} is remote \u2014 it has no in-process body`);
2149
+ }, "fn"),
2150
+ remote
2151
+ };
2152
+ }
2153
+ /**
2154
+ * When `remoteByConvention` is enabled, attempt to route an unregistered workflow to the live
2155
+ * worker group of the same name: if the transport reports a live heartbeat for group
2156
+ * `run.workflow`, synthesize a throwaway {@link RegisteredWorkflow} that dispatches to that group
2157
+ * over the primary transport. Returns `undefined` when the flag is off or the group is not live.
2158
+ *
2159
+ * Recomputed per call (never memoized into `this.workflows`) so it stays correct if group
2160
+ * liveness changes between calls, exactly as {@link synthesizeRemoteChild} does.
2161
+ */
2162
+ async resolveRemoteByConvention(run) {
2163
+ if (!this.remoteByConvention) return void 0;
2164
+ const liveGroups = await this.pool.listWorkerGroups();
2165
+ const group = tenantGroup(run.workflow, run.namespace);
2166
+ if (!liveGroups.includes(group)) return void 0;
2167
+ const executor = new RemoteWorkflowExecutor(this.pool.primary, group);
2168
+ return {
2169
+ name: run.workflow,
2170
+ version: run.workflowVersion,
2171
+ fn: /* @__PURE__ */ __name(() => {
2172
+ throw new Error(`remote workflow ${run.workflow} has no local body`);
2173
+ }, "fn"),
2174
+ hasBody: false,
2175
+ remote: {
2176
+ group,
2177
+ executor
2178
+ }
2179
+ };
2180
+ }
2181
+ /**
2182
+ * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
2183
+ * registration (or `undefined` if there is none). The parent of an awaited child is found via its
2184
+ * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
2185
+ * child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
2186
+ * cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
2187
+ * unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
2188
+ * unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
2189
+ * `visited` guards against a pathological cyclic id graph.
2190
+ */
2191
+ async findRemoteAncestor(childRunId, visited = /* @__PURE__ */ new Set()) {
2192
+ if (visited.has(childRunId)) return void 0;
2193
+ visited.add(childRunId);
2194
+ const parentRunId = await this.findParentRunId(childRunId);
2195
+ if (!parentRunId) return void 0;
2196
+ const parent = await this.store.getRun(parentRunId);
2197
+ if (!parent) return void 0;
2198
+ const parentReg = this.workflows.get(versionKey(parent.workflow, parent.workflowVersion));
2199
+ if (parentReg?.remote) return parentReg;
2200
+ if (parentReg) return void 0;
2201
+ return this.findRemoteAncestor(parent.id, visited);
2202
+ }
2203
+ /**
2204
+ * The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
2205
+ * child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
2206
+ * and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
2207
+ * registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
2208
+ * waiter is consumed), but by then the child is terminal and no longer resolved.
2209
+ */
2210
+ async findParentRunId(childRunId) {
2211
+ const token = `child:${childRunId}`;
2212
+ for (const waiter of await this.store.listSignalWaiters("child:")) {
2213
+ if (waiter.token === token) return waiter.runId;
2214
+ }
2215
+ return void 0;
2216
+ }
2217
+ /** Track an in-flight execution so {@link drain} can wait for it. */
2218
+ track(p) {
2219
+ this.inflight.add(p);
2220
+ void p.finally(() => this.inflight.delete(p));
2221
+ return p;
2222
+ }
2223
+ /**
2224
+ * Graceful shutdown: stop picking up new runs (recovery/timer become no-ops) and wait for
2225
+ * in-flight executions to settle, up to `timeoutMs`. Call from your app's shutdown hook so a
2226
+ * deploy hands off cleanly instead of leaving runs to the lease timeout.
2227
+ */
2228
+ async drain(timeoutMs = 1e4) {
2229
+ this.draining = true;
2230
+ if (this.inflight.size === 0) return;
2231
+ const timer = new Promise((resolve) => {
2232
+ const t = setTimeout(resolve, timeoutMs);
2233
+ t.unref?.();
2234
+ });
2235
+ await Promise.race([
2236
+ Promise.allSettled([
2237
+ ...this.inflight
2238
+ ]),
2239
+ timer
2240
+ ]);
2241
+ }
2242
+ /**
2243
+ * Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
2244
+ * timer poller alongside {@link resumeDueTimers}. A timed-out run is moved to `cancelled` with an
2245
+ * `execution_timeout` error (terminal, so a late step result can't resurrect it).
2246
+ */
2247
+ async sweepTimeouts(now = this.clock()) {
2248
+ for (const reg of new Set(this.latest.values())) {
2249
+ if (reg.executionTimeoutMs == null) continue;
2250
+ const deadline = now - reg.executionTimeoutMs;
2251
+ const inflight = [
2252
+ ...await this.store.listRuns({
2253
+ workflow: reg.name,
2254
+ status: "running",
2255
+ namespace: this.namespace
2256
+ }),
2257
+ ...await this.store.listRuns({
2258
+ workflow: reg.name,
2259
+ status: "suspended",
2260
+ namespace: this.namespace
2261
+ })
2262
+ ];
2263
+ for (const run of inflight) {
2264
+ if (run.createdAt.getTime() > deadline) continue;
2265
+ const error = {
2266
+ message: "execution timeout",
2267
+ code: "execution_timeout"
2268
+ };
2269
+ await this.store.updateRun(run.id, {
2270
+ status: "cancelled",
2271
+ error,
2272
+ updatedAt: /* @__PURE__ */ new Date()
2273
+ });
2274
+ this.emit({
2275
+ type: "run.failed",
2276
+ runId: run.id,
2277
+ workflow: run.workflow,
2278
+ error
2279
+ });
2280
+ }
2281
+ }
2282
+ }
2283
+ /**
2284
+ * Resume every run left incomplete by a crash or deploy. Called on boot. Completed steps
2285
+ * replay from their checkpoints, so only the work that had not finished runs again.
2286
+ */
2287
+ async recoverIncomplete(nowMs = this.clock()) {
2288
+ if (this.draining) return [];
2289
+ const results = [];
2290
+ for (const run of await this.store.listIncompleteRuns(this.namespace)) {
2291
+ const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
2292
+ if (!acquired) continue;
2293
+ const settled = await this.countRecovery(run);
2294
+ if (settled) {
2295
+ results.push(settled);
2296
+ continue;
2297
+ }
2298
+ await this.store.releaseRunLock(run.id);
2299
+ if (run.status !== "cancelling") {
2300
+ await this.store.updateRun(run.id, {
2301
+ status: "pending",
2302
+ updatedAt: /* @__PURE__ */ new Date()
2303
+ });
2304
+ }
2305
+ await this.runDispatcher.dispatch(run.id);
2306
+ results.push({
2307
+ runId: run.id,
2308
+ status: run.status === "cancelling" ? "cancelling" : "pending"
2309
+ });
2310
+ }
2311
+ return results;
2312
+ }
2313
+ /**
2314
+ * Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
2315
+ * `maxRecoveryAttempts` — move a poison pill to the `dead` dead-letter state. Returns a terminal
2316
+ * result to skip the resume, or `undefined` to proceed.
2317
+ */
2318
+ async countRecovery(run) {
2319
+ const attempts = (run.recoveryAttempts ?? 0) + 1;
2320
+ if (this.maxRecoveryAttempts != null && attempts > this.maxRecoveryAttempts) {
2321
+ const error = {
2322
+ message: `run exceeded maxRecoveryAttempts (${this.maxRecoveryAttempts}) \u2014 moved to dead-letter`,
2323
+ code: "max_recovery_attempts"
2324
+ };
2325
+ await this.store.updateRun(run.id, {
2326
+ status: "dead",
2327
+ error,
2328
+ updatedAt: /* @__PURE__ */ new Date()
2329
+ });
2330
+ await this.store.releaseRunLock(run.id);
2331
+ this.emit({
2332
+ type: "run.failed",
2333
+ runId: run.id,
2334
+ workflow: run.workflow,
2335
+ error
2336
+ });
2337
+ this.notifyDead({
2338
+ ...run,
2339
+ status: "dead",
2340
+ error,
2341
+ recoveryAttempts: attempts
2342
+ });
2343
+ return {
2344
+ runId: run.id,
2345
+ status: "dead",
2346
+ error
2347
+ };
2348
+ }
2349
+ await this.store.updateRun(run.id, {
2350
+ recoveryAttempts: attempts,
2351
+ updatedAt: /* @__PURE__ */ new Date()
2352
+ });
2353
+ return void 0;
2354
+ }
2355
+ /**
2356
+ * Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
2357
+ * boot. A run still not due re-suspends cheaply without running new work.
2358
+ */
2359
+ async resumeDueTimers(nowMs = this.clock()) {
2360
+ return this.resumeLeased(await this.store.listDueTimers(nowMs, this.namespace), nowMs);
2361
+ }
2362
+ /**
2363
+ * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
2364
+ * exactly one instance runs it), then resumes the body. Returns the result, or null if another
2365
+ * instance holds the lease or the engine is draining. The default in-process dispatcher calls this;
2366
+ * a broker-backed worker calls it per consumed run id.
2367
+ */
2368
+ async runOne(runId) {
2369
+ if (this.draining) return null;
2370
+ const nowMs = this.clock();
2371
+ const acquired = await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs);
2372
+ if (!acquired) return null;
2373
+ return this.resume(runId).catch((err) => {
2374
+ if (err instanceof NamespaceMismatch) return null;
2375
+ throw err;
2376
+ });
2377
+ }
2378
+ /**
2379
+ * Re-enqueue a run for a worker to (re-)execute — the dispatch-model **retry**. Sets it back to
2380
+ * `pending`, clears any stale lease, and dispatches; a worker resumes it (replaying its checkpoints,
2381
+ * re-attempting the failed step). Returns the enqueued state immediately — never runs the body
2382
+ * inline — or null if the run is unknown. The dashboard "retry" goes through here so it can't block
2383
+ * the HTTP request on workflow execution.
2384
+ */
2385
+ async requeue(runId) {
2386
+ const run = await this.store.getRun(runId);
2387
+ if (!run) return null;
2388
+ await this.store.releaseRunLock(runId);
2389
+ await this.store.updateRun(runId, {
2390
+ status: "pending",
2391
+ updatedAt: /* @__PURE__ */ new Date()
2392
+ });
2393
+ await this.runDispatcher.dispatch(runId);
2394
+ return {
2395
+ runId,
2396
+ status: "pending"
2397
+ };
2398
+ }
2399
+ /**
2400
+ * **Fix-and-replay**: re-run a run (typically a `dead`/`failed` one) with a corrected `input`, as a
2401
+ * fresh run with clean history. It's a NEW run — `newRunId` defaults to `<runId>~retry~<uuid>` — so
2402
+ * the original stays inspectable. Returns the new run's id, or null if `runId` is unknown.
2403
+ */
2404
+ async retryWithInput(runId, input, newRunId) {
2405
+ const run = await this.store.getRun(runId);
2406
+ if (!run) return null;
2407
+ const id = newRunId ?? `${runId}~retry~${globalThis.crypto.randomUUID().slice(0, 8)}`;
2408
+ await this.start(run.workflow, input, id, {
2409
+ tags: run.tags,
2410
+ namespace: run.namespace
2411
+ });
2412
+ return {
2413
+ runId: id
2414
+ };
2415
+ }
2416
+ /**
2417
+ * Pick up and execute every `pending` run — the poll-based side of dispatch for a worker pod with
2418
+ * no broker. Runs enqueued by other pods (or by a caller using a no-op dispatcher) sit `pending`
2419
+ * in the store until polled; leasing ensures exactly one pod runs each. Call periodically alongside
2420
+ * {@link resumeDueTimers}.
2421
+ */
2422
+ async runPending(nowMs = this.clock()) {
2423
+ return this.resumeLeased(await this.store.listPendingRuns(100, this.namespace), nowMs);
2424
+ }
2425
+ /**
2426
+ * Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
2427
+ * suspended (handed off to a timer/signal/event). The async counterpart to dispatch: pair it with
2428
+ * `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
2429
+ */
2430
+ waitForRun(runId, opts) {
2431
+ const isSettled = /* @__PURE__ */ __name((run) => run.status !== "pending" && run.status !== "running" && run.status !== "cancelling" && !(run.status === "suspended" && run.awaitingDecisionTaskId !== void 0), "isSettled");
2432
+ const toResult = /* @__PURE__ */ __name((run) => ({
2433
+ runId,
2434
+ status: run.status,
2435
+ output: run.output,
2436
+ error: run.error
2437
+ }), "toResult");
2438
+ return new Promise((resolve, reject) => {
2439
+ let done = false;
2440
+ let off = /* @__PURE__ */ __name(() => {
2441
+ }, "off");
2442
+ const timer = opts?.timeoutMs != null ? setTimeout(() => {
2443
+ if (done) return;
2444
+ done = true;
2445
+ off();
2446
+ reject(new Error(`waitForRun(${runId}) timed out after ${opts.timeoutMs}ms`));
2447
+ }, opts.timeoutMs) : void 0;
2448
+ const finish = /* @__PURE__ */ __name((run) => {
2449
+ if (done) return;
2450
+ done = true;
2451
+ if (timer) clearTimeout(timer);
2452
+ off();
2453
+ resolve(toResult(run));
2454
+ }, "finish");
2455
+ const check = /* @__PURE__ */ __name(() => {
2456
+ void this.store.getRun(runId).then((run) => {
2457
+ if (run && isSettled(run)) finish(run);
2458
+ });
2459
+ }, "check");
2460
+ off = this.subscribe((ev) => {
2461
+ if (ev.runId === runId && (ev.type === "run.completed" || ev.type === "run.failed" || ev.type === "run.suspended")) {
2462
+ check();
2463
+ }
2464
+ });
2465
+ check();
2466
+ });
2467
+ }
2468
+ /**
2469
+ * Resume each run only if this instance can acquire its recovery lease — so when several
2470
+ * replicas recover or poll at once, each run is picked up by exactly one of them.
2471
+ */
2472
+ async resumeLeased(runs, nowMs = this.clock(), onLocked) {
2473
+ if (this.draining) return [];
2474
+ const results = [];
2475
+ for (const run of runs) {
2476
+ const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
2477
+ if (!acquired) continue;
2478
+ const settled = onLocked ? await onLocked(run) : void 0;
2479
+ results.push(settled ?? await this.resume(run.id));
2480
+ }
2481
+ return results;
2482
+ }
2483
+ /**
2484
+ * Deliver an external signal to the run waiting on `token` and resume it with `payload`.
2485
+ * Returns the run result, or null if no run is waiting for that token.
2486
+ */
2487
+ /**
2488
+ * Publish a named event. It does two things, and returns how many runs it touched (the sum):
2489
+ * 1. **Resumes** every in-flight run waiting on it via `ctx.waitForEvent(name, { match })` whose
2490
+ * match the payload satisfies (fan-out, vs `signal`'s point-to-point token).
2491
+ * 2. **Starts** a fresh run of every workflow registered with `onEvent: [name]`, passing the
2492
+ * payload as input. Idempotent by `evt:<id>:<workflow>` — pass `opts.id` to dedupe redeliveries
2493
+ * of the same logical event (default: a fresh uuid, so each publish triggers once).
2494
+ */
2495
+ async publishEvent(name, payload, opts) {
2496
+ let touched = 0;
2497
+ const waiters = await this.store.listSignalWaiters(eventPrefix(name));
2498
+ for (const w of waiters) {
2499
+ if (eventMatches(payload, eventMatchOf(w.token))) {
2500
+ await this.signal(w.token, payload);
2501
+ touched += 1;
2502
+ }
2503
+ }
2504
+ const subscribers = this.eventTriggers.get(name);
2505
+ if (subscribers?.size) {
2506
+ const eventId = opts?.id ?? globalThis.crypto.randomUUID();
2507
+ for (const workflow of subscribers) {
2508
+ try {
2509
+ const batch = this.latest.get(workflow)?.eventBatch;
2510
+ if (batch) {
2511
+ await this.accumulators.route(workflow, batch, payload);
2512
+ } else {
2513
+ await this.start(workflow, payload, `evt:${eventId}:${workflow}`);
2514
+ }
2515
+ touched += 1;
2516
+ } catch {
2517
+ }
2518
+ }
2519
+ }
2520
+ return touched;
2521
+ }
2522
+ async signal(token, payload) {
2523
+ const waiter = await this.store.takeSignalWaiter(token);
2524
+ if (!waiter) {
2525
+ await this.store.bufferSignal(token, payload);
2526
+ return null;
2527
+ }
2528
+ await this.store.saveCheckpoint(instantCheckpoint({
2529
+ runId: waiter.runId,
2530
+ seq: waiter.seq,
2531
+ name: `signal:${token}`,
2532
+ kind: "signal",
2533
+ output: payload,
2534
+ // Carry the awaiting command's fan group (set when a `ctx.gather_children`/`ctx.all` fan-out
2535
+ // registered this waiter) onto the resolving `signal:child:<id>` checkpoint, so the dashboard
2536
+ // groups the child fan-out vertically instead of rendering it as a sequential chain. Undefined
2537
+ // for an ordinary (non-fan) signal/child await.
2538
+ parallelGroup: waiter.parallelGroup
2539
+ }));
2540
+ return this.resume(waiter.runId).catch((err) => {
2541
+ if (err instanceof NamespaceMismatch) return null;
2542
+ throw err;
2543
+ });
2544
+ }
2545
+ /**
2546
+ * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
2547
+ * signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
2548
+ * consumed when it reaches `waitForSignal(token)`. The canonical **durable-entity / accumulator**
2549
+ * pattern: one long-lived run per key (the `runId`) that loops on `waitForSignal`, fed events by
2550
+ * many `signalWithStart` calls. `start` is idempotent by `runId`, so concurrent callers converge on
2551
+ * one run. (Use a per-run `token`, e.g. derived from `runId`, so the signal targets this entity.)
2552
+ */
2553
+ async signalWithStart(workflow, input, runId, signal, opts) {
2554
+ await this.start(workflow, input, runId, opts);
2555
+ await this.signal(signal.token, signal.payload);
2556
+ return {
2557
+ runId
2558
+ };
2559
+ }
2560
+ /**
2561
+ * Report the result of a `ctx.task(name, …)` back to its run (async completion). The external
2562
+ * worker that the task dispatched to calls this when done; the run resumes with `result`. Returns
2563
+ * null if no run is waiting on the task (e.g. a duplicate/late delivery) — a safe no-op.
2564
+ */
2565
+ async completeTask(runId, name, result) {
2566
+ return this.signal(`task:${runId}:${name}`, {
2567
+ ok: true,
2568
+ value: result
2569
+ });
2570
+ }
2571
+ /** Report that a `ctx.task` failed — the run resumes and throws a FatalError at the task. */
2572
+ async failTask(runId, name, error) {
2573
+ return this.signal(`task:${runId}:${name}`, {
2574
+ ok: false,
2575
+ error
2576
+ });
2577
+ }
2578
+ /**
2579
+ * Notify a parent that's waiting on `runId` as a child of its terminal outcome (the `ctx.child`
2580
+ * rendezvous). A no-op when no parent is waiting, so `execute()` can call it on every run without
2581
+ * knowing about the child feature.
2582
+ */
2583
+ notifyParent(runId, completion) {
2584
+ void this.signal(`child:${runId}`, completion).catch(() => void 0);
2585
+ }
2586
+ /**
2587
+ * Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
2588
+ * `{ compensate: true }` to undo the saga first: the suspended run is resumed so its completed
2589
+ * steps' compensations run in reverse (visible as `compensate:<step>` events), then it's marked
2590
+ * cancelled. Without it, cancellation is immediate (no undo).
2591
+ */
2592
+ async cancel(runId, opts) {
2593
+ const run = await this.store.getRun(runId);
2594
+ if (!run) return null;
2595
+ if (run.status === "completed" || run.status === "cancelled" || run.status === "dead") {
2596
+ return {
2597
+ runId,
2598
+ status: run.status,
2599
+ output: run.output,
2600
+ error: run.error
2601
+ };
2602
+ }
2603
+ if (run.status === "cancelling") {
2604
+ return {
2605
+ runId,
2606
+ status: "cancelling"
2607
+ };
2608
+ }
2609
+ if (opts?.compensate && (run.status === "suspended" || run.status === "running")) {
2610
+ this.cancelRequested.add(runId);
2611
+ await this.store.updateRun(runId, {
2612
+ status: "cancelling",
2613
+ updatedAt: /* @__PURE__ */ new Date()
2614
+ });
2615
+ run.status = "cancelling";
2616
+ if (this.controlPlane) {
2617
+ void this.controlPlane.publishControl({
2618
+ kind: "cancel",
2619
+ runId,
2620
+ from: this.instanceId
2621
+ }).catch(() => void 0);
2622
+ }
2623
+ queueMicrotask(() => {
2624
+ void this.resume(runId).then(() => this.notifyCancelled(runId)).catch(() => void 0);
2625
+ });
2626
+ await this.cancelChildren(runId, opts);
2627
+ return {
2628
+ runId,
2629
+ status: "cancelling"
2630
+ };
2631
+ }
2632
+ const error = {
2633
+ message: "cancelled"
2634
+ };
2635
+ await this.store.updateRun(runId, {
2636
+ status: "cancelled",
2637
+ error,
2638
+ updatedAt: /* @__PURE__ */ new Date()
2639
+ });
2640
+ this.emit({
2641
+ type: "run.failed",
2642
+ runId,
2643
+ workflow: run.workflow,
2644
+ error
2645
+ });
2646
+ await this.cancelChildren(runId, opts);
2647
+ this.notifyCancelled(runId);
2648
+ if (this.controlPlane) {
2649
+ void this.controlPlane.publishControl({
2650
+ kind: "cancel",
2651
+ runId,
2652
+ from: this.instanceId
2653
+ }).catch(() => void 0);
2654
+ }
2655
+ void this.singletons.wakeNext(run).catch(() => void 0);
2656
+ return {
2657
+ runId,
2658
+ status: "cancelled",
2659
+ error
2660
+ };
2661
+ }
2662
+ /**
2663
+ * Bulk-cancel every run matching a filter — e.g. cancel all `order` runs tagged `vip`, or every run
2664
+ * whose `tier` search attribute is `free`. The filter is a {@link RunQuery} (workflow / status / tag
2665
+ * / search-attribute predicates), so it reuses the same matching the dashboard list uses. Each match
2666
+ * is run through {@link cancel}, so the same plumbing applies per run: child cascade, the optional
2667
+ * saga `compensate`, local cancel listeners, and the control-plane broadcast that tells the owning
2668
+ * worker to abort. Returns one {@link RunResult} per matched run (already-finished matches report
2669
+ * their terminal status — `cancel` is a no-op on them, never clobbering a completed/dead run).
2670
+ */
2671
+ async cancelWhere(filter, opts) {
2672
+ const runs = await this.store.listRuns(filter);
2673
+ const results = [];
2674
+ for (const run of runs) {
2675
+ const r = await this.cancel(run.id, opts);
2676
+ if (r) results.push(r);
2677
+ }
2678
+ return results;
2679
+ }
2680
+ /**
2681
+ * Hard-delete a run and its entire subtree. Unlike {@link cancel} (which marks a run `cancelled`
2682
+ * but keeps it as history), delete REMOVES the run and all its rows (checkpoints, signal waiters,
2683
+ * search-attribute rows) — it vanishes from {@link getRun} and {@link listRuns}. Use it to purge a
2684
+ * finished run whose data is being deleted (e.g. a pipeline run whose result rows are cleared).
2685
+ *
2686
+ * Cascades depth-first via {@link getRunChildren} so children are gone before the parent (no
2687
+ * orphaned child runs). Prefer {@link cancel} first for a live run — deleting one mid-flight orphans
2688
+ * its worker (its next checkpoint write fails). Returns the number of runs deleted (0 if absent).
2689
+ */
2690
+ async deleteRun(runId) {
2691
+ const run = await this.store.getRun(runId);
2692
+ if (!run) return 0;
2693
+ let deleted = 0;
2694
+ for (const childId of await this.getRunChildren(runId)) {
2695
+ deleted += await this.deleteRun(childId);
2696
+ }
2697
+ await this.store.deleteRun(runId);
2698
+ return deleted + 1;
2699
+ }
2700
+ /**
2701
+ * Cascade cancellation to a run's children — both awaited (`ctx.child`, found via its live
2702
+ * `child:<id>` waiter) and fire-and-forget (`ctx.startChild`, found via its `spawn:<id>`
2703
+ * checkpoint). Recursive, so a whole subtree is cancelled; the terminal guard in `cancel` stops it
2704
+ * at finished / already-cancelled runs (no loops, no re-cancel).
2705
+ */
2706
+ async cancelChildren(parentRunId, opts) {
2707
+ for (const id of await this.getRunChildren(parentRunId)) {
2708
+ await this.cancel(id, opts).catch(() => void 0);
2709
+ }
2710
+ }
2711
+ /**
2712
+ * The ids of the runs a run spawned — both awaited (`ctx.child`) and fire-and-forget
2713
+ * (`ctx.startChild`, found via its `spawn:<id>` checkpoint). The canonical parent→children edge,
2714
+ * used for both cancellation cascades and the dashboard run-tree.
2715
+ *
2716
+ * An awaited child is discovered two ways, because the live `child:<id>` waiter only exists WHILE
2717
+ * the parent is suspended on it: the waiter resolves and is consumed the moment the child settles,
2718
+ * so a completed parent (or a completed awaited child) would otherwise drop out of the tree. The
2719
+ * `signal:child:<id>` checkpoint (the placeholder written when the parent first awaits the child,
2720
+ * overwritten as completed/failed when it settles) persists across completion, so we read both and
2721
+ * dedupe — the edge stays stable for a finished run, not just a live one.
2722
+ */
2723
+ async getRunChildren(parentRunId) {
2724
+ const childIds = /* @__PURE__ */ new Set();
2725
+ for (const w of await this.store.listSignalWaiters("child:")) {
2726
+ if (w.runId === parentRunId) childIds.add(w.token.slice("child:".length));
2727
+ }
2728
+ const prefixes = [
2729
+ "signal:child:",
2730
+ "spawn:"
2731
+ ];
2732
+ const childCheckpoints = this.store.listCheckpointsByNamePrefix ? await this.store.listCheckpointsByNamePrefix(parentRunId, prefixes) : (await this.store.listCheckpoints(parentRunId)).filter((cp) => prefixes.some((p) => cp.name.startsWith(p)));
2733
+ for (const cp of childCheckpoints) {
2734
+ if (cp.name.startsWith("signal:child:")) childIds.add(cp.name.slice("signal:child:".length));
2735
+ if (cp.name.startsWith("spawn:") && typeof cp.output === "string") childIds.add(cp.output);
2736
+ }
2737
+ return [
2738
+ ...childIds
2739
+ ];
2740
+ }
2741
+ /** The worker groups this engine dispatches to: every registered remote workflow's group, plus any
2742
+ * `extra` the caller declares. Local-step groups (a group consumed by in-process `@DurableStep`
2743
+ * workers, e.g. `pipeline`) aren't derivable from registrations — pass them via `extra` so a group
2744
+ * with backlog and ZERO workers is still reported (the alert case has no heartbeat to discover). */
2745
+ knownGroups(extra = []) {
2746
+ const groups = new Set(extra);
2747
+ for (const def of this.workflows.values()) {
2748
+ if (def.remote?.group) groups.add(def.remote.group);
2749
+ }
2750
+ return [
2751
+ ...groups
2752
+ ];
2753
+ }
2754
+ /** Per-group worker health (queue backlog + live worker heartbeats). Covers {@link knownGroups}
2755
+ * (so a registered group with backlog and ZERO workers still reports — the alert case) UNION the
2756
+ * groups discovered from live heartbeats (so a local-step group like `pipeline`, not derivable
2757
+ * from registrations, shows once its workers beat). Empty when no transport can introspect health
2758
+ * (only the BullMQ transport implements `groupHealth`). */
2759
+ async workerHealth(extra = []) {
2760
+ const groups = /* @__PURE__ */ new Set([
2761
+ ...this.knownGroups(extra),
2762
+ ...await this.pool.listWorkerGroups()
2763
+ ]);
2764
+ const out = [];
2765
+ for (const group of groups) {
2766
+ const health = await this.pool.groupHealth(group);
2767
+ if (health) out.push(health);
2768
+ }
2769
+ return out;
2770
+ }
2771
+ /**
2772
+ * Persist a streamed local-step lifecycle event from a remote workflow worker (see
2773
+ * {@link WorkflowStepEvent}). A Python `@workflow` runs its `ctx.step`s inline over one turn that
2774
+ * can last minutes; the worker streams each step's start/finish so the engine checkpoints it LIVE —
2775
+ * a step shows `running` the moment its body begins, then resolves to `completed`/`failed` with its
2776
+ * real wall-clock window and sub-process events — instead of every step appearing at once when the
2777
+ * turn ends. The turn's final `recordStep` command re-persists the same (runId, seq) checkpoint
2778
+ * idempotently, so this is purely additive observability and never changes the run's outcome.
2779
+ */
2780
+ async persistStepEvent(event) {
2781
+ const startedAt = new Date(event.startedAt);
2782
+ if (event.phase === "running") {
2783
+ await this.store.saveCheckpoint(stepCheckpoint({
2784
+ runId: event.runId,
2785
+ seq: event.seq,
2786
+ name: event.name,
2787
+ kind: "local",
2788
+ status: "running",
2789
+ events: event.events,
2790
+ attempts: 1,
2791
+ enqueuedAt: startedAt,
2792
+ startedAt,
2793
+ finishedAt: startedAt,
2794
+ parallelGroup: event.parallelGroup
2795
+ }));
2796
+ this.emit({
2797
+ type: "step.started",
2798
+ runId: event.runId,
2799
+ seq: event.seq,
2800
+ name: event.name,
2801
+ kind: "local"
2802
+ });
2803
+ return;
2804
+ }
2805
+ const failed = event.phase === "failed";
2806
+ await this.store.saveCheckpoint(stepCheckpoint({
2807
+ runId: event.runId,
2808
+ seq: event.seq,
2809
+ name: event.name,
2810
+ kind: "local",
2811
+ status: failed ? "failed" : "completed",
2812
+ output: failed ? void 0 : event.output,
2813
+ error: failed ? event.error : void 0,
2814
+ events: event.events,
2815
+ attempts: 1,
2816
+ enqueuedAt: startedAt,
2817
+ startedAt,
2818
+ finishedAt: event.finishedAt != null ? new Date(event.finishedAt) : /* @__PURE__ */ new Date(),
2819
+ parallelGroup: event.parallelGroup
2820
+ }));
2821
+ this.emit({
2822
+ type: failed ? "step.failed" : "step.completed",
2823
+ runId: event.runId,
2824
+ seq: event.seq,
2825
+ name: event.name,
2826
+ kind: "local",
2827
+ output: failed ? void 0 : event.output,
2828
+ error: failed ? event.error : void 0
2829
+ });
2830
+ }
2831
+ /**
2832
+ * Announce a local step's body has begun and (when `trackStepStart`) checkpoint it as `running`,
2833
+ * so it's visible in flight rather than appearing only on completion. The checkpoint is a
2834
+ * placeholder overwritten by {@link completeStep}/{@link failStep}; it never short-circuits replay
2835
+ * (only `completed` does), so a crash mid-body just re-runs the step. The `step.started` event
2836
+ * fires regardless — the live SSE view sees the start even with persistence off.
2837
+ */
2838
+ async startStep(step) {
2839
+ if (this.trackStepStart) {
2840
+ await this.store.saveCheckpoint(stepCheckpoint({
2841
+ runId: step.runId,
2842
+ seq: step.seq,
2843
+ name: step.name,
2844
+ kind: step.kind,
2845
+ status: "running",
2846
+ input: step.input,
2847
+ events: step.events,
2848
+ attempts: step.attempts,
2849
+ workerGroup: step.workerGroup,
2850
+ enqueuedAt: step.enqueuedAt,
2851
+ startedAt: step.startedAt,
2852
+ finishedAt: step.startedAt
2853
+ }));
2854
+ }
2855
+ this.emit({
2856
+ type: "step.started",
2857
+ runId: step.runId,
2858
+ seq: step.seq,
2859
+ name: step.name,
2860
+ kind: step.kind
2861
+ });
2862
+ }
2863
+ /** Checkpoint a finished step and announce it — the two things that must always happen together. */
2864
+ async completeStep(step) {
2865
+ await this.store.saveCheckpoint(stepCheckpoint({
2866
+ runId: step.runId,
2867
+ seq: step.seq,
2868
+ name: step.name,
2869
+ kind: step.kind,
2870
+ status: "completed",
2871
+ input: step.input,
2872
+ output: step.output,
2873
+ events: step.events,
2874
+ attempts: step.attempts,
2875
+ workerGroup: step.workerGroup,
2876
+ enqueuedAt: step.enqueuedAt,
2877
+ startedAt: step.startedAt,
2878
+ finishedAt: /* @__PURE__ */ new Date()
2879
+ }));
2880
+ this.emit({
2881
+ type: "step.completed",
2882
+ runId: step.runId,
2883
+ seq: step.seq,
2884
+ name: step.name,
2885
+ kind: step.kind,
2886
+ output: step.output,
2887
+ queueMs: step.startedAt.getTime() - step.enqueuedAt.getTime(),
2888
+ durationMs: Date.now() - step.startedAt.getTime()
2889
+ });
2890
+ }
2891
+ /** Checkpoint a step that failed terminally, so the failure point is visible (not just the run). */
2892
+ async failStep(step) {
2893
+ await this.store.saveCheckpoint(stepCheckpoint({
2894
+ runId: step.runId,
2895
+ seq: step.seq,
2896
+ name: step.name,
2897
+ kind: step.kind,
2898
+ status: "failed",
2899
+ input: step.input,
2900
+ error: step.error,
2901
+ events: step.events,
2902
+ attempts: step.attempts,
2903
+ workerGroup: step.workerGroup,
2904
+ enqueuedAt: step.enqueuedAt,
2905
+ startedAt: step.startedAt,
2906
+ finishedAt: /* @__PURE__ */ new Date()
2907
+ }));
2908
+ this.emit({
2909
+ type: "step.failed",
2910
+ runId: step.runId,
2911
+ seq: step.seq,
2912
+ name: step.name,
2913
+ kind: step.kind,
2914
+ error: step.error,
2915
+ queueMs: step.startedAt.getTime() - step.enqueuedAt.getTime(),
2916
+ durationMs: Date.now() - step.startedAt.getTime()
2917
+ });
2918
+ }
2919
+ async execute(run, fn) {
2920
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run) ?? await this.resolveRemoteByConvention(run);
2921
+ const lockNow = this.clock();
2922
+ if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
2923
+ if (!await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow)) {
2924
+ return {
2925
+ runId: run.id,
2926
+ status: run.status
2927
+ };
2928
+ }
2929
+ }
2930
+ const renew = setInterval(() => {
2931
+ void this.store.renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
2932
+ }, Math.max(50, Math.floor(this.leaseMs / 2)));
2933
+ renew.unref?.();
2934
+ try {
2935
+ const result = registered?.remote ? await this.runRemoteExecution(run, registered) : await this.runExecution(run, fn, registered);
2936
+ if (registered?.singleton && (result.status === "completed" || result.status === "failed" || result.status === "cancelled" || result.status === "dead")) {
2937
+ void this.singletons.wakeNext(run).catch(() => void 0);
2938
+ }
2939
+ return result;
2940
+ } finally {
2941
+ clearInterval(renew);
2942
+ }
2943
+ }
2944
+ /**
2945
+ * Advance a remote (cross-SDK) workflow one turn: hand its history to the executor (which dispatches
2946
+ * a workflow task to the worker and awaits its replay) and apply the decision. Mirrors
2947
+ * {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
2948
+ * us back here (a remote step finished, a timer fired) goes through `resume` like any TS workflow.
2949
+ */
2950
+ /**
2951
+ * Apply a terminal/suspended transition once: persist the new run status, emit the matching
2952
+ * lifecycle event, and (on completion/failure) wake a waiting parent. Both the TS and remote
2953
+ * executors funnel their completed/failed/suspended outcomes through here so a status update can
2954
+ * never drift from its event or `notifyParent` call. Does NOT touch the run lock — each executor
2955
+ * keeps its own lease handling (the TS executor's `finally`, the remote path's caller).
2956
+ */
2957
+ async settleRun(run, outcome) {
2958
+ const updatedAt = /* @__PURE__ */ new Date();
2959
+ if (outcome.kind === "completed") {
2960
+ await this.store.updateRun(run.id, {
2961
+ status: "completed",
2962
+ output: outcome.output,
2963
+ error: void 0,
2964
+ updatedAt
2965
+ });
2966
+ this.emit({
2967
+ type: "run.completed",
2968
+ runId: run.id,
2969
+ workflow: run.workflow,
2970
+ output: outcome.output
2971
+ });
2972
+ void this.notifyParent(run.id, {
2973
+ ok: true,
2974
+ value: outcome.output
2975
+ });
2976
+ return {
2977
+ runId: run.id,
2978
+ status: "completed",
2979
+ output: outcome.output
2980
+ };
2981
+ }
2982
+ if (outcome.kind === "failed") {
2983
+ await this.store.updateRun(run.id, {
2984
+ status: "failed",
2985
+ error: outcome.error,
2986
+ updatedAt
2987
+ });
2988
+ this.emit({
2989
+ type: "run.failed",
2990
+ runId: run.id,
2991
+ workflow: run.workflow,
2992
+ error: outcome.error
2993
+ });
2994
+ void this.notifyParent(run.id, {
2995
+ ok: false,
2996
+ error: outcome.error.message
2997
+ });
2998
+ return {
2999
+ runId: run.id,
3000
+ status: "failed",
3001
+ error: outcome.error
3002
+ };
3003
+ }
3004
+ await this.store.updateRun(run.id, {
3005
+ status: "suspended",
3006
+ wakeAt: outcome.wakeAt,
3007
+ updatedAt
3008
+ });
3009
+ this.emit({
3010
+ type: "run.suspended",
3011
+ runId: run.id,
3012
+ workflow: run.workflow
3013
+ });
3014
+ return {
3015
+ runId: run.id,
3016
+ status: "suspended"
3017
+ };
3018
+ }
3019
+ async runRemoteExecution(run, registered) {
3020
+ if (run.status === "cancelling") {
3021
+ const error = {
3022
+ message: "cancelled"
3023
+ };
3024
+ await this.store.updateRun(run.id, {
3025
+ status: "cancelled",
3026
+ error,
3027
+ updatedAt: /* @__PURE__ */ new Date()
3028
+ });
3029
+ this.emit({
3030
+ type: "run.failed",
3031
+ runId: run.id,
3032
+ workflow: run.workflow,
3033
+ error
3034
+ });
3035
+ this.notifyCancelled(run.id);
3036
+ if (this.controlPlane) {
3037
+ void this.controlPlane.publishControl({
3038
+ kind: "cancel",
3039
+ runId: run.id,
3040
+ from: this.instanceId
3041
+ }).catch(() => void 0);
3042
+ }
3043
+ await this.store.releaseRunLock(run.id);
3044
+ return {
3045
+ runId: run.id,
3046
+ status: "cancelled",
3047
+ error
3048
+ };
3049
+ }
3050
+ const remote = registered.remote;
3051
+ if (run.status === "pending") {
3052
+ await this.store.updateRun(run.id, {
3053
+ status: "running",
3054
+ updatedAt: /* @__PURE__ */ new Date()
3055
+ });
3056
+ run.status = "running";
3057
+ this.emit({
3058
+ type: "run.started",
3059
+ runId: run.id,
3060
+ workflow: run.workflow
3061
+ });
3062
+ }
3063
+ if (registered.singleton && !await this.singletons.admit(run, registered.singleton)) {
3064
+ const wakeAt = this.singletons.retryWakeAt();
3065
+ await this.store.updateRun(run.id, {
3066
+ status: "suspended",
3067
+ wakeAt,
3068
+ updatedAt: /* @__PURE__ */ new Date()
3069
+ });
3070
+ this.emit({
3071
+ type: "run.suspended",
3072
+ runId: run.id,
3073
+ workflow: run.workflow
3074
+ });
3075
+ await this.store.releaseRunLock(run.id);
3076
+ return {
3077
+ runId: run.id,
3078
+ status: "suspended"
3079
+ };
3080
+ }
3081
+ const history = await this.remoteHistory(run.id);
3082
+ if (remote.executor.dispatch) {
3083
+ const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
3084
+ const silenceMs = this.remoteAdvanceSilenceMs;
3085
+ const wakeAt = silenceMs != null ? this.clock() + silenceMs : void 0;
3086
+ await this.store.updateRun(run.id, {
3087
+ status: "suspended",
3088
+ awaitingDecisionTaskId: taskId,
3089
+ wakeAt,
3090
+ updatedAt: /* @__PURE__ */ new Date()
3091
+ });
3092
+ this.emit({
3093
+ type: "run.suspended",
3094
+ runId: run.id,
3095
+ workflow: run.workflow
3096
+ });
3097
+ await this.store.releaseRunLock(run.id);
3098
+ await remote.executor.dispatch(run, history, taskId);
3099
+ return {
3100
+ runId: run.id,
3101
+ status: "suspended"
3102
+ };
3103
+ }
3104
+ const advanceExecutor = remote.executor.advance;
3105
+ if (!advanceExecutor) {
3106
+ throw new Error(`workflow ${run.workflow}@${run.workflowVersion}: executor implements neither advance nor dispatch`);
3107
+ }
3108
+ let decision;
3109
+ try {
3110
+ const silenceMs = this.remoteAdvanceSilenceMs;
3111
+ const advance = advanceExecutor.call(remote.executor, run, history);
3112
+ decision = silenceMs != null ? await this.awaitWithLivenessDeadline(run.id, advance, silenceMs, () => new RemoteWorkflowTimeout(run.id, silenceMs)) : await advance;
3113
+ } catch (err) {
3114
+ if (err instanceof RemoteWorkflowTimeout) {
3115
+ await this.store.releaseRunLock(run.id);
3116
+ return {
3117
+ runId: run.id,
3118
+ status: "running"
3119
+ };
3120
+ }
3121
+ const error = {
3122
+ message: err instanceof Error ? err.message : String(err),
3123
+ stack: err instanceof Error ? err.stack : void 0
3124
+ };
3125
+ await this.store.updateRun(run.id, {
3126
+ status: "failed",
3127
+ error,
3128
+ updatedAt: /* @__PURE__ */ new Date()
3129
+ });
3130
+ this.emit({
3131
+ type: "run.failed",
3132
+ runId: run.id,
3133
+ workflow: run.workflow,
3134
+ error
3135
+ });
3136
+ return {
3137
+ runId: run.id,
3138
+ status: "failed",
3139
+ error
3140
+ };
3141
+ }
3142
+ return this.applyDecision(run, decision);
3143
+ }
3144
+ /**
3145
+ * Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then
3146
+ * settle (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by
3147
+ * the INLINE-advance path ({@link runRemoteExecution}, under the held lease) and the durable
3148
+ * DISPATCH path ({@link completeRemoteDecision}, which holds the lease itself). Idempotent transitions
3149
+ * — the caller has already ensured this is the decision for the currently-awaited turn.
3150
+ */
3151
+ async applyDecision(run, decision) {
3152
+ if (decision.status === "completed") {
3153
+ await this.applyCommands(run, decision.commands);
3154
+ return this.settleRun(run, {
3155
+ kind: "completed",
3156
+ output: decision.output
3157
+ });
3158
+ }
3159
+ if (decision.status === "failed") {
3160
+ await this.applyCommands(run, decision.commands);
3161
+ return this.settleRun(run, {
3162
+ kind: "failed",
3163
+ error: decision.error ?? {
3164
+ message: "workflow failed"
3165
+ }
3166
+ });
3167
+ }
3168
+ if (decision.status === "cancelled") {
3169
+ await this.applyCommands(run, decision.commands);
3170
+ await this.store.updateRun(run.id, {
3171
+ status: "cancelled",
3172
+ updatedAt: /* @__PURE__ */ new Date()
3173
+ });
3174
+ return {
3175
+ runId: run.id,
3176
+ status: "cancelled"
3177
+ };
3178
+ }
3179
+ const fresh = await this.store.getRun(run.id);
3180
+ if (fresh && (fresh.status === "cancelled" || fresh.status === "completed" || fresh.status === "dead")) {
3181
+ return {
3182
+ runId: run.id,
3183
+ status: fresh.status,
3184
+ output: fresh.output,
3185
+ error: fresh.error
3186
+ };
3187
+ }
3188
+ const wakeAt = await this.applyCommands(run, decision.commands);
3189
+ return this.settleRun(run, {
3190
+ kind: "suspended",
3191
+ wakeAt
3192
+ });
3193
+ }
3194
+ /** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
3195
+ async remoteHistory(runId) {
3196
+ const checkpoints = await this.store.listCheckpoints(runId);
3197
+ const kindOf = {
3198
+ remote: "call",
3199
+ local: "step",
3200
+ sleep: "timer",
3201
+ signal: "signal"
3202
+ };
3203
+ const events = [];
3204
+ for (const cp of checkpoints) {
3205
+ if (cp.status === "completed" || cp.status === "failed") {
3206
+ if (cp.kind === "signal" && cp.name.startsWith("signal:child:")) {
3207
+ const completion = cp.output;
3208
+ events.push({
3209
+ seq: cp.seq,
3210
+ kind: "child",
3211
+ output: completion?.ok ? completion.value : void 0,
3212
+ error: completion && completion.ok === false ? {
3213
+ message: completion.error
3214
+ } : void 0
3215
+ });
3216
+ continue;
3217
+ }
3218
+ events.push({
3219
+ seq: cp.seq,
3220
+ kind: kindOf[cp.kind] ?? "step",
3221
+ // A signal checkpoint's name is the internal `signal:<token>`, not the workflow-level signal
3222
+ // name the replay used — omit it so the replay matches on seq + kind (its determinism anchor).
3223
+ name: cp.kind === "signal" ? void 0 : cp.name,
3224
+ output: cp.status === "completed" ? cp.output : void 0,
3225
+ error: cp.status === "failed" ? cp.error : void 0
3226
+ });
3227
+ } else if (cp.kind === "sleep" && cp.wakeAt != null && cp.wakeAt <= this.clock()) {
3228
+ events.push({
3229
+ seq: cp.seq,
3230
+ kind: "timer",
3231
+ name: cp.name
3232
+ });
3233
+ }
3234
+ }
3235
+ return events.sort((a, b) => a.seq - b.seq);
3236
+ }
3237
+ /** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
3238
+ * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result). */
3239
+ async applyCommands(run, commands) {
3240
+ let wakeAt;
3241
+ for (const cmd of commands) {
3242
+ const at = /* @__PURE__ */ new Date();
3243
+ const id = stepId(run.id, cmd.seq);
3244
+ if (cmd.kind === "recordStep") {
3245
+ const startedAt = cmd.startedAt != null ? new Date(cmd.startedAt) : at;
3246
+ const finishedAt = cmd.finishedAt != null ? new Date(cmd.finishedAt) : at;
3247
+ await this.store.saveCheckpoint(stepCheckpoint({
3248
+ runId: run.id,
3249
+ seq: cmd.seq,
3250
+ name: cmd.name,
3251
+ kind: "local",
3252
+ status: cmd.error ? "failed" : "completed",
3253
+ output: cmd.output,
3254
+ error: cmd.error,
3255
+ events: cmd.events,
3256
+ attempts: 1,
3257
+ enqueuedAt: startedAt,
3258
+ startedAt,
3259
+ finishedAt,
3260
+ // Carry the worker's `parallelGroup` (a Python `ctx.gather` tags every step in the fan with
3261
+ // the same group) onto the checkpoint, so the dashboard can render the steps as one parallel
3262
+ // group instead of a sequential list. Additive: undefined for ordinary sequential steps.
3263
+ parallelGroup: cmd.parallelGroup
3264
+ }));
3265
+ this.emit({
3266
+ type: cmd.error ? "step.failed" : "step.completed",
3267
+ runId: run.id,
3268
+ seq: cmd.seq,
3269
+ name: cmd.name,
3270
+ kind: "local",
3271
+ output: cmd.output,
3272
+ error: cmd.error
3273
+ });
3274
+ } else if (cmd.kind === "call") {
3275
+ if (await this.store.getCheckpoint(run.id, cmd.seq)) continue;
3276
+ await this.store.saveCheckpoint(stepCheckpoint({
3277
+ runId: run.id,
3278
+ seq: cmd.seq,
3279
+ name: cmd.name,
3280
+ kind: "remote",
3281
+ status: "pending",
3282
+ input: cmd.input,
3283
+ attempts: 1,
3284
+ workerGroup: cmd.group,
3285
+ enqueuedAt: at,
3286
+ startedAt: at,
3287
+ finishedAt: at,
3288
+ // A `ctx.gather_calls([...])` fan-out stamps every dispatched `call` in the fan with the
3289
+ // same group, so the dashboard renders the remote steps as one parallel fan (parity with
3290
+ // the gathered `recordStep`/`startChild` tags). Undefined for a lone sequential `ctx.call`.
3291
+ parallelGroup: cmd.parallelGroup
3292
+ }));
3293
+ await this.pool.dispatch({
3294
+ runId: run.id,
3295
+ seq: cmd.seq,
3296
+ name: cmd.name,
3297
+ stepId: id,
3298
+ group: cmd.group,
3299
+ input: cmd.input,
3300
+ traceparent: this.traceparent?.(),
3301
+ context: this.context?.(),
3302
+ attempt: 1
3303
+ }, void 0);
3304
+ this.emit({
3305
+ type: "step.started",
3306
+ runId: run.id,
3307
+ seq: cmd.seq,
3308
+ name: cmd.name,
3309
+ kind: "remote"
3310
+ });
3311
+ } else if (cmd.kind === "sleep") {
3312
+ const deadline = this.clock() + cmd.ms;
3313
+ await this.store.saveCheckpoint(stepCheckpoint({
3314
+ runId: run.id,
3315
+ seq: cmd.seq,
3316
+ name: `sleep:${cmd.seq}`,
3317
+ kind: "sleep",
3318
+ status: "pending",
3319
+ attempts: 1,
3320
+ wakeAt: deadline,
3321
+ enqueuedAt: at,
3322
+ startedAt: at,
3323
+ finishedAt: at
3324
+ }));
3325
+ wakeAt = wakeAt == null ? deadline : Math.min(wakeAt, deadline);
3326
+ } else if (cmd.kind === "waitSignal") {
3327
+ const buffered = await this.store.takeBufferedSignal(cmd.signal);
3328
+ if (buffered) {
3329
+ await this.store.saveCheckpoint(instantCheckpoint({
3330
+ runId: run.id,
3331
+ seq: cmd.seq,
3332
+ name: `signal:${cmd.signal}`,
3333
+ kind: "signal",
3334
+ output: buffered.payload
3335
+ }));
3336
+ setTimeout(() => void this.resume(run.id).catch(() => void 0), 0);
3337
+ } else {
3338
+ await this.store.putSignalWaiter({
3339
+ token: cmd.signal,
3340
+ runId: run.id,
3341
+ seq: cmd.seq
3342
+ });
3343
+ }
3344
+ } else if (cmd.kind === "startChild") {
3345
+ const childId = `${run.id}.child.${cmd.seq}`;
3346
+ await this.store.putSignalWaiter({
3347
+ token: `child:${childId}`,
3348
+ runId: run.id,
3349
+ seq: cmd.seq,
3350
+ // A `ctx.gather_children`/`ctx.all` fan-out stamps every `startChild` in the fan with the same
3351
+ // group. Thread it onto the waiter so the child's terminal `signal:child:<id>` checkpoint
3352
+ // (written by engine.signal when the child notifies the parent) carries the group and the
3353
+ // dashboard renders the fan as one parallel group. Undefined for a lone (non-fan) child.
3354
+ parallelGroup: cmd.parallelGroup
3355
+ });
3356
+ if (!await this.store.getRun(childId)) {
3357
+ queueMicrotask(() => void this.start(cmd.workflow, cmd.input, childId).catch(() => void 0));
3358
+ }
3359
+ } else {
3360
+ throw new Error(`remote workflow command '${cmd.kind}' is not supported yet`);
3361
+ }
3362
+ }
3363
+ return wakeAt;
3364
+ }
3365
+ /**
3366
+ * Undo the registered saga compensations in reverse and settle the run `cancelled`. Shared by EVERY
3367
+ * exit path of a run with a compensating cancel in flight — whether the resumed replay returned,
3368
+ * suspended, hit continue-as-new, or threw — so a cancel is never lost just because the body didn't
3369
+ * stop at a suspension point. Clears the in-memory cancel flag.
3370
+ */
3371
+ async compensateAndCancel(run, compensations) {
3372
+ this.cancelRequested.delete(run.id);
3373
+ for (let i = compensations.length - 1; i >= 0; i -= 1) {
3374
+ const comp = compensations[i];
3375
+ if (comp) await this.runCompensation(run, comp);
3376
+ }
3377
+ const error = {
3378
+ message: "cancelled"
3379
+ };
3380
+ await this.store.updateRun(run.id, {
3381
+ status: "cancelled",
3382
+ error,
3383
+ updatedAt: /* @__PURE__ */ new Date()
3384
+ });
3385
+ this.emit({
3386
+ type: "run.failed",
3387
+ runId: run.id,
3388
+ workflow: run.workflow,
3389
+ error
3390
+ });
3391
+ return {
3392
+ runId: run.id,
3393
+ status: "cancelled",
3394
+ error
3395
+ };
3396
+ }
3397
+ /** The run body, lease held + renewed by {@link execute}. */
3398
+ async runExecution(run, fn, registered) {
3399
+ if (run.status === "cancelling") this.cancelRequested.add(run.id);
3400
+ if (run.status === "pending") {
3401
+ await this.store.updateRun(run.id, {
3402
+ status: "running",
3403
+ updatedAt: /* @__PURE__ */ new Date()
3404
+ });
3405
+ run.status = "running";
3406
+ this.emit({
3407
+ type: "run.started",
3408
+ runId: run.id,
3409
+ workflow: run.workflow
3410
+ });
3411
+ }
3412
+ if (run.status !== "cancelling" && registered?.singleton && !await this.singletons.admit(run, registered.singleton)) {
3413
+ const wakeAt = this.singletons.retryWakeAt();
3414
+ await this.store.updateRun(run.id, {
3415
+ status: "suspended",
3416
+ wakeAt,
3417
+ updatedAt: /* @__PURE__ */ new Date()
3418
+ });
3419
+ this.emit({
3420
+ type: "run.suspended",
3421
+ runId: run.id,
3422
+ workflow: run.workflow
3423
+ });
3424
+ await this.store.releaseRunLock(run.id);
3425
+ return {
3426
+ runId: run.id,
3427
+ status: "suspended"
3428
+ };
3429
+ }
3430
+ const compensations = [];
3431
+ const snapshot = await this.store.listCheckpoints(run.id);
3432
+ const replay = /* @__PURE__ */ new Map();
3433
+ for (const cp of snapshot) replay.set(cp.seq, cp);
3434
+ const ctx = createWorkflowCtx(this.ctxHostFor(replay), run.id, compensations, run.workflow);
3435
+ try {
3436
+ const output = await fn(ctx, run.input);
3437
+ if (this.cancelRequested.has(run.id)) return this.compensateAndCancel(run, compensations);
3438
+ return this.settleRun(run, {
3439
+ kind: "completed",
3440
+ output
3441
+ });
3442
+ } catch (err) {
3443
+ if (err instanceof ContinueAsNew) {
3444
+ if (this.cancelRequested.has(run.id)) return this.compensateAndCancel(run, compensations);
3445
+ await this.store.updateRun(run.id, {
3446
+ status: "completed",
3447
+ output: void 0,
3448
+ error: void 0,
3449
+ updatedAt: /* @__PURE__ */ new Date()
3450
+ });
3451
+ this.emit({
3452
+ type: "run.completed",
3453
+ runId: run.id,
3454
+ workflow: run.workflow
3455
+ });
3456
+ void this.notifyParent(run.id, {
3457
+ ok: true,
3458
+ value: void 0
3459
+ });
3460
+ const nextId = nextContinuationId(run.id);
3461
+ queueMicrotask(() => void this.start(run.workflow, err.input, nextId).catch(() => void 0));
3462
+ return {
3463
+ runId: run.id,
3464
+ status: "completed"
3465
+ };
3466
+ }
3467
+ if (err instanceof WorkflowSuspended) {
3468
+ if (this.cancelRequested.has(run.id)) return this.compensateAndCancel(run, compensations);
3469
+ return this.settleRun(run, {
3470
+ kind: "suspended",
3471
+ wakeAt: err.wakeAt
3472
+ });
3473
+ }
3474
+ if (this.cancelRequested.has(run.id)) return this.compensateAndCancel(run, compensations);
3475
+ const error = {
3476
+ message: err instanceof Error ? err.message : String(err),
3477
+ stack: err instanceof Error ? err.stack : void 0
3478
+ };
3479
+ for (let i = compensations.length - 1; i >= 0; i -= 1) {
3480
+ const comp = compensations[i];
3481
+ if (!comp) continue;
3482
+ await this.runCompensation(run, comp);
3483
+ }
3484
+ return this.settleRun(run, {
3485
+ kind: "failed",
3486
+ error
3487
+ });
3488
+ } finally {
3489
+ await this.store.releaseRunLock(run.id);
3490
+ }
3491
+ }
3492
+ /**
3493
+ * Run one saga compensation, retried up to `compensationRetries`, emitting a `compensate:<step>`
3494
+ * step event for its outcome so a stranded undo is visible. Never throws — a permanently-failing
3495
+ * compensation is skipped so it can't mask the original failure.
3496
+ */
3497
+ async runCompensation(run, comp) {
3498
+ const name = `compensate:${comp.name}`;
3499
+ for (let attempt = 1; attempt <= this.compensationRetries; attempt += 1) {
3500
+ const startedAt = Date.now();
3501
+ try {
3502
+ await comp.fn();
3503
+ this.emit({
3504
+ type: "step.completed",
3505
+ runId: run.id,
3506
+ workflow: run.workflow,
3507
+ name,
3508
+ kind: "local",
3509
+ durationMs: Date.now() - startedAt
3510
+ });
3511
+ return;
3512
+ } catch (err) {
3513
+ if (attempt >= this.compensationRetries) {
3514
+ this.emit({
3515
+ type: "step.failed",
3516
+ runId: run.id,
3517
+ workflow: run.workflow,
3518
+ name,
3519
+ kind: "local",
3520
+ error: {
3521
+ message: err instanceof Error ? err.message : String(err)
3522
+ },
3523
+ durationMs: Date.now() - startedAt
3524
+ });
3525
+ }
3526
+ }
3527
+ }
3528
+ }
3529
+ /** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
3530
+ * (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
3531
+ * module and the engine stays the orchestrator. */
3532
+ ctxHostFor(replay) {
3533
+ return {
3534
+ store: this.store,
3535
+ replay,
3536
+ clock: this.clock,
3537
+ webhookUrl: this.webhookUrl,
3538
+ startStep: /* @__PURE__ */ __name((s) => this.startStep(s), "startStep"),
3539
+ completeStep: /* @__PURE__ */ __name((s) => this.completeStep(s), "completeStep"),
3540
+ failStep: /* @__PURE__ */ __name((s) => this.failStep(s), "failStep"),
3541
+ callRemote: /* @__PURE__ */ __name((runId, seq, step, input, queue, transport, admission) => this.callRemote(runId, seq, step, input, queue, transport, replay, admission), "callRemote"),
3542
+ // Defer so a fast child can't reentrantly resume a still-running parent.
3543
+ startChild: /* @__PURE__ */ __name((workflow, input, id, priority) => {
3544
+ queueMicrotask(() => void this.start(workflow, input, id, {
3545
+ priority
3546
+ }).catch(() => void 0));
3547
+ }, "startChild"),
3548
+ // Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
3549
+ upsertSearchAttributes: /* @__PURE__ */ __name(async (runId, attrs) => {
3550
+ const run = await this.store.getRun(runId);
3551
+ await this.store.updateRun(runId, {
3552
+ searchAttributes: {
3553
+ ...run?.searchAttributes ?? {},
3554
+ ...attrs
3555
+ },
3556
+ updatedAt: /* @__PURE__ */ new Date()
3557
+ });
3558
+ }, "upsertSearchAttributes"),
3559
+ signalEntity: /* @__PURE__ */ __name((name, key, op, arg, reply) => {
3560
+ queueMicrotask(() => void this.entities.dispatch(name, key, op, arg, reply).catch(() => void 0));
3561
+ }, "signalEntity"),
3562
+ interceptStep: /* @__PURE__ */ __name((invocation, body) => this.interceptStep(invocation, body), "interceptStep")
3563
+ };
3564
+ }
3565
+ /**
3566
+ * Resume a run paused at a {@link WorkflowCtx.breakpoint} (e.g. the dashboard "continue" button).
3567
+ * Finds the run's pending breakpoint checkpoint and signals it. Returns null if the run isn't
3568
+ * paused at a breakpoint.
3569
+ */
3570
+ async continue(runId) {
3571
+ const checkpoints = await this.store.listCheckpoints(runId);
3572
+ const bp = checkpoints.find(isBreakpoint);
3573
+ if (!bp) return null;
3574
+ return this.signal(breakpointToken(runId, bp.seq), void 0);
3575
+ }
3576
+ /**
3577
+ * Read the latest value a run published for `key` via {@link WorkflowCtx.setEvent} — a
3578
+ * side-effect-free query of a live (or finished) run's state. Returns `undefined` if the run
3579
+ * never published that key. The suspend-model counterpart of a Temporal query.
3580
+ */
3581
+ async getEvent(runId, key) {
3582
+ const name = `event:${key}`;
3583
+ if (this.store.getLatestCheckpointByName) {
3584
+ const latest2 = await this.store.getLatestCheckpointByName(runId, name);
3585
+ return latest2?.output;
3586
+ }
3587
+ let latest;
3588
+ for (const cp of await this.store.listCheckpoints(runId)) if (cp.name === name) latest = cp.output;
3589
+ return latest;
3590
+ }
3591
+ /**
3592
+ * Register a validator gating `engine.update(runId, name, …)` for runs of `workflow`. The
3593
+ * validator runs BEFORE the update is delivered, so a rejection leaves the run untouched. One
3594
+ * validator per (workflow, update name); registering again replaces it.
3595
+ */
3596
+ registerUpdateValidator(workflow, name, validate) {
3597
+ this.updateValidators.set(`${workflow}:${name}`, validate);
3598
+ }
3599
+ /**
3600
+ * Deliver a validated update to the run waiting at `ctx.onUpdate(name)`. Runs the registered
3601
+ * validator (if any) first: on rejection returns `{ accepted: false, reason }` without disturbing
3602
+ * the run; otherwise delivers `arg` and resumes, returning `{ accepted: true, run }` (`run` is null
3603
+ * if nothing was waiting — a too-early or duplicate update).
3604
+ */
3605
+ async update(runId, name, arg) {
3606
+ const run = await this.store.getRun(runId);
3607
+ if (!run) return {
3608
+ accepted: false,
3609
+ reason: `run ${runId} not found`
3610
+ };
3611
+ const validate = this.updateValidators.get(`${run.workflow}:${name}`);
3612
+ if (validate) {
3613
+ try {
3614
+ const reason = await validate(arg);
3615
+ if (typeof reason === "string" && reason.length > 0) return {
3616
+ accepted: false,
3617
+ reason
3618
+ };
3619
+ } catch (err) {
3620
+ return {
3621
+ accepted: false,
3622
+ reason: err instanceof Error ? err.message : String(err)
3623
+ };
3624
+ }
3625
+ }
3626
+ const result = await this.signal(`update:${runId}:${name}`, arg);
3627
+ return {
3628
+ accepted: true,
3629
+ run: result
3630
+ };
3631
+ }
3632
+ async callRemote(runId, seq, step, input, queue, transport, replay, admission) {
3633
+ const existing = replay?.get(seq) ?? await this.store.getCheckpoint(runId, seq);
3634
+ if (existing && existing.name !== step.name) {
3635
+ throw new NonDeterminismError(runId, seq, step.name, existing.name);
3636
+ }
3637
+ if (existing?.status === "completed") return existing.output;
3638
+ if (this.pool.size === 0) throw new Error("remote steps require a Transport");
3639
+ if (step.timeoutMs) return this.callRemoteInMemory(runId, seq, step, input, transport);
3640
+ if (existing?.status === "pending") throw new WorkflowSuspended();
3641
+ let attempt = 1;
3642
+ if (existing?.status === "failed") {
3643
+ const maxAttempts = Math.max(1, step.retries ?? 1);
3644
+ const retryable = existing.error?.retryable !== false;
3645
+ if (!retryable || existing.attempts >= maxAttempts) throw new RemoteStepError(existing.error);
3646
+ if (existing.wakeAt == null) {
3647
+ const wakeAt = this.clock() + backoffDelay(existing.attempts, step);
3648
+ await this.store.saveCheckpoint({
3649
+ ...existing,
3650
+ wakeAt
3651
+ });
3652
+ throw new WorkflowSuspended(wakeAt);
3653
+ }
3654
+ if (this.clock() < existing.wakeAt) throw new WorkflowSuspended(existing.wakeAt);
3655
+ attempt = existing.attempts + 1;
3656
+ }
3657
+ const id = stepId(runId, seq);
3658
+ if (queue && this.admission.handles(queue)) {
3659
+ const decision = await this.admission.tryAdmit(queue, {
3660
+ priority: admission?.priority,
3661
+ key: admission?.fairnessKey ?? runId,
3662
+ waiterId: id
3663
+ });
3664
+ if (!decision.ok) {
3665
+ const waiters = this.queueWaiters.get(queue) ?? /* @__PURE__ */ new Set();
3666
+ waiters.add(runId);
3667
+ this.queueWaiters.set(queue, waiters);
3668
+ throw new WorkflowSuspended(decision.retryAt);
3669
+ }
3670
+ this.queueWaiters.get(queue)?.delete(runId);
3671
+ this.stepQueue.set(id, queue);
3672
+ }
3673
+ const validInput = step.input.parse(input);
3674
+ const enqueuedAt = /* @__PURE__ */ new Date();
3675
+ await this.store.saveCheckpoint({
3676
+ runId,
3677
+ seq,
3678
+ name: step.name,
3679
+ kind: "remote",
3680
+ stepId: id,
3681
+ status: "pending",
3682
+ input: validInput,
3683
+ attempts: attempt,
3684
+ workerGroup: step.group,
3685
+ enqueuedAt,
3686
+ startedAt: enqueuedAt,
3687
+ finishedAt: enqueuedAt
3688
+ });
3689
+ await this.pool.dispatch({
3690
+ runId,
3691
+ seq,
3692
+ name: step.name,
3693
+ stepId: id,
3694
+ group: step.group,
3695
+ input: validInput,
3696
+ traceparent: this.traceparent?.(),
3697
+ context: this.context?.(),
3698
+ priority: admission?.priority,
3699
+ attempt
3700
+ }, transport);
3701
+ this.emit({
3702
+ type: "step.started",
3703
+ runId,
3704
+ seq,
3705
+ name: step.name,
3706
+ kind: "remote"
3707
+ });
3708
+ throw new WorkflowSuspended();
3709
+ }
3710
+ /**
3711
+ * Complete a durable remote step from its worker result and resume the run — runs on whichever
3712
+ * instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
3713
+ * A no-op if the checkpoint isn't `pending` (a duplicate or late delivery).
3714
+ */
3715
+ async completeRemoteResult(result) {
3716
+ const cp = await this.store.getCheckpoint(result.runId, result.seq);
3717
+ if (!cp || cp.status !== "pending") return;
3718
+ void this.releaseQueueSlot(cp.stepId).catch(() => void 0);
3719
+ const run = await this.store.getRun(result.runId);
3720
+ if (run && (run.status === "cancelled" || run.status === "completed")) return;
3721
+ const finishedAt = /* @__PURE__ */ new Date();
3722
+ const startedAt = result.startedAt ? new Date(result.startedAt) : cp.startedAt;
3723
+ await this.store.saveCheckpoint({
3724
+ ...cp,
3725
+ status: result.status,
3726
+ output: result.status === "completed" ? result.output : cp.output,
3727
+ error: result.error,
3728
+ events: result.events ?? cp.events,
3729
+ startedAt,
3730
+ finishedAt
3731
+ });
3732
+ this.emit({
3733
+ type: result.status === "completed" ? "step.completed" : "step.failed",
3734
+ runId: result.runId,
3735
+ seq: result.seq,
3736
+ name: cp.name,
3737
+ kind: cp.kind,
3738
+ output: result.output,
3739
+ error: result.error,
3740
+ queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
3741
+ durationMs: finishedAt.getTime() - startedAt.getTime()
3742
+ });
3743
+ await this.resume(result.runId).catch((err) => {
3744
+ if (err instanceof NamespaceMismatch) return null;
3745
+ throw err;
3746
+ });
3747
+ }
3748
+ /**
3749
+ * Apply a remote workflow-turn {@link WorkflowDecision} durably — runs on WHATEVER instance receives
3750
+ * it over the broker (the dispatching one may be gone, or a point-to-point broker handed it to a
3751
+ * non-dispatcher). The durable, multi-instance-safe twin of {@link completeRemoteResult} for the
3752
+ * workflow TURN: look the run up by `decision.runId`, verify the decision matches the run's
3753
+ * CURRENTLY-awaited turn (`awaitingDecisionTaskId`) — so a stale / duplicate / foreign / re-driven
3754
+ * decision is dropped (applied AT MOST ONCE) — lease it, clear the marker, and apply the decision.
3755
+ */
3756
+ async completeRemoteDecision(decision) {
3757
+ const run = await this.store.getRun(decision.runId);
3758
+ if (!run || run.awaitingDecisionTaskId !== decision.taskId) return;
3759
+ if (run.status === "cancelled" || run.status === "completed" || run.status === "dead") return;
3760
+ if (this.namespace !== void 0 && run.namespace !== void 0 && run.namespace !== this.namespace) {
3761
+ return;
3762
+ }
3763
+ const nowMs = this.clock();
3764
+ if (!await this.store.tryLockRun(decision.runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
3765
+ return;
3766
+ }
3767
+ const renew = setInterval(() => {
3768
+ void this.store.renewRunLock(decision.runId, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
3769
+ }, Math.max(50, Math.floor(this.leaseMs / 2)));
3770
+ renew.unref?.();
3771
+ try {
3772
+ const fresh = await this.store.getRun(decision.runId);
3773
+ if (!fresh || fresh.awaitingDecisionTaskId !== decision.taskId) return;
3774
+ if (fresh.status === "cancelled" || fresh.status === "completed" || fresh.status === "dead") {
3775
+ return;
3776
+ }
3777
+ await this.store.updateRun(decision.runId, {
3778
+ awaitingDecisionTaskId: void 0,
3779
+ wakeAt: void 0,
3780
+ updatedAt: /* @__PURE__ */ new Date()
3781
+ });
3782
+ fresh.awaitingDecisionTaskId = void 0;
3783
+ await this.applyDecision(fresh, decision);
3784
+ } finally {
3785
+ clearInterval(renew);
3786
+ await this.store.releaseRunLock(decision.runId);
3787
+ }
3788
+ }
3789
+ /**
3790
+ * A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
3791
+ * push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
3792
+ * re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
3793
+ */
3794
+ async rearmDecisionDeadline(runId) {
3795
+ const silenceMs = this.remoteAdvanceSilenceMs;
3796
+ if (silenceMs == null) return;
3797
+ const run = await this.store.getRun(runId);
3798
+ if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) return;
3799
+ await this.store.updateRun(runId, {
3800
+ wakeAt: this.clock() + silenceMs,
3801
+ updatedAt: /* @__PURE__ */ new Date()
3802
+ });
3803
+ }
3804
+ /**
3805
+ * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
3806
+ * re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
3807
+ * retry re-registers itself, and one that's gone (cancelled/admitted) is dropped. Best-effort.
3808
+ */
3809
+ wakeQueueWaiters(queue) {
3810
+ const waiters = this.queueWaiters.get(queue);
3811
+ if (!waiters || waiters.size === 0) return;
3812
+ const runIds = [
3813
+ ...waiters
3814
+ ];
3815
+ waiters.clear();
3816
+ for (const runId of runIds) void this.resume(runId).catch(() => void 0);
3817
+ }
3818
+ /** Release the flow-control slot a dispatched step held (if any), by its stepId. */
3819
+ async releaseQueueSlot(id) {
3820
+ const queue = this.stepQueue.get(id);
3821
+ if (queue === void 0) return;
3822
+ this.stepQueue.delete(id);
3823
+ await this.admission.release(queue, id);
3824
+ }
3825
+ /** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
3826
+ async callRemoteInMemory(runId, seq, step, input, transport) {
3827
+ if (this.pool.size === 0) throw new Error("remote steps require a Transport");
3828
+ const validInput = step.input.parse(input);
3829
+ const id = stepId(runId, seq);
3830
+ const enqueuedAt = /* @__PURE__ */ new Date();
3831
+ this.emit({
3832
+ type: "step.started",
3833
+ runId,
3834
+ seq,
3835
+ name: step.name,
3836
+ kind: "remote"
3837
+ });
3838
+ const maxAttempts = step.timeoutMs ? Math.max(1, step.retries ?? 1) : 1;
3839
+ for (let attempt = 1; ; attempt += 1) {
3840
+ const resultPromise = new Promise((resolve, reject) => {
3841
+ this.pending.set(id, {
3842
+ resolve,
3843
+ reject
3844
+ });
3845
+ });
3846
+ await this.pool.dispatch({
3847
+ runId,
3848
+ seq,
3849
+ name: step.name,
3850
+ stepId: id,
3851
+ group: step.group,
3852
+ input: validInput,
3853
+ traceparent: this.traceparent?.(),
3854
+ context: this.context?.(),
3855
+ attempt
3856
+ }, transport);
3857
+ try {
3858
+ const resolution = step.timeoutMs ? await this.awaitWithHeartbeat(id, resultPromise, step.timeoutMs) : await resultPromise;
3859
+ const output = step.output.parse(resolution.output);
3860
+ const startedAt = resolution.startedAt ? new Date(resolution.startedAt) : enqueuedAt;
3861
+ await this.completeStep({
3862
+ runId,
3863
+ seq,
3864
+ name: step.name,
3865
+ kind: "remote",
3866
+ input: validInput,
3867
+ output,
3868
+ events: resolution.events,
3869
+ attempts: attempt,
3870
+ workerGroup: step.group,
3871
+ enqueuedAt,
3872
+ startedAt
3873
+ });
3874
+ return output;
3875
+ } catch (err) {
3876
+ this.pending.delete(id);
3877
+ if (err instanceof RemoteStepTimeout && attempt < maxAttempts) continue;
3878
+ throw err;
3879
+ }
3880
+ }
3881
+ }
3882
+ /**
3883
+ * Await `resultPromise`, but reject with `onTimeout()` if neither it settles nor a heartbeat for `id`
3884
+ * arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`, looked up in
3885
+ * `heartbeatResets` by `id`) REARMS the window, so a worker that keeps beating stays alive past
3886
+ * `timeoutMs`. Shared by the remote-STEP liveness (keyed by stepId) and the remote-WORKFLOW advance
3887
+ * liveness (keyed by runId); the caller supplies the timeout error and any extra cleanup via `onTimeout`.
3888
+ */
3889
+ awaitWithLivenessDeadline(id, resultPromise, timeoutMs, onTimeout) {
3890
+ return new Promise((resolve, reject) => {
3891
+ let timer;
3892
+ const cleanup = /* @__PURE__ */ __name(() => {
3893
+ clearTimeout(timer);
3894
+ this.heartbeatResets.delete(id);
3895
+ }, "cleanup");
3896
+ const arm = /* @__PURE__ */ __name(() => {
3897
+ timer = setTimeout(() => {
3898
+ cleanup();
3899
+ reject(onTimeout());
3900
+ }, timeoutMs);
3901
+ timer.unref?.();
3902
+ }, "arm");
3903
+ this.heartbeatResets.set(id, () => {
3904
+ clearTimeout(timer);
3905
+ arm();
3906
+ });
3907
+ arm();
3908
+ resultPromise.then((value) => {
3909
+ cleanup();
3910
+ resolve(value);
3911
+ }, (err) => {
3912
+ cleanup();
3913
+ reject(err);
3914
+ });
3915
+ });
3916
+ }
3917
+ /** Remote-STEP liveness: await a worker result, timing out (with the step's pending-map cleanup) to
3918
+ * `RemoteStepTimeout` if it neither lands nor beats within `timeoutMs`. See {@link awaitWithLivenessDeadline}. */
3919
+ awaitWithHeartbeat(id, resultPromise, timeoutMs) {
3920
+ return this.awaitWithLivenessDeadline(id, resultPromise, timeoutMs, () => {
3921
+ this.pending.delete(id);
3922
+ return new RemoteStepTimeout(id, timeoutMs);
3923
+ });
3924
+ }
3925
+ };
3926
+ var RemoteStepError = class extends Error {
3927
+ static {
3928
+ __name(this, "RemoteStepError");
3929
+ }
3930
+ stepError;
3931
+ constructor(stepError) {
3932
+ super(stepError?.message ?? "remote step failed");
3933
+ this.name = "RemoteStepError";
3934
+ this.stepError = stepError;
3935
+ }
3936
+ };
3937
+
3938
+ // src/interfaces.ts
3939
+ var TERMINAL_RUN_STATUSES = [
3940
+ "completed",
3941
+ "failed",
3942
+ "cancelled",
3943
+ "dead"
3944
+ ];
3945
+
3946
+ // src/remote-step-factory.ts
3947
+ function remoteStep(config) {
3948
+ return {
3949
+ ...config,
3950
+ group: config.group ?? config.name.split(".")[0] ?? config.name,
3951
+ __remote: true
3952
+ };
3953
+ }
3954
+ __name(remoteStep, "remoteStep");
3955
+
3956
+ // src/codec-state-store.ts
3957
+ var CodecStateStore = class {
3958
+ static {
3959
+ __name(this, "CodecStateStore");
3960
+ }
3961
+ inner;
3962
+ codec;
3963
+ constructor(inner, codec) {
3964
+ this.inner = inner;
3965
+ this.codec = codec;
3966
+ }
3967
+ enc(v) {
3968
+ return v === void 0 ? void 0 : this.codec.encode(v);
3969
+ }
3970
+ dec(v) {
3971
+ return v === void 0 ? void 0 : this.codec.decode(v);
3972
+ }
3973
+ encRun(run) {
3974
+ return {
3975
+ ...run,
3976
+ input: this.enc(run.input),
3977
+ output: this.enc(run.output)
3978
+ };
3979
+ }
3980
+ decRun(run) {
3981
+ return run && {
3982
+ ...run,
3983
+ input: this.dec(run.input),
3984
+ output: this.dec(run.output)
3985
+ };
3986
+ }
3987
+ encCp(cp) {
3988
+ return {
3989
+ ...cp,
3990
+ input: this.enc(cp.input),
3991
+ output: this.enc(cp.output)
3992
+ };
3993
+ }
3994
+ decCp(cp) {
3995
+ return cp && {
3996
+ ...cp,
3997
+ input: this.dec(cp.input),
3998
+ output: this.dec(cp.output)
3999
+ };
4000
+ }
4001
+ ensureSchema() {
4002
+ return this.inner.ensureSchema?.() ?? Promise.resolve();
4003
+ }
4004
+ createRun(run) {
4005
+ return this.inner.createRun(this.encRun(run));
4006
+ }
4007
+ updateRun(runId, patch) {
4008
+ const next = {
4009
+ ...patch
4010
+ };
4011
+ if ("input" in patch) next.input = this.enc(patch.input);
4012
+ if ("output" in patch) next.output = this.enc(patch.output);
4013
+ return this.inner.updateRun(runId, next);
4014
+ }
4015
+ async getRun(runId) {
4016
+ return this.decRun(await this.inner.getRun(runId));
4017
+ }
4018
+ deleteRun(runId) {
4019
+ return this.inner.deleteRun(runId);
4020
+ }
4021
+ async getCheckpoint(runId, seq) {
4022
+ return this.decCp(await this.inner.getCheckpoint(runId, seq));
4023
+ }
4024
+ saveCheckpoint(checkpoint) {
4025
+ return this.inner.saveCheckpoint(this.encCp(checkpoint));
4026
+ }
4027
+ transaction(work) {
4028
+ if (!this.inner.transaction) {
4029
+ throw new Error("the wrapped store does not support transactions");
4030
+ }
4031
+ return this.inner.transaction((tx) => work({
4032
+ raw: tx.raw,
4033
+ saveCheckpoint: /* @__PURE__ */ __name((cp) => tx.saveCheckpoint(this.encCp(cp)), "saveCheckpoint")
4034
+ }));
4035
+ }
4036
+ async listIncompleteRuns(namespace) {
4037
+ return (await this.inner.listIncompleteRuns(namespace)).map((r) => this.decRun(r));
4038
+ }
4039
+ async listPendingRuns(limit, namespace) {
4040
+ return (await this.inner.listPendingRuns(limit, namespace)).map((r) => this.decRun(r));
4041
+ }
4042
+ async listDueTimers(nowMs, namespace) {
4043
+ return (await this.inner.listDueTimers(nowMs, namespace)).map((r) => this.decRun(r));
4044
+ }
4045
+ tryLockRun(runId, owner, leaseUntilMs, nowMs) {
4046
+ return this.inner.tryLockRun(runId, owner, leaseUntilMs, nowMs);
4047
+ }
4048
+ releaseRunLock(runId) {
4049
+ return this.inner.releaseRunLock(runId);
4050
+ }
4051
+ renewRunLock(runId, owner, leaseUntilMs) {
4052
+ return this.inner.renewRunLock(runId, owner, leaseUntilMs);
4053
+ }
4054
+ putSignalWaiter(waiter) {
4055
+ return this.inner.putSignalWaiter(waiter);
4056
+ }
4057
+ takeSignalWaiter(token) {
4058
+ return this.inner.takeSignalWaiter(token);
4059
+ }
4060
+ listSignalWaiters(prefix) {
4061
+ return this.inner.listSignalWaiters(prefix);
4062
+ }
4063
+ bufferSignal(token, payload) {
4064
+ return this.inner.bufferSignal(token, this.enc(payload));
4065
+ }
4066
+ async takeBufferedSignal(token) {
4067
+ const buffered = await this.inner.takeBufferedSignal(token);
4068
+ return buffered && {
4069
+ payload: this.dec(buffered.payload)
4070
+ };
4071
+ }
4072
+ async listRuns(query) {
4073
+ return (await this.inner.listRuns(query)).map((r) => this.decRun(r));
4074
+ }
4075
+ async listCheckpoints(runId) {
4076
+ return (await this.inner.listCheckpoints(runId)).map((c) => this.decCp(c));
4077
+ }
4078
+ async getLatestCheckpointByName(runId, name) {
4079
+ if (this.inner.getLatestCheckpointByName) {
4080
+ const cp = await this.inner.getLatestCheckpointByName(runId, name);
4081
+ return cp ? this.decCp(cp) : void 0;
4082
+ }
4083
+ let latest;
4084
+ for (const cp of await this.inner.listCheckpoints(runId)) if (cp.name === name) latest = cp;
4085
+ return latest ? this.decCp(latest) : void 0;
4086
+ }
4087
+ async listCheckpointsByNamePrefix(runId, prefixes) {
4088
+ const matches = this.inner.listCheckpointsByNamePrefix ? await this.inner.listCheckpointsByNamePrefix(runId, prefixes) : (await this.inner.listCheckpoints(runId)).filter((cp) => prefixes.some((p) => cp.name.startsWith(p)));
4089
+ return matches.map((c) => this.decCp(c));
4090
+ }
4091
+ };
4092
+
4093
+ // src/metrics.ts
4094
+ function collectMetrics(engine) {
4095
+ const m = {
4096
+ runs: {
4097
+ started: 0,
4098
+ completed: 0,
4099
+ failed: 0,
4100
+ suspended: 0
4101
+ },
4102
+ steps: {
4103
+ started: 0,
4104
+ completed: 0,
4105
+ failed: 0
4106
+ },
4107
+ byWorkflow: {},
4108
+ stepDuration: {
4109
+ sumMs: 0,
4110
+ count: 0
4111
+ }
4112
+ };
4113
+ const wf = /* @__PURE__ */ __name((name) => {
4114
+ const k = name ?? "unknown";
4115
+ let entry = m.byWorkflow[k];
4116
+ if (!entry) {
4117
+ entry = {
4118
+ started: 0,
4119
+ completed: 0,
4120
+ failed: 0
4121
+ };
4122
+ m.byWorkflow[k] = entry;
4123
+ }
4124
+ return entry;
4125
+ }, "wf");
4126
+ const stop = engine.subscribe((e) => {
4127
+ switch (e.type) {
4128
+ case "run.started":
4129
+ m.runs.started += 1;
4130
+ wf(e.workflow).started += 1;
4131
+ break;
4132
+ case "run.completed":
4133
+ m.runs.completed += 1;
4134
+ wf(e.workflow).completed += 1;
4135
+ break;
4136
+ case "run.failed":
4137
+ m.runs.failed += 1;
4138
+ wf(e.workflow).failed += 1;
4139
+ break;
4140
+ case "run.suspended":
4141
+ m.runs.suspended += 1;
4142
+ break;
4143
+ case "step.started":
4144
+ m.steps.started += 1;
4145
+ break;
4146
+ case "step.completed":
4147
+ m.steps.completed += 1;
4148
+ if (e.durationMs != null) {
4149
+ m.stepDuration.sumMs += e.durationMs;
4150
+ m.stepDuration.count += 1;
4151
+ }
4152
+ break;
4153
+ case "step.failed":
4154
+ m.steps.failed += 1;
4155
+ break;
4156
+ }
4157
+ });
4158
+ return {
4159
+ snapshot: /* @__PURE__ */ __name(() => structuredClone(m), "snapshot"),
4160
+ stop,
4161
+ prometheus: /* @__PURE__ */ __name(() => {
4162
+ const lines = [];
4163
+ lines.push("# TYPE durable_runs_total counter");
4164
+ for (const [k, v] of Object.entries(m.runs)) lines.push(`durable_runs_total{event="${k}"} ${v}`);
4165
+ lines.push("# TYPE durable_steps_total counter");
4166
+ for (const [k, v] of Object.entries(m.steps)) lines.push(`durable_steps_total{event="${k}"} ${v}`);
4167
+ lines.push("# TYPE durable_runs_by_workflow_total counter");
4168
+ for (const [name, v] of Object.entries(m.byWorkflow)) for (const [event, n] of Object.entries(v)) lines.push(`durable_runs_by_workflow_total{workflow="${name}",event="${event}"} ${n}`);
4169
+ lines.push("# TYPE durable_step_duration_ms_sum counter");
4170
+ lines.push(`durable_step_duration_ms_sum ${m.stepDuration.sumMs}`);
4171
+ lines.push("# TYPE durable_step_duration_count counter");
4172
+ lines.push(`durable_step_duration_count ${m.stepDuration.count}`);
4173
+ return `${lines.join("\n")}
4174
+ `;
4175
+ }, "prometheus")
4176
+ };
4177
+ }
4178
+ __name(collectMetrics, "collectMetrics");
4179
+
4180
+ // src/scheduler.ts
4181
+ var import_node_module = require("module");
4182
+ var nodeRequire = (0, import_node_module.createRequire)(__importMetaUrl);
4183
+ var defaultSleep = /* @__PURE__ */ __name((ms) => new Promise((r) => setTimeout(r, ms)), "defaultSleep");
4184
+ function scheduledRunId(key, everyMs, nowMs) {
4185
+ return `sched:${key}:${Math.floor(nowMs / everyMs)}`;
4186
+ }
4187
+ __name(scheduledRunId, "scheduledRunId");
4188
+ var cronParser;
4189
+ function loadCronParser() {
4190
+ if (cronParser) return cronParser;
4191
+ try {
4192
+ cronParser = nodeRequire("cron-parser");
4193
+ } catch {
4194
+ throw new Error('cron schedules need the optional peer dependency "cron-parser" \u2014 install it (e.g. `npm i cron-parser`).');
4195
+ }
4196
+ return cronParser;
4197
+ }
4198
+ __name(loadCronParser, "loadCronParser");
4199
+ function prevCronFireMs(expr, nowMs, timezone = "UTC") {
4200
+ const parser = loadCronParser();
4201
+ const it = parser.parseExpression(expr, {
4202
+ currentDate: new Date(nowMs + 1),
4203
+ tz: timezone
4204
+ });
4205
+ return it.prev().toDate().getTime();
4206
+ }
4207
+ __name(prevCronFireMs, "prevCronFireMs");
4208
+ function scheduleRunIdAt(s, nowMs) {
4209
+ if (s.cron != null) return `sched:${s.key}:${prevCronFireMs(s.cron, nowMs, s.timezone)}`;
4210
+ if (s.everyMs != null) return scheduledRunId(s.key, s.everyMs, nowMs);
4211
+ throw new Error(`schedule "${s.key}" needs either "everyMs" or "cron"`);
4212
+ }
4213
+ __name(scheduleRunIdAt, "scheduleRunIdAt");
4214
+ function scheduleRunIdsAt(s, nowMs) {
4215
+ const current = scheduleRunIdAt(s, nowMs);
4216
+ if (!s.backfill || s.backfill.maxCatchup <= 0) return [
4217
+ current
4218
+ ];
4219
+ const ids = [];
4220
+ if (s.everyMs != null) {
4221
+ const bucket = Math.floor(nowMs / s.everyMs);
4222
+ const oldest = Math.max(0, bucket - s.backfill.maxCatchup);
4223
+ for (let b = oldest; b < bucket; b += 1) ids.push(`sched:${s.key}:${b}`);
4224
+ } else if (s.cron != null) {
4225
+ const prior = [];
4226
+ let cursor = prevCronFireMs(s.cron, nowMs, s.timezone);
4227
+ for (let i = 0; i < s.backfill.maxCatchup; i += 1) {
4228
+ const before = prevCronFireMs(s.cron, cursor - 1, s.timezone);
4229
+ prior.push(`sched:${s.key}:${before}`);
4230
+ cursor = before;
4231
+ }
4232
+ ids.push(...prior.reverse());
4233
+ }
4234
+ ids.push(current);
4235
+ return ids;
4236
+ }
4237
+ __name(scheduleRunIdsAt, "scheduleRunIdsAt");
4238
+ async function runSchedules(engine, schedules, nowMs, opts) {
4239
+ const random = opts?.random ?? Math.random;
4240
+ const sleep = opts?.sleep ?? defaultSleep;
4241
+ const ids = [];
4242
+ for (const s of schedules) {
4243
+ if (s.paused) continue;
4244
+ if (s.overlap === "skip" && s.everyMs) {
4245
+ const prevBucket = Math.floor(nowMs / s.everyMs) - 1;
4246
+ if (prevBucket >= 0) {
4247
+ const prev = await engine.getRun(`sched:${s.key}:${prevBucket}`);
4248
+ if (prev && (prev.status === "running" || prev.status === "suspended")) continue;
4249
+ }
4250
+ }
4251
+ if (s.jitter && s.jitter > 0) await sleep(Math.floor(random() * s.jitter));
4252
+ for (const runId of scheduleRunIdsAt(s, nowMs)) {
4253
+ await engine.start(s.workflow, s.input, runId);
4254
+ ids.push(runId);
4255
+ }
4256
+ }
4257
+ return ids;
4258
+ }
4259
+ __name(runSchedules, "runSchedules");
4260
+
4261
+ // src/search-attributes.ts
4262
+ function compare(actual, op, expected) {
4263
+ switch (op) {
4264
+ case "eq":
4265
+ return actual === expected;
4266
+ case "ne":
4267
+ return actual !== expected;
4268
+ case "gt":
4269
+ return actual != null && actual > expected;
4270
+ case "gte":
4271
+ return actual != null && actual >= expected;
4272
+ case "lt":
4273
+ return actual != null && actual < expected;
4274
+ case "lte":
4275
+ return actual != null && actual <= expected;
4276
+ }
4277
+ }
4278
+ __name(compare, "compare");
4279
+ function matchesAttributes(attributes, filters) {
4280
+ if (!filters?.length) return true;
4281
+ if (!attributes) return false;
4282
+ return filters.every((f) => f.key in attributes && compare(attributes[f.key], f.op, f.value));
4283
+ }
4284
+ __name(matchesAttributes, "matchesAttributes");
4285
+ function applyAttributeQuery(rows, query) {
4286
+ const filtered = rows.filter((r) => matchesAttributes(r.searchAttributes, query.attributes));
4287
+ const offset = query.offset ?? 0;
4288
+ const limit = query.limit ?? filtered.length;
4289
+ return filtered.slice(offset, offset + limit);
4290
+ }
4291
+ __name(applyAttributeQuery, "applyAttributeQuery");
4292
+ function normalizeAttributeRows(runId, attributes) {
4293
+ if (!attributes) return [];
4294
+ const out = [];
4295
+ for (const [key, value] of Object.entries(attributes)) {
4296
+ if (typeof value === "number") {
4297
+ out.push({
4298
+ runId,
4299
+ key,
4300
+ strValue: null,
4301
+ numValue: value
4302
+ });
4303
+ } else if (typeof value === "boolean") {
4304
+ out.push({
4305
+ runId,
4306
+ key,
4307
+ strValue: value ? "true" : "false",
4308
+ numValue: null
4309
+ });
4310
+ } else {
4311
+ out.push({
4312
+ runId,
4313
+ key,
4314
+ strValue: value,
4315
+ numValue: null
4316
+ });
4317
+ }
4318
+ }
4319
+ return out;
4320
+ }
4321
+ __name(normalizeAttributeRows, "normalizeAttributeRows");
4322
+ function sqlComparator(op) {
4323
+ switch (op) {
4324
+ case "eq":
4325
+ return "=";
4326
+ case "ne":
4327
+ return "<>";
4328
+ case "gt":
4329
+ return ">";
4330
+ case "gte":
4331
+ return ">=";
4332
+ case "lt":
4333
+ return "<";
4334
+ case "lte":
4335
+ return "<=";
4336
+ }
4337
+ }
4338
+ __name(sqlComparator, "sqlComparator");
4339
+ function attributeColumnFor(filter) {
4340
+ return typeof filter.value === "number" ? "numValue" : "strValue";
4341
+ }
4342
+ __name(attributeColumnFor, "attributeColumnFor");
4343
+ function attributeOperand(filter) {
4344
+ if (typeof filter.value === "boolean") return filter.value ? "true" : "false";
4345
+ return filter.value;
4346
+ }
4347
+ __name(attributeOperand, "attributeOperand");
4348
+
4349
+ // src/tokens.ts
4350
+ var STATE_STORE = Symbol.for("nestjs-durable:STATE_STORE");
4351
+ var TRANSPORT = Symbol.for("nestjs-durable:TRANSPORT");
4352
+ var DURABLE_OPTIONS = Symbol.for("nestjs-durable:DURABLE_OPTIONS");
4353
+ var STATE_STORE_CANONICAL = Symbol.for("@dudousxd/nestjs-durable:state-store");
4354
+ var TRANSPORT_CANONICAL = Symbol.for("@dudousxd/nestjs-durable:transport");
4355
+ var DURABLE_OPTIONS_CANONICAL = Symbol.for("@dudousxd/nestjs-durable:options");
4356
+
4357
+ // src/testing/in-memory-state-store.ts
4358
+ var InMemoryStateStore = class {
4359
+ static {
4360
+ __name(this, "InMemoryStateStore");
4361
+ }
4362
+ runs = /* @__PURE__ */ new Map();
4363
+ checkpoints = /* @__PURE__ */ new Map();
4364
+ signalWaiters = /* @__PURE__ */ new Map();
4365
+ /**
4366
+ * Normalized search-attribute side-table, the in-memory analog of the SQL stores'
4367
+ * `durable_run_attributes`: `key -> (runId -> row)`. Maintained on every create/update so an
4368
+ * attribute query can be pushed DOWN to a key-indexed candidate set instead of scanning every run
4369
+ * and comparing in-process. Mirrors {@link normalizeAttributeRows} so behavior matches the SQL path.
4370
+ */
4371
+ attributeIndex = /* @__PURE__ */ new Map();
4372
+ /** Test/observability hook: how many candidate runs the last attribute query's index produced. */
4373
+ lastAttributeCandidates = 0;
4374
+ /**
4375
+ * Test/observability hook: how many checkpoint rows the LAST checkpoint read materialized (the rows
4376
+ * actually returned to the caller). `listCheckpoints` returns all rows for the run; the targeted
4377
+ * {@link getLatestCheckpointByName} / {@link listCheckpointsByNamePrefix} return only the matches —
4378
+ * so this lets the benchmark contrast "fetch N, scan in JS" against "fetch the k that match".
4379
+ */
4380
+ lastCheckpointRowsRead = 0;
4381
+ key(runId, seq) {
4382
+ return `${runId}:${seq}`;
4383
+ }
4384
+ /** Drop then re-add a run's normalized attribute rows so the index tracks its current attributes. */
4385
+ reindexAttributes(runId, attributes) {
4386
+ for (const byRun of this.attributeIndex.values()) byRun.delete(runId);
4387
+ for (const row of normalizeAttributeRows(runId, attributes)) {
4388
+ let byRun = this.attributeIndex.get(row.key);
4389
+ if (!byRun) {
4390
+ byRun = /* @__PURE__ */ new Map();
4391
+ this.attributeIndex.set(row.key, byRun);
4392
+ }
4393
+ byRun.set(runId, row);
4394
+ }
4395
+ }
4396
+ async createRun(run) {
4397
+ this.runs.set(run.id, {
4398
+ ...run,
4399
+ namespace: run.namespace ?? "default"
4400
+ });
4401
+ this.reindexAttributes(run.id, run.searchAttributes);
4402
+ }
4403
+ async updateRun(runId, patch) {
4404
+ const existing = this.runs.get(runId);
4405
+ if (!existing) throw new Error(`run ${runId} not found`);
4406
+ const next = {
4407
+ ...existing,
4408
+ ...patch
4409
+ };
4410
+ this.runs.set(runId, next);
4411
+ if ("searchAttributes" in patch) this.reindexAttributes(runId, next.searchAttributes);
4412
+ }
4413
+ async getRun(runId) {
4414
+ const run = this.runs.get(runId);
4415
+ return run ? {
4416
+ ...run
4417
+ } : null;
4418
+ }
4419
+ async deleteRun(runId) {
4420
+ this.runs.delete(runId);
4421
+ for (const [key, cp] of this.checkpoints) {
4422
+ if (cp.runId === runId) this.checkpoints.delete(key);
4423
+ }
4424
+ for (const [token, waiter] of this.signalWaiters) {
4425
+ if (waiter.runId === runId) this.signalWaiters.delete(token);
4426
+ }
4427
+ this.reindexAttributes(runId, void 0);
4428
+ }
4429
+ async getCheckpoint(runId, seq) {
4430
+ const cp = this.checkpoints.get(this.key(runId, seq));
4431
+ return cp ? {
4432
+ ...cp
4433
+ } : null;
4434
+ }
4435
+ async saveCheckpoint(checkpoint) {
4436
+ this.checkpoints.set(this.key(checkpoint.runId, checkpoint.seq), {
4437
+ ...checkpoint
4438
+ });
4439
+ }
4440
+ async listIncompleteRuns(namespace) {
4441
+ return [
4442
+ ...this.runs.values()
4443
+ ].filter((r) => (r.status === "running" || r.status === "cancelling") && (namespace === void 0 || r.namespace === namespace)).map((r) => ({
4444
+ ...r
4445
+ }));
4446
+ }
4447
+ async listPendingRuns(limit, namespace) {
4448
+ return [
4449
+ ...this.runs.values()
4450
+ ].filter((r) => r.status === "pending" && (namespace === void 0 || r.namespace === namespace)).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id)).slice(0, limit).map((r) => ({
4451
+ ...r
4452
+ }));
4453
+ }
4454
+ async listDueTimers(nowMs, namespace) {
4455
+ return [
4456
+ ...this.runs.values()
4457
+ ].filter((r) => r.status === "suspended" && r.wakeAt !== void 0 && r.wakeAt <= nowMs && (namespace === void 0 || r.namespace === namespace)).map((r) => ({
4458
+ ...r
4459
+ }));
4460
+ }
4461
+ async tryLockRun(runId, owner, leaseUntilMs, nowMs) {
4462
+ const run = this.runs.get(runId);
4463
+ if (!run) return false;
4464
+ if (run.lockedUntil !== void 0 && run.lockedUntil > nowMs) return false;
4465
+ run.lockedBy = owner;
4466
+ run.lockedUntil = leaseUntilMs;
4467
+ return true;
4468
+ }
4469
+ async releaseRunLock(runId) {
4470
+ const run = this.runs.get(runId);
4471
+ if (run) {
4472
+ run.lockedBy = void 0;
4473
+ run.lockedUntil = void 0;
4474
+ }
4475
+ }
4476
+ async renewRunLock(runId, owner, leaseUntilMs) {
4477
+ const run = this.runs.get(runId);
4478
+ if (!run || run.lockedBy !== owner) return false;
4479
+ run.lockedUntil = leaseUntilMs;
4480
+ return true;
4481
+ }
4482
+ async putSignalWaiter(waiter) {
4483
+ this.signalWaiters.set(waiter.token, {
4484
+ ...waiter
4485
+ });
4486
+ }
4487
+ async takeSignalWaiter(token) {
4488
+ const waiter = this.signalWaiters.get(token);
4489
+ if (!waiter) return null;
4490
+ this.signalWaiters.delete(token);
4491
+ return {
4492
+ ...waiter
4493
+ };
4494
+ }
4495
+ async listSignalWaiters(prefix) {
4496
+ return [
4497
+ ...this.signalWaiters.values()
4498
+ ].filter((w) => w.token.startsWith(prefix)).map((w) => ({
4499
+ ...w
4500
+ }));
4501
+ }
4502
+ // No real DB transaction (there's no DB) — run the work and save the checkpoint. Exactly-once
4503
+ // across a crash needs a SQL store; this keeps `ctx.transaction` usable in tests / local dev.
4504
+ async transaction(work) {
4505
+ return work({
4506
+ raw: this,
4507
+ saveCheckpoint: /* @__PURE__ */ __name((cp) => this.saveCheckpoint(cp), "saveCheckpoint")
4508
+ });
4509
+ }
4510
+ bufferedSignals = /* @__PURE__ */ new Map();
4511
+ async bufferSignal(token, payload) {
4512
+ const queue = this.bufferedSignals.get(token) ?? [];
4513
+ queue.push(payload);
4514
+ this.bufferedSignals.set(token, queue);
4515
+ }
4516
+ async takeBufferedSignal(token) {
4517
+ const queue = this.bufferedSignals.get(token);
4518
+ if (!queue || queue.length === 0) return null;
4519
+ const payload = queue.shift();
4520
+ if (queue.length === 0) this.bufferedSignals.delete(token);
4521
+ return {
4522
+ payload
4523
+ };
4524
+ }
4525
+ /**
4526
+ * Candidate run ids satisfying ONE attribute predicate, served from {@link attributeIndex} — the
4527
+ * pushdown step. Numeric predicates scan only `numValue` rows under the key; string/boolean ones
4528
+ * scan `strValue` rows. This is the in-memory analog of the SQL EXISTS on `durable_run_attributes`.
4529
+ */
4530
+ candidatesForFilter(f) {
4531
+ const byRun = this.attributeIndex.get(f.key);
4532
+ const out = /* @__PURE__ */ new Set();
4533
+ if (!byRun) return out;
4534
+ const numeric = typeof f.value === "number";
4535
+ const operand = typeof f.value === "boolean" ? f.value ? "true" : "false" : f.value;
4536
+ for (const row of byRun.values()) {
4537
+ const actual = numeric ? row.numValue : row.strValue;
4538
+ if (actual == null) continue;
4539
+ let ok = false;
4540
+ switch (f.op) {
4541
+ case "eq":
4542
+ ok = actual === operand;
4543
+ break;
4544
+ case "ne":
4545
+ ok = actual !== operand;
4546
+ break;
4547
+ case "gt":
4548
+ ok = actual > operand;
4549
+ break;
4550
+ case "gte":
4551
+ ok = actual >= operand;
4552
+ break;
4553
+ case "lt":
4554
+ ok = actual < operand;
4555
+ break;
4556
+ case "lte":
4557
+ ok = actual <= operand;
4558
+ break;
4559
+ }
4560
+ if (ok) out.add(row.runId);
4561
+ }
4562
+ return out;
4563
+ }
4564
+ async listRuns(query) {
4565
+ let runs = [
4566
+ ...this.runs.values()
4567
+ ];
4568
+ if (query.workflow) runs = runs.filter((r) => r.workflow === query.workflow);
4569
+ if (query.status) runs = runs.filter((r) => r.status === query.status);
4570
+ if (query.statuses) runs = runs.filter((r) => query.statuses?.includes(r.status));
4571
+ if (query.tag) runs = runs.filter((r) => r.tags?.includes(query.tag));
4572
+ if (query.namespace !== void 0) runs = runs.filter((r) => r.namespace === query.namespace);
4573
+ if (query.attributes?.length) {
4574
+ let candidates = null;
4575
+ for (const f of query.attributes) {
4576
+ const next = this.candidatesForFilter(f);
4577
+ if (candidates == null) {
4578
+ candidates = next;
4579
+ } else {
4580
+ const prev = candidates;
4581
+ candidates = new Set([
4582
+ ...prev
4583
+ ].filter((id) => next.has(id)));
4584
+ }
4585
+ if (candidates.size === 0) break;
4586
+ }
4587
+ const ids = candidates ?? /* @__PURE__ */ new Set();
4588
+ this.lastAttributeCandidates = ids.size;
4589
+ runs = runs.filter((r) => ids.has(r.id));
4590
+ }
4591
+ runs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
4592
+ const offset = query.offset ?? 0;
4593
+ const limit = query.limit ?? runs.length;
4594
+ return runs.slice(offset, offset + limit).map((r) => ({
4595
+ ...r
4596
+ }));
4597
+ }
4598
+ async listCheckpoints(runId) {
4599
+ const rows = [
4600
+ ...this.checkpoints.values()
4601
+ ].filter((cp) => cp.runId === runId).sort((a, b) => a.seq - b.seq).map((cp) => ({
4602
+ ...cp
4603
+ }));
4604
+ this.lastCheckpointRowsRead = rows.length;
4605
+ return rows;
4606
+ }
4607
+ async getLatestCheckpointByName(runId, name) {
4608
+ let latest;
4609
+ for (const cp of this.checkpoints.values()) {
4610
+ if (cp.runId !== runId || cp.name !== name) continue;
4611
+ if (!latest || cp.seq > latest.seq) latest = cp;
4612
+ }
4613
+ this.lastCheckpointRowsRead = latest ? 1 : 0;
4614
+ return latest ? {
4615
+ ...latest
4616
+ } : void 0;
4617
+ }
4618
+ async listCheckpointsByNamePrefix(runId, prefixes) {
4619
+ const rows = [
4620
+ ...this.checkpoints.values()
4621
+ ].filter((cp) => cp.runId === runId && prefixes.some((p) => cp.name.startsWith(p))).sort((a, b) => a.seq - b.seq).map((cp) => ({
4622
+ ...cp
4623
+ }));
4624
+ this.lastCheckpointRowsRead = rows.length;
4625
+ return rows;
4626
+ }
4627
+ };
4628
+
4629
+ // src/testing/in-memory-transport.ts
4630
+ var InMemoryTransport = class {
4631
+ static {
4632
+ __name(this, "InMemoryTransport");
4633
+ }
4634
+ handlers = /* @__PURE__ */ new Map();
4635
+ resultHandler;
4636
+ heartbeatHandler;
4637
+ stepEventHandler;
4638
+ controlHandlers = /* @__PURE__ */ new Set();
4639
+ /** Register a fake worker handler for a step name. */
4640
+ handle(name, fn) {
4641
+ this.handlers.set(name, fn);
4642
+ }
4643
+ async dispatch(task) {
4644
+ if (!this.resultHandler) throw new Error("no result handler registered");
4645
+ const result = await runStepHandler(task, this.handlers.get(task.name));
4646
+ setImmediate(() => void this.resultHandler?.(result));
4647
+ }
4648
+ onResult(handler) {
4649
+ this.resultHandler = handler;
4650
+ }
4651
+ onHeartbeat(handler) {
4652
+ this.heartbeatHandler = handler;
4653
+ }
4654
+ /** Test hook: deliver a liveness heartbeat to the engine, as a real broker would on its channel. */
4655
+ async emitHeartbeat(beat) {
4656
+ await this.heartbeatHandler?.(beat);
4657
+ }
4658
+ /** Workflow step lifecycle: deliver to the engine's handler (async, mirroring a real broker). */
4659
+ async dispatchStepEvent(event) {
4660
+ await this.stepEventHandler?.(event);
4661
+ }
4662
+ onStepEvent(handler) {
4663
+ this.stepEventHandler = handler;
4664
+ }
4665
+ // Control plane: broadcast to every registered handler (including the publisher's own — the
4666
+ // engine dedupes by `from`), mirroring how a real broker echoes a publish to all subscribers.
4667
+ async publishControl(msg) {
4668
+ for (const handler of this.controlHandlers) handler(msg);
4669
+ }
4670
+ onControl(handler) {
4671
+ this.controlHandlers.add(handler);
4672
+ }
4673
+ };
4674
+ // Annotate the CommonJS export names for ESM import in node:
4675
+ 0 && (module.exports = {
4676
+ CodecStateStore,
4677
+ ContinueAsNew,
4678
+ DURABLE_OPTIONS,
4679
+ DURABLE_OPTIONS_CANONICAL,
4680
+ Entities,
4681
+ EventAccumulators,
4682
+ FatalError,
4683
+ GatherError,
4684
+ InMemoryAdmissionBackend,
4685
+ InMemoryStateStore,
4686
+ InMemoryTransport,
4687
+ NonDeterminismError,
4688
+ QueueController,
4689
+ RemoteStepError,
4690
+ RemoteStepTimeout,
4691
+ RemoteWorkflowExecutor,
4692
+ RemoteWorkflowTimeout,
4693
+ STATE_STORE,
4694
+ STATE_STORE_CANONICAL,
4695
+ SignalTimeoutError,
4696
+ SingletonQueueFullError,
4697
+ TERMINAL_RUN_STATUSES,
4698
+ TRANSPORT,
4699
+ TRANSPORT_CANONICAL,
4700
+ WORKFLOW_NAME_KEY,
4701
+ WorkflowEngine,
4702
+ WorkflowSuspended,
4703
+ applyAttributeQuery,
4704
+ attributeColumnFor,
4705
+ attributeOperand,
4706
+ breakpointToken,
4707
+ collectMetrics,
4708
+ createStepLogger,
4709
+ eventMatchOf,
4710
+ eventMatches,
4711
+ eventPrefix,
4712
+ eventToken,
4713
+ matchesAttributes,
4714
+ normalizeAttributeRows,
4715
+ parseDuration,
4716
+ prevCronFireMs,
4717
+ remoteStep,
4718
+ runSchedules,
4719
+ runStepHandler,
4720
+ scheduledRunId,
4721
+ sqlComparator,
4722
+ stepId,
4723
+ tenantGroup,
4724
+ workflowName
4725
+ });
4726
+ //# sourceMappingURL=index.cjs.map