@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.
@@ -0,0 +1,2717 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Flow control for remote steps. A queue caps how much work `ctx.call(step, input, { queue })`
5
+ * admits at once — a concurrency limit and/or a fixed-window rate limit. When a call can't be
6
+ * admitted it doesn't dispatch: the run re-suspends with a near-future wake time, and the timer
7
+ * poller re-tries admission later — so flow control is durable (survives crashes) without holding
8
+ * the run in memory.
9
+ *
10
+ * Accounting is per engine instance (the DBOS `workerConcurrency` tier): correct for the common
11
+ * single-orchestrator deployment. Global, cross-instance limits would need a durable counter in the
12
+ * store — a deliberate follow-up, not built here.
13
+ *
14
+ * Beyond raw caps, the controller can admit contended slots by PRIORITY and by per-KEY FAIRNESS
15
+ * (round-robin), so a single noisy key/tenant can't monopolize the concurrency budget. This is the
16
+ * DISPATCH/admission layer — it never touches workflow replay (engine positional logic). Default
17
+ * behaviour is unchanged FIFO when neither priority nor fairness is configured/passed.
18
+ *
19
+ * Design (vs Temporal/Inngest/DBOS): Temporal task queues are FIFO with separate priority levels;
20
+ * Inngest concurrency keys + throttle give per-key fairness; DBOS queues are FIFO with a
21
+ * `workerConcurrency` cap. We fold both knobs into the same in-process admission gate: an optional
22
+ * per-call `priority` (higher first) and an optional queue-level `fairness: 'key'` (round-robin by
23
+ * the per-call `key`). Because blocked calls re-suspend durably and retry independently (there is no
24
+ * resident FIFO list to reorder), the controller remembers the set of WAITERS that asked-and-were-
25
+ * blocked, and on a later retry only admits the one that is rightful-next under the policy.
26
+ */
27
+ interface QueueConfig {
28
+ /** Queue name, referenced by `ctx.call(step, input, { queue: name })`. */
29
+ name: string;
30
+ /** Max steps in flight at once for this queue (this instance). Omit for unlimited. */
31
+ concurrency?: number;
32
+ /** Fixed-window rate limit: at most `limit` admissions per `periodMs`. Omit for unlimited. */
33
+ rateLimit?: {
34
+ limit: number;
35
+ periodMs: number;
36
+ };
37
+ /** Delay (ms) before a concurrency-blocked call re-checks for a free slot. Default 1000. */
38
+ retryMs?: number;
39
+ /**
40
+ * Fairness policy for a contended concurrency slot. `'key'` round-robins across distinct per-call
41
+ * keys (the least-recently-served key wins the next slot), so one key can't monopolize the budget.
42
+ * Omit (or `'fifo'`) for plain first-come ordering. Priority always wins over fairness; fairness
43
+ * only breaks ties within the same priority.
44
+ */
45
+ fairness?: 'fifo' | 'key';
46
+ /**
47
+ * Arrival-order tiebreak among otherwise-equal waiters (same priority, same fairness rank).
48
+ * `'fifo'` (default) admits the earliest arrival; `'lifo'` admits the most recent (a stack). Orthogonal
49
+ * to `priority` and `fairness` — those still win first; this only breaks their ties.
50
+ */
51
+ order?: 'fifo' | 'lifo';
52
+ }
53
+ /**
54
+ * Per-call admission hints. All optional — omitting them keeps the legacy FIFO behaviour.
55
+ * - `priority`: higher is admitted first (default 0).
56
+ * - `key`: the fairness bucket (e.g. a tenant id) for `fairness: 'key'`.
57
+ * - `waiterId`: a stable id for THIS call across its retries (the engine passes the stepId), so the
58
+ * controller can track it as one waiter instead of counting each retry as a new contender.
59
+ */
60
+ interface AdmissionItem {
61
+ priority?: number | undefined;
62
+ key?: string | undefined;
63
+ waiterId?: string | undefined;
64
+ }
65
+ /** Admission decision: either admitted, or blocked until `retryAt` (epoch ms). */
66
+ type Admission = {
67
+ ok: true;
68
+ } | {
69
+ ok: false;
70
+ retryAt: number;
71
+ };
72
+ /** In-process admission controller for one {@link QueueConfig}. */
73
+ declare class QueueController {
74
+ readonly config: QueueConfig;
75
+ private readonly clock;
76
+ private inFlight;
77
+ private windowStart;
78
+ private windowCount;
79
+ /** Registered waiters by id (only used when priority/fairness ordering is in play). */
80
+ private readonly waiters;
81
+ /** Monotonic counter stamping each newly-seen waiter for a stable FIFO tiebreak. */
82
+ private waiterSeq;
83
+ /** Per-key "last served" order for round-robin fairness — higher = more recently served. */
84
+ private readonly keyServed;
85
+ private servedTick;
86
+ constructor(config: QueueConfig, clock: () => number);
87
+ /** Whether this controller does any priority/fairness ordering (else it's a plain FIFO gate). */
88
+ private get ordered();
89
+ /**
90
+ * Try to admit one unit of work. On `ok`, the caller has taken a concurrency slot and must call
91
+ * {@link release} when the step settles. On block, `retryAt` is when admission may next succeed.
92
+ *
93
+ * `item` carries optional priority/fairness hints; omitting it is the legacy FIFO path. When
94
+ * ordering is configured (or a priority is given), a blocked call REGISTERS as a waiter and is only
95
+ * admitted on a later retry once it is the rightful next under (priority desc, fairness, FIFO).
96
+ */
97
+ tryAdmit(item?: AdmissionItem): Admission;
98
+ /** Release a concurrency slot taken by a successful {@link tryAdmit}. */
99
+ release(): void;
100
+ /** Track (or refresh) a blocked call as a waiter; its FIFO seq is stamped only on first sight. */
101
+ private register;
102
+ /**
103
+ * Is the calling item the best candidate among all registered waiters? Ordering, best first:
104
+ * 1. higher `priority`;
105
+ * 2. fairness: with `fairness: 'key'`, the least-recently-served key wins (round-robin);
106
+ * 3. earliest registration (FIFO) — stable tiebreak.
107
+ * A bare item (no waiterId) is compared on its hints directly.
108
+ */
109
+ private isNext;
110
+ /** Strict "a should be admitted before b" comparison under the active policy. */
111
+ private better;
112
+ /** Last-served tick for a key (0 = never served, so an unseen key is admitted before a busy one). */
113
+ private servedOf;
114
+ }
115
+
116
+ /**
117
+ * Pluggable backend for the remote-step flow-control gate (`ctx.call(step, input, { queue })`). The
118
+ * engine asks it whether a queued call may be admitted now (concurrency / rate / ordering) and tells
119
+ * it when an admitted slot is released.
120
+ *
121
+ * The default {@link InMemoryAdmissionBackend} keeps per-instance counts — correct for a single
122
+ * orchestrator. Swap in a store/Redis-backed backend to make the caps and ordering GLOBAL across
123
+ * engine replicas (so `concurrency: 5` means 5 in-flight across the whole fleet, not 5 per pod).
124
+ *
125
+ * `tryAdmit`/`release` are async so a global backend can do its atomic round-trip; the in-memory one
126
+ * resolves synchronously. An UNREGISTERED queue name is treated as ungated (admits immediately) — the
127
+ * engine only tracks/releases a slot for a registered queue.
128
+ */
129
+ interface AdmissionBackend {
130
+ /** Register (or replace) a queue's config. Called from `engine.registerQueue`. */
131
+ register(config: QueueConfig): void;
132
+ /** Whether `queue` is registered (gated). The engine only tracks/releases a slot for a gated queue. */
133
+ handles(queue: string): boolean;
134
+ /** May a call on `queue` take a slot now? On `ok`, the caller holds a slot until {@link release}. */
135
+ tryAdmit(queue: string, item: AdmissionItem): Promise<Admission>;
136
+ /**
137
+ * Release the slot granted by {@link tryAdmit} for `queue`. `slotId` is the admitted item's
138
+ * `waiterId` (the engine's stepId), so a lease-based backend can drop the exact slot; an in-process
139
+ * counter ignores it.
140
+ */
141
+ release(queue: string, slotId: string): Promise<void>;
142
+ /**
143
+ * Optional: subscribe to "a slot just freed on `queue`" events (e.g. a fleet-wide pub/sub). The
144
+ * engine uses it to wake its admission-blocked runs early instead of waiting for their retry tick.
145
+ * A backend without it falls back to retry-tick polling. Fired best-effort across the fleet.
146
+ */
147
+ onFreed?(handler: (queue: string) => void): void;
148
+ }
149
+ /**
150
+ * Default admission backend: one in-process {@link QueueController} per registered queue. Caps and
151
+ * waiter ordering are per engine instance (see {@link QueueConfig}). Preserves the exact pre-backend
152
+ * behaviour — the engine used to hold these controllers directly.
153
+ */
154
+ declare class InMemoryAdmissionBackend implements AdmissionBackend {
155
+ private readonly clock;
156
+ private readonly controllers;
157
+ constructor(clock: () => number);
158
+ register(config: QueueConfig): void;
159
+ handles(queue: string): boolean;
160
+ tryAdmit(queue: string, item: AdmissionItem): Promise<Admission>;
161
+ release(queue: string, _slotId: string): Promise<void>;
162
+ }
163
+
164
+ /**
165
+ * Parse a human duration to milliseconds. Accepts a number (already ms) or a string like
166
+ * `'500ms'`, `'30s'`, `'15m'`, `'2h'`, `'7d'`, `'7 days'`.
167
+ */
168
+ declare function parseDuration(duration: string | number): number;
169
+
170
+ /**
171
+ * A **workflow reference** is how one workflow names another to call it: either a registered workflow
172
+ * **name** (a string — the only option across runtimes, e.g. a Python workflow) or, for a same-runtime
173
+ * TypeScript workflow, its **class**. The class carries the input/output types through the call, so
174
+ * `ctx.child(ShippingWorkflow, input)` type-checks the input and returns a typed result — while a
175
+ * string stays available for the cross-runtime case.
176
+ */
177
+ /**
178
+ * The symbol the `@Workflow` decorator stamps a workflow's registered name onto, so a class ref can
179
+ * be resolved back to its name. A global-registry symbol (`Symbol.for`) so it survives duplicate
180
+ * copies of this package in a dependency tree.
181
+ */
182
+ declare const WORKFLOW_NAME_KEY: unique symbol;
183
+ /** Structural shape of a `@Workflow` class — its `run(ctx, input)` carries the input/output types. */
184
+ type WorkflowClass<TInput = unknown, TOutput = unknown> = abstract new (...args: never[]) => {
185
+ run(ctx: never, input: TInput): Promise<TOutput> | TOutput;
186
+ };
187
+ /** A workflow reference: a registered name (cross-runtime) or a workflow class (typed, same-runtime). */
188
+ type WorkflowRef<TInput = unknown, TOutput = unknown> = string | WorkflowClass<TInput, TOutput>;
189
+ /** The input type a workflow class's `run` accepts. */
190
+ type WorkflowInputOf<C> = C extends abstract new (...args: never[]) => {
191
+ run(ctx: never, input: infer I): unknown;
192
+ } ? I : unknown;
193
+ /** The output type a workflow class's `run` resolves to (Promise unwrapped). */
194
+ type WorkflowOutputOf<C> = C extends abstract new (...args: never[]) => {
195
+ run(ctx: never, input: never): infer R;
196
+ } ? Awaited<R> : unknown;
197
+ /**
198
+ * Resolve a {@link WorkflowRef} to its registered workflow name: a string is returned as-is; a
199
+ * `@Workflow` class is resolved via the name the decorator stamped on it. Throws if a class was
200
+ * never decorated (so it carries no registered name).
201
+ */
202
+ declare function workflowName(ref: WorkflowRef): string;
203
+
204
+ /**
205
+ * Core type contracts for nestjs-durable.
206
+ *
207
+ * These are intentionally framework-agnostic: `@dudousxd/nestjs-durable-core` knows only
208
+ * these interfaces, never a concrete transport, store or ORM. Adapters implement them.
209
+ */
210
+ type RunStatus =
211
+ /** Created + enqueued by `start`, not yet picked up — a worker will lease and execute it. */
212
+ 'pending' | 'running' | 'suspended'
213
+ /**
214
+ * Compensating cancel in progress: `cancel(runId, { compensate: true })` has been requested and the
215
+ * saga undo is running in the background. NON-TERMINAL — the run is still in-flight (counted by the
216
+ * admission gate, re-driven by recovery if the engine crashes mid-compensation), and flips to
217
+ * `cancelled` once compensation completes. A non-compensating cancel skips this and goes straight to
218
+ * `cancelled`.
219
+ */
220
+ | 'cancelling' | 'completed' | 'failed' | 'cancelled'
221
+ /** Dead-letter: recovery gave up after `maxRecoveryAttempts` (a poison pill). Terminal; inspect it. */
222
+ | 'dead';
223
+ /** One execution of a workflow. The unit of durability and the unit shown in the dashboard. */
224
+ interface WorkflowRun {
225
+ id: string;
226
+ /** Registered workflow name, e.g. `checkout`. */
227
+ workflow: string;
228
+ /** Code version at start; old runs must resume on the version they began on. */
229
+ workflowVersion: string;
230
+ status: RunStatus;
231
+ /** Serialized workflow input (the args the run was started with). */
232
+ input: unknown;
233
+ /** Serialized workflow output, once `completed`. */
234
+ output?: unknown;
235
+ /** Structured error, once `failed`. */
236
+ error?: StepError | undefined;
237
+ /** When `suspended` on a durable sleep: epoch ms at which the run becomes due to resume. */
238
+ wakeAt?: number | undefined;
239
+ /** Recovery lease owner (engine instance id) while a run is being resumed. */
240
+ lockedBy?: string | undefined;
241
+ /** Recovery lease expiry (epoch ms); another instance may take over once it passes. */
242
+ lockedUntil?: number | undefined;
243
+ /** How many times crash-recovery has picked this run up — caps poison pills (see maxRecoveryAttempts). */
244
+ recoveryAttempts?: number | undefined;
245
+ /** Searchable labels: the workflow's static `@Workflow({ tags })` merged with the run's start-time tags. */
246
+ tags?: string[] | undefined;
247
+ /** Typed, queryable run data (e.g. `{ amount: 200, tier: 'pro' }`) — see {@link RunQuery.attributes}. */
248
+ searchAttributes?: SearchAttributes | undefined;
249
+ /**
250
+ * The worker-pool partition this run belongs to. Stamped at creation from the creating engine's
251
+ * `namespace` (default `'default'`). A worker only picks up / recovers / resumes-timers-for /
252
+ * times-out runs in its own namespace. `undefined` on a run created before this field existed; the
253
+ * store persists it as `'default'`. Read paths (dashboard, `getRun`) are NOT namespace-scoped.
254
+ */
255
+ namespace?: string | undefined;
256
+ /**
257
+ * Dispatch priority for a REMOTE run (one advanced by a {@link WorkflowExecutor} over a broker):
258
+ * carried onto each {@link WorkflowTask} so an urgent child workflow can jump ahead of enqueued
259
+ * lower-priority ones at the worker. Higher wins; absent = unprioritised. Best-effort ordering, not
260
+ * durable state required for correctness — a transport without priority support ignores it.
261
+ */
262
+ priority?: number | undefined;
263
+ /**
264
+ * REMOTE workflow turn awaiting a decision: the `taskId` of the turn the engine dispatched to a
265
+ * workflow worker and then SUSPENDED on. Set by the dispatch-and-suspend path; matched by
266
+ * {@link WorkflowEngine.completeRemoteDecision} so ONLY the decision for the currently-awaited turn
267
+ * is applied (a stale/duplicate/foreign decision is dropped), then cleared the moment it is applied.
268
+ * This is what makes a workflow-turn decision multi-instance safe: any engine instance that receives
269
+ * the decision looks the run up by `decision.runId` and applies it durably, instead of resolving an
270
+ * in-memory promise that only the dispatching instance held. Absent on non-remote / not-awaiting runs.
271
+ */
272
+ awaitingDecisionTaskId?: string | undefined;
273
+ createdAt: Date;
274
+ updatedAt: Date;
275
+ }
276
+ type StepKind = 'local' | 'remote' | 'sleep' | 'signal';
277
+ /**
278
+ * The recorded result of a single step at a deterministic logical position (`seq`).
279
+ * On replay, a `completed` checkpoint means the step is NOT re-executed — its `output` is
280
+ * returned. A non-terminal checkpoint (`pending`/`running`) does not short-circuit: the step is
281
+ * re-awaited (remote) or re-run (local).
282
+ */
283
+ interface StepCheckpoint {
284
+ runId: string;
285
+ /** Deterministic logical position of the step within the run. */
286
+ seq: number;
287
+ /** Registered step name (matches the remote handler name for remote steps). */
288
+ name: string;
289
+ kind: StepKind;
290
+ /** Stable id passed to remote workers so they can dedupe a re-delivered task. */
291
+ stepId: string;
292
+ /**
293
+ * `pending` = a remote step dispatched and awaiting its worker result (the run is durably
294
+ * suspended, not held in memory); it becomes `completed`/`failed` when the result arrives.
295
+ * `running` = a local step whose body is executing in-process right now (see `trackStepStart`);
296
+ * it's overwritten by `completed`/`failed` when the body settles, and on a crash mid-body it
297
+ * simply re-runs on replay (only `completed` short-circuits).
298
+ */
299
+ status: 'pending' | 'running' | 'completed' | 'failed';
300
+ /** What the step was called with — the `ctx.call` args for a remote step (a local step has none). */
301
+ input?: unknown;
302
+ output?: unknown;
303
+ error?: StepError | undefined;
304
+ attempts: number;
305
+ /** For remote steps: which worker group ran it. */
306
+ workerGroup?: string | undefined;
307
+ /** Structured events/logs the step emitted (sub-step outcomes, debug/error lines). */
308
+ events?: StepEvent[] | undefined;
309
+ /** For sleep steps: epoch ms the sleep elapses at. */
310
+ wakeAt?: number | undefined;
311
+ /**
312
+ * Set on the running placeholder checkpoints of the children dispatched by one `ctx.all` call —
313
+ * a shared tag (`all:<firstSeq>`) grouping the N siblings so the dashboard can render them as one
314
+ * parallel fan-out. Optional and additive: absent on every non-`all` checkpoint. Mirrors the
315
+ * Python SDK's `parallelGroup`.
316
+ */
317
+ parallelGroup?: string | undefined;
318
+ /**
319
+ * When the step entered the system: for a remote step, when the engine dispatched it to the
320
+ * transport; for a local step, when it began. Queue-wait time = `startedAt − enqueuedAt`.
321
+ */
322
+ enqueuedAt: Date;
323
+ /** When processing actually began: worker pickup for a remote step, execution start for a local one. */
324
+ startedAt: Date;
325
+ finishedAt: Date;
326
+ }
327
+ /**
328
+ * A structured event a step (or its worker) emits while running — a log line and/or a sub-step
329
+ * outcome. The dashboard renders these under the step, so you can see what happened inside a step
330
+ * that the workflow treats as one unit (e.g. which of N parallel sub-processes ok/failed/skipped).
331
+ */
332
+ interface StepEvent {
333
+ /** Epoch ms. */
334
+ at: number;
335
+ level: 'debug' | 'info' | 'warn' | 'error';
336
+ message: string;
337
+ /** Stable run identity for a sub-process. Distinct invocations of the same `name` carry distinct
338
+ * ids, so their phases and log trails never collapse into one. Absent on events emitted by the
339
+ * legacy `sub()` path, which keys by `name` instead. */
340
+ subId?: string;
341
+ /** For a sub-step/sub-process within the step: its name. */
342
+ name?: string;
343
+ /** Open, consumer-defined grouping label for a sub-process (e.g. a handler/lane). The dashboard
344
+ * groups rows by it. The library never interprets it. */
345
+ group?: string;
346
+ /** For a sub-step: its terminal outcome (closed enum — drives colour + aggregation). */
347
+ status?: 'ok' | 'failed' | 'skipped';
348
+ /** Open, consumer-defined intermediate phase label for a sub-process transition. Carries no
349
+ * terminal `status`; the library timestamps and orders it but never interprets it. */
350
+ phase?: string;
351
+ /** For a log line emitted *inside* a sub-process: that owning sub-process's name, so the dashboard
352
+ * can group a step's log trail under each sub-process instead of one flat list. Set on logs (no
353
+ * `status`); a worker stamps it from the sub-process it's running.
354
+ * @deprecated Superseded by `subId` for run-distinct grouping; kept so existing workers/runs render. */
355
+ process?: string;
356
+ /** Optional structured payload. `data.durationMs` (number) overrides the derived duration. */
357
+ data?: unknown;
358
+ }
359
+ /**
360
+ * Handed to a local step's body (`ctx.step(name, (log) => …)`) so it can record what happened
361
+ * inside the step — debug/info/warn/error lines and per-sub-process outcomes. The events are
362
+ * checkpointed with the step and rendered under it in the dashboard. The remote/cross-language
363
+ * counterpart is the worker attaching the same `StepEvent[]` to its `StepResult` (see the Python
364
+ * SDK's `StepContext`), so observability is symmetric regardless of where the step ran.
365
+ */
366
+ interface StepLogger {
367
+ debug(message: string, data?: unknown): void;
368
+ info(message: string, data?: unknown): void;
369
+ warn(message: string, data?: unknown): void;
370
+ error(message: string, data?: unknown): void;
371
+ /** Record a sub-step / sub-process outcome (e.g. one of N parallel p-processes). */
372
+ sub(name: string, status: 'ok' | 'failed' | 'skipped', message?: string, data?: unknown): void;
373
+ /** Record a sub-process event. Typically pass `phase` for an intermediate transition (carrying no
374
+ * terminal status), or `status` for the terminal outcome — the type permits either; the engine
375
+ * interprets which is set. `id` is the run identity (distinct per invocation); `group` is an
376
+ * open grouping label. */
377
+ subEvent(e: {
378
+ id: string;
379
+ name: string;
380
+ group?: string | undefined;
381
+ phase?: string | undefined;
382
+ status?: 'ok' | 'failed' | 'skipped' | undefined;
383
+ message?: string | undefined;
384
+ data?: unknown;
385
+ }): void;
386
+ /**
387
+ * Ergonomic sub-process lifecycle: run `body`, timing it, and record a terminal `ok` with the
388
+ * measured `durationMs` on success — or `failed` (with the error message) if it throws, then
389
+ * re-throw. `sp.phase(label)` records an intermediate transition; `sp.skip(reason)` a terminal
390
+ * `skipped`. Logs emitted inside `body` are tagged to this sub-process so the dashboard groups
391
+ * them under it. Returns whatever `body` returns. The TS twin of the Python SDK's `sub_process`.
392
+ *
393
+ * ```ts
394
+ * const rows = await log.subProcess('fetch-data', async () => readEverything());
395
+ * await log.subProcess('export-file', () => upload(rows));
396
+ * ```
397
+ */
398
+ subProcess<T>(name: string, body: (sp: SubProcessHandle) => Promise<T> | T, opts?: {
399
+ group?: string;
400
+ id?: string;
401
+ }): Promise<T>;
402
+ }
403
+ /** The handle a {@link StepLogger.subProcess} body receives to mark phases / a non-`ok` outcome. */
404
+ interface SubProcessHandle {
405
+ /** Record an intermediate phase transition (a consumer-defined label, no terminal status). */
406
+ phase(phase: string, data?: unknown): SubProcessHandle;
407
+ /** Record a terminal `skipped` outcome (e.g. nothing to do / validation failed). */
408
+ skip(reason?: string, data?: unknown): void;
409
+ /** Record a terminal `failed` outcome explicitly (the wrapper also does this if the body throws). */
410
+ fail(reason?: string, data?: unknown): void;
411
+ }
412
+ interface StepError {
413
+ message: string;
414
+ /** Optional machine-readable code, e.g. `declined`, `timeout`. */
415
+ code?: string | undefined;
416
+ /** Whether the engine should treat this as retryable. */
417
+ retryable?: boolean | undefined;
418
+ stack?: string | undefined;
419
+ }
420
+ interface StateStore {
421
+ /**
422
+ * Provision the tables/collections this store needs, idempotently. Called on boot when the
423
+ * module's `autoSchema` is on. Optional: stores that need no setup (in-memory) omit it.
424
+ */
425
+ ensureSchema?(): Promise<void>;
426
+ createRun(run: WorkflowRun): Promise<void>;
427
+ updateRun(runId: string, patch: Partial<WorkflowRun>): Promise<void>;
428
+ getRun(runId: string): Promise<WorkflowRun | null>;
429
+ /**
430
+ * Hard-delete a run and all of its rows — the run plus its checkpoints, signal waiters, and
431
+ * normalized search-attribute rows. Unlike a cancelled run (which stays as terminal history), a
432
+ * deleted run is GONE: it no longer appears in {@link getRun} or {@link listRuns}. This removes
433
+ * exactly the one run — the engine's {@link WorkflowEngine.deleteRun} handles the child cascade.
434
+ * No-op if the run doesn't exist. (Token-keyed buffered signals are transient and left alone.)
435
+ */
436
+ deleteRun(runId: string): Promise<void>;
437
+ /**
438
+ * Hard-delete up to `limit` terminal runs that fall OUTSIDE `policy` (oldest / over the count cap
439
+ * first), cascading to their child rows exactly like {@link deleteRun}. Returns how many runs were
440
+ * deleted — call again while it returns `limit` to drain a large backlog in bounded batches.
441
+ *
442
+ * Optional: only adapters that implement a bulk prune provide it; the retention poller no-ops with a
443
+ * warning when the configured store omits it. See {@link RetentionPolicy} for the keep/prune rule.
444
+ */
445
+ pruneTerminalRuns?(policy: RetentionPolicy, nowMs: number, limit: number): Promise<number>;
446
+ getCheckpoint(runId: string, seq: number): Promise<StepCheckpoint | null>;
447
+ /**
448
+ * Persist a checkpoint and advance the run atomically. Durable semantics depend on this
449
+ * being a single transaction; stores without transactions cannot give the strong guarantee.
450
+ */
451
+ saveCheckpoint(checkpoint: StepCheckpoint): Promise<void>;
452
+ /** Used by recovery on boot to find runs to resume (crashed, left `running`). */
453
+ listIncompleteRuns(namespace?: string): Promise<WorkflowRun[]>;
454
+ /** The oldest `pending` runs awaiting dispatch (FIFO, by `createdAt`), capped at `limit`. */
455
+ listPendingRuns(limit: number, namespace?: string): Promise<WorkflowRun[]>;
456
+ /** Suspended runs whose durable timer is due (`wakeAt <= nowMs`), ready to resume. */
457
+ listDueTimers(nowMs: number, namespace?: string): Promise<WorkflowRun[]>;
458
+ /**
459
+ * Atomically acquire the recovery lease on a run for `owner` until `leaseUntilMs`, but only if
460
+ * it is currently unlocked or its lease has expired (`<= nowMs`). Returns whether it was
461
+ * acquired — so concurrent engine instances never recover the same run twice.
462
+ */
463
+ tryLockRun(runId: string, owner: string, leaseUntilMs: number, nowMs: number): Promise<boolean>;
464
+ /** Release a run's recovery lease so another instance can pick it up (e.g. once it suspends). */
465
+ releaseRunLock(runId: string): Promise<void>;
466
+ /**
467
+ * Extend a run's lease to `leaseUntilMs`, but ONLY if `owner` still holds it — so a live worker
468
+ * heartbeating its long run keeps the lease, while a dead worker's lease still expires and gets
469
+ * reclaimed. Returns false if the lease was lost (taken over or released).
470
+ */
471
+ renewRunLock(runId: string, owner: string, leaseUntilMs: number): Promise<boolean>;
472
+ /** Record that a run is suspended waiting for an external signal `token`. */
473
+ putSignalWaiter(waiter: SignalWaiter): Promise<void>;
474
+ /** Atomically take (and remove) the run waiting on `token`, if any. */
475
+ takeSignalWaiter(token: string): Promise<SignalWaiter | null>;
476
+ /** List waiters whose `token` starts with `prefix` — used to fan out an event to its subscribers. */
477
+ listSignalWaiters(prefix: string): Promise<SignalWaiter[]>;
478
+ /**
479
+ * Buffer a signal whose waiter hasn't arrived yet, so the next `waitForSignal(token)` consumes it
480
+ * instead of it being lost (FIFO per token). Makes signals reliable regardless of timing and
481
+ * powers `signalWithStart`.
482
+ */
483
+ bufferSignal(token: string, payload: unknown): Promise<void>;
484
+ /** Take the oldest buffered signal for `token` (FIFO), or null if none is buffered. */
485
+ takeBufferedSignal(token: string): Promise<{
486
+ payload: unknown;
487
+ } | null>;
488
+ /**
489
+ * Run `work` in a SINGLE store transaction — giving it the store-native transaction handle (`raw`)
490
+ * for the caller's own DB writes plus a `saveCheckpoint` that commits IN THE SAME transaction, so a
491
+ * business write and the step's "done" checkpoint are atomic (exactly-once). Optional — only the SQL
492
+ * adapters implement it; `ctx.transaction` errors on a store without it.
493
+ */
494
+ transaction?<T>(work: (tx: StoreTransaction) => Promise<T>): Promise<T>;
495
+ listRuns(query: RunQuery): Promise<WorkflowRun[]>;
496
+ listCheckpoints(runId: string): Promise<StepCheckpoint[]>;
497
+ /**
498
+ * The LATEST checkpoint for `runId` whose `name` equals `name` exactly (highest `seq` wins), or
499
+ * `undefined` if none. A targeted read that avoids fetching + deserializing every checkpoint just to
500
+ * keep one match — the store does the filter (`WHERE name = … ORDER BY seq DESC LIMIT 1`). Preserves
501
+ * the "last in seq order wins" semantics the engine relies on for `getEvent` (a re-published key
502
+ * overwrites the prior value at a higher seq, so the highest-seq match is the current value).
503
+ *
504
+ * Optional: a store that omits it still works — the engine falls back to {@link listCheckpoints}
505
+ * plus an in-JS filter that produces the identical result.
506
+ */
507
+ getLatestCheckpointByName?(runId: string, name: string): Promise<StepCheckpoint | undefined>;
508
+ /**
509
+ * All checkpoints for `runId` whose `name` starts with ANY of `prefixes`, ordered by `seq` ascending
510
+ * (same order as {@link listCheckpoints}). A targeted read that avoids scanning every checkpoint just
511
+ * to keep the prefix matches — the store does the filter (`WHERE name LIKE 'prefix%' …`). Used by the
512
+ * run-tree to find a parent's child edges (`signal:child:` / `spawn:` checkpoints) without loading the
513
+ * whole history. An empty `prefixes` array matches nothing.
514
+ *
515
+ * Optional: a store that omits it still works — the engine falls back to {@link listCheckpoints}
516
+ * plus an in-JS prefix scan that produces the identical result.
517
+ */
518
+ listCheckpointsByNamePrefix?(runId: string, prefixes: string[]): Promise<StepCheckpoint[]>;
519
+ }
520
+ /** The terminal run statuses — a run in one of these is finished and will never change on its own.
521
+ * Only these are eligible for retention pruning (a {@link RetentionPolicy} targeting a non-terminal
522
+ * status would race the engine and delete live work). */
523
+ declare const TERMINAL_RUN_STATUSES: readonly RunStatus[];
524
+ /**
525
+ * One retention rule for hard-pruning terminal run history. Within `statuses` (terminal only), a run
526
+ * is KEPT only if it satisfies EVERY bound you set, and pruned the moment it violates any:
527
+ * - `maxAge` — prune runs whose `updatedAt` (≈ the time they reached terminal) is older than
528
+ * `now - maxAge`. A number is milliseconds; a string is an `ms`-style duration parsed by
529
+ * {@link parseDuration} (`'7d'`, `'2w'`, `'90m'` — note `'m'` is MINUTES; use `'30d'` for a month).
530
+ * - `maxCount` — keep only the `maxCount` most-recent (by `updatedAt`) runs in the status set; prune
531
+ * everything past that.
532
+ *
533
+ * Set one or both (both = the most-restrictive wins: capped at `maxCount` rows AND nothing older than
534
+ * `maxAge`). Statuses not named by any policy are never pruned.
535
+ */
536
+ interface RetentionPolicy {
537
+ statuses: RunStatus[];
538
+ maxAge?: number | string;
539
+ maxCount?: number;
540
+ }
541
+ /** Typed, queryable per-run data — exact values for `eq`/`ne`, numbers/strings for range ops. */
542
+ type SearchAttributes = Record<string, string | number | boolean>;
543
+ type AttributeOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
544
+ /** One predicate over a run's {@link SearchAttributes}; a {@link RunQuery} ANDs them all. */
545
+ interface AttributeFilter {
546
+ key: string;
547
+ op: AttributeOp;
548
+ value: string | number | boolean;
549
+ }
550
+ interface RunQuery {
551
+ workflow?: string | undefined;
552
+ status?: RunStatus | undefined;
553
+ /**
554
+ * Match any of these statuses (a `status IN (...)` filter). ORed together, and ANDed with the other
555
+ * predicates. Use this instead of issuing one {@link listRuns} call per status — e.g. the singleton
556
+ * admission gate counts `running` + `suspended` in-flight runs in a single scan. If both `status` and
557
+ * `statuses` are set, both must hold (the single `status` further narrows the set). Empty array =
558
+ * matches nothing.
559
+ */
560
+ statuses?: RunStatus[] | undefined;
561
+ /** Only runs carrying this tag (exact match against {@link WorkflowRun.tags}). */
562
+ tag?: string | undefined;
563
+ /**
564
+ * Typed/range predicates over {@link WorkflowRun.searchAttributes}, ANDed together (e.g. `amount`
565
+ * >= 200 and `tier` = 'pro'). Applied in-process after the coarse filters, so pair with
566
+ * `workflow`/`status`/`tag` to bound the scan on large stores.
567
+ */
568
+ attributes?: AttributeFilter[] | undefined;
569
+ /** Restrict to runs in this namespace (exact match), ANDed with the other predicates. */
570
+ namespace?: string | undefined;
571
+ limit?: number | undefined;
572
+ offset?: number | undefined;
573
+ }
574
+ /** The transaction handle `StateStore.transaction` hands to its work callback. */
575
+ interface StoreTransaction {
576
+ /** The store-native transaction handle (TypeORM `EntityManager`, Prisma tx client, MikroORM `EntityManager`,
577
+ * Drizzle tx) — do your business DB writes on THIS so they commit atomically with the checkpoint. */
578
+ readonly raw: unknown;
579
+ /** Persist the step checkpoint inside this transaction. */
580
+ saveCheckpoint(checkpoint: StepCheckpoint): Promise<void>;
581
+ }
582
+ /** Binds an external signal `token` to the suspended run/step position waiting for it. */
583
+ interface SignalWaiter {
584
+ token: string;
585
+ runId: string;
586
+ seq: number;
587
+ /**
588
+ * The parallel-fan group this waiter belongs to, carried from the awaiting command so the resolving
589
+ * `signal:<token>` checkpoint (notably `signal:child:<id>` for an awaited child run) can be tagged
590
+ * with it. A worker's `ctx.gather_children`/`ctx.all` fan-out stamps every `startChild` with the same
591
+ * group; without threading it through the waiter, the child-await checkpoint comes out untagged and the
592
+ * dashboard renders the fan as a sequential chain instead of one parallel group. Undefined for an
593
+ * ordinary (non-fan) signal/child await.
594
+ */
595
+ parallelGroup?: string | undefined;
596
+ }
597
+ /**
598
+ * A DB-less tenant worker → control plane request to start a run. Published by a worker that has
599
+ * no direct DB access (the hosted-control-plane tenant model) onto the
600
+ * `<effectivePrefix>-start-run` queue; the control plane consumes it and turns it into a durable
601
+ * run (P4 of the tenants plan).
602
+ *
603
+ * The `tenant` identifies the namespace that owns the run (maps to the engine's `namespace`); it
604
+ * is separate from the wire-level key prefix so a single transport can serve multiple tenants.
605
+ */
606
+ interface StartRunMessage {
607
+ tenant: string;
608
+ /** Registered workflow name. */
609
+ workflow: string;
610
+ input: unknown;
611
+ /** Optional caller-supplied run id (idempotency key). The engine generates one if absent. */
612
+ runId?: string | undefined;
613
+ /** Tags to stamp on the run (merged with the workflow's static @Workflow tags). */
614
+ tags?: string[] | undefined;
615
+ }
616
+ /** A unit of work dispatched to a remote worker. This is the documented wire payload. */
617
+ interface RemoteTask {
618
+ runId: string;
619
+ seq: number;
620
+ /** Handler name the worker registered, e.g. `payments.charge-card`. */
621
+ name: string;
622
+ stepId: string;
623
+ /** Worker group expected to handle this task. */
624
+ group: string;
625
+ input: unknown;
626
+ /** W3C traceparent so the worker can continue the distributed trace. */
627
+ traceparent?: string | undefined;
628
+ /**
629
+ * Opaque context carrier (tenant / user / correlation ids) the worker re-exposes to the step
630
+ * handler, for cross-process propagation alongside the {@link traceparent}. The engine treats it
631
+ * as a pass-through object and never inspects its shape — the producer (e.g. `@dudousxd/nestjs-context`)
632
+ * owns the keys. Absent when no `context` provider is configured.
633
+ */
634
+ context?: Record<string, unknown> | undefined;
635
+ /**
636
+ * Id of the transport this task was dispatched on (when the engine runs a pool — see
637
+ * {@link NamedTransport}). A worker that consumes several transports replies via the matching one,
638
+ * so failover is symmetric without the worker choosing a transport. Absent for a single transport.
639
+ */
640
+ transport?: string | undefined;
641
+ /**
642
+ * Admission priority carried through to the broker (BullMQ job `priority`) so a transport that
643
+ * supports priority ordering lets an urgent task jump ahead of already-enqueued lower-priority
644
+ * ones at the worker. Mirrors the per-call `priority` from `ctx.call(..., { priority })`. Higher
645
+ * wins; absent means default/unprioritised. Transports without priority support ignore it.
646
+ */
647
+ priority?: number | undefined;
648
+ attempt: number;
649
+ }
650
+ interface StepResult {
651
+ runId: string;
652
+ seq: number;
653
+ stepId: string;
654
+ status: 'completed' | 'failed';
655
+ output?: unknown;
656
+ error?: StepError | undefined;
657
+ /** Epoch ms when the worker began processing — lets the engine report queue-wait time. */
658
+ startedAt?: number | undefined;
659
+ /** Structured events the worker emitted while running the step (sub-step outcomes, logs). */
660
+ events?: StepEvent[] | undefined;
661
+ }
662
+ interface Heartbeat {
663
+ runId: string;
664
+ seq: number;
665
+ /**
666
+ * The in-flight step this beat keeps alive. ABSENT for a RUN-scoped (workflow-turn) heartbeat: a
667
+ * worker replaying a long workflow turn beats for its `runId` so the engine rearms the run's
668
+ * `advance` deadline (see `WorkflowEngineDeps.remoteAdvanceSilenceMs`). The engine then keys the
669
+ * liveness reset by `runId` instead of `stepId`.
670
+ */
671
+ stepId?: string | undefined;
672
+ group: string;
673
+ }
674
+ /** engine → workflow worker: advance this run one turn by replaying the function against `history`. */
675
+ interface WorkflowTask {
676
+ /** Dedupe id for this turn (a re-delivered task must be idempotent). */
677
+ taskId: string;
678
+ runId: string;
679
+ /** Registered workflow name + the version the run started on — replay must use that version. */
680
+ workflow: string;
681
+ workflowVersion: string;
682
+ input: unknown;
683
+ /** Completed durable ops so far, ordered by seq — what the worker replays its results from. */
684
+ history: HistoryEvent[];
685
+ /** Signals delivered to the run but not yet consumed, so `wait_signal` resolves on replay. */
686
+ pendingSignals?: Array<{
687
+ seq: number;
688
+ signal: string;
689
+ payload: unknown;
690
+ }>;
691
+ group: string;
692
+ /** Id of the transport this task was dispatched on (pool failover) — see {@link NamedTransport}. */
693
+ transport?: string;
694
+ traceparent?: string;
695
+ /**
696
+ * Admission priority carried to the broker (BullMQ job `priority`) so an urgent child workflow can
697
+ * jump ahead of already-enqueued lower-priority ones at the worker. Higher wins; absent means
698
+ * default/unprioritised. Transports without priority support ignore it.
699
+ */
700
+ priority?: number | undefined;
701
+ attempt: number;
702
+ }
703
+ /** One resolved durable op in a run's history — a superset of a completed {@link StepCheckpoint}. */
704
+ interface HistoryEvent {
705
+ seq: number;
706
+ kind: 'step' | 'call' | 'timer' | 'signal' | 'child';
707
+ name?: string | undefined;
708
+ /** Resolved value: a step/call output, a child run's output, a signal payload. */
709
+ output?: unknown;
710
+ /** Set when the op resolved to a failure (e.g. a failed remote step the workflow may catch). */
711
+ error?: StepError | undefined;
712
+ }
713
+ /** A decision the workflow function produced at a `seq` that was not yet in history. */
714
+ type WorkflowCommand =
715
+ /** `ctx.call(remoteStep, input)` — dispatch a remote step and await it. A worker's
716
+ * `ctx.gather_calls([...])` fan-out tags every dispatched call with the same `parallelGroup` so the
717
+ * dashboard renders the remote steps as one parallel fan (parity with the gathered `recordStep` /
718
+ * `startChild` tags). Undefined for a lone sequential `ctx.call`. */
719
+ {
720
+ kind: 'call';
721
+ seq: number;
722
+ name: string;
723
+ group: string;
724
+ input: unknown;
725
+ parallelGroup?: string;
726
+ }
727
+ /** `ctx.step(name, body)` — a LOCAL step the worker already ran this turn; the engine persists its
728
+ * result so replay returns it instead of re-running (durability for side-effectful work).
729
+ * `startedAt`/`finishedAt` (epoch ms) carry the step's real wall-clock window so the dashboard
730
+ * shows a true duration instead of 0ms, and `events` carry the sub-process/log trail the step
731
+ * emitted (so each handler's p-processes show under it). All optional for back-compat. */
732
+ | {
733
+ kind: 'recordStep';
734
+ seq: number;
735
+ name: string;
736
+ output?: unknown;
737
+ error?: StepError;
738
+ startedAt?: number;
739
+ finishedAt?: number;
740
+ events?: StepEvent[];
741
+ /** A worker's `ctx.gather([...])` tags every step in the parallel fan with the same group, so the
742
+ * engine carries it onto the checkpoint and the dashboard renders the fan as one group. */
743
+ parallelGroup?: string;
744
+ }
745
+ /** `ctx.sleep(ms)` — a durable timer of `ms` duration. The engine computes the absolute deadline
746
+ * (now + ms) when it applies the command, so the worker never reads the clock (determinism). */
747
+ | {
748
+ kind: 'sleep';
749
+ seq: number;
750
+ ms: number;
751
+ }
752
+ /** `ctx.wait_signal(name)` — block until a signal `name` is delivered to the run. */
753
+ | {
754
+ kind: 'waitSignal';
755
+ seq: number;
756
+ signal: string;
757
+ }
758
+ /** `ctx.start_child(workflow, input)` — start a child run with its own lifecycle. A worker's
759
+ * `ctx.all([...])` fan-out tags every dispatched child with the same `parallelGroup` so the
760
+ * dashboard can render the fan as one group (parity with the gathered `recordStep` tag). */
761
+ | {
762
+ kind: 'startChild';
763
+ seq: number;
764
+ workflow: string;
765
+ input: unknown;
766
+ parallelGroup?: string;
767
+ };
768
+ /** workflow worker → engine: the result of replaying one turn of a remote workflow. */
769
+ interface WorkflowDecision {
770
+ taskId: string;
771
+ runId: string;
772
+ /** `continue` = produced `commands` and is blocked on an await; otherwise the run settles.
773
+ * `cancelled` = the worker bailed at an op boundary because the run was cancelled mid-turn. */
774
+ status: 'continue' | 'completed' | 'failed' | 'cancelled';
775
+ /** New durable ops the replay produced this turn (status === 'continue'), ordered by seq. */
776
+ commands: WorkflowCommand[];
777
+ /** Final workflow output (status === 'completed'). */
778
+ output?: unknown;
779
+ /** Terminal error (status === 'failed'). */
780
+ error?: StepError;
781
+ }
782
+ /**
783
+ * workflow worker → engine: a LOCAL step's lifecycle, streamed AS IT HAPPENS (not batched into the
784
+ * turn's final {@link WorkflowDecision}). A Python `@workflow` runs its `ctx.step`s inline over one
785
+ * turn that can last minutes; without this the engine learns of the steps only when the turn ends,
786
+ * so the dashboard shows nothing mid-run. The worker emits `running` when a step's body starts and
787
+ * `completed`/`failed` when it settles; the engine checkpoints each immediately, so a step appears
788
+ * in-flight and then resolves live. The turn's final `recordStep` command re-persists the same
789
+ * checkpoint idempotently (replay history), so this is purely additive observability.
790
+ */
791
+ interface WorkflowStepEvent {
792
+ runId: string;
793
+ seq: number;
794
+ name: string;
795
+ phase: 'running' | 'completed' | 'failed';
796
+ /** Epoch ms the step body began (all phases) and settled (`completed`/`failed`). */
797
+ startedAt: number;
798
+ finishedAt?: number;
799
+ /** The replayed result / failure for the settled phases. */
800
+ output?: unknown;
801
+ error?: StepError;
802
+ /** Sub-process + log trail the step emitted so far (the handler's p-processes). */
803
+ events?: StepEvent[];
804
+ /** A worker's `ctx.gather([...])` fan tags every step's lifecycle with the same group so the
805
+ * dashboard renders the live fan-out as one group (parity with the `recordStep` tag). */
806
+ parallelGroup?: string | undefined;
807
+ }
808
+ /**
809
+ * Advances a workflow run one turn. The engine has one per workflow: the default {@link InProcess}
810
+ * one runs a registered TS function with the in-process replay machinery; a remote one dispatches a
811
+ * {@link WorkflowTask} to a worker (Python) and awaits its {@link WorkflowDecision}. Either way the
812
+ * engine applies the returned decision — so recovery, timers, singleton and dead-letter stay engine
813
+ * concerns, identical for in-process and remote workflows.
814
+ */
815
+ interface WorkflowExecutor {
816
+ /**
817
+ * INLINE replay: advance the turn and RETURN its decision (the in-process executor runs the
818
+ * registered TS function synchronously). The engine awaits this under the run's lease. Provide this
819
+ * OR {@link dispatch} — exactly one. An executor with only `advance` is single-instance by nature
820
+ * (the decision is produced in-process, not delivered over a broker).
821
+ */
822
+ advance?(run: WorkflowRun, history: HistoryEvent[], pendingSignals?: WorkflowTask['pendingSignals']): Promise<WorkflowDecision>;
823
+ /**
824
+ * DISPATCH-AND-SUSPEND (broker-backed, multi-instance safe): enqueue the turn to a worker under the
825
+ * ENGINE-SUPPLIED `taskId`, WITHOUT awaiting the decision. The engine generates `taskId`, records it
826
+ * on the run as the awaited marker, AND releases the run's lease — all BEFORE calling this — so the
827
+ * worker's {@link WorkflowDecision} (delivered over {@link Transport.onDecision} and applied DURABLY
828
+ * by {@link WorkflowEngine.completeRemoteDecision}, which looks the run up by `decision.runId`) can
829
+ * never arrive ahead of its marker or contend with a still-held lease and be dropped. This method
830
+ * therefore ONLY enqueues — it owns no taskId generation and returns nothing. No in-memory await, so
831
+ * the decision is never lost to a point-to-point broker handing it to a non-dispatching instance.
832
+ * Provide this OR {@link advance} — exactly one.
833
+ */
834
+ dispatch?(run: WorkflowRun, history: HistoryEvent[], taskId: string, pendingSignals?: WorkflowTask['pendingSignals']): Promise<void>;
835
+ }
836
+ /**
837
+ * A transport in an ordered pool, identified by `id`. The engine dispatches on the first by default
838
+ * and fails over to the next on a dispatch error; a step can pin one via `ctx.call(…, { transport })`.
839
+ * The chosen `id` is stamped on the {@link RemoteTask} so a worker replies through the matching one.
840
+ */
841
+ interface NamedTransport {
842
+ id: string;
843
+ transport: Transport;
844
+ }
845
+ /**
846
+ * Decides where a freshly-`start`ed run executes. `start` creates the run as `pending` and hands its
847
+ * id here instead of running the body inline — so the API/caller never blocks on workflow execution.
848
+ * The default in-process dispatcher runs it on this instance (a microtask); a broker-backed one
849
+ * enqueues the id for a worker pool to consume (`engine.runOne(runId)`); a no-op one leaves it
850
+ * `pending` in the store for a worker's `runPending` poll to pick up (DB-only, caller-doesn't-execute).
851
+ */
852
+ interface RunDispatcher {
853
+ dispatch(runId: string): void | Promise<void>;
854
+ }
855
+ interface Transport {
856
+ /** engine → worker */
857
+ dispatch(task: RemoteTask): Promise<void>;
858
+ /** worker → engine: a step finished (ok or error). */
859
+ onResult(handler: (result: StepResult) => Promise<void>): void;
860
+ /** worker → engine: liveness signal for an in-flight long step. */
861
+ onHeartbeat(handler: (beat: Heartbeat) => Promise<void>): void;
862
+ /**
863
+ * Partition this transport's queues/keys by `namespace` (matching the engine's store namespace), so
864
+ * multiple logical deployments can share one broker. Idempotent. An explicit namespace passed to the
865
+ * transport's constructor TAKES PRECEDENCE over this. No-op when `namespace === "default"`. Optional —
866
+ * transports that don't partition simply omit it.
867
+ */
868
+ useNamespace?(namespace: string): void;
869
+ /** Release the transport's resources (broker workers, queues, connections) for a clean shutdown.
870
+ * Optional — an in-process transport has nothing to close. Called on `onApplicationShutdown`
871
+ * after the engine drains, so a deploy hands off instead of leaving the broker to time out. */
872
+ close?(): Promise<void>;
873
+ /** engine → workflow worker: dispatch a {@link WorkflowTask} (the polyglot-workflow path). Optional
874
+ * — only transports that carry workflow tasks (BullMQ) implement it; the {@link RemoteWorkflowExecutor}
875
+ * uses it + {@link onDecision} to advance a remote workflow over the broker. */
876
+ dispatchWorkflowTask?(task: WorkflowTask): Promise<void>;
877
+ /** workflow worker → engine: a replayed turn's {@link WorkflowDecision}. Pair with dispatchWorkflowTask. */
878
+ onDecision?(handler: (decision: WorkflowDecision) => Promise<void>): void;
879
+ /** workflow worker → engine: a LOCAL step's {@link WorkflowStepEvent}, streamed mid-turn so the
880
+ * engine can checkpoint it live. Point-to-point (a single engine instance consumes each event and
881
+ * persists it once — no cross-pod duplicate writes). Optional; only broker transports carry it. */
882
+ dispatchStepEvent?(event: WorkflowStepEvent): Promise<void>;
883
+ /** engine ← workflow worker: consume streamed {@link WorkflowStepEvent}s. Pair with dispatchStepEvent. */
884
+ onStepEvent?(handler: (event: WorkflowStepEvent) => Promise<void>): void;
885
+ /** Worker-health for a group: queue backlog + live worker heartbeats. Optional — only broker
886
+ * transports (BullMQ) that can introspect the task queue and the worker-heartbeat keys implement
887
+ * it. The engine aggregates this across its groups in {@link WorkflowEngine.workerHealth}. */
888
+ groupHealth?(group: string): Promise<GroupHealth>;
889
+ /** Distinct groups that currently have a live worker heartbeat — discovered from the heartbeat
890
+ * keyspace, so a group with workers but no engine-side registration (e.g. a local-step group)
891
+ * still surfaces. Pairs with {@link groupHealth}. */
892
+ listWorkerGroups?(): Promise<string[]>;
893
+ /**
894
+ * DB-less tenant worker → control plane: publish a {@link StartRunMessage} requesting a new run.
895
+ * Optional — only transports that carry the hosted-control-plane protocol (P4) implement this.
896
+ * The message is enqueued on `<effectivePrefix>-start-run`; the control plane's
897
+ * `onStartRun` consumer turns it into a durable run.
898
+ */
899
+ dispatchStartRun?(msg: StartRunMessage): Promise<void>;
900
+ /**
901
+ * control plane ← tenant worker: consume {@link StartRunMessage}s and start runs. Pair with
902
+ * {@link dispatchStartRun}. Optional — only control-plane-side transports implement this.
903
+ */
904
+ onStartRun?(handler: (msg: StartRunMessage) => Promise<void>): void;
905
+ }
906
+ /** How a worker decides its concurrency, carried in {@link WorkerStatus} so a dashboard can tell a
907
+ * fixed knob from a self-regulating one (and, for the latter, where the controller currently sits). */
908
+ interface WorkerConcurrencyStatus {
909
+ /** `fixed` = the configured `concurrency` number, never moves. `adaptive` = the controller tunes it. */
910
+ mode: 'fixed' | 'adaptive';
911
+ /** The concurrency ceiling in effect right now (fixed: the configured value; adaptive: the live limit). */
912
+ limit: number;
913
+ /** Adaptive only — the floor the controller won't go below. */
914
+ min?: number;
915
+ /** Adaptive only — the ceiling the controller won't exceed. */
916
+ max?: number;
917
+ }
918
+ /** The adaptive controller's most recent limit change — the "why did it move" a dashboard shows so an
919
+ * auto-tuner isn't a black box. Absent until the controller first adjusts (or for a fixed worker). */
920
+ interface WorkerAdjust {
921
+ /** Epoch ms of the adjustment. */
922
+ at: number;
923
+ /** Limit before this change. */
924
+ from: number;
925
+ /** Limit after this change. */
926
+ to: number;
927
+ /** Why it moved: `ram_ceiling` (hard brake), `backpressure` (errors/stall), `grow` (had headroom),
928
+ * `shrink` (latency gradient showed queuing), `cpu_ceiling`. */
929
+ reason: 'ram_ceiling' | 'cpu_ceiling' | 'backpressure' | 'grow' | 'shrink';
930
+ }
931
+ /** A live snapshot of a worker's execution state, carried in the worker-liveness heartbeat (so it
932
+ * rides the existing TTL'd key — no extra round-trip) and surfaced per worker in the dashboard. Every
933
+ * field beyond {@link concurrency}/{@link inFlight} is best-effort: a field a runtime can't measure is
934
+ * simply omitted. The shape is shared by the TS and Python SDKs so a mixed-language group reports
935
+ * uniformly. */
936
+ interface WorkerStatus {
937
+ /** Which SDK is running this worker — `node` (TS) or `python`. */
938
+ runtime?: 'node' | 'python';
939
+ /** The worker's concurrency knob and its live ceiling. */
940
+ concurrency: WorkerConcurrencyStatus;
941
+ /** Tasks executing right now (0..limit). With `limit`, this is the saturation a dashboard charts. */
942
+ inFlight: number;
943
+ /** Resident set size in bytes, if the runtime can read it. */
944
+ rssBytes?: number;
945
+ /** The process memory ceiling in bytes (cgroup `memory.max`, else host total), if known. */
946
+ rssLimitBytes?: number;
947
+ /** RSS as a percent of {@link rssLimitBytes} (0..100) — what the adaptive RAM brake watches. */
948
+ rssPct?: number;
949
+ /** Process CPU utilisation percent over the last control tick (0..100×cores), if measured. */
950
+ cpuPct?: number;
951
+ /** Completed tasks per minute over the recent window — the controller's throughput signal. */
952
+ throughputPerMin?: number;
953
+ /** p95 of recent task durations in ms — the latency a dashboard charts against the limit. */
954
+ p95Ms?: number;
955
+ /** The adaptive controller's last limit change (absent for a fixed worker or before the first move). */
956
+ lastAdjust?: WorkerAdjust;
957
+ }
958
+ /** One worker's liveness record — a TTL'd heartbeat a worker refreshes while it's consuming. Its
959
+ * ABSENCE (the key expired) is the signal: a worker that died or stalled stops refreshing. */
960
+ interface WorkerHeartbeat {
961
+ /** The worker group this instance serves (e.g. `pipeline`, `processing-workflows`). */
962
+ group: string;
963
+ /** Stable per-process id (host + pid), so N replicas of a group each show as a distinct worker. */
964
+ instanceId: string;
965
+ /** Epoch ms of the worker's most recent heartbeat. */
966
+ lastBeatAt: number;
967
+ /** The worker's live execution snapshot, when its heartbeat carries one (newer SDKs stamp a JSON
968
+ * value; an older SDK's bare-timestamp heartbeat leaves this undefined). */
969
+ status?: WorkerStatus;
970
+ }
971
+ /** Per-group worker-health snapshot: how much work is queued vs. how many workers are alive to do it.
972
+ * The actionable alert state is `depth > 0 && liveWorkers.length === 0` — work piling up with no
973
+ * consumer (exactly the failure where a worker is "alive but not consuming"). */
974
+ interface GroupHealth {
975
+ group: string;
976
+ /** Outstanding jobs in the group's task queue (waiting + active + delayed + prioritized). */
977
+ depth: number;
978
+ /** Workers with a non-expired heartbeat for this group. */
979
+ liveWorkers: WorkerHeartbeat[];
980
+ }
981
+ /**
982
+ * The **control plane** — a broadcast pub/sub across ALL engine instances (every pod), separate
983
+ * from the {@link Transport}'s point-to-point work queues (`dispatch`/`onResult`). It carries what
984
+ * every instance may need regardless of who runs a given run: lifecycle events (so a dashboard-only
985
+ * pod can live-tail a run executing on a worker pod) and cancellation (so the pod actually running a
986
+ * run learns it was cancelled elsewhere). In-process implementations broadcast locally; a
987
+ * cross-process one (BullMQ) fans out over its broker (Redis pub/sub). Give the engine a
988
+ * `controlPlane` to enable cross-instance events/cancellation; omit it and the engine is local-only.
989
+ * A transport that can broadcast may implement this too and be passed as both.
990
+ */
991
+ interface ControlPlane {
992
+ publishControl(msg: ControlMessage): Promise<void>;
993
+ onControl(handler: (msg: ControlMessage) => void): void;
994
+ }
995
+ /** A message on the {@link ControlPlane}. `from` is the originating engine's `instanceId`, so a
996
+ * broker that echoes a publish back to its own subscriber (e.g. Redis pub/sub) can be deduped by
997
+ * the originator. */
998
+ type ControlMessage = {
999
+ from?: string;
1000
+ } & ({
1001
+ kind: 'event';
1002
+ event: EngineEvent;
1003
+ } | {
1004
+ kind: 'cancel';
1005
+ runId: string;
1006
+ } | {
1007
+ kind: 'enqueued';
1008
+ runId: string;
1009
+ });
1010
+ type BackoffStrategy = 'fixed' | 'exp';
1011
+ /**
1012
+ * Options for `ctx.child` / `ctx.startChild`. A bare string passed instead is shorthand for
1013
+ * `{ childId }`, so the existing `ctx.child(ref, input, 'my-id')` form keeps working.
1014
+ */
1015
+ interface ChildCallOptions {
1016
+ /** Deterministic child run id; defaults to one derived from the parent run id + call position. */
1017
+ childId?: string | undefined;
1018
+ /**
1019
+ * Dispatch priority for a REMOTE child workflow — stamped on the child run and carried onto every
1020
+ * {@link WorkflowTask} dispatched to advance it, so an urgent child can jump ahead of enqueued
1021
+ * lower-priority ones at the worker. Higher wins; absent = unprioritised. Ignored for an in-process
1022
+ * (TS class) child, which runs in the engine and never hits a broker queue.
1023
+ */
1024
+ priority?: number | undefined;
1025
+ }
1026
+ interface StepOptions {
1027
+ /** Max attempts before the step (and run) fails. */
1028
+ retries?: number;
1029
+ /** How the delay between retries grows: `fixed` (constant) or `exp` (doubles each attempt). */
1030
+ backoff?: BackoffStrategy;
1031
+ /** Base delay in ms between retries. Omit (or 0) to retry with no delay. */
1032
+ backoffMs?: number;
1033
+ /** Upper bound on the (exponential) backoff delay. */
1034
+ backoffMaxMs?: number;
1035
+ /** Add random jitter (50–100% of the computed delay) to avoid thundering-herd retries. */
1036
+ jitter?: boolean;
1037
+ /**
1038
+ * Liveness window for a **remote** step (`ctx.call`): if the worker produces no result and no
1039
+ * heartbeat within this many ms, the engine presumes it dead and fails the dispatch with a
1040
+ * `RemoteStepTimeout` (retryable — it re-dispatches per `retries`). Each heartbeat resets the
1041
+ * window. Ignored for local steps. Omit to wait indefinitely.
1042
+ */
1043
+ timeoutMs?: number;
1044
+ /**
1045
+ * Saga compensation: if this step completes but the run later **fails**, the engine runs the
1046
+ * registered `compensate` callbacks in reverse order (undo what was done). Local steps only.
1047
+ * Idempotency note: a step is already deduplicated by its deterministic `stepId` (runId:seq) —
1048
+ * remote workers can use it as the idempotency key, so there's no separate key option.
1049
+ */
1050
+ compensate?: () => Promise<void>;
1051
+ }
1052
+ /**
1053
+ * A typed handle to a step that runs on a remote worker. The `name` string is the contract:
1054
+ * the worker registers a handler under the same name. `input`/`output` validate at the boundary.
1055
+ */
1056
+ interface RemoteStepDef<TInput = unknown, TOutput = unknown> extends StepOptions {
1057
+ name: string;
1058
+ /** Worker group expected to handle this step. */
1059
+ group: string;
1060
+ input: z.ZodType<TInput>;
1061
+ output: z.ZodType<TOutput>;
1062
+ /** Branding so `ctx.call` can infer types. */
1063
+ readonly __remote: true;
1064
+ }
1065
+ /**
1066
+ * A durable webhook handle minted by {@link WorkflowCtx.webhook}. Hand `url` to a third party,
1067
+ * then `await wait()` — the run suspends with zero compute until the external system POSTs the
1068
+ * callback (delivered as `engine.signal(token, body)`), and resumes with the body.
1069
+ */
1070
+ interface DurableWebhook<TPayload = unknown> {
1071
+ /** Deterministic signal token (`wh:<runId>:<seq>`) the callback delivers on — stable across replay. */
1072
+ readonly token: string;
1073
+ /**
1074
+ * Public callback URL for `token`, built by the engine's `webhookUrl` option. Hand this to the
1075
+ * third party. `undefined` when no builder is configured (use {@link DurableWebhook.token} to
1076
+ * build your own).
1077
+ */
1078
+ readonly url?: string | undefined;
1079
+ /** Suspend until the callback arrives, then resume with its payload. */
1080
+ wait(): Promise<TPayload>;
1081
+ }
1082
+ /**
1083
+ * The context handed to a workflow function. Every interaction with the outside world goes
1084
+ * through it so the engine can checkpoint — the workflow body itself stays deterministic.
1085
+ */
1086
+ interface WorkflowCtx {
1087
+ readonly runId: string;
1088
+ /**
1089
+ * Run a local durable step: executed once, then its result is checkpointed and replayed. The
1090
+ * body receives a {@link StepLogger} to record debug/error lines and sub-process outcomes — these
1091
+ * are checkpointed with the step and shown under it in the dashboard.
1092
+ */
1093
+ step<TOutput>(name: string, fn: (log: StepLogger) => Promise<TOutput>, options?: StepOptions): Promise<TOutput>;
1094
+ /**
1095
+ * **Exactly-once** durable step for DB work: runs `fn` and writes the step's checkpoint in ONE
1096
+ * store transaction, so the business write and the "done" marker commit atomically — a crash can
1097
+ * never leave the write done-but-not-checkpointed (which a plain `ctx.step` would re-run). `fn`
1098
+ * receives the store-native transaction handle (`tx` — a TypeORM/MikroORM `EntityManager`, a Prisma
1099
+ * tx client, or a Drizzle tx); do your writes on it. Needs a SQL store that supports transactions
1100
+ * (the bundled SQL adapters do); throws otherwise.
1101
+ */
1102
+ transaction<TOutput>(name: string, fn: (tx: unknown) => Promise<TOutput>): Promise<TOutput>;
1103
+ /**
1104
+ * Call a durable **entity** op and await its result — the entity (`engine.registerEntity`) runs the
1105
+ * op serialized per `key` over durable state. e.g. `await ctx.callEntity('cart', userId, 'add', item)`.
1106
+ */
1107
+ callEntity<TResult = unknown>(name: string, key: string, op: string, arg?: unknown): Promise<TResult>;
1108
+ /** Send a durable entity op without awaiting a result (fire-and-forget, dispatched once). */
1109
+ signalEntity(name: string, key: string, op: string, arg?: unknown): Promise<void>;
1110
+ /**
1111
+ * Dispatch a typed remote step and await its checkpointed result. Options:
1112
+ * - `queue` — subject the dispatch to a registered flow-control queue (concurrency / rate limit).
1113
+ * - `priority` — admission priority within that queue; higher is admitted first when a slot is
1114
+ * contended (default 0). No effect without a `queue`.
1115
+ * - `fairnessKey` — the fairness bucket for a queue with `fairness: 'key'` (e.g. a tenant id);
1116
+ * the queue round-robins across distinct keys so one key can't monopolize the budget. Defaults
1117
+ * to the run id when omitted. No effect without a `queue`.
1118
+ * - `transport` — pin the dispatch to a named transport in the pool (else the pool's first, with
1119
+ * failover to the rest). See `engine.registerQueue` / the engine's `transports` option.
1120
+ */
1121
+ call<TInput, TOutput>(step: RemoteStepDef<TInput, TOutput>, input: TInput, opts?: {
1122
+ queue?: string;
1123
+ priority?: number;
1124
+ fairnessKey?: string;
1125
+ transport?: string;
1126
+ }): Promise<TOutput>;
1127
+ /**
1128
+ * Durable sleep: suspends the run for `duration` (e.g. `'30s'`, `'2h'`, `'7 days'`, or ms as a
1129
+ * number) without consuming resources, resuming automatically once the timer is due — even
1130
+ * across restarts.
1131
+ */
1132
+ sleep(duration: string | number): Promise<void>;
1133
+ /**
1134
+ * Durable sleep until an **absolute** time (a `Date` or epoch ms) — like {@link sleep} but for a
1135
+ * fixed deadline (e.g. "resume at midnight"). Resumes automatically once the time passes, across
1136
+ * restarts. The recorded wake time is fixed on the first run, so it's replay-stable.
1137
+ */
1138
+ sleepUntil(when: Date | number): Promise<void>;
1139
+ /**
1140
+ * End this run and **continue as a fresh execution** of the same workflow with `input` and a clean
1141
+ * history — for long-running / looping workflows that would otherwise accumulate unbounded
1142
+ * checkpoints (and slow replays). The next run gets id `<runId>~N`. Terminal: it always throws, so
1143
+ * code after it never runs. Carry forward whatever state the next iteration needs in `input`.
1144
+ */
1145
+ continueAsNew(input?: unknown): Promise<never>;
1146
+ /**
1147
+ * Suspend the run until an external `engine.signal(token, payload)` arrives (e.g. a webhook or
1148
+ * human approval), then resume with the payload. Waits indefinitely by default — no compute
1149
+ * consumed. Pass `{ timeoutMs }` to bound the wait: if the deadline passes first the call throws
1150
+ * a `SignalTimeoutError` (catch it in the workflow to branch).
1151
+ */
1152
+ waitForSignal<TPayload>(token: string, opts?: {
1153
+ timeoutMs?: number;
1154
+ }): Promise<TPayload>;
1155
+ /**
1156
+ * Wait for a named **event** published via `engine.publishEvent(name, payload)`, then resume with
1157
+ * the payload. Unlike a signal (point-to-point by token), events are name-based pub/sub: pass an
1158
+ * optional `match` (a subset of the payload that must deep-equal) so a publish fans out only to the
1159
+ * runs it concerns — e.g. `ctx.waitForEvent('payment.settled', { match: { orderId } })`. `timeoutMs`
1160
+ * bounds the wait (throws `SignalTimeoutError`). No compute consumed while waiting.
1161
+ */
1162
+ waitForEvent<TPayload>(name: string, opts?: {
1163
+ match?: Record<string, unknown>;
1164
+ timeoutMs?: number;
1165
+ }): Promise<TPayload>;
1166
+ /**
1167
+ * An external task with **async completion**: run `dispatch` once (checkpointed — e.g. send to a
1168
+ * queue, kick off a non-durable worker or a foreign service like a Python process), then suspend
1169
+ * with zero compute until `engine.completeTask(runId, name, result)` (or `failTask`) reports back,
1170
+ * and resume with the result. The durable, first-class counterpart of the hand-rolled
1171
+ * "dispatch over SQS → wait for COMPLETE_PHASE → signal" pattern. `name` must be unique per run.
1172
+ */
1173
+ task<TResult>(name: string, dispatch: () => Promise<void>, options?: StepOptions): Promise<TResult>;
1174
+ /**
1175
+ * Run another registered workflow as a **tracked child** and await its result: starts it once and
1176
+ * suspends — zero compute — until the child reaches a terminal state, then resumes with the child's
1177
+ * output (or throws a FatalError if the child failed). `childId` defaults to a deterministic id
1178
+ * derived from this run and the call position, so it's stable across replay.
1179
+ *
1180
+ * Pass the child's **class** (`ctx.child(ShippingWorkflow, input)`) for a typed input + result; pass
1181
+ * a **string** name for a cross-runtime child (e.g. a Python workflow) where there's no class.
1182
+ */
1183
+ child<C extends WorkflowClass>(workflow: C, input: WorkflowInputOf<C>, options?: string | ChildCallOptions): Promise<WorkflowOutputOf<C>>;
1184
+ child<TOutput>(workflow: string, input: unknown, options?: string | ChildCallOptions): Promise<TOutput>;
1185
+ /**
1186
+ * Start a child workflow **fire-and-forget**: dispatches it once (checkpointed, replay-safe) and
1187
+ * returns its run id immediately — the parent keeps running instead of suspending. Use it to kick
1188
+ * off side work (an audit log, a notification) you don't need to wait on, or to fan out: collect
1189
+ * the ids, then later `await ctx.child(...)` each with the same id to join (the start is idempotent
1190
+ * by id, so the child runs exactly once). Class or string ref, like {@link child}.
1191
+ */
1192
+ startChild<C extends WorkflowClass>(workflow: C, input: WorkflowInputOf<C>, options?: string | ChildCallOptions): Promise<string>;
1193
+ startChild(workflow: string, input: unknown, options?: string | ChildCallOptions): Promise<string>;
1194
+ /**
1195
+ * Run N children of the SAME workflow **in parallel** and wait for ALL of them: dispatches one
1196
+ * child per entry in `inputs` (concurrently, each with its own durable lifecycle), suspends — zero
1197
+ * compute — until every child reaches a terminal state, then resumes with their outputs in **input
1198
+ * order**. Child ids are group-scoped and stable (`<runId>.all.<firstSeq>.<i>`), and the running
1199
+ * placeholders share a `parallelGroup` tag so the dashboard renders the fan-out as one group.
1200
+ *
1201
+ * `mode` (default `waitAll`): `waitAll` waits for all then throws an aggregate {@link GatherError}
1202
+ * if any failed; `failFast` throws as soon as a failed child is seen (siblings are not cancelled in
1203
+ * v1 — their eventual results are ignored). Empty `inputs` returns `[]` with no side effects. The
1204
+ * wait-all / fan-out counterpart to {@link child}; parity with the Python SDK's `gather_children`.
1205
+ */
1206
+ all<C extends WorkflowClass>(workflow: C, inputs: WorkflowInputOf<C>[], opts?: {
1207
+ mode?: 'waitAll' | 'failFast';
1208
+ }): Promise<WorkflowOutputOf<C>[]>;
1209
+ all<TOutput = unknown>(workflow: string, inputs: unknown[], opts?: {
1210
+ mode?: 'waitAll' | 'failFast';
1211
+ }): Promise<TOutput[]>;
1212
+ /**
1213
+ * Pause the run at this point until a human resumes it from the dashboard (or
1214
+ * `engine.continue(runId)`). Records a visible `pending` checkpoint so the breakpoint shows up
1215
+ * in the timeline, then suspends with zero compute — the durable equivalent of a debugger
1216
+ * breakpoint. Gate it on your own config to make breakpoints opt-in per run:
1217
+ * `if (cfg.breakAfterExtraction) await ctx.breakpoint('after-extraction')`.
1218
+ */
1219
+ breakpoint(label?: string): Promise<void>;
1220
+ /**
1221
+ * Mint a durable webhook: returns a handle with a deterministic `token` and (if the engine has a
1222
+ * `webhookUrl` builder) a public `url`. Hand the url to a third party — inside a `ctx.step` — then
1223
+ * `await handle.wait()` to suspend with zero compute until they POST the callback (the dashboard
1224
+ * turns that POST into `engine.signal(token, body)`). The first-class, replay-safe version of
1225
+ * "expose a callback URL and wait for it".
1226
+ */
1227
+ webhook<TPayload>(): DurableWebhook<TPayload>;
1228
+ /**
1229
+ * Publish a named, queryable value from inside the run — the latest value for `key` is readable
1230
+ * externally via `engine.getEvent(runId, key)` while the run is still in flight (progress, a
1231
+ * partial result, a status). Checkpointed and replay-safe (overwrites the previous value for the
1232
+ * same key). The read side has no effect on the run — the durable, suspend-model counterpart of a
1233
+ * Temporal query.
1234
+ */
1235
+ setEvent<TValue>(key: string, value: TValue): Promise<void>;
1236
+ /**
1237
+ * Expose a named **update point**: suspend until an external `engine.update(runId, name, arg)`
1238
+ * delivers `arg`, then resume with it. The update is run-scoped (`name` need only be unique within
1239
+ * the run) and gated by any validator registered via `engine.registerUpdateValidator` — a rejected
1240
+ * update never reaches here. Pass `{ timeoutMs }` to bound the wait (throws `SignalTimeoutError`).
1241
+ * The durable counterpart of a Temporal update handler.
1242
+ */
1243
+ onUpdate<TArg>(name: string, opts?: {
1244
+ timeoutMs?: number;
1245
+ }): Promise<TArg>;
1246
+ /**
1247
+ * Guard an in-place workflow change without a new version. Wrap the changed code in
1248
+ * `if (await ctx.patched('my-change')) { …new… } else { …old… }`: a fresh run records a marker and
1249
+ * takes the new branch (`true`); a run already recorded under the old code keeps the old branch
1250
+ * (`false`), because its history has a real step where the marker would sit. The marker is
1251
+ * position-transparent for old runs (it doesn't shift their recorded steps), so guarding code is
1252
+ * replay-safe. Once every old run has drained, remove the guard (keep the new branch).
1253
+ */
1254
+ patched(id: string): Promise<boolean>;
1255
+ /**
1256
+ * Deterministic wall-clock (epoch ms): records the time on the first run and replays the SAME
1257
+ * value afterwards. Use this instead of `Date.now()` inside a workflow — a raw `Date.now()` returns
1258
+ * a different value on every replay, which silently corrupts a durable run.
1259
+ */
1260
+ now(): Promise<number>;
1261
+ /**
1262
+ * Deterministic random in `[0, 1)`: recorded once, then replayed. Use instead of `Math.random()`
1263
+ * (same replay-safety reason as {@link now}).
1264
+ */
1265
+ random(): Promise<number>;
1266
+ /** Deterministic UUID v4: recorded once, then replayed. Use instead of `crypto.randomUUID()`. */
1267
+ uuid(): Promise<string>;
1268
+ /**
1269
+ * Merge `attrs` into THIS run's {@link WorkflowRun.searchAttributes} — the indexed metadata the
1270
+ * dashboard and {@link RunQuery} filter on. Shallow merge: keys you don't pass are kept. Durable +
1271
+ * exactly-once — recorded at this position on the first run and SKIPPED on replay, so it does one
1272
+ * write, not one per turn. Use this instead of injecting the {@link StateStore} to mutate the run
1273
+ * you're executing (`@Inject(state-store)` + `store.updateRun(ctx.runId, …)` becomes
1274
+ * `ctx.upsertSearchAttributes(…)`).
1275
+ */
1276
+ upsertSearchAttributes(attrs: SearchAttributes): Promise<void>;
1277
+ }
1278
+ /** Result of executing or resuming a workflow run. */
1279
+ interface RunResult {
1280
+ runId: string;
1281
+ status: RunStatus;
1282
+ output?: unknown;
1283
+ error?: StepError | undefined;
1284
+ }
1285
+ /**
1286
+ * Validates an incoming `engine.update` before it is delivered to the run. Throw (or return a
1287
+ * non-empty string) to reject — the run is left untouched. Return nothing/void to accept. May be
1288
+ * async (e.g. a business-rule check against a DB).
1289
+ */
1290
+ type UpdateValidator<TArg = unknown> = (arg: TArg) => void | string | Promise<void | string>;
1291
+ /** Outcome of `engine.update`: rejected by the validator, or accepted and delivered. */
1292
+ type UpdateResult = {
1293
+ accepted: false;
1294
+ reason: string;
1295
+ } | {
1296
+ accepted: true;
1297
+ run: RunResult | null;
1298
+ };
1299
+ type EngineEventType = 'run.started' | 'run.completed' | 'run.failed' | 'run.suspended' | 'step.started' | 'step.completed' | 'step.failed' | 'step.progress';
1300
+ /**
1301
+ * A lifecycle event emitted by the engine. The observability surfaces (dashboard, OTel, the
1302
+ * Telescope integration) all subscribe to these rather than reaching into the store.
1303
+ */
1304
+ interface EngineEvent {
1305
+ type: EngineEventType;
1306
+ runId: string;
1307
+ workflow?: string | undefined;
1308
+ seq?: number | undefined;
1309
+ name?: string | undefined;
1310
+ kind?: StepKind | undefined;
1311
+ output?: unknown;
1312
+ error?: StepError | undefined;
1313
+ /** Wall-clock duration of the unit that just finished (step or run), when known. */
1314
+ durationMs?: number | undefined;
1315
+ /** For a remote step: how long it waited in the queue before a worker picked it up. */
1316
+ queueMs?: number | undefined;
1317
+ /** The live step event carried by a `step.progress` (the single log line / sub-process outcome a
1318
+ * running step just emitted). Absent on lifecycle events. */
1319
+ event?: StepEvent | undefined;
1320
+ at: Date;
1321
+ }
1322
+ type EngineListener = (event: EngineEvent) => void;
1323
+ /** What a {@link StepInterceptor} is told about the local step it is wrapping. */
1324
+ interface StepInvocation {
1325
+ readonly runId: string;
1326
+ readonly workflow: string;
1327
+ /** The step name passed to `ctx.step(name, ...)` (also `'now'`/`'random'`/`'uuid'` internals). */
1328
+ readonly stepName: string;
1329
+ /** The step's logical position within the run. */
1330
+ readonly seq: number;
1331
+ /** 1-based attempt number — increments across `ctx.step` retries. */
1332
+ readonly attempt: number;
1333
+ }
1334
+ /**
1335
+ * Wraps the **real execution** of a local `ctx.step` (Template/Nest-style onion middleware). Call
1336
+ * `next()` to run the step body (or the next interceptor) and return — or transform — its result;
1337
+ * throw to fail the step. First-registered runs outermost. Interceptors fire only when a step
1338
+ * actually executes, NOT on replay (a replayed step returns its recorded output without running),
1339
+ * so they see true execution timing. Register with `engine.use`.
1340
+ */
1341
+ type StepInterceptor = (invocation: StepInvocation, next: () => Promise<unknown>) => Promise<unknown>;
1342
+
1343
+ /** One operation on a durable entity: mutate `state` in place and/or return a result. */
1344
+ type EntityHandler<S = unknown> = (state: S, arg: unknown) => unknown | Promise<unknown>;
1345
+ /** A durable keyed entity (a virtual object): per-key serialized handlers over durable state. */
1346
+ interface EntityConfig<S = unknown> {
1347
+ /** Build the initial state for a fresh key. */
1348
+ initialState: () => S;
1349
+ /** Operation handlers, keyed by op name. Each runs serially per key, exactly once. */
1350
+ handlers: Record<string, EntityHandler<S>>;
1351
+ }
1352
+ /** The slice of the engine the entity subsystem needs (so it stays a standalone module). */
1353
+ interface EntityHost {
1354
+ register(name: string, version: string, fn: (ctx: WorkflowCtx, input: unknown) => Promise<unknown>): void;
1355
+ signalWithStart(workflow: string, input: unknown, runId: string, signal: {
1356
+ token: string;
1357
+ payload?: unknown;
1358
+ }): Promise<{
1359
+ runId: string;
1360
+ }>;
1361
+ getEvent<T>(runId: string, key: string): Promise<T | undefined>;
1362
+ signal(token: string, payload: unknown): Promise<unknown>;
1363
+ }
1364
+ /**
1365
+ * Durable keyed **entities** (virtual objects): a keyed actor whose handlers run **serialized per
1366
+ * key** over **durable state**, exactly once. Each key is one long-lived run (`entity:<name>:<key>`)
1367
+ * processing ops in order. Drive it with `signal` (fire) or `ctx.callEntity` (call + await result),
1368
+ * read state with `getState`. Registers its built-in runner on construction.
1369
+ */
1370
+ declare class Entities {
1371
+ private readonly host;
1372
+ private readonly configs;
1373
+ constructor(host: EntityHost);
1374
+ register<S>(name: string, config: EntityConfig<S>): void;
1375
+ /** Deliver an op to an entity's runner, starting it on first contact. `reply` (a signal token)
1376
+ * receives the handler's result, for `ctx.callEntity`. */
1377
+ dispatch(name: string, key: string, op: string, arg: unknown, reply?: string): Promise<{
1378
+ runId: string;
1379
+ }>;
1380
+ signal(name: string, key: string, op: string, arg?: unknown): Promise<void>;
1381
+ getState<S = unknown>(name: string, key: string): Promise<S | undefined>;
1382
+ /** The built-in runner: one long-lived run per key, processing ops serially over state. */
1383
+ private run;
1384
+ }
1385
+
1386
+ /** How `onEvent` triggers are coalesced into fewer runs (see `@Workflow({ debounce | batch })`). */
1387
+ type EventBatchConfig = {
1388
+ mode: 'debounce';
1389
+ windowMs: number;
1390
+ } | {
1391
+ mode: 'batch';
1392
+ maxSize: number;
1393
+ windowMs: number;
1394
+ };
1395
+ /** The slice of the engine the accumulators need (so they stay a standalone module). */
1396
+ interface AccumulatorHost {
1397
+ register(name: string, version: string, fn: (ctx: WorkflowCtx, input: unknown) => Promise<unknown>): void;
1398
+ signalWithStart(workflow: string, input: unknown, runId: string, signal: {
1399
+ token: string;
1400
+ payload?: unknown;
1401
+ }): Promise<{
1402
+ runId: string;
1403
+ }>;
1404
+ }
1405
+ /**
1406
+ * Built-in workflows that coalesce `onEvent` triggers. Each is a long-lived per-target accumulator
1407
+ * fed by `signalWithStart`: it collects payloads then starts the target once, and `continueAsNew`s to
1408
+ * re-arm for the next burst. Signal buffering keeps the re-arm race-free. Registers its runners on
1409
+ * construction.
1410
+ */
1411
+ declare class EventAccumulators {
1412
+ private readonly host;
1413
+ constructor(host: AccumulatorHost);
1414
+ /** Route an `onEvent` payload into its target's debounce/batch accumulator. */
1415
+ route(target: string, config: EventBatchConfig, payload: unknown): Promise<{
1416
+ runId: string;
1417
+ }>;
1418
+ /** Fire the target with the LAST payload once events have been quiet for `windowMs`. */
1419
+ private debounce;
1420
+ /** Fire the target with all payloads (`{ events }`) once `maxSize` or `windowMs` from the first. */
1421
+ private batch;
1422
+ }
1423
+
1424
+ type WorkflowFn = (ctx: WorkflowCtx, input: unknown) => Promise<unknown>;
1425
+ /** Options for {@link WorkflowEngine.start}. */
1426
+ interface StartOptions {
1427
+ /** Run-scoped tags, merged with the workflow's static `@Workflow({ tags })` onto the run. */
1428
+ tags?: string[] | undefined;
1429
+ /** Typed, queryable run data stamped on the run (e.g. `{ amount: 200, tier: 'pro' }`). */
1430
+ searchAttributes?: SearchAttributes | undefined;
1431
+ /**
1432
+ * Dispatch priority for a remote run — stamped on the run and carried onto every {@link WorkflowTask}
1433
+ * the engine dispatches to advance it. Higher wins; absent = unprioritised. See {@link WorkflowRun.priority}.
1434
+ */
1435
+ priority?: number | undefined;
1436
+ /**
1437
+ * Override the namespace stamped on the run. Defaults to the engine's own `namespace`. Set by the
1438
+ * control plane when it starts a run on behalf of a tenant (a {@link StartRunMessage}'s `tenant`
1439
+ * maps here), so one DB-less-tenant-facing engine can create runs across many namespaces.
1440
+ */
1441
+ namespace?: string | undefined;
1442
+ }
1443
+ /**
1444
+ * Serialize runs of a workflow that share a key — a durable, FIFO mutex (e.g. one pipeline per base).
1445
+ * Excess runs are admitted in creation order, `limit` at a time; the rest wait (suspended) and retry
1446
+ * admission on a timer until a slot frees. Race-free on a consistent store (admission order is the
1447
+ * same `(createdAt, id)` view for every instance).
1448
+ */
1449
+ interface SingletonConfig {
1450
+ /** Derive the serialization key from the workflow input. */
1451
+ key: (input: unknown) => string;
1452
+ /** Max concurrent runs sharing the key. Default 1 (a mutex). */
1453
+ limit?: number;
1454
+ /**
1455
+ * Max GATED (waiting-for-admission) runs allowed to queue behind the `limit` in-flight ones. When
1456
+ * set, `start` rejects with {@link SingletonQueueFullError} once in-flight + gated reaches
1457
+ * `limit + maxQueueDepth` — back-pressure against an unbounded same-key backlog. Omit for the
1458
+ * default unbounded queue. Counts `pending`/`running`/`suspended` runs sharing the key.
1459
+ */
1460
+ maxQueueDepth?: number;
1461
+ }
1462
+ interface WorkflowEngineDeps {
1463
+ store: StateStore;
1464
+ /** A single task transport. Shorthand for a one-entry `transports` pool (id `default`). */
1465
+ transport?: Transport | undefined;
1466
+ /**
1467
+ * An ordered pool of named transports. The engine dispatches on the first and fails over to the
1468
+ * next on a dispatch error; a step pins one via `ctx.call(step, input, { transport: id })`. Use
1469
+ * this instead of `transport` for failover / multi-broker setups.
1470
+ */
1471
+ transports?: NamedTransport[] | undefined;
1472
+ /**
1473
+ * Cross-instance broadcast pub/sub for lifecycle events + cancellation (see {@link ControlPlane}).
1474
+ * Separate from the task `transport`; omit for a single-instance / local-only setup. A transport
1475
+ * that can also broadcast may be passed here as well.
1476
+ */
1477
+ controlPlane?: ControlPlane | undefined;
1478
+ /** Epoch-ms clock; injectable for tests. Defaults to `Date.now`. */
1479
+ clock?: (() => number) | undefined;
1480
+ /**
1481
+ * Flow-control admission backend for `ctx.call(step, input, { queue })`. Defaults to an in-process
1482
+ * {@link InMemoryAdmissionBackend} (per-instance caps). Inject a store/Redis-backed backend to make
1483
+ * concurrency / rate-limit / ordering GLOBAL across engine replicas.
1484
+ */
1485
+ admission?: AdmissionBackend | undefined;
1486
+ /** Unique id for this engine instance, used for recovery leases. Defaults to a random id. */
1487
+ instanceId?: string | undefined;
1488
+ /**
1489
+ * Worker-pool partition for this engine. Stamped on every run it creates; the poll paths
1490
+ * (`runPending`/`recoverIncomplete`/`resumeDueTimers`/`sweepTimeouts`) only act on runs in this
1491
+ * namespace. Set distinct values to safely share ONE state store across non-interchangeable pools
1492
+ * (e.g. local dev vs a cluster). **Omit it to make this engine an OPERATOR** — an unset namespace
1493
+ * drives/recovers/resumes runs of EVERY namespace (the poll filters no-op, the resume/decision
1494
+ * guards fall through) and leaves the transport on its bare prefix. This is the hosted control
1495
+ * plane: "ver tudo = ausência de namespace".
1496
+ */
1497
+ namespace?: string | undefined;
1498
+ /** Recovery lease duration in ms — how long this instance owns a run it picked up. Default 30s. */
1499
+ leaseMs?: number | undefined;
1500
+ /**
1501
+ * Cap how many times crash-recovery may pick up the same still-`running` run before giving up and
1502
+ * moving it to the `dead` dead-letter state (a poison pill that crashes the process every boot).
1503
+ * Omit for unlimited (the default — recovery always retries).
1504
+ */
1505
+ maxRecoveryAttempts?: number | undefined;
1506
+ /**
1507
+ * Opt-in liveness deadline (ms) for a remote workflow `advance`. If the worker neither returns a
1508
+ * decision NOR sends a run-scoped {@link Heartbeat} within this window, the engine presumes it dead:
1509
+ * it rejects the advance with {@link RemoteWorkflowTimeout}, releases the lease, and lets recovery
1510
+ * re-drive (replaying completed steps from history). Each beat REARMS the window, so a worker still
1511
+ * legitimately replaying a long turn (it keeps beating) is never re-driven — closing the duplicate-
1512
+ * side-effect hazard a fixed {@link RemoteWorkflowExecutor} `timeoutMs` cannot avoid. Omit (default)
1513
+ * for the prior unbounded await — byte-identical behavior when unset. If a `RemoteWorkflowExecutor` is
1514
+ * ALSO constructed with its own `timeoutMs`, the shorter of the two deadlines wins; prefer setting only
1515
+ * one (this rearmed window for heartbeat-aware workers, or the fixed `timeoutMs` otherwise).
1516
+ */
1517
+ remoteAdvanceSilenceMs?: number | undefined;
1518
+ /**
1519
+ * Build the public callback URL for a `ctx.webhook()` token (e.g.
1520
+ * ``(t) => `https://api.example.com/durable/webhooks/${t}` ``). Populates
1521
+ * {@link DurableWebhook.url}. Omit if you build URLs yourself from the token.
1522
+ */
1523
+ webhookUrl?: ((token: string) => string) | undefined;
1524
+ /**
1525
+ * Provide the current W3C `traceparent` to stamp on each dispatched {@link RemoteTask}, so a
1526
+ * worker (including the Python SDK) continues the distributed trace. Keep core OTel-free: supply
1527
+ * `otelTraceparent` from `@dudousxd/nestjs-durable-otel`, or your own context reader. Omit to send none.
1528
+ */
1529
+ traceparent?: (() => string | undefined) | undefined;
1530
+ /**
1531
+ * Provide an opaque context carrier (tenant / user / correlation ids) to attach to each dispatched
1532
+ * {@link RemoteTask} as its `context`, so a worker (including the Python SDK) re-exposes it to the
1533
+ * step handler — cross-process propagation alongside the {@link traceparent}. Keep core dependency-free:
1534
+ * supply this from `@dudousxd/nestjs-context` or your own request-scoped reader. The engine never
1535
+ * inspects the returned object's shape. Omit to send none.
1536
+ *
1537
+ * Re-evaluated at each (re)dispatch — including a retry or a resume that the engine drives AFTER a
1538
+ * crash/scale-down, which runs OUTSIDE the originating request scope. On such a path this provider
1539
+ * may return empty or stale values (the request-scoped tenant/user is gone). Treat the carrier as
1540
+ * best-effort correlation/propagation metadata only — do NOT treat it as an authorization boundary.
1541
+ */
1542
+ context?: (() => Record<string, unknown> | undefined) | undefined;
1543
+ /**
1544
+ * Re-hydrate the originating context around a LOCAL step body, so a `@DurableStep` reader sees the
1545
+ * tenant / user / correlation ids that were live when the run was started — even on a path the
1546
+ * engine drives outside the originating request scope (a resume after crash/scale-down, a timer).
1547
+ * Given the carrier produced by {@link context} (may be empty/undefined) and the step body `fn`, it
1548
+ * runs `fn` with that context ambiently established (e.g. inside `@dudousxd/nestjs-context`'s ALS)
1549
+ * and returns its result. Keep core dependency-free: supply this from the nestjs wiring (which owns
1550
+ * nestjs-context) or your own ALS bridge. The handler signature is unchanged — re-hydration is
1551
+ * ambient. Default: passthrough (`(_, fn) => fn()`), so behavior is byte-identical when unset.
1552
+ */
1553
+ rehydrate?: (<T>(carrier: Record<string, unknown> | undefined, fn: () => T) => T) | undefined;
1554
+ /**
1555
+ * Attempts for each saga compensation when the run fails (a transient undo — e.g. a refund API
1556
+ * hiccup — gets another try). Default 1 (no retry). Compensations must be idempotent.
1557
+ */
1558
+ compensationRetries?: number | undefined;
1559
+ /**
1560
+ * Persist a `running` checkpoint when a local step's body begins, so an in-flight step shows up
1561
+ * in the dashboard (and a fresh page load / REST query) the moment it starts — not only once it
1562
+ * completes. The `step.started` lifecycle event is emitted either way (the live SSE view always
1563
+ * sees the start); this flag only controls the extra checkpoint write. Default `true`. Set
1564
+ * `false` on hot paths with many short local steps to halve their checkpoint writes — you keep
1565
+ * the live event but lose reload-survivable in-flight visibility.
1566
+ */
1567
+ trackStepStart?: boolean | undefined;
1568
+ /**
1569
+ * Where a freshly-`start`ed run executes (see {@link RunDispatcher}). Defaults to in-process: the
1570
+ * run executes on this instance asynchronously (a microtask), so `start` returns without blocking.
1571
+ * Pass a no-op dispatcher on a caller that must NOT run workflows (e.g. an API/dashboard pod), and
1572
+ * run `runPending` on a worker pod to pick those up; or a broker-backed one for a worker pool.
1573
+ */
1574
+ runDispatcher?: RunDispatcher | undefined;
1575
+ /**
1576
+ * When `true`, the engine routes an unregistered workflow to the live worker group of the SAME
1577
+ * name: if the transport reports a live heartbeat for group `"processing"` and no local
1578
+ * `register`/`registerRemote` exists for that name, a `start`/`resume` for it is dispatched to
1579
+ * that group over the primary transport — no `engine.remote()` boilerplate needed. Default `false`
1580
+ * (opt-in; existing behavior is unchanged). Requires a transport that implements
1581
+ * `listWorkerGroups` (e.g. BullMQ); when the transport has none, or the group is not live, the
1582
+ * existing "not registered" error is still thrown.
1583
+ */
1584
+ remoteByConvention?: boolean | undefined;
1585
+ }
1586
+ /**
1587
+ * The orchestrator. Owns workflow state and replays runs deterministically: each step's
1588
+ * result is checkpointed, so on resume a completed step returns its saved output instead of
1589
+ * re-executing. Remote steps are dispatched over the Transport; their results checkpoint the
1590
+ * same way local steps do.
1591
+ */
1592
+ declare class WorkflowEngine {
1593
+ private readonly store;
1594
+ /** Ordered transport pool (dispatch + failover). Empty = no remote steps. */
1595
+ private readonly pool;
1596
+ private readonly controlPlane?;
1597
+ private readonly clock;
1598
+ private readonly instanceId;
1599
+ private readonly namespace;
1600
+ private readonly leaseMs;
1601
+ private readonly maxRecoveryAttempts?;
1602
+ private readonly remoteAdvanceSilenceMs?;
1603
+ private readonly webhookUrl?;
1604
+ private readonly traceparent?;
1605
+ private readonly context?;
1606
+ /** Establish the originating context ambiently around a local step body (see {@link WorkflowEngineDeps.rehydrate}). Default passthrough. */
1607
+ private readonly rehydrate;
1608
+ private readonly compensationRetries;
1609
+ /** Persist a `running` checkpoint at the start of a local step body (see {@link WorkflowEngineDeps.trackStepStart}). */
1610
+ private readonly trackStepStart;
1611
+ /** Where a freshly-started run executes — in-process by default (see {@link RunDispatcher}). */
1612
+ private readonly runDispatcher;
1613
+ /** Per-key serialization for singleton workflows (admission, back-pressure, notify-on-release). */
1614
+ private readonly singletons;
1615
+ /** Every registered workflow, keyed by `name@version` — so old versions stay runnable. */
1616
+ private readonly workflows;
1617
+ /** The newest registered version per workflow name — used to `start` new runs. */
1618
+ private readonly latest;
1619
+ /** Event name → workflow names started when that event is published (see `onEvent`). */
1620
+ private readonly eventTriggers;
1621
+ /** Durable-entity subsystem (registers the `__entity` runner; see `registerEntity`). */
1622
+ private readonly entities;
1623
+ /** Event debounce/batch accumulators (register the `__evt_*` runners; see `accumulators.route`). */
1624
+ private readonly accumulators;
1625
+ /** In-flight remote steps awaiting a worker result, keyed by stepId. */
1626
+ private readonly pending;
1627
+ /** "Reset the liveness timer" callbacks keyed by the beat's target — a stepId (in-flight remote step)
1628
+ * or a runId (in-flight remote workflow turn) — invoked when a matching heartbeat arrives. The two id
1629
+ * shapes don't collide in practice: a stepId is `${runId}:${seq}`, a run key is the bare runId. */
1630
+ private readonly heartbeatResets;
1631
+ private readonly listeners;
1632
+ /** Step interceptors (onion middleware around real local-step execution), first = outermost. */
1633
+ private readonly interceptors;
1634
+ /** Callbacks notified (on any instance) when a run is cancelled — for cooperative cancellation. */
1635
+ private readonly cancelListeners;
1636
+ /** Callbacks notified when a run is enqueued elsewhere — for low-latency cross-pod dispatch. */
1637
+ private readonly enqueuedListeners;
1638
+ /** Notified when a run is dead-lettered (moved to `dead`) — a hook for a DLQ handler. */
1639
+ private readonly deadListeners;
1640
+ /** Validators gating `engine.update`, keyed by `<workflow>:<updateName>`. */
1641
+ private readonly updateValidators;
1642
+ /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
1643
+ private readonly cancelRequested;
1644
+ /** Flow-control admission backend for remote steps (see {@link registerQueue}). */
1645
+ private readonly admission;
1646
+ /** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
1647
+ private readonly queueWaiters;
1648
+ /** Which queue a dispatched step took a slot from, by stepId — so the result can release it. */
1649
+ private readonly stepQueue;
1650
+ /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
1651
+ private readonly inflight;
1652
+ private draining;
1653
+ /** When true, route an unregistered workflow to the live worker group of the same name. */
1654
+ private readonly remoteByConvention;
1655
+ constructor(deps: WorkflowEngineDeps);
1656
+ /**
1657
+ * Register a **durable entity** (a virtual object): a keyed actor whose `handlers` run **serialized
1658
+ * per key** over **durable state**, exactly once. Drive it with `signalEntity` (fire) /
1659
+ * `ctx.callEntity` (call + await result) and read its state with `getEntityState`. See {@link Entities}.
1660
+ */
1661
+ registerEntity<S>(name: string, config: EntityConfig<S>): void;
1662
+ /** Send an operation to an entity (fire-and-forget). Ordered + exactly-once per key. */
1663
+ signalEntity(name: string, key: string, op: string, arg?: unknown): Promise<void>;
1664
+ /** Read an entity's current durable state (published after each op), or undefined if it has none yet. */
1665
+ getEntityState<S = unknown>(name: string, key: string): Promise<S | undefined>;
1666
+ /**
1667
+ * Be notified when a run is enqueued on ANOTHER instance (via the control plane), so a worker can
1668
+ * pick it up at once — e.g. `engine.onEnqueued((runId) => engine.runOne(runId))`. Returns an
1669
+ * unsubscribe function. Only wire this on instances that should execute runs (workers).
1670
+ */
1671
+ onEnqueued(listener: (runId: string) => void): () => void;
1672
+ /** Fire cooperative-cancellation listeners for `runId` (a worker bridge aborts in-flight work). */
1673
+ private notifyCancelled;
1674
+ /**
1675
+ * Register a workflow version. Register multiple versions of the same name to keep in-flight
1676
+ * runs working across a breaking change: old runs resume on the version they started on, new
1677
+ * runs start on the newest registered version.
1678
+ *
1679
+ * GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
1680
+ * this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
1681
+ * inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
1682
+ * body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
1683
+ * ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
1684
+ * engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
1685
+ * engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
1686
+ * inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
1687
+ * step toward routing every run through a group with no local/remote flag (Phase 3).
1688
+ */
1689
+ register(name: string, version: string, fn: WorkflowFn, opts?: {
1690
+ tags?: string[] | undefined;
1691
+ singleton?: SingletonConfig | undefined;
1692
+ executionTimeout?: string | number | undefined;
1693
+ validateInput?: ((input: unknown) => void | Promise<void>) | undefined;
1694
+ onEvent?: string[] | undefined;
1695
+ eventBatch?: EventBatchConfig | undefined;
1696
+ /** Worker group to DISPATCH this workflow's turns to (uniform dispatch). Requires `executor`.
1697
+ * Omit for the default inline fast path. See the method doc. */
1698
+ group?: string | undefined;
1699
+ /** Advances a turn by dispatching a {@link WorkflowTask} to `group`. Requires `group`. For an
1700
+ * in-app worker, pair a transport-backed {@link RemoteWorkflowExecutor} (group consumed by a
1701
+ * worker that calls {@link workflowBody}) so the retained `fn` runs through the transport hop. */
1702
+ executor?: WorkflowExecutor | undefined;
1703
+ }): void;
1704
+ /**
1705
+ * The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
1706
+ * own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
1707
+ * the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
1708
+ * it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
1709
+ * remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
1710
+ */
1711
+ workflowBody(name: string, version: string): WorkflowFn | undefined;
1712
+ /**
1713
+ * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
1714
+ * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
1715
+ * the run's history to `executor` (which dispatches a {@link WorkflowTask} to the worker) and applying
1716
+ * the {@link WorkflowDecision} the worker's replay returns. The worker never touches the store.
1717
+ */
1718
+ registerRemote(name: string, version: string, opts: {
1719
+ group: string;
1720
+ executor: WorkflowExecutor;
1721
+ tags?: string[];
1722
+ singleton?: SingletonConfig;
1723
+ executionTimeout?: string | number;
1724
+ validateInput?: (input: unknown) => void | Promise<void>;
1725
+ }): void;
1726
+ /**
1727
+ * The convenience form of {@link registerRemote}: register a workflow whose body runs on a worker
1728
+ * `group` (e.g. the Python `durable-worker`, or a separate TS worker), without hand-building a
1729
+ * {@link RemoteWorkflowExecutor}. It wires the standard broker executor — a `RemoteWorkflowExecutor`
1730
+ * over the engine's own transport, dispatching to `opts.group` — for you, so you write
1731
+ * `engine.remote('processing', { group: 'processing' })` instead of
1732
+ * `engine.registerRemote('processing', '1', { group: 'processing', executor: new RemoteWorkflowExecutor(transport, 'processing') })`.
1733
+ *
1734
+ * Defaults the version to `'1'`. All the other {@link registerRemote} options
1735
+ * (`tags` / `singleton` / `executionTimeout` / `validateInput`) pass straight through. Reach for the
1736
+ * low-level {@link registerRemote} when you need a custom {@link WorkflowExecutor} (a different
1737
+ * transport, a fixed `timeoutMs`, a polyglot stub) — `remote()` is the standard wiring on the rails.
1738
+ */
1739
+ remote(name: string, opts: {
1740
+ /** Worker group this workflow's turns are dispatched to (also the executor's target group). */
1741
+ group: string;
1742
+ /** Workflow version. Defaults to `'1'`. */
1743
+ version?: string;
1744
+ tags?: string[];
1745
+ singleton?: SingletonConfig;
1746
+ executionTimeout?: string | number;
1747
+ validateInput?: (input: unknown) => void | Promise<void>;
1748
+ }): void;
1749
+ /**
1750
+ * Register a flow-control queue referenced by `ctx.call(step, input, { queue })`. Caps concurrent
1751
+ * in-flight steps and/or the admission rate; blocked calls re-suspend and retry, so the limit is
1752
+ * durable. Per engine instance (see {@link QueueConfig}). Registering the same name replaces it.
1753
+ */
1754
+ registerQueue(config: QueueConfig): void;
1755
+ /** Subscribe to lifecycle events. Returns an unsubscribe function. */
1756
+ subscribe(listener: EngineListener): () => void;
1757
+ /**
1758
+ * Register a {@link StepInterceptor} — onion middleware run around the real execution of every
1759
+ * local `ctx.step` (timing, logging, tracing, error enrichment, context propagation). First
1760
+ * registered is outermost; interceptors fire only when a step executes, never on replay. Returns
1761
+ * an unsubscribe function.
1762
+ */
1763
+ use(interceptor: StepInterceptor): () => void;
1764
+ /**
1765
+ * Fold the registered interceptors around a local step body (identity when there are none), then
1766
+ * run the whole thing inside the re-hydrated originating context. The carrier is read at execution
1767
+ * time from {@link context} — the SAME reader stamped on dispatched remote tasks — so a local step
1768
+ * sees the live tenant / user / trace ids ambiently (via the {@link rehydrate} bridge). Default
1769
+ * `rehydrate` is a passthrough, so this is byte-identical to a bare body call when unwired.
1770
+ */
1771
+ private interceptStep;
1772
+ /**
1773
+ * Be notified when a run is cancelled — on ANY instance, via the transport control plane. A
1774
+ * worker bridge can use this for cooperative cancellation: abort the in-flight work for `runId`
1775
+ * instead of finishing it just to have the result discarded. Returns an unsubscribe function.
1776
+ */
1777
+ onCancel(listener: (runId: string) => void): () => void;
1778
+ /**
1779
+ * Be notified when a run is **dead-lettered** — moved to `dead` after exceeding
1780
+ * `maxRecoveryAttempts`. The listener receives the dead run (status `dead`, with its error), so a
1781
+ * DLQ handler can do something other than just leaving it parked: alert, push to a real queue, or
1782
+ * start a dead-letter workflow (e.g. `engine.onDead((run) => engine.start('pipeline-dlq', run, ...))`).
1783
+ * Returns an unsubscribe function.
1784
+ */
1785
+ onDead(listener: (run: WorkflowRun) => void): () => void;
1786
+ private notifyDead;
1787
+ /** Emit a locally-produced lifecycle event: deliver to subscribers AND broadcast it on the
1788
+ * control plane so other instances (e.g. a dashboard pod) can live-tail this run. */
1789
+ private emit;
1790
+ /** Deliver an event to local subscribers only (no re-broadcast) — used for both locally-produced
1791
+ * events and ones received from the control plane, so an event shows up once on every instance. */
1792
+ private deliver;
1793
+ start<C extends WorkflowClass>(workflow: C, input: WorkflowInputOf<C>, runId: string, opts?: StartOptions): Promise<RunResult>;
1794
+ start<TInput>(workflow: string, input: TInput, runId: string, opts?: StartOptions): Promise<RunResult>;
1795
+ /** Read a run's current persisted state (or null if unknown). A thin pass-through to the store. */
1796
+ getRun(runId: string): Promise<WorkflowRun | null>;
1797
+ resume(runId: string): Promise<RunResult>;
1798
+ /**
1799
+ * The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
1800
+ * `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
1801
+ * flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
1802
+ * never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
1803
+ * Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
1804
+ * is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
1805
+ * no redundant registration per child name.
1806
+ *
1807
+ * RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
1808
+ * registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
1809
+ * registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
1810
+ * new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
1811
+ */
1812
+ private findInheritedRegistration;
1813
+ /**
1814
+ * A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
1815
+ * group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
1816
+ * {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
1817
+ * is correct AND required — a second executor on the same group would race the worker for results.
1818
+ * Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
1819
+ * its identity stays its own.
1820
+ */
1821
+ private synthesizeRemoteChild;
1822
+ /**
1823
+ * When `remoteByConvention` is enabled, attempt to route an unregistered workflow to the live
1824
+ * worker group of the same name: if the transport reports a live heartbeat for group
1825
+ * `run.workflow`, synthesize a throwaway {@link RegisteredWorkflow} that dispatches to that group
1826
+ * over the primary transport. Returns `undefined` when the flag is off or the group is not live.
1827
+ *
1828
+ * Recomputed per call (never memoized into `this.workflows`) so it stays correct if group
1829
+ * liveness changes between calls, exactly as {@link synthesizeRemoteChild} does.
1830
+ */
1831
+ private resolveRemoteByConvention;
1832
+ /**
1833
+ * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
1834
+ * registration (or `undefined` if there is none). The parent of an awaited child is found via its
1835
+ * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
1836
+ * child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
1837
+ * cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
1838
+ * unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
1839
+ * unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
1840
+ * `visited` guards against a pathological cyclic id graph.
1841
+ */
1842
+ private findRemoteAncestor;
1843
+ /**
1844
+ * The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
1845
+ * child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
1846
+ * and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
1847
+ * registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
1848
+ * waiter is consumed), but by then the child is terminal and no longer resolved.
1849
+ */
1850
+ private findParentRunId;
1851
+ /** Track an in-flight execution so {@link drain} can wait for it. */
1852
+ private track;
1853
+ /**
1854
+ * Graceful shutdown: stop picking up new runs (recovery/timer become no-ops) and wait for
1855
+ * in-flight executions to settle, up to `timeoutMs`. Call from your app's shutdown hook so a
1856
+ * deploy hands off cleanly instead of leaving runs to the lease timeout.
1857
+ */
1858
+ drain(timeoutMs?: number): Promise<void>;
1859
+ /**
1860
+ * Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
1861
+ * timer poller alongside {@link resumeDueTimers}. A timed-out run is moved to `cancelled` with an
1862
+ * `execution_timeout` error (terminal, so a late step result can't resurrect it).
1863
+ */
1864
+ sweepTimeouts(now?: number): Promise<void>;
1865
+ /**
1866
+ * Resume every run left incomplete by a crash or deploy. Called on boot. Completed steps
1867
+ * replay from their checkpoints, so only the work that had not finished runs again.
1868
+ */
1869
+ recoverIncomplete(nowMs?: number): Promise<RunResult[]>;
1870
+ /**
1871
+ * Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
1872
+ * `maxRecoveryAttempts` — move a poison pill to the `dead` dead-letter state. Returns a terminal
1873
+ * result to skip the resume, or `undefined` to proceed.
1874
+ */
1875
+ private countRecovery;
1876
+ /**
1877
+ * Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
1878
+ * boot. A run still not due re-suspends cheaply without running new work.
1879
+ */
1880
+ resumeDueTimers(nowMs?: number): Promise<RunResult[]>;
1881
+ /**
1882
+ * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
1883
+ * exactly one instance runs it), then resumes the body. Returns the result, or null if another
1884
+ * instance holds the lease or the engine is draining. The default in-process dispatcher calls this;
1885
+ * a broker-backed worker calls it per consumed run id.
1886
+ */
1887
+ runOne(runId: string): Promise<RunResult | null>;
1888
+ /**
1889
+ * Re-enqueue a run for a worker to (re-)execute — the dispatch-model **retry**. Sets it back to
1890
+ * `pending`, clears any stale lease, and dispatches; a worker resumes it (replaying its checkpoints,
1891
+ * re-attempting the failed step). Returns the enqueued state immediately — never runs the body
1892
+ * inline — or null if the run is unknown. The dashboard "retry" goes through here so it can't block
1893
+ * the HTTP request on workflow execution.
1894
+ */
1895
+ requeue(runId: string): Promise<RunResult | null>;
1896
+ /**
1897
+ * **Fix-and-replay**: re-run a run (typically a `dead`/`failed` one) with a corrected `input`, as a
1898
+ * fresh run with clean history. It's a NEW run — `newRunId` defaults to `<runId>~retry~<uuid>` — so
1899
+ * the original stays inspectable. Returns the new run's id, or null if `runId` is unknown.
1900
+ */
1901
+ retryWithInput(runId: string, input: unknown, newRunId?: string): Promise<{
1902
+ runId: string;
1903
+ } | null>;
1904
+ /**
1905
+ * Pick up and execute every `pending` run — the poll-based side of dispatch for a worker pod with
1906
+ * no broker. Runs enqueued by other pods (or by a caller using a no-op dispatcher) sit `pending`
1907
+ * in the store until polled; leasing ensures exactly one pod runs each. Call periodically alongside
1908
+ * {@link resumeDueTimers}.
1909
+ */
1910
+ runPending(nowMs?: number): Promise<RunResult[]>;
1911
+ /**
1912
+ * Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
1913
+ * suspended (handed off to a timer/signal/event). The async counterpart to dispatch: pair it with
1914
+ * `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
1915
+ */
1916
+ waitForRun(runId: string, opts?: {
1917
+ timeoutMs?: number;
1918
+ }): Promise<RunResult>;
1919
+ /**
1920
+ * Resume each run only if this instance can acquire its recovery lease — so when several
1921
+ * replicas recover or poll at once, each run is picked up by exactly one of them.
1922
+ */
1923
+ private resumeLeased;
1924
+ /**
1925
+ * Deliver an external signal to the run waiting on `token` and resume it with `payload`.
1926
+ * Returns the run result, or null if no run is waiting for that token.
1927
+ */
1928
+ /**
1929
+ * Publish a named event. It does two things, and returns how many runs it touched (the sum):
1930
+ * 1. **Resumes** every in-flight run waiting on it via `ctx.waitForEvent(name, { match })` whose
1931
+ * match the payload satisfies (fan-out, vs `signal`'s point-to-point token).
1932
+ * 2. **Starts** a fresh run of every workflow registered with `onEvent: [name]`, passing the
1933
+ * payload as input. Idempotent by `evt:<id>:<workflow>` — pass `opts.id` to dedupe redeliveries
1934
+ * of the same logical event (default: a fresh uuid, so each publish triggers once).
1935
+ */
1936
+ publishEvent(name: string, payload: unknown, opts?: {
1937
+ id?: string;
1938
+ }): Promise<number>;
1939
+ signal(token: string, payload: unknown): Promise<RunResult | null>;
1940
+ /**
1941
+ * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
1942
+ * signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
1943
+ * consumed when it reaches `waitForSignal(token)`. The canonical **durable-entity / accumulator**
1944
+ * pattern: one long-lived run per key (the `runId`) that loops on `waitForSignal`, fed events by
1945
+ * many `signalWithStart` calls. `start` is idempotent by `runId`, so concurrent callers converge on
1946
+ * one run. (Use a per-run `token`, e.g. derived from `runId`, so the signal targets this entity.)
1947
+ */
1948
+ signalWithStart(workflow: WorkflowRef, input: unknown, runId: string, signal: {
1949
+ token: string;
1950
+ payload?: unknown;
1951
+ }, opts?: StartOptions): Promise<{
1952
+ runId: string;
1953
+ }>;
1954
+ /**
1955
+ * Report the result of a `ctx.task(name, …)` back to its run (async completion). The external
1956
+ * worker that the task dispatched to calls this when done; the run resumes with `result`. Returns
1957
+ * null if no run is waiting on the task (e.g. a duplicate/late delivery) — a safe no-op.
1958
+ */
1959
+ completeTask(runId: string, name: string, result: unknown): Promise<RunResult | null>;
1960
+ /** Report that a `ctx.task` failed — the run resumes and throws a FatalError at the task. */
1961
+ failTask(runId: string, name: string, error: string): Promise<RunResult | null>;
1962
+ /**
1963
+ * Notify a parent that's waiting on `runId` as a child of its terminal outcome (the `ctx.child`
1964
+ * rendezvous). A no-op when no parent is waiting, so `execute()` can call it on every run without
1965
+ * knowing about the child feature.
1966
+ */
1967
+ private notifyParent;
1968
+ /**
1969
+ * Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
1970
+ * `{ compensate: true }` to undo the saga first: the suspended run is resumed so its completed
1971
+ * steps' compensations run in reverse (visible as `compensate:<step>` events), then it's marked
1972
+ * cancelled. Without it, cancellation is immediate (no undo).
1973
+ */
1974
+ cancel(runId: string, opts?: {
1975
+ compensate?: boolean;
1976
+ }): Promise<RunResult | null>;
1977
+ /**
1978
+ * Bulk-cancel every run matching a filter — e.g. cancel all `order` runs tagged `vip`, or every run
1979
+ * whose `tier` search attribute is `free`. The filter is a {@link RunQuery} (workflow / status / tag
1980
+ * / search-attribute predicates), so it reuses the same matching the dashboard list uses. Each match
1981
+ * is run through {@link cancel}, so the same plumbing applies per run: child cascade, the optional
1982
+ * saga `compensate`, local cancel listeners, and the control-plane broadcast that tells the owning
1983
+ * worker to abort. Returns one {@link RunResult} per matched run (already-finished matches report
1984
+ * their terminal status — `cancel` is a no-op on them, never clobbering a completed/dead run).
1985
+ */
1986
+ cancelWhere(filter: Omit<RunQuery, 'limit' | 'offset'>, opts?: {
1987
+ compensate?: boolean;
1988
+ }): Promise<RunResult[]>;
1989
+ /**
1990
+ * Hard-delete a run and its entire subtree. Unlike {@link cancel} (which marks a run `cancelled`
1991
+ * but keeps it as history), delete REMOVES the run and all its rows (checkpoints, signal waiters,
1992
+ * search-attribute rows) — it vanishes from {@link getRun} and {@link listRuns}. Use it to purge a
1993
+ * finished run whose data is being deleted (e.g. a pipeline run whose result rows are cleared).
1994
+ *
1995
+ * Cascades depth-first via {@link getRunChildren} so children are gone before the parent (no
1996
+ * orphaned child runs). Prefer {@link cancel} first for a live run — deleting one mid-flight orphans
1997
+ * its worker (its next checkpoint write fails). Returns the number of runs deleted (0 if absent).
1998
+ */
1999
+ deleteRun(runId: string): Promise<number>;
2000
+ /**
2001
+ * Cascade cancellation to a run's children — both awaited (`ctx.child`, found via its live
2002
+ * `child:<id>` waiter) and fire-and-forget (`ctx.startChild`, found via its `spawn:<id>`
2003
+ * checkpoint). Recursive, so a whole subtree is cancelled; the terminal guard in `cancel` stops it
2004
+ * at finished / already-cancelled runs (no loops, no re-cancel).
2005
+ */
2006
+ private cancelChildren;
2007
+ /**
2008
+ * The ids of the runs a run spawned — both awaited (`ctx.child`) and fire-and-forget
2009
+ * (`ctx.startChild`, found via its `spawn:<id>` checkpoint). The canonical parent→children edge,
2010
+ * used for both cancellation cascades and the dashboard run-tree.
2011
+ *
2012
+ * An awaited child is discovered two ways, because the live `child:<id>` waiter only exists WHILE
2013
+ * the parent is suspended on it: the waiter resolves and is consumed the moment the child settles,
2014
+ * so a completed parent (or a completed awaited child) would otherwise drop out of the tree. The
2015
+ * `signal:child:<id>` checkpoint (the placeholder written when the parent first awaits the child,
2016
+ * overwritten as completed/failed when it settles) persists across completion, so we read both and
2017
+ * dedupe — the edge stays stable for a finished run, not just a live one.
2018
+ */
2019
+ getRunChildren(parentRunId: string): Promise<string[]>;
2020
+ /** The worker groups this engine dispatches to: every registered remote workflow's group, plus any
2021
+ * `extra` the caller declares. Local-step groups (a group consumed by in-process `@DurableStep`
2022
+ * workers, e.g. `pipeline`) aren't derivable from registrations — pass them via `extra` so a group
2023
+ * with backlog and ZERO workers is still reported (the alert case has no heartbeat to discover). */
2024
+ knownGroups(extra?: string[]): string[];
2025
+ /** Per-group worker health (queue backlog + live worker heartbeats). Covers {@link knownGroups}
2026
+ * (so a registered group with backlog and ZERO workers still reports — the alert case) UNION the
2027
+ * groups discovered from live heartbeats (so a local-step group like `pipeline`, not derivable
2028
+ * from registrations, shows once its workers beat). Empty when no transport can introspect health
2029
+ * (only the BullMQ transport implements `groupHealth`). */
2030
+ workerHealth(extra?: string[]): Promise<GroupHealth[]>;
2031
+ /**
2032
+ * Persist a streamed local-step lifecycle event from a remote workflow worker (see
2033
+ * {@link WorkflowStepEvent}). A Python `@workflow` runs its `ctx.step`s inline over one turn that
2034
+ * can last minutes; the worker streams each step's start/finish so the engine checkpoints it LIVE —
2035
+ * a step shows `running` the moment its body begins, then resolves to `completed`/`failed` with its
2036
+ * real wall-clock window and sub-process events — instead of every step appearing at once when the
2037
+ * turn ends. The turn's final `recordStep` command re-persists the same (runId, seq) checkpoint
2038
+ * idempotently, so this is purely additive observability and never changes the run's outcome.
2039
+ */
2040
+ private persistStepEvent;
2041
+ /**
2042
+ * Announce a local step's body has begun and (when `trackStepStart`) checkpoint it as `running`,
2043
+ * so it's visible in flight rather than appearing only on completion. The checkpoint is a
2044
+ * placeholder overwritten by {@link completeStep}/{@link failStep}; it never short-circuits replay
2045
+ * (only `completed` does), so a crash mid-body just re-runs the step. The `step.started` event
2046
+ * fires regardless — the live SSE view sees the start even with persistence off.
2047
+ */
2048
+ private startStep;
2049
+ /** Checkpoint a finished step and announce it — the two things that must always happen together. */
2050
+ private completeStep;
2051
+ /** Checkpoint a step that failed terminally, so the failure point is visible (not just the run). */
2052
+ private failStep;
2053
+ private execute;
2054
+ /**
2055
+ * Advance a remote (cross-SDK) workflow one turn: hand its history to the executor (which dispatches
2056
+ * a workflow task to the worker and awaits its replay) and apply the decision. Mirrors
2057
+ * {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
2058
+ * us back here (a remote step finished, a timer fired) goes through `resume` like any TS workflow.
2059
+ */
2060
+ /**
2061
+ * Apply a terminal/suspended transition once: persist the new run status, emit the matching
2062
+ * lifecycle event, and (on completion/failure) wake a waiting parent. Both the TS and remote
2063
+ * executors funnel their completed/failed/suspended outcomes through here so a status update can
2064
+ * never drift from its event or `notifyParent` call. Does NOT touch the run lock — each executor
2065
+ * keeps its own lease handling (the TS executor's `finally`, the remote path's caller).
2066
+ */
2067
+ private settleRun;
2068
+ private runRemoteExecution;
2069
+ /**
2070
+ * Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then
2071
+ * settle (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by
2072
+ * the INLINE-advance path ({@link runRemoteExecution}, under the held lease) and the durable
2073
+ * DISPATCH path ({@link completeRemoteDecision}, which holds the lease itself). Idempotent transitions
2074
+ * — the caller has already ensured this is the decision for the currently-awaited turn.
2075
+ */
2076
+ private applyDecision;
2077
+ /** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
2078
+ private remoteHistory;
2079
+ /** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
2080
+ * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result). */
2081
+ private applyCommands;
2082
+ /**
2083
+ * Undo the registered saga compensations in reverse and settle the run `cancelled`. Shared by EVERY
2084
+ * exit path of a run with a compensating cancel in flight — whether the resumed replay returned,
2085
+ * suspended, hit continue-as-new, or threw — so a cancel is never lost just because the body didn't
2086
+ * stop at a suspension point. Clears the in-memory cancel flag.
2087
+ */
2088
+ private compensateAndCancel;
2089
+ /** The run body, lease held + renewed by {@link execute}. */
2090
+ private runExecution;
2091
+ /**
2092
+ * Run one saga compensation, retried up to `compensationRetries`, emitting a `compensate:<step>`
2093
+ * step event for its outcome so a stranded undo is visible. Never throws — a permanently-failing
2094
+ * compensation is skipped so it can't mask the original failure.
2095
+ */
2096
+ private runCompensation;
2097
+ /** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
2098
+ * (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
2099
+ * module and the engine stays the orchestrator. */
2100
+ private ctxHostFor;
2101
+ /**
2102
+ * Resume a run paused at a {@link WorkflowCtx.breakpoint} (e.g. the dashboard "continue" button).
2103
+ * Finds the run's pending breakpoint checkpoint and signals it. Returns null if the run isn't
2104
+ * paused at a breakpoint.
2105
+ */
2106
+ continue(runId: string): Promise<RunResult | null>;
2107
+ /**
2108
+ * Read the latest value a run published for `key` via {@link WorkflowCtx.setEvent} — a
2109
+ * side-effect-free query of a live (or finished) run's state. Returns `undefined` if the run
2110
+ * never published that key. The suspend-model counterpart of a Temporal query.
2111
+ */
2112
+ getEvent<TValue = unknown>(runId: string, key: string): Promise<TValue | undefined>;
2113
+ /**
2114
+ * Register a validator gating `engine.update(runId, name, …)` for runs of `workflow`. The
2115
+ * validator runs BEFORE the update is delivered, so a rejection leaves the run untouched. One
2116
+ * validator per (workflow, update name); registering again replaces it.
2117
+ */
2118
+ registerUpdateValidator<TArg>(workflow: string, name: string, validate: UpdateValidator<TArg>): void;
2119
+ /**
2120
+ * Deliver a validated update to the run waiting at `ctx.onUpdate(name)`. Runs the registered
2121
+ * validator (if any) first: on rejection returns `{ accepted: false, reason }` without disturbing
2122
+ * the run; otherwise delivers `arg` and resumes, returning `{ accepted: true, run }` (`run` is null
2123
+ * if nothing was waiting — a too-early or duplicate update).
2124
+ */
2125
+ update(runId: string, name: string, arg: unknown): Promise<UpdateResult>;
2126
+ private callRemote;
2127
+ /**
2128
+ * Complete a durable remote step from its worker result and resume the run — runs on whichever
2129
+ * instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
2130
+ * A no-op if the checkpoint isn't `pending` (a duplicate or late delivery).
2131
+ */
2132
+ private completeRemoteResult;
2133
+ /**
2134
+ * Apply a remote workflow-turn {@link WorkflowDecision} durably — runs on WHATEVER instance receives
2135
+ * it over the broker (the dispatching one may be gone, or a point-to-point broker handed it to a
2136
+ * non-dispatcher). The durable, multi-instance-safe twin of {@link completeRemoteResult} for the
2137
+ * workflow TURN: look the run up by `decision.runId`, verify the decision matches the run's
2138
+ * CURRENTLY-awaited turn (`awaitingDecisionTaskId`) — so a stale / duplicate / foreign / re-driven
2139
+ * decision is dropped (applied AT MOST ONCE) — lease it, clear the marker, and apply the decision.
2140
+ */
2141
+ private completeRemoteDecision;
2142
+ /**
2143
+ * A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
2144
+ * push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
2145
+ * re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
2146
+ */
2147
+ private rearmDecisionDeadline;
2148
+ /**
2149
+ * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
2150
+ * re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
2151
+ * retry re-registers itself, and one that's gone (cancelled/admitted) is dropped. Best-effort.
2152
+ */
2153
+ private wakeQueueWaiters;
2154
+ /** Release the flow-control slot a dispatched step held (if any), by its stepId. */
2155
+ private releaseQueueSlot;
2156
+ /** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
2157
+ private callRemoteInMemory;
2158
+ /**
2159
+ * Await `resultPromise`, but reject with `onTimeout()` if neither it settles nor a heartbeat for `id`
2160
+ * arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`, looked up in
2161
+ * `heartbeatResets` by `id`) REARMS the window, so a worker that keeps beating stays alive past
2162
+ * `timeoutMs`. Shared by the remote-STEP liveness (keyed by stepId) and the remote-WORKFLOW advance
2163
+ * liveness (keyed by runId); the caller supplies the timeout error and any extra cleanup via `onTimeout`.
2164
+ */
2165
+ private awaitWithLivenessDeadline;
2166
+ /** Remote-STEP liveness: await a worker result, timing out (with the step's pending-map cleanup) to
2167
+ * `RemoteStepTimeout` if it neither lands nor beats within `timeoutMs`. See {@link awaitWithLivenessDeadline}. */
2168
+ private awaitWithHeartbeat;
2169
+ }
2170
+ /** Raised inside the workflow when a remote worker reports a step failure. */
2171
+ declare class RemoteStepError extends Error {
2172
+ readonly stepError?: StepError | undefined;
2173
+ constructor(stepError?: StepError);
2174
+ }
2175
+
2176
+ /**
2177
+ * Thrown inside a step to signal an unrecoverable failure: the engine will NOT retry it,
2178
+ * regardless of the step's `retries` setting, and fails the run immediately. Use it for
2179
+ * business errors that retrying cannot fix (e.g. a declined card, invalid input).
2180
+ */
2181
+ declare class FatalError extends Error {
2182
+ readonly code?: string | undefined;
2183
+ constructor(message: string, code?: string);
2184
+ }
2185
+ /**
2186
+ * Thrown by `start` when a singleton workflow's wait queue is full: the count of in-flight + gated
2187
+ * runs sharing the key already equals `limit + maxQueueDepth`, so admitting another would let the
2188
+ * same-key backlog grow unbounded. Back-pressure — the caller should retry later or shed load. Only
2189
+ * raised when {@link SingletonConfig.maxQueueDepth} is set (omit it for the old unbounded behavior).
2190
+ */
2191
+ declare class SingletonQueueFullError extends Error {
2192
+ readonly workflow: string;
2193
+ readonly key: string;
2194
+ readonly maxQueueDepth: number;
2195
+ constructor(workflow: string, key: string, maxQueueDepth: number);
2196
+ }
2197
+ /**
2198
+ * Internal control signal thrown to suspend a run (e.g. on a durable sleep). Not an error the
2199
+ * user should throw or catch; the engine uses it to stop execution and persist `wakeAt`.
2200
+ */
2201
+ declare class SignalTimeoutError extends Error {
2202
+ readonly token: string;
2203
+ readonly timeoutMs: number;
2204
+ constructor(token: string, timeoutMs: number);
2205
+ }
2206
+ /**
2207
+ * Thrown when a remote step produces no result and no heartbeat within its `timeoutMs` window —
2208
+ * i.e. the worker is presumed dead. Subject to the step's `retries` (it's retryable), so the engine
2209
+ * re-dispatches before giving up.
2210
+ */
2211
+ declare class RemoteStepTimeout extends Error {
2212
+ readonly stepId: string;
2213
+ readonly timeoutMs: number;
2214
+ constructor(stepId: string, timeoutMs: number);
2215
+ }
2216
+ /**
2217
+ * Thrown by a remote {@link WorkflowExecutor} (e.g. {@link RemoteWorkflowExecutor}) when an `advance`
2218
+ * does not produce its decision within the configured `timeoutMs` — i.e. the worker is presumed gone
2219
+ * (the decision was dropped by a stall/redelivery or an instance restart spanning the in-memory waiter
2220
+ * map). Crucially this is NOT a run failure: the work may have actually completed and only the decision
2221
+ * was lost. The engine treats it as RECOVERABLE — it releases the run lease and leaves the run
2222
+ * `running` so `recoverIncomplete` re-drives it deterministically (replaying completed steps from
2223
+ * history). Distinct from a real executor error, which still fails the run.
2224
+ *
2225
+ * OPT-IN: only thrown when the executor was constructed with a `timeoutMs`. Absent a timeout, the
2226
+ * engine awaits the decision with its prior (unbounded) behavior — so existing users see no change.
2227
+ *
2228
+ * Known hazard: a timeout that fires while a worker is LEGITIMATELY still executing a not-yet-
2229
+ * checkpointed step will re-drive and re-run that in-flight step → DUPLICATE side effects. Therefore
2230
+ * the timeout is only safe when set GENEROUSLY (longer than the longest legitimate single turn). The
2231
+ * robust fix — a liveness/heartbeat-rearmed deadline so only a genuinely-dead worker re-drives — is the
2232
+ * documented follow-up (see the Track A diagnosis doc, "Part B").
2233
+ */
2234
+ declare class RemoteWorkflowTimeout extends Error {
2235
+ readonly taskId: string;
2236
+ readonly timeoutMs: number;
2237
+ constructor(taskId: string, timeoutMs: number);
2238
+ }
2239
+ /**
2240
+ * Thrown on resume when the workflow code no longer matches the recorded history: the step at a
2241
+ * logical position has a different name/kind than the checkpoint saved there. This means the
2242
+ * workflow definition changed (a step was added/removed/reordered) under an in-flight run without a
2243
+ * new `@Workflow` version — continuing would replay the wrong checkpoint into the wrong step and
2244
+ * silently corrupt the run, so the engine fails loudly instead. Register a new workflow version for
2245
+ * breaking changes (old runs finish on the version they started on).
2246
+ */
2247
+ declare class NonDeterminismError extends Error {
2248
+ readonly runId: string;
2249
+ readonly seq: number;
2250
+ constructor(runId: string, seq: number, expected: string, recorded: string);
2251
+ }
2252
+ /**
2253
+ * Thrown by `ctx.all` when one or more parallel child workflows fail. Carries the per-item failures
2254
+ * (input index, child run id, error message) and presents an aggregate message summarizing the count
2255
+ * and the failing ids — the wait-all/fail-fast counterpart to a single child's FatalError. Mirrors
2256
+ * the Python SDK's `GatherFailed`.
2257
+ */
2258
+ declare class GatherError extends Error {
2259
+ readonly failures: {
2260
+ index: number;
2261
+ id: string;
2262
+ error: string;
2263
+ }[];
2264
+ constructor(failures: {
2265
+ index: number;
2266
+ id: string;
2267
+ error: string;
2268
+ }[], total?: number);
2269
+ }
2270
+ declare class WorkflowSuspended extends Error {
2271
+ /** Epoch ms to auto-resume (durable sleep), or undefined when waiting on an external signal. */
2272
+ readonly wakeAt?: number | undefined;
2273
+ constructor(wakeAt?: number);
2274
+ }
2275
+ /**
2276
+ * Thrown by `ctx.continueAsNew(input)` to end the current run and hand off to a fresh execution of
2277
+ * the same workflow with a clean history — for long-running / looping workflows that would otherwise
2278
+ * accumulate unbounded checkpoints. The engine completes this run and starts the next one.
2279
+ */
2280
+ declare class ContinueAsNew extends Error {
2281
+ readonly input: unknown;
2282
+ constructor(input: unknown);
2283
+ }
2284
+
2285
+ /** Canonical step id — the stable identity of a step within a run, used for dedupe and
2286
+ * correlation. The format is part of the cross-language wire contract (Python builds the same). */
2287
+ declare function stepId(runId: string, seq: number): string;
2288
+ /** Deterministic signal token a breakpoint suspends on — derived from its logical position. Shared
2289
+ * so `ctx.breakpoint` (which records it) and `engine.continue` (which signals it) agree. */
2290
+ declare const breakpointToken: (runId: string, seq: number) => string;
2291
+ /** A remote-worker step body. The optional `log` records sub-process outcomes and debug/error
2292
+ * lines that ride back on the result — the TypeScript twin of the Python SDK's `StepContext`. */
2293
+ type StepHandler = (input: unknown, log: StepLogger) => Promise<unknown> | unknown;
2294
+ /**
2295
+ * Run `handler` for `task` and produce the wire-format {@link StepResult}. Pure (no transport,
2296
+ * no I/O beyond the handler), so every transport — and any language port — can share the exact
2297
+ * same completed / failed / no-handler contract instead of re-deriving it.
2298
+ */
2299
+ declare function runStepHandler(task: RemoteTask, handler: StepHandler | undefined): Promise<StepResult>;
2300
+
2301
+ interface RemoteStepConfig<TInput, TOutput> extends StepOptions {
2302
+ name: string;
2303
+ /** Worker group expected to handle this step. Defaults to the `name` prefix before the first dot. */
2304
+ group?: string;
2305
+ input: z.ZodType<TInput>;
2306
+ output: z.ZodType<TOutput>;
2307
+ }
2308
+ /**
2309
+ * Defines a typed handle to a step that runs on a remote worker. Call it from a workflow with
2310
+ * `ctx.call(step, input)`; a worker registers a handler under the same `name`.
2311
+ */
2312
+ declare function remoteStep<TInput, TOutput>(config: RemoteStepConfig<TInput, TOutput>): RemoteStepDef<TInput, TOutput>;
2313
+
2314
+ /**
2315
+ * A {@link WorkflowExecutor} backed by a {@link Transport}: it advances a remote workflow by
2316
+ * DISPATCHING a {@link WorkflowTask} over the broker under the ENGINE-SUPPLIED `taskId`, WITHOUT
2317
+ * awaiting the decision. Pass one to `engine.registerRemote(name, version, { group, executor })` so a
2318
+ * workflow authored in another SDK (e.g. the Python `durable-worker`) is driven over Redis/BullMQ.
2319
+ *
2320
+ * Multi-instance safe by construction: the executor holds NO in-memory state correlating a dispatched
2321
+ * turn to its decision. The engine records the `taskId` on the run as the awaited marker AND releases
2322
+ * the run's lease BEFORE calling `dispatch`, so the worker's {@link import('./interfaces').WorkflowDecision},
2323
+ * delivered over the transport's `onDecision` and applied DURABLY (by run id) on whatever engine
2324
+ * instance consumes it — even one that did not dispatch the turn — can never arrive ahead of its marker
2325
+ * or contend with a still-held lease and be dropped. A point-to-point broker can therefore hand the
2326
+ * decision to any instance without it being dropped (the bug this design fixes).
2327
+ *
2328
+ * Recovery-safe: if the engine crashes (or a decision is genuinely lost because the worker died), the
2329
+ * suspended turn's heartbeat-rearmed window (`remoteAdvanceSilenceMs`) lapses and the timer poller
2330
+ * re-dispatches a fresh task with the same history — the worker's replay is deterministic, so it
2331
+ * returns the same decision; a late decision for the old `taskId` no longer matches the run's
2332
+ * currently-awaited turn and is dropped.
2333
+ */
2334
+ declare class RemoteWorkflowExecutor implements WorkflowExecutor {
2335
+ private readonly transport;
2336
+ private readonly group;
2337
+ constructor(transport: Transport, group: string, _opts?: {
2338
+ timeoutMs?: number;
2339
+ });
2340
+ /**
2341
+ * Enqueue one workflow turn under the engine-supplied `taskId`. Does NOT await the decision — the
2342
+ * engine has already suspended the run on `taskId` and applies the decision durably via `onDecision`.
2343
+ */
2344
+ dispatch(run: WorkflowRun, history: HistoryEvent[], taskId: string, pendingSignals?: WorkflowTask['pendingSignals']): Promise<void>;
2345
+ }
2346
+
2347
+ /**
2348
+ * Transforms a payload value as it crosses the store boundary — e.g. encrypt-at-rest, compress, or
2349
+ * redact PII. `decode` must be the exact inverse of `encode`. Both are synchronous (use a local
2350
+ * cipher / codec); wrap an async KMS yourself if you need one.
2351
+ */
2352
+ interface PayloadCodec {
2353
+ encode(value: unknown): unknown;
2354
+ decode(value: unknown): unknown;
2355
+ }
2356
+ /**
2357
+ * A `StateStore` decorator that runs run/step **payloads** (input + output) through a
2358
+ * {@link PayloadCodec} — encoded on write, decoded on read — so they're never stored in the clear.
2359
+ * Adapter-agnostic: wrap any store. Searchable metadata (id, status, workflow, tags, timestamps) and
2360
+ * the structured `error` are left untouched so the dashboard, queries, and recovery still work.
2361
+ *
2362
+ * ```ts
2363
+ * const store = new CodecStateStore(new TypeOrmStateStore(ds), aesCodec);
2364
+ * ```
2365
+ */
2366
+ declare class CodecStateStore implements StateStore {
2367
+ private readonly inner;
2368
+ private readonly codec;
2369
+ constructor(inner: StateStore, codec: PayloadCodec);
2370
+ private enc;
2371
+ private dec;
2372
+ private encRun;
2373
+ private decRun;
2374
+ private encCp;
2375
+ private decCp;
2376
+ ensureSchema(): Promise<void>;
2377
+ createRun(run: WorkflowRun): Promise<void>;
2378
+ updateRun(runId: string, patch: Partial<WorkflowRun>): Promise<void>;
2379
+ getRun(runId: string): Promise<WorkflowRun | null>;
2380
+ deleteRun(runId: string): Promise<void>;
2381
+ getCheckpoint(runId: string, seq: number): Promise<StepCheckpoint | null>;
2382
+ saveCheckpoint(checkpoint: StepCheckpoint): Promise<void>;
2383
+ transaction<T>(work: (tx: {
2384
+ raw: unknown;
2385
+ saveCheckpoint: (cp: StepCheckpoint) => Promise<void>;
2386
+ }) => Promise<T>): Promise<T>;
2387
+ listIncompleteRuns(namespace?: string): Promise<WorkflowRun[]>;
2388
+ listPendingRuns(limit: number, namespace?: string): Promise<WorkflowRun[]>;
2389
+ listDueTimers(nowMs: number, namespace?: string): Promise<WorkflowRun[]>;
2390
+ tryLockRun(runId: string, owner: string, leaseUntilMs: number, nowMs: number): Promise<boolean>;
2391
+ releaseRunLock(runId: string): Promise<void>;
2392
+ renewRunLock(runId: string, owner: string, leaseUntilMs: number): Promise<boolean>;
2393
+ putSignalWaiter(waiter: SignalWaiter): Promise<void>;
2394
+ takeSignalWaiter(token: string): Promise<SignalWaiter | null>;
2395
+ listSignalWaiters(prefix: string): Promise<SignalWaiter[]>;
2396
+ bufferSignal(token: string, payload: unknown): Promise<void>;
2397
+ takeBufferedSignal(token: string): Promise<{
2398
+ payload: unknown;
2399
+ } | null>;
2400
+ listRuns(query: RunQuery): Promise<WorkflowRun[]>;
2401
+ listCheckpoints(runId: string): Promise<StepCheckpoint[]>;
2402
+ getLatestCheckpointByName(runId: string, name: string): Promise<StepCheckpoint | undefined>;
2403
+ listCheckpointsByNamePrefix(runId: string, prefixes: string[]): Promise<StepCheckpoint[]>;
2404
+ }
2405
+
2406
+ /** Token prefix for every waiter on `name` — used by `listSignalWaiters` to fan an event out. */
2407
+ declare function eventPrefix(name: string): string;
2408
+ /** Build the unique waiter token for one `waitForEvent` call (name + match + run position). */
2409
+ declare function eventToken(name: string, match: Record<string, unknown> | undefined, runId: string, seq: number): string;
2410
+ /** The match criteria embedded in an event waiter token (the 3rd `:`-segment is the base64 match). */
2411
+ declare function eventMatchOf(token: string): Record<string, unknown>;
2412
+ /** Whether `payload` satisfies a waiter's `match`: every match key deep-equals the payload's value. */
2413
+ declare function eventMatches(payload: unknown, match: Record<string, unknown>): boolean;
2414
+
2415
+ interface MetricsSnapshot {
2416
+ runs: {
2417
+ started: number;
2418
+ completed: number;
2419
+ failed: number;
2420
+ suspended: number;
2421
+ };
2422
+ steps: {
2423
+ started: number;
2424
+ completed: number;
2425
+ failed: number;
2426
+ };
2427
+ /** Per-workflow run counters, keyed by workflow name. */
2428
+ byWorkflow: Record<string, {
2429
+ started: number;
2430
+ completed: number;
2431
+ failed: number;
2432
+ }>;
2433
+ /** Sum + count of recorded step durations (ms), for an average. */
2434
+ stepDuration: {
2435
+ sumMs: number;
2436
+ count: number;
2437
+ };
2438
+ }
2439
+ interface MetricsCollector {
2440
+ snapshot(): MetricsSnapshot;
2441
+ /** Prometheus text exposition of the counters — serve this from a `/metrics` endpoint. */
2442
+ prometheus(): string;
2443
+ /** Unsubscribe from the engine. */
2444
+ stop(): void;
2445
+ }
2446
+ type Subscribable = {
2447
+ subscribe(listener: (event: EngineEvent) => void): () => void;
2448
+ };
2449
+ /**
2450
+ * Subscribe to an engine's lifecycle events and accumulate Prometheus-style counters (runs + steps by
2451
+ * outcome, per-workflow run counts, step-duration sum/count). Dependency-free — call `.prometheus()`
2452
+ * from your metrics endpoint, or `.snapshot()` for the raw numbers. Counters are per process; scrape
2453
+ * each instance.
2454
+ */
2455
+ declare function collectMetrics(engine: Subscribable): MetricsCollector;
2456
+
2457
+ interface ScheduledWorkflow {
2458
+ /** Stable key identifying this schedule — part of the deterministic run id. */
2459
+ key: string;
2460
+ workflow: string;
2461
+ input?: unknown;
2462
+ /** Start one run every `everyMs`. Mutually exclusive with `cron`. */
2463
+ everyMs?: number;
2464
+ /**
2465
+ * A cron expression (5 fields `m h dom mon dow`, or 6 with leading seconds) evaluated in
2466
+ * {@link ScheduledWorkflow.timezone}. Mutually exclusive with `everyMs`. Needs the optional peer
2467
+ * dependency `cron-parser`.
2468
+ */
2469
+ cron?: string;
2470
+ /** IANA timezone the `cron` fires in (e.g. `America/Sao_Paulo`). Defaults to UTC. */
2471
+ timezone?: string;
2472
+ /** Temporarily stop firing this schedule (kept registered). Defaults to false. */
2473
+ paused?: boolean;
2474
+ /**
2475
+ * What to do when the previous window's run hasn't finished yet (fixed-interval schedules only):
2476
+ * `'allow'` (default) starts the new window anyway; `'skip'` skips it while the prior run is still
2477
+ * `running`/`suspended`, so a slow run can't pile up overlapping executions.
2478
+ */
2479
+ overlap?: 'allow' | 'skip';
2480
+ /**
2481
+ * Randomly delay the dispatch by up to this many ms BEFORE firing, to spread load when many
2482
+ * instances tick on the same boundary (avoids a thundering herd). Opt-in; absent = fire immediately.
2483
+ *
2484
+ * This is dispatch-path jitter only — it does NOT affect the run id (still the time bucket), so
2485
+ * idempotency is unchanged: two instances jittering the same window still start it exactly once.
2486
+ * (The engine forbids `Math.random` inside workflow code; the scheduler dispatch path is not
2487
+ * workflow code, so jitter here is fine.)
2488
+ */
2489
+ jitter?: number;
2490
+ /**
2491
+ * Enqueue windows that were missed while the scheduler was down, instead of silently skipping
2492
+ * them. Opt-in; absent = only the current window fires. `maxCatchup` bounds how many PRIOR windows
2493
+ * are backfilled (a long outage can't flood the system). Backfilled runs use each missed window's
2494
+ * deterministic bucket run id, so `engine.start` idempotency skips any that already ran.
2495
+ */
2496
+ backfill?: {
2497
+ maxCatchup: number;
2498
+ };
2499
+ }
2500
+ /** Injectable dispatch-path effects (kept out of the deterministic run-id logic). Defaults to real
2501
+ * `Math.random` + `setTimeout`; overridden in tests so jitter is deterministic and instant. */
2502
+ interface RunSchedulesOptions {
2503
+ random?: () => number;
2504
+ sleep?: (ms: number) => Promise<void>;
2505
+ }
2506
+ /** Deterministic run id for a fixed-interval schedule's current time window. */
2507
+ declare function scheduledRunId(key: string, everyMs: number, nowMs: number): string;
2508
+ /**
2509
+ * Epoch ms of the most recent cron fire at or before `nowMs`, evaluated in `timezone` (default UTC).
2510
+ * This is the deterministic "bucket" a cron run is keyed on, so polling repeatedly within an
2511
+ * interval resolves to the same fire time — and thus the same idempotent run id.
2512
+ */
2513
+ declare function prevCronFireMs(expr: string, nowMs: number, timezone?: string): number;
2514
+ /**
2515
+ * Start each schedule's current-window run. The run id is the time bucket (a fixed-interval index,
2516
+ * or the cron fire time) and `engine.start` is idempotent, so firing this on an interval — or
2517
+ * racing two instances on the same tick — starts **each window exactly once**. Wire it to a
2518
+ * `setInterval`, the durable timer poller, or `@nestjs/schedule`. Returns the run ids for the
2519
+ * current windows.
2520
+ */
2521
+ declare function runSchedules(engine: Pick<WorkflowEngine, 'start' | 'getRun'>, schedules: readonly ScheduledWorkflow[], nowMs: number, opts?: RunSchedulesOptions): Promise<string[]>;
2522
+
2523
+ /**
2524
+ * A {@link StepLogger} that appends to `events`, stamping each line with `now()`. Shared by the
2525
+ * local-step path (`ctx.step`) and the remote-worker path (`runStepHandler`) so a step records
2526
+ * the same {@link StepEvent} shape wherever it runs — the TypeScript twin of the Python SDK's
2527
+ * `StepContext`.
2528
+ */
2529
+ declare function createStepLogger(events: StepEvent[], now: () => number): StepLogger;
2530
+
2531
+ /**
2532
+ * Does a run's search attributes satisfy EVERY filter (AND)? A missing key never matches (so `ne`
2533
+ * on an absent key is false too — the attribute simply isn't there to compare). Empty/undefined
2534
+ * filters match everything. Shared by every store so typed/range queries behave identically across
2535
+ * adapters (applied in-process after the coarse workflow/status/tag filters).
2536
+ */
2537
+ declare function matchesAttributes(attributes: SearchAttributes | undefined, filters: AttributeFilter[] | undefined): boolean;
2538
+ /**
2539
+ * Apply a query's attribute predicates then its `offset`/`limit`, in-process — for store adapters
2540
+ * that can't express typed/range predicates in SQL. Pass rows already coarse-filtered (workflow /
2541
+ * status / tag) and sorted newest-first; this filters by `attributes` and paginates. Only call it
2542
+ * when `query.attributes` is set (otherwise let the DB do `LIMIT`/`OFFSET`).
2543
+ */
2544
+ declare function applyAttributeQuery(rows: WorkflowRun[], query: RunQuery): WorkflowRun[];
2545
+ /**
2546
+ * One normalized row of the `durable_run_attributes` side-table: a single (key → value) pair of a
2547
+ * run's search attributes, split into typed columns so SQL can index/range-scan it. Exactly one of
2548
+ * `strValue`/`numValue` is set per row (booleans normalize to a string `"true"`/`"false"` so `eq`/`ne`
2549
+ * still match). NULL for the column that doesn't apply, which keeps `(key, numValue)` /
2550
+ * `(key, strValue)` indexes selective.
2551
+ */
2552
+ interface RunAttributeRow {
2553
+ runId: string;
2554
+ key: string;
2555
+ strValue: string | null;
2556
+ numValue: number | null;
2557
+ }
2558
+ /**
2559
+ * Explode a run's search attributes into normalized side-table rows (one per key). Used by SQL stores
2560
+ * to maintain `durable_run_attributes` on every create/update and by the in-memory store's index, so
2561
+ * attribute predicates can be pushed DOWN into a join/EXISTS instead of scanned in-process. Numbers go
2562
+ * to `numValue`; strings to `strValue`; booleans to `strValue` as `"true"`/`"false"` (matching how the
2563
+ * in-process `compare` treats `eq`/`ne` on booleans). Returns `[]` for a run with no attributes.
2564
+ */
2565
+ declare function normalizeAttributeRows(runId: string, attributes: SearchAttributes | undefined): RunAttributeRow[];
2566
+ /** SQL comparison operator for an {@link AttributeOp} (used to build pushdown predicates). */
2567
+ declare function sqlComparator(op: AttributeOp): string;
2568
+ /**
2569
+ * Which side-table column an attribute filter compares against: numeric operands (and range ops on
2570
+ * numbers) hit `numValue`; everything else hits `strValue` (booleans are stored as `"true"`/`"false"`).
2571
+ * Keeping this in core means every SQL adapter pushes predicates down identically.
2572
+ */
2573
+ declare function attributeColumnFor(filter: AttributeFilter): 'numValue' | 'strValue';
2574
+ /** The literal a side-table predicate compares against (booleans → `"true"`/`"false"` strings). */
2575
+ declare function attributeOperand(filter: AttributeFilter): string | number;
2576
+
2577
+ /**
2578
+ * Compute the worker group a tenant's run dispatches to. Mirrors the transport's
2579
+ * default-is-bare rule (see {@link TransportPool.useNamespace}'s `'default'` no-op): a
2580
+ * `tenant` of `undefined`, `''`, or `'default'` is the bare `baseGroup`, so a single-tenant
2581
+ * deployment (or the `'default'` tenant of a multi-tenant one) stays byte-identical to today.
2582
+ * Any other tenant suffixes the group as `<baseGroup>@<tenant>`, so an operator control plane
2583
+ * (namespace: undefined) can route each tenant's run to its own worker pool.
2584
+ */
2585
+ declare function tenantGroup(baseGroup: string, tenant: string | undefined): string;
2586
+
2587
+ /**
2588
+ * Dependency-injection tokens. Adapters bind their implementations to these so `core` and
2589
+ * `@dudousxd/nestjs-durable` depend only on the interfaces in `./interfaces`.
2590
+ *
2591
+ * These use `Symbol.for(key)` (the global symbol registry), NOT `Symbol(key)`, so the token is the
2592
+ * SAME instance no matter how many times `core` is evaluated in a process. A consumer can hold more
2593
+ * than one physical copy of `core` at runtime — pnpm peer-dependency multiplexing installs a
2594
+ * separate virtual copy per distinct peer set, and the dual ESM/CJS build can be loaded once as
2595
+ * `import` (`index.js`) and once as `require` (`index.cjs`). A plain `Symbol()` would mint a
2596
+ * DISTINCT token per copy, so `DurableModule` (provider) and `DashboardService` / a store adapter
2597
+ * (injector) could resolve different symbols → Nest can't satisfy the dependency
2598
+ * (`STATE_STORE is not available in the DurableApiModule context`). A registered symbol collapses
2599
+ * them to one. Mirrors the `CONTEXT_ACCESSOR` token in `@dudousxd/nestjs-durable`'s `tokens.ts`.
2600
+ */
2601
+ /**
2602
+ * @deprecated Use {@link STATE_STORE_CANONICAL} (`@dudousxd/nestjs-durable:state-store`). The legacy
2603
+ * token stays bound as a back-compat alias and still resolves; it will be removed in a future major.
2604
+ */
2605
+ declare const STATE_STORE: unique symbol;
2606
+ /**
2607
+ * @deprecated Use {@link TRANSPORT_CANONICAL} (`@dudousxd/nestjs-durable:transport`). The legacy
2608
+ * token stays bound as a back-compat alias and still resolves; it will be removed in a future major.
2609
+ */
2610
+ declare const TRANSPORT: unique symbol;
2611
+ /**
2612
+ * @deprecated Use {@link DURABLE_OPTIONS_CANONICAL} (`@dudousxd/nestjs-durable:options`). The legacy
2613
+ * token stays bound as a back-compat alias and still resolves; it will be removed in a future major.
2614
+ */
2615
+ declare const DURABLE_OPTIONS: unique symbol;
2616
+ /**
2617
+ * Canonical, cross-lib-discoverable aliases of the durable DI tokens, following the ecosystem
2618
+ * capability naming `@dudousxd/nestjs-durable:<name>` (identical to `capability('durable', <name>)`).
2619
+ * `DurableModule` binds these alongside the legacy tokens (dual-bind), so an external library can
2620
+ * resolve durable's store/transport/options by the canonical name without importing durable
2621
+ * internals. Bare `Symbol.for` (same cross-copy-stability rationale as the legacy tokens above)
2622
+ * keeps `core` free of any `@dudousxd/nestjs-diagnostics` runtime dependency.
2623
+ */
2624
+ declare const STATE_STORE_CANONICAL: unique symbol;
2625
+ declare const TRANSPORT_CANONICAL: unique symbol;
2626
+ declare const DURABLE_OPTIONS_CANONICAL: unique symbol;
2627
+
2628
+ /**
2629
+ * A non-durable, in-process `StateStore` for tests and local development.
2630
+ * The shipped `@dudousxd/nestjs-durable-store` package re-exports an equivalent.
2631
+ */
2632
+ declare class InMemoryStateStore implements StateStore {
2633
+ private readonly runs;
2634
+ private readonly checkpoints;
2635
+ private readonly signalWaiters;
2636
+ /**
2637
+ * Normalized search-attribute side-table, the in-memory analog of the SQL stores'
2638
+ * `durable_run_attributes`: `key -> (runId -> row)`. Maintained on every create/update so an
2639
+ * attribute query can be pushed DOWN to a key-indexed candidate set instead of scanning every run
2640
+ * and comparing in-process. Mirrors {@link normalizeAttributeRows} so behavior matches the SQL path.
2641
+ */
2642
+ private readonly attributeIndex;
2643
+ /** Test/observability hook: how many candidate runs the last attribute query's index produced. */
2644
+ lastAttributeCandidates: number;
2645
+ /**
2646
+ * Test/observability hook: how many checkpoint rows the LAST checkpoint read materialized (the rows
2647
+ * actually returned to the caller). `listCheckpoints` returns all rows for the run; the targeted
2648
+ * {@link getLatestCheckpointByName} / {@link listCheckpointsByNamePrefix} return only the matches —
2649
+ * so this lets the benchmark contrast "fetch N, scan in JS" against "fetch the k that match".
2650
+ */
2651
+ lastCheckpointRowsRead: number;
2652
+ private key;
2653
+ /** Drop then re-add a run's normalized attribute rows so the index tracks its current attributes. */
2654
+ private reindexAttributes;
2655
+ createRun(run: WorkflowRun): Promise<void>;
2656
+ updateRun(runId: string, patch: Partial<WorkflowRun>): Promise<void>;
2657
+ getRun(runId: string): Promise<WorkflowRun | null>;
2658
+ deleteRun(runId: string): Promise<void>;
2659
+ getCheckpoint(runId: string, seq: number): Promise<StepCheckpoint | null>;
2660
+ saveCheckpoint(checkpoint: StepCheckpoint): Promise<void>;
2661
+ listIncompleteRuns(namespace?: string): Promise<WorkflowRun[]>;
2662
+ listPendingRuns(limit: number, namespace?: string): Promise<WorkflowRun[]>;
2663
+ listDueTimers(nowMs: number, namespace?: string): Promise<WorkflowRun[]>;
2664
+ tryLockRun(runId: string, owner: string, leaseUntilMs: number, nowMs: number): Promise<boolean>;
2665
+ releaseRunLock(runId: string): Promise<void>;
2666
+ renewRunLock(runId: string, owner: string, leaseUntilMs: number): Promise<boolean>;
2667
+ putSignalWaiter(waiter: SignalWaiter): Promise<void>;
2668
+ takeSignalWaiter(token: string): Promise<SignalWaiter | null>;
2669
+ listSignalWaiters(prefix: string): Promise<SignalWaiter[]>;
2670
+ transaction<T>(work: (tx: {
2671
+ raw: unknown;
2672
+ saveCheckpoint: (cp: StepCheckpoint) => Promise<void>;
2673
+ }) => Promise<T>): Promise<T>;
2674
+ private readonly bufferedSignals;
2675
+ bufferSignal(token: string, payload: unknown): Promise<void>;
2676
+ takeBufferedSignal(token: string): Promise<{
2677
+ payload: unknown;
2678
+ } | null>;
2679
+ /**
2680
+ * Candidate run ids satisfying ONE attribute predicate, served from {@link attributeIndex} — the
2681
+ * pushdown step. Numeric predicates scan only `numValue` rows under the key; string/boolean ones
2682
+ * scan `strValue` rows. This is the in-memory analog of the SQL EXISTS on `durable_run_attributes`.
2683
+ */
2684
+ private candidatesForFilter;
2685
+ listRuns(query: RunQuery): Promise<WorkflowRun[]>;
2686
+ listCheckpoints(runId: string): Promise<StepCheckpoint[]>;
2687
+ getLatestCheckpointByName(runId: string, name: string): Promise<StepCheckpoint | undefined>;
2688
+ listCheckpointsByNamePrefix(runId: string, prefixes: string[]): Promise<StepCheckpoint[]>;
2689
+ }
2690
+
2691
+ /**
2692
+ * An in-process `Transport` (and `ControlPlane`) for tests and local development: registered
2693
+ * handlers stand in for remote workers, so a whole cross-app workflow runs in a single process,
2694
+ * and control messages broadcast locally. Pass the same instance as both `transport` and
2695
+ * `controlPlane`.
2696
+ */
2697
+ declare class InMemoryTransport implements Transport, ControlPlane {
2698
+ private readonly handlers;
2699
+ private resultHandler?;
2700
+ private heartbeatHandler?;
2701
+ private stepEventHandler?;
2702
+ private readonly controlHandlers;
2703
+ /** Register a fake worker handler for a step name. */
2704
+ handle(name: string, fn: StepHandler): void;
2705
+ dispatch(task: RemoteTask): Promise<void>;
2706
+ onResult(handler: (result: StepResult) => Promise<void>): void;
2707
+ onHeartbeat(handler: (beat: Heartbeat) => Promise<void>): void;
2708
+ /** Test hook: deliver a liveness heartbeat to the engine, as a real broker would on its channel. */
2709
+ emitHeartbeat(beat: Heartbeat): Promise<void>;
2710
+ /** Workflow step lifecycle: deliver to the engine's handler (async, mirroring a real broker). */
2711
+ dispatchStepEvent(event: WorkflowStepEvent): Promise<void>;
2712
+ onStepEvent(handler: (event: WorkflowStepEvent) => Promise<void>): void;
2713
+ publishControl(msg: ControlMessage): Promise<void>;
2714
+ onControl(handler: (msg: ControlMessage) => void): void;
2715
+ }
2716
+
2717
+ export { type AccumulatorHost, type Admission, type AdmissionBackend, type AdmissionItem, type AttributeFilter, type AttributeOp, type BackoffStrategy, type ChildCallOptions, CodecStateStore, ContinueAsNew, type ControlMessage, type ControlPlane, DURABLE_OPTIONS, DURABLE_OPTIONS_CANONICAL, type DurableWebhook, type EngineEvent, type EngineEventType, type EngineListener, Entities, type EntityConfig, type EntityHandler, type EntityHost, EventAccumulators, type EventBatchConfig, FatalError, GatherError, type GroupHealth, type Heartbeat, type HistoryEvent, InMemoryAdmissionBackend, InMemoryStateStore, InMemoryTransport, type MetricsCollector, type MetricsSnapshot, type NamedTransport, NonDeterminismError, type PayloadCodec, type QueueConfig, QueueController, type RemoteStepConfig, type RemoteStepDef, RemoteStepError, RemoteStepTimeout, type RemoteTask, RemoteWorkflowExecutor, RemoteWorkflowTimeout, type RetentionPolicy, type RunAttributeRow, type RunDispatcher, type RunQuery, type RunResult, type RunSchedulesOptions, type RunStatus, STATE_STORE, STATE_STORE_CANONICAL, type ScheduledWorkflow, type SearchAttributes, SignalTimeoutError, type SignalWaiter, type SingletonConfig, SingletonQueueFullError, type StartOptions, type StartRunMessage, type StateStore, type StepCheckpoint, type StepError, type StepEvent, type StepHandler, type StepInterceptor, type StepInvocation, type StepKind, type StepLogger, type StepOptions, type StepResult, type StoreTransaction, type SubProcessHandle, TERMINAL_RUN_STATUSES, TRANSPORT, TRANSPORT_CANONICAL, type Transport, type UpdateResult, type UpdateValidator, WORKFLOW_NAME_KEY, type WorkerAdjust, type WorkerConcurrencyStatus, type WorkerHeartbeat, type WorkerStatus, type WorkflowClass, type WorkflowCommand, type WorkflowCtx, type WorkflowDecision, WorkflowEngine, type WorkflowEngineDeps, type WorkflowExecutor, type WorkflowInputOf, type WorkflowOutputOf, type WorkflowRef, type WorkflowRun, type WorkflowStepEvent, WorkflowSuspended, type WorkflowTask, applyAttributeQuery, attributeColumnFor, attributeOperand, breakpointToken, collectMetrics, createStepLogger, eventMatchOf, eventMatches, eventPrefix, eventToken, matchesAttributes, normalizeAttributeRows, parseDuration, prevCronFireMs, remoteStep, runSchedules, runStepHandler, scheduledRunId, sqlComparator, stepId, tenantGroup, workflowName };