@dudousxd/nestjs-durable-core 0.64.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -168,6 +168,34 @@ declare class InMemoryAdmissionBackend implements AdmissionBackend {
168
168
  * wire contract — the adonis + python SDKs build the byte-identical shape (design §7.8), and the
169
169
  * golden fixtures in `<repo>/fixtures/wire/` pin the exact bytes all three SDKs must agree on.
170
170
  */
171
+ /**
172
+ * One workflow a worker ANNOUNCES it can execute — the richer form of a `workflows` name.
173
+ *
174
+ * The rule is "announce only what you can run": a process publishes a registration for a workflow
175
+ * whose body it holds and whose queue it consumes. It never publishes one for a workflow it merely
176
+ * knows how to *route to* (an engine's `registerRemote`), because that claim says nothing about a
177
+ * live executor existing — which is precisely the ambiguity an aggregate registry has to remove.
178
+ *
179
+ * Every field but `name` is optional because the SDKs hold different amounts of this. A worker that
180
+ * knows only names (the Python `@worker.workflow("x")` form, or any pre-registrations SDK) publishes
181
+ * bare `workflows` names instead and is still a first-class announcer — see {@link WorkerDescriptor.registrations}.
182
+ * A reader must therefore treat an absent field as "not stated", never as a value: the aggregate in
183
+ * `./announced` never invents a version, a group or an origin that nobody announced.
184
+ */
185
+ interface WorkflowRegistration {
186
+ /** Registered workflow name — the `workflow` a {@link WorkflowTask} carries. */
187
+ name: string;
188
+ /** Registered version, when the announcer knows one. Absent = unversioned announcement. */
189
+ version?: string;
190
+ /** The routing token this workflow's turns are dispatched to (the queue this worker consumes for
191
+ * it). Absent = the announcer did not say; a reader must NOT assume the by-convention token. */
192
+ group?: string;
193
+ /** Capabilities a worker must advertise to run it (design §7.5), as the announcer declares them. */
194
+ requires?: string[];
195
+ /** The package that DECLARED the workflow (see `WorkflowRun.origin`). Absent = unknown; nothing
196
+ * ever substitutes a default, because a wrong origin is worse than no origin. */
197
+ origin?: string;
198
+ }
171
199
  /**
172
200
  * A worker's (or control-plane's) advertised identity, wire-protocol support, feature capabilities
173
201
  * and registered handlers. Published on startup + on change (design §7.2) and consumed by
@@ -200,6 +228,19 @@ interface WorkerDescriptor {
200
228
  workflows: string[];
201
229
  /** Registered step handler names → routing targets. Order-insensitive (a set). */
202
230
  steps: string[];
231
+ /**
232
+ * The richer, per-workflow ANNOUNCEMENT of what this instance can execute (name + version + group
233
+ * + requires + origin). OPTIONAL and purely additive: an SDK that has only names keeps publishing
234
+ * {@link workflows} and is aggregated all the same, just unversioned. An SDK that publishes this
235
+ * SHOULD also list the same names in {@link workflows}, so a reader that predates this field still
236
+ * sees the workflow at all.
237
+ *
238
+ * It rides the descriptor rather than a registry of its own so it inherits the whole liveness
239
+ * story for free: the descriptor key is written with the heartbeat's TTL and refreshed by the same
240
+ * beat, so an announcement from a worker that has since died EXPIRES instead of offering a choice
241
+ * that fails at run time.
242
+ */
243
+ registrations?: WorkflowRegistration[];
203
244
  /** Optional routing partition (queue/group sharding). */
204
245
  partition?: string;
205
246
  /** Optional tenant namespace this instance serves. */
@@ -252,7 +293,13 @@ declare function isLegacyDescriptor(raw: RawWorkerDescriptor | WorkerDescriptor)
252
293
  * - absent `workflows`/`steps` → `[]`, absent `sdk` → an `unknown` marker, absent `startedAt` → `0`.
253
294
  *
254
295
  * The undefined-vs-`[]` distinction is deliberate: an absent field means "legacy, doesn't advertise
255
- * this axis"; an explicit empty array means "modern SDK that genuinely advertises nothing".
296
+ * this axis"; an explicit empty array means "modern SDK that genuinely advertises nothing". The same
297
+ * reasoning keeps `registrations` ABSENT when it was absent (rather than defaulting to `[]`): "this
298
+ * SDK does not announce registrations" and "this SDK announces none" are different facts, and only
299
+ * the second one licenses a reader to conclude the worker serves nothing. (No SDK publishes `[]`
300
+ * today — {@link describeWorker} and the Python builder both OMIT the field when there is nothing to
301
+ * announce, so a worker with no workflows keeps its pre-announcement bytes and ETag — but a reader
302
+ * must not collapse the two cases on that basis.)
256
303
  */
257
304
  declare function normalizeDescriptor(raw: RawWorkerDescriptor | WorkerDescriptor): WorkerDescriptor;
258
305
  /**
@@ -292,6 +339,35 @@ declare function heartbeatStatus(descriptor: RawWorkerDescriptor | WorkerDescrip
292
339
  ts?: number;
293
340
  status?: WorkerLifecycle;
294
341
  }): HeartbeatStatus;
342
+ /**
343
+ * Build a worker's own {@link WorkerDescriptor} in the exact §7.1 shape every SDK publishes — the
344
+ * single builder both TS advertisers use (the BullMQ transport's in-app worker and the standalone
345
+ * `runRedisWorker`), so two processes in the same fleet cannot drift into two dialects of the same
346
+ * wire.
347
+ *
348
+ * Set-valued fields are sorted here (not just in the hash projection) so the published BYTES are
349
+ * stable across registration order too — a re-registration in a different order must not look like a
350
+ * change to a reader diffing the raw value. `partition` and `namespace` are omitted when empty, and
351
+ * `namespace` additionally when it is `default`, keeping a single-tenant deployment byte-identical
352
+ * to the un-namespaced scheme. `capabilities` is the canonical v1 baseline this protocol guarantees.
353
+ */
354
+ declare function describeWorker(opts: {
355
+ instanceId: string;
356
+ runtime: 'node' | 'python';
357
+ sdk: {
358
+ name: string;
359
+ version: string;
360
+ };
361
+ steps: string[];
362
+ workflows?: string[] | undefined;
363
+ /** What this instance announces it can EXECUTE — see {@link WorkflowRegistration}. Omitted from
364
+ * the descriptor entirely when absent or empty, so a worker with nothing to announce keeps the
365
+ * pre-registrations bytes (and therefore the pre-registrations hash). */
366
+ registrations?: readonly WorkflowRegistration[] | undefined;
367
+ startedAt: number;
368
+ partition?: string | undefined;
369
+ namespace?: string | undefined;
370
+ }): WorkerDescriptor;
295
371
 
296
372
  /**
297
373
  * Capability- & protocol-aware dispatch planning (design §7.5/§7.6). Sits BETWEEN the pure handshake
@@ -1463,6 +1539,34 @@ interface Transport {
1463
1539
  * a run can dispatch to `group` or must park `blocked`.
1464
1540
  */
1465
1541
  readWorkerDescriptors?(group: string): Promise<WorkerDescriptor[]>;
1542
+ /**
1543
+ * Every live {@link WorkerDescriptor} in the deployment, across all groups — the read behind
1544
+ * {@link WorkflowEngine.announcedWorkflows}. Optional, same as {@link readWorkerDescriptors}: a
1545
+ * transport that cannot introspect the advertisement keyspace returns nothing and the registry is
1546
+ * simply empty.
1547
+ *
1548
+ * Deliberately NOT `listWorkerGroups()` + a read per group: a worker publishes the SAME descriptor
1549
+ * under every token it consumes, so per-group reads cost one round trip per group to re-fetch bytes
1550
+ * the caller already has. One pass over the advertisement keyspace, de-duplicated by `instanceId`,
1551
+ * answers with one scan and no per-group fan-out — and it also sees an instance whose groups the
1552
+ * caller could not have enumerated.
1553
+ */
1554
+ readAllWorkerDescriptors?(): Promise<WorkerDescriptor[]>;
1555
+ /**
1556
+ * Every live worker heartbeat in the deployment, across all groups — the LIVENESS floor under
1557
+ * {@link WorkflowEngine.workflowDirectory}. Optional, same as {@link readAllWorkerDescriptors}.
1558
+ *
1559
+ * It exists because a descriptor is the richer advertisement but not the universal one: an SDK
1560
+ * that predates the handshake beats a liveness key and publishes no descriptor at all, and reading
1561
+ * only descriptors reports such a fleet as EMPTY while it is serving work. Convention routing
1562
+ * already resolves those workers off exactly these keys, so a directory built without them
1563
+ * contradicts the dispatcher standing next to it.
1564
+ *
1565
+ * Distinct from {@link listWorkerGroups}, which answers with tokens alone: this carries the
1566
+ * instance behind each one, so the directory can say how many workers back a name and a partition
1567
+ * sighting can name who is beating there.
1568
+ */
1569
+ readAllWorkerHeartbeats?(): Promise<WorkerHeartbeat[]>;
1466
1570
  /**
1467
1571
  * DB-less tenant worker → control plane: publish a {@link StartRunMessage} requesting a new run.
1468
1572
  * Optional — only transports that carry the hosted-control-plane protocol (P4) implement this.
@@ -1615,6 +1719,19 @@ type BackoffStrategy = 'fixed' | 'exp';
1615
1719
  interface ChildCallOptions {
1616
1720
  /** Deterministic child run id; defaults to one derived from the parent run id + call position. */
1617
1721
  childId?: string | undefined;
1722
+ /**
1723
+ * Pin the child to this EXACT registered version instead of the newest one — the same contract as
1724
+ * `StartOptions.version`, reached from inside a workflow body: an unregistered version fails the
1725
+ * child's start rather than quietly running a different body, and a version can only be pinned on a
1726
+ * workflow that is really registered (not on the synthesized remote/convention routes). A `ctx.child`
1727
+ * whose pinned start fails surfaces it as a failed child on the parent's waiter; a fire-and-forget
1728
+ * `ctx.startChild` buffers it until the parent joins the id.
1729
+ *
1730
+ * Replay-safe as long as you pass a CONSTANT (`{ version: '2' }`), which is the point — it names the
1731
+ * body you meant. Computing it from a live registry read at call time would make the body
1732
+ * non-deterministic, exactly like reading the clock.
1733
+ */
1734
+ version?: string | undefined;
1618
1735
  /**
1619
1736
  * Dispatch priority for a REMOTE child workflow — stamped on the child run and carried onto every
1620
1737
  * {@link WorkflowTask} dispatched to advance it, so an urgent child can jump ahead of enqueued
@@ -2288,6 +2405,438 @@ declare class EventAccumulators {
2288
2405
  private batch;
2289
2406
  }
2290
2407
 
2408
+ /**
2409
+ * The announced workflow registry (design §7.9) — the aggregate answer to "what workflows exist in
2410
+ * this deployment?", built ONLY from what live workers say about themselves.
2411
+ *
2412
+ * ## Why this is announced, not inferred
2413
+ *
2414
+ * `WorkflowEngine.workflowBody(name, version)` answers for the process that asks, and a missing body
2415
+ * is ambiguous by design: it means "not registered here", but equally "registered via
2416
+ * `registerRemote` against another SDK" or "a group this pod resolves by convention against a live
2417
+ * worker". A picker built on that inference would offer different options depending on which replica
2418
+ * served the request. So the registry is not derived from any engine's registry: a worker publishes
2419
+ * what it can execute ({@link WorkerDescriptor.registrations}, or bare `workflows` names), and this
2420
+ * module folds those statements together. Every entry is therefore a fact somebody asserted, and the
2421
+ * aggregate is the same on every pod because every pod reads the same published statements.
2422
+ *
2423
+ * The announcer is always the process that CONSUMES the queue, never the engine that dispatches to
2424
+ * it — including when the two share a process (the co-located in-app worker announces; the engine
2425
+ * beside it does not). One consequence is worth stating plainly rather than leaving to be
2426
+ * rediscovered: a pure operator (`store` with no `connection`) runs its bodies inline, consumes no
2427
+ * queue, and therefore announces NOTHING. Its workflows are real and startable through its own
2428
+ * `start()`, but they are not in this registry, because nothing about them is externally addressable
2429
+ * for a picker to point at.
2430
+ *
2431
+ * ## Liveness
2432
+ *
2433
+ * There is no expiry logic here because there is no state here. An announcement lives on the
2434
+ * descriptor key, which is written with the worker-heartbeat TTL and refreshed by the same beat; a
2435
+ * worker that dies stops refreshing and its key expires, taking its announcements with it. The
2436
+ * resolution of that liveness is therefore the TTL (35s against a 10s beat), so an entry can name a
2437
+ * worker that died within the last beat window — the same staleness the capability router already
2438
+ * accepts when it reads descriptors to decide whether to dispatch. A caller that needs a stronger
2439
+ * guarantee should not seek it here: dispatch to a dead worker parks the run `blocked`/`pending` on
2440
+ * its queue and the existing worker-health surface reports it. What this design DOES rule out is the
2441
+ * unbounded failure — an announcement that outlives its worker forever.
2442
+ *
2443
+ * ## Disagreement
2444
+ *
2445
+ * Two live workers can announce the same `name@version` with different groups, origins or capability
2446
+ * demands. The aggregate keeps EVERY distinct claim and reports the axes they differ on
2447
+ * ({@link AnnouncedWorkflow.disagreements}); it never merges them into one value and never picks a
2448
+ * winner, because either choice would make a caller act on a claim nobody made. Silence is not a
2449
+ * claim: a worker that announced no origin does not disagree with one that did.
2450
+ *
2451
+ * ## Steps are deliberately out of scope
2452
+ *
2453
+ * There is no equivalent registry for steps, and this is not an omission to be fixed later. A step is
2454
+ * not addressable from outside a run: it is identified by its `(runId, seq)` position in one
2455
+ * workflow's history, `ctx.step` is only callable from inside a replaying body, and no engine entry
2456
+ * point starts a step on its own. "Call this step" is therefore not something the engine can do, so a
2457
+ * picker offering steps would be offering an operation that does not exist. Step HANDLER names are
2458
+ * still advertised (`WorkerDescriptor.steps`) — that is a routing/capability fact used to decide
2459
+ * whether a dispatched step can be run, not an invocable catalog.
2460
+ *
2461
+ * ## The floor: a live worker that announces nothing
2462
+ *
2463
+ * An SDK old enough to predate the descriptor advertisement publishes only its TTL'd liveness
2464
+ * heartbeat — one key per routing token it consumes. Read strictly, such a fleet announces nothing
2465
+ * and this registry would be empty while work is being served. That answer is not merely unhelpful,
2466
+ * it CONTRADICTS the engine beside it: `resolveRemoteByConvention` routes a call to `X` the moment a
2467
+ * token named `X` heartbeats, so the deployment already treats liveness as sufficient to CALL a
2468
+ * workflow while claiming to know nothing when asked to LIST one. Both answers come out of the same
2469
+ * Redis.
2470
+ *
2471
+ * So the heartbeat is folded in as a second, weaker tier of evidence ({@link AnnouncedWorkflow.evidence}
2472
+ * `'observed'`): the entry exists because a live token of that name exists, which is exactly — no more
2473
+ * and no less — the condition under which convention routing would resolve it. Listing it therefore
2474
+ * cannot introduce a failure the dispatcher did not already have. What it CANNOT do is state a
2475
+ * version, an origin, a runtime, or even that the token serves a workflow rather than a step handler
2476
+ * of the same name: none of that is in a heartbeat, and every one of those stays un-stated rather than
2477
+ * being guessed. A descriptor always wins — a token an announcement already covers is never re-added
2478
+ * as an observation.
2479
+ */
2480
+
2481
+ /** One live worker's claim about one workflow: the registration plus who announced it. */
2482
+ interface WorkflowAnnouncement extends WorkflowRegistration {
2483
+ /** The announcing worker's instance id. */
2484
+ instanceId: string;
2485
+ /** The announcing worker's runtime — how a Python-served workflow is told from a Node-served one. */
2486
+ runtime: 'node' | 'python';
2487
+ }
2488
+ /** An axis on which the live announcers of one workflow do not agree. `values` holds every distinct
2489
+ * DECLARED value (sorted); an announcer that stated nothing on the axis contributes nothing. */
2490
+ interface Disagreement {
2491
+ axis: 'group' | 'origin' | 'requires';
2492
+ values: string[];
2493
+ }
2494
+ /**
2495
+ * How strong the fleet's claim about a workflow is. The two tiers are NOT interchangeable and a
2496
+ * caller must not read them as the same fact:
2497
+ *
2498
+ * - `'declared'` — at least one live worker published a descriptor naming this workflow. Everything
2499
+ * populated on the entry was STATED by that worker; everything absent it declined to state.
2500
+ * - `'observed'` — nobody described it. What exists is a live routing token of this name, which is
2501
+ * precisely the condition `resolveRemoteByConvention` uses to route a call, so the name IS
2502
+ * reachable. Nothing else is known: no version, no origin, no runtime, and no assurance that the
2503
+ * worker consuming that token serves a WORKFLOW there rather than a step handler of the same name.
2504
+ * An entry like this is a live queue, not a promise.
2505
+ */
2506
+ type AnnouncementEvidence = 'declared' | 'observed';
2507
+ /** One workflow the live fleet announces, folded across every worker that announced it. */
2508
+ interface AnnouncedWorkflow {
2509
+ /** What kind of claim this entry rests on — see {@link AnnouncementEvidence}. A caller that shows
2510
+ * a version, an origin or a runtime must check this first: an `'observed'` entry has none of
2511
+ * them, and the emptiness is a fact about the fleet, not a rendering gap. */
2512
+ evidence: AnnouncementEvidence;
2513
+ /** `name@version`, or the bare `name` when no announcer stated a version. Stable sort key. */
2514
+ key: string;
2515
+ name: string;
2516
+ /** Absent when no announcer stated one — never inferred from another announcer's version. */
2517
+ version?: string;
2518
+ /** Every distinct routing group announced (sorted). Empty = nobody stated one. On an `'observed'`
2519
+ * entry this is the token the heartbeat was published UNDER — not a claim by anybody, but the
2520
+ * queue a call would actually land on, which is the one thing an observation does establish. */
2521
+ groups: string[];
2522
+ /** Every distinct declaring package announced (sorted). Empty = nobody stated one. */
2523
+ origins: string[];
2524
+ /** Every distinct capability demand announced, each de-duplicated + sorted. Empty = nobody stated
2525
+ * one. Two entries here means two workers demand different capabilities for the same version. */
2526
+ requires: string[][];
2527
+ /** Runtimes of the live announcers (sorted, de-duplicated). EMPTY on an `'observed'` entry: a
2528
+ * heartbeat does not say what runtime wrote it, and the instance id's shape is a convention, not
2529
+ * a statement, so it is not read as one. */
2530
+ runtimes: ('node' | 'python')[];
2531
+ /** Instance ids of the live workers announcing it (sorted, de-duplicated). Its length is how many
2532
+ * workers can currently run this workflow — `1` is a single point of failure, and it is never 0
2533
+ * (an entry exists only because somebody announced it, or because somebody is beating on it). */
2534
+ instances: string[];
2535
+ /** The axes the announcers differ on, empty when they speak with one voice. A caller that must
2536
+ * pick a single target has to resolve these itself — the registry refuses to guess. */
2537
+ disagreements: Disagreement[];
2538
+ }
2539
+ /**
2540
+ * What one descriptor announces. `registrations` is authoritative when present; any `workflows` name
2541
+ * it does not cover is still announced as a bare, unversioned claim, so a worker that describes some
2542
+ * of its workflows richly and others by name alone is reported in full rather than in part.
2543
+ *
2544
+ * A descriptor with neither field announces nothing — including the control plane's own descriptor,
2545
+ * which is a router, not an executor.
2546
+ */
2547
+ declare function announcementsOf(raw: RawWorkerDescriptor | WorkerDescriptor): WorkflowAnnouncement[];
2548
+ /**
2549
+ * Fold every live descriptor's announcements into one entry per `name@version`, sorted by key.
2550
+ *
2551
+ * Pure: it holds nothing and caches nothing, so no pod ever carries a growing table of the fleet's
2552
+ * registrations — the caller reads the live descriptors it already knows how to read, folds them
2553
+ * here, and drops the result. The work is O(descriptors × announcements) per call, on demand.
2554
+ *
2555
+ * The same instance announcing the same workflow twice (a worker publishes its descriptor under
2556
+ * every routing token it consumes, so the same bytes arrive several times) folds to ONE announcer:
2557
+ * `instances` is a set, so it counts workers, not keys.
2558
+ */
2559
+ declare function aggregateAnnouncements(descriptors: readonly (RawWorkerDescriptor | WorkerDescriptor)[]): AnnouncedWorkflow[];
2560
+ /**
2561
+ * The part of one live worker heartbeat this registry can read: the routing token the beat was
2562
+ * published under, and which process published it. That is genuinely all a heartbeat holds — it is
2563
+ * the cheap tier of the two-tier advertisement, and its job is liveness, not description.
2564
+ *
2565
+ * Structurally satisfied by `WorkerHeartbeat` (the transport-facing shape, which also carries a beat
2566
+ * time and an execution snapshot). Declared here rather than imported so this module keeps its "pure
2567
+ * data, no transport" footing and `interfaces.ts` can keep importing FROM it.
2568
+ */
2569
+ interface HeartbeatSighting {
2570
+ /** The routing token the beat was published under (already partition-suffixed when partitioned). */
2571
+ group: string;
2572
+ /** The beating process's instance id. */
2573
+ instanceId: string;
2574
+ }
2575
+ /**
2576
+ * The weaker tier: one entry per live routing token that NO descriptor accounts for.
2577
+ *
2578
+ * `covered` holds every token the declared announcements already explain — both the sanitized form of
2579
+ * each announced NAME and every group any announcer stated. A token in that set is dropped, so a
2580
+ * fleet that describes itself properly gets exactly the same registry it got before this tier
2581
+ * existed, and a worker is never counted twice under two kinds of evidence.
2582
+ *
2583
+ * Everything an observation cannot know stays empty rather than being filled with a plausible guess:
2584
+ * no version (so nothing can be pinned against it), no origin, no requires, no runtime. The `name` is
2585
+ * the token itself, which equals the workflow's name for every name a queue token can round-trip —
2586
+ * `sanitizeQueueToken` rewrites `:` to `-` and that rewrite is not invertible, so a workflow named
2587
+ * `orders:fulfill` is observed as `orders-fulfill`. Stated here because a picker showing that string
2588
+ * should show what the queue is really called, not a reconstruction that might be wrong.
2589
+ */
2590
+ declare function observedAnnouncements(heartbeats: readonly HeartbeatSighting[], covered: ReadonlySet<string>): AnnouncedWorkflow[];
2591
+ /**
2592
+ * Every routing token a live descriptor already ACCOUNTS FOR, and which therefore needs no
2593
+ * observation — for either of the two opposite reasons.
2594
+ *
2595
+ * - A token some worker announced as a WORKFLOW: already in the registry, richer than an
2596
+ * observation could ever be, and re-adding it would count one worker twice.
2597
+ * - A token some worker declared as a STEP HANDLER (`WorkerDescriptor.steps`): it must be left OUT,
2598
+ * because a step is not addressable from outside a run and offering one as callable would be
2599
+ * offering an operation the engine does not have. Route-by-handler gives every step its own queue,
2600
+ * so without this the liveness floor turns a fleet's entire step surface into fake workflows.
2601
+ *
2602
+ * That second exclusion is a DECLARED negative — the worker said "this name is a step" — which is
2603
+ * exactly the sort of fact an observation cannot supply for itself, and the clearest illustration of
2604
+ * what the two tiers are each worth. A worker on an SDK too old to describe itself declares neither
2605
+ * its workflows nor its steps, so every one of its tokens stays observed and indistinguishable: its
2606
+ * step queues are listed alongside its workflow queues because nothing published says which is
2607
+ * which. That is a fair report of the fleet, not a defect in the read.
2608
+ *
2609
+ * `sanitize` is injected so this module keeps no dependency on the queue layer; a declared name is
2610
+ * covered in its bare token form and, for a partitioned worker, in the suffixed form its queues
2611
+ * actually carry.
2612
+ */
2613
+ declare function coveredTokens(announced: readonly AnnouncedWorkflow[], descriptors: readonly (RawWorkerDescriptor | WorkerDescriptor)[], sanitize: (name: string) => string): Set<string>;
2614
+
2615
+ /**
2616
+ * The workflow directory — {@link ./announced}'s registry plus the thing an empty registry could
2617
+ * never say: WHY it is empty.
2618
+ *
2619
+ * ## The three states an empty list used to collapse
2620
+ *
2621
+ * "What workflows can I call?" has three honest answers, and returning `[]` for all of them is the
2622
+ * bug this module exists to fix. They are not shades of the same answer; they call for three
2623
+ * different actions from whoever asked.
2624
+ *
2625
+ * 1. **This process cannot ask.** No transport here can introspect the advertisement or liveness
2626
+ * keyspace — a pure in-process pool advertises nothing and scans nothing. Workers may well be
2627
+ * running; nobody looked. ({@link WorkflowDirectory.supported} is `false`.) The action is to
2628
+ * configure a transport, or to stop treating this pod's answer as the deployment's answer.
2629
+ * 2. **Nothing is live.** The fleet WAS asked and the fleet is empty. ({@link WorkflowDirectory.supported}
2630
+ * is `true`, `workflows` is `[]`.) The action is to start a worker.
2631
+ * 3. **Here it is.** ({@link WorkflowDirectory.supported} is `true`, `workflows` is populated.)
2632
+ *
2633
+ * There is a fourth that hides inside the second and is the most expensive to debug, because it
2634
+ * looks exactly like "nobody is running" from the caller's side: **a worker is live on a partition
2635
+ * this engine does not serve.** A worker started with a partition consumes `<token>@<partition>`,
2636
+ * convention resolution computes the token for THIS engine's namespace, misses, and reports absence.
2637
+ * The heartbeat keyspace is shared, so the evidence is right there — and
2638
+ * {@link WorkflowDirectory.otherPartitions} reports it instead of letting the caller conclude that
2639
+ * nothing exists.
2640
+ *
2641
+ * ## Why a sentence rides along
2642
+ *
2643
+ * Every field here is structured and a caller can branch on all of it. {@link WorkflowDirectory.detail}
2644
+ * exists anyway because the three states are only useful if the human reading the picker learns which
2645
+ * one they are in, and a UI that has to re-derive that prose from booleans will re-derive it
2646
+ * differently in each place it is shown. The sentence is written once, next to the logic that decides
2647
+ * which state it is.
2648
+ */
2649
+
2650
+ /** The namespace a descriptor or a bare routing token belongs to when it states none. Mirrors
2651
+ * `tenantGroup`'s default-is-bare rule, so a single-tenant deployment reads as `'default'`
2652
+ * everywhere rather than as a special case. */
2653
+ declare const DEFAULT_PARTITION = "default";
2654
+ /** Live workers seen on a partition the asking engine does not serve — the case that otherwise reads
2655
+ * identically to "nothing is running". */
2656
+ interface PartitionSighting {
2657
+ /** The partition suffix carried by the tokens (never this engine's own). */
2658
+ partition: string;
2659
+ /** The routing tokens live on it, as published (sorted). */
2660
+ groups: string[];
2661
+ /** The instance ids beating there (sorted). */
2662
+ instances: string[];
2663
+ }
2664
+ /** What the live fleet can be asked, and what it answered. See the module docblock for the states. */
2665
+ interface WorkflowDirectory {
2666
+ /**
2667
+ * Could this process ask the fleet at all?
2668
+ *
2669
+ * `false` means no transport here can read the advertisement or liveness keyspace, so `workflows`
2670
+ * is empty because nothing was asked — NOT because nothing is running. `true` means the read
2671
+ * happened and `workflows` is the answer, including when it is empty.
2672
+ */
2673
+ supported: boolean;
2674
+ /** Everything callable in this engine's partition, declared and observed alike. */
2675
+ workflows: AnnouncedWorkflow[];
2676
+ /** Live workers on OTHER partitions (empty for an operator engine, which serves them all). */
2677
+ otherPartitions: PartitionSighting[];
2678
+ /** One sentence saying which state this is and what it implies. Written for a human. */
2679
+ detail: string;
2680
+ }
2681
+ /** The partition a routing token belongs to: the suffix after the LAST `@`, or {@link DEFAULT_PARTITION}
2682
+ * for a bare token. Matches how `tenantGroup` builds the token and how `workerHealth` takes it apart. */
2683
+ declare function partitionOfToken(token: string): string;
2684
+ /**
2685
+ * Fold live descriptors and live heartbeats into the directory for one engine.
2686
+ *
2687
+ * `namespace` is the asking engine's, and `undefined` means an operator that serves every partition
2688
+ * ("ver tudo = ausência de namespace"): it filters nothing and reports no foreign partitions, because
2689
+ * none of them are foreign to it. A namespaced engine sees only its own partition's workers, and
2690
+ * everything else it can see is reported as a sighting rather than silently dropped — dropping is
2691
+ * what made a misconfigured partition indistinguishable from an empty fleet.
2692
+ *
2693
+ * Pure and holds nothing: the caller does the two live reads it already knows how to do, folds them
2694
+ * here, and drops the result.
2695
+ */
2696
+ declare function buildWorkflowDirectory(input: {
2697
+ descriptors: readonly (RawWorkerDescriptor | WorkerDescriptor)[];
2698
+ heartbeats: readonly HeartbeatSighting[];
2699
+ namespace: string | undefined;
2700
+ /** Whether ANY transport in the pool could perform the reads above. */
2701
+ supported: boolean;
2702
+ /** The queue-token form of a workflow name — injected so this module needs no queue dependency. */
2703
+ sanitize: (name: string) => string;
2704
+ }): WorkflowDirectory;
2705
+
2706
+ /**
2707
+ * Bilateral compatibility negotiation between two handshake descriptors (design §7.3/§7.4). Pure
2708
+ * logic: given a local and a remote {@link WorkerDescriptor}, compute the negotiated session (highest
2709
+ * common protocol major + capability intersection) and classify it into one of three outcomes with a
2710
+ * precise, structured reason — never a bare boolean (design §7.6).
2711
+ */
2712
+
2713
+ /**
2714
+ * The three compatibility outcomes (design §7.4):
2715
+ * - `compatible` — protocol ranges intersect + full capability parity → dispatch freely.
2716
+ * - `degraded` — ranges intersect but a capability is missing on one side → dispatch, but route
2717
+ * capability-requiring work only to capable workers (soft warning).
2718
+ * - `incompatible` — no protocol-range overlap → do NOT dispatch; red flag with the exact reason.
2719
+ */
2720
+ type NegotiationOutcome = 'compatible' | 'degraded' | 'incompatible';
2721
+ /** Structured failure/warning payload — carries the precise delta so a diagnostics event can render
2722
+ * it on the timeline + dashboard health panel (design §7.6). Never a bare boolean. */
2723
+ interface NegotiationReason {
2724
+ /** Stable machine code for alerting/telescope. */
2725
+ code: 'protocol.incompatible' | 'capability.unavailable';
2726
+ /** Human-readable one-liner (dashboard/telescope copy). */
2727
+ message: string;
2728
+ /** The exact delta: which protocol ranges failed to overlap, or which capabilities are missing. */
2729
+ detail: {
2730
+ localRange?: [number, number];
2731
+ remoteRange?: [number, number];
2732
+ /** Capabilities the local side advertises that the remote lacks. */
2733
+ missingOnRemote?: string[];
2734
+ /** Capabilities the remote side advertises that the local lacks. */
2735
+ missingOnLocal?: string[];
2736
+ /** Explicitly-required capabilities not satisfied by the negotiated session. */
2737
+ missingRequired?: string[];
2738
+ };
2739
+ }
2740
+ /** The negotiated session + full delta between two sides. Carries both normalized descriptors so a
2741
+ * downstream diagnostics event can attach them wholesale (design §7.6). */
2742
+ interface NegotiationResult {
2743
+ outcome: NegotiationOutcome;
2744
+ /** Highest common protocol major, or `null` when ranges do not overlap (incompatible). */
2745
+ negotiatedProtocol: number | null;
2746
+ protocol: {
2747
+ localRange: [number, number];
2748
+ remoteRange: [number, number];
2749
+ };
2750
+ capabilities: {
2751
+ /** Intersection — the capabilities the negotiated session can rely on everywhere. */
2752
+ shared: string[];
2753
+ /** Local advertises, remote lacks. */
2754
+ missingOnRemote: string[];
2755
+ /** Remote advertises, local lacks. */
2756
+ missingOnLocal: string[];
2757
+ };
2758
+ /** Present for `degraded` + `incompatible`; absent for `compatible`. */
2759
+ reason?: NegotiationReason;
2760
+ local: WorkerDescriptor;
2761
+ remote: WorkerDescriptor;
2762
+ }
2763
+ interface NegotiateOptions {
2764
+ /**
2765
+ * Capabilities the local side needs the negotiated session to provide. A required capability the
2766
+ * remote lacks degrades the session (dispatch generally, but capability-requiring runs park via
2767
+ * the router, design §7.5) — it never, on its own, makes the pair *incompatible*: only a protocol
2768
+ * range gap does that (design §7.4).
2769
+ */
2770
+ required?: string[];
2771
+ }
2772
+ /**
2773
+ * Negotiate compatibility between `local` and `remote` (design §7.3/§7.4). Accepts raw/partial
2774
+ * descriptors (a missing `protocol`/`capabilities` is normalized to the legacy-v1 baseline, so a
2775
+ * legacy peer negotiates as **assume-compatible**, design §7.7).
2776
+ *
2777
+ * Outcome logic:
2778
+ * 1. no protocol-range overlap → `incompatible` (`protocol.incompatible`);
2779
+ * 2. else if a `required` capability is missing on the remote → `degraded` (`capability.unavailable`);
2780
+ * 3. else if the capability sets differ at all (either direction) → `degraded`;
2781
+ * 4. else full parity → `compatible`.
2782
+ *
2783
+ * **Bilateral-symmetric** on outcome for the parity path: `negotiate(a, b).outcome ===
2784
+ * negotiate(b, a).outcome` (the `missingOnLocal`/`missingOnRemote` delta swaps). `required` is the
2785
+ * caller's own need and intentionally asymmetric.
2786
+ */
2787
+ declare function negotiate(localRaw: RawWorkerDescriptor | WorkerDescriptor, remoteRaw: RawWorkerDescriptor | WorkerDescriptor, opts?: NegotiateOptions): NegotiationResult;
2788
+
2789
+ /**
2790
+ * Capability-aware routing (design §7.5). A workflow/step handler may declare
2791
+ * `requires: ['saga', 'search-attr-v2']`; the control-plane dispatches only to workers whose
2792
+ * descriptor advertises every required capability. If no live capable worker exists the run must
2793
+ * **park as `blocked`** with a precise reason (visible in the dashboard) — never a silent hang.
2794
+ *
2795
+ * Pure logic: no transport, no store. Consumers (the dispatcher/provider) feed live descriptors in.
2796
+ */
2797
+
2798
+ /** A handler's capability demand — either a bare list, or anything carrying a `requires` array
2799
+ * (a workflow/step ref). An absent/empty list means "runs anywhere" (design §7.5). */
2800
+ type CapabilityRequirement = string[] | {
2801
+ requires?: string[];
2802
+ };
2803
+ /** Normalize the two accepted shapes to a de-duplicated list of required capability names. */
2804
+ declare function requiredCapabilities(handler: CapabilityRequirement): string[];
2805
+ /**
2806
+ * Can `worker` run a handler with these requirements? True iff the worker's (normalized) descriptor
2807
+ * advertises **every** required capability. A legacy worker (no `capabilities` field) is normalized
2808
+ * to the v1 baseline first (design §7.7), so it can still run baseline-only work.
2809
+ */
2810
+ declare function canRoute(handler: CapabilityRequirement, worker: RawWorkerDescriptor | WorkerDescriptor): boolean;
2811
+ /**
2812
+ * The capabilities a handler requires that `worker` does NOT advertise — the precise routing delta.
2813
+ * Empty iff {@link canRoute} is true.
2814
+ */
2815
+ declare function missingCapabilities(handler: CapabilityRequirement, worker: RawWorkerDescriptor | WorkerDescriptor): string[];
2816
+ /** Filter a fleet down to the workers that can run a handler (design §7.5). */
2817
+ declare function capableWorkers<T extends RawWorkerDescriptor | WorkerDescriptor>(handler: CapabilityRequirement, workers: readonly T[]): T[];
2818
+ /** A run is dispatchable — at least one live worker can execute the handler. */
2819
+ interface RoutableResolution {
2820
+ status: 'routable';
2821
+ /** The normalized subset of the fleet that can run it. */
2822
+ workers: WorkerDescriptor[];
2823
+ requires: string[];
2824
+ }
2825
+ /** No live capable worker — the run parks as `blocked` with a human reason (design §7.5). */
2826
+ interface BlockedResolution {
2827
+ status: 'blocked';
2828
+ /** Exactly the dashboard copy: `blocked: no compatible worker (requires <caps>)`. */
2829
+ reason: string;
2830
+ requires: string[];
2831
+ }
2832
+ type RoutingResolution = RoutableResolution | BlockedResolution;
2833
+ /**
2834
+ * Decide whether a handler can be dispatched to the given live fleet (design §7.5). Returns
2835
+ * `routable` with the capable subset, or `blocked` with a precise reason when nobody can run it —
2836
+ * so the run parks visibly instead of hanging silently. An empty fleet always blocks.
2837
+ */
2838
+ declare function resolveRouting(handler: CapabilityRequirement, workers: readonly (RawWorkerDescriptor | WorkerDescriptor)[]): RoutingResolution;
2839
+
2291
2840
  type WorkflowFn = (ctx: InternalWorkflowCtx, input: unknown) => Promise<unknown>;
2292
2841
  /**
2293
2842
  * Options for {@link WorkflowEngine.start}.
@@ -2298,6 +2847,26 @@ type WorkflowFn = (ctx: InternalWorkflowCtx, input: unknown) => Promise<unknown>
2298
2847
  * another lib's, and an origin filter would quietly lie.
2299
2848
  */
2300
2849
  interface StartOptions {
2850
+ /**
2851
+ * Start THIS registered version instead of the newest one. Omit (the default) and the run starts on
2852
+ * the newest registered version, exactly as it always has — this option only ever narrows.
2853
+ *
2854
+ * Resolved against the by-name@version registry, so it pins anything explicitly registered:
2855
+ * {@link WorkflowEngine.register}, {@link WorkflowEngine.registerRemote}, {@link WorkflowEngine.remote}.
2856
+ * A version that is NOT registered throws before a run row is created and NEVER falls back to
2857
+ * `latest` — a silent fallback is the exact failure this option exists to prevent (a caller that
2858
+ * asked for `name@3` and got `name@4` has no way to tell, and its run is already going).
2859
+ *
2860
+ * NOT available on the two SYNTHESIZED registration paths, which exist precisely because nothing is
2861
+ * registered: a child inheriting its remote ancestor's routing (see `findInheritedRegistration`),
2862
+ * and convention routing to a live worker group of the same name (`resolveRemoteByConvention`).
2863
+ * Both INVENT the version they stamp — the ancestor's, and the `'1'` convention default — as a
2864
+ * routing placeholder; neither is evidence that the worker has a body at the version you asked for.
2865
+ * A pinned start that reaches them therefore throws rather than pretending to have honoured the pin.
2866
+ * To pin a cross-SDK workflow, register it for real — `engine.remote('name', { version: '2', group })`
2867
+ * — which puts `name@2` in the registry and pins like any other registration.
2868
+ */
2869
+ version?: string | undefined;
2301
2870
  /** Run-scoped tags, merged with the workflow's static `@Workflow({ tags })` onto the run. */
2302
2871
  tags?: string[] | undefined;
2303
2872
  /** Typed, queryable run data stamped on the run (e.g. `{ amount: 200, tier: 'pro' }`). */
@@ -2333,6 +2902,30 @@ interface SingletonConfig {
2333
2902
  */
2334
2903
  maxQueueDepth?: number;
2335
2904
  }
2905
+ /**
2906
+ * Stamped on a run whose workflow version NOBODY declared: a remote resolved by convention, served
2907
+ * by a worker that announces no version for it (an SDK too old to publish a descriptor, or a body
2908
+ * whose author simply never stated one).
2909
+ *
2910
+ * ## Why a run has to carry this
2911
+ *
2912
+ * Such a run's `workflowVersion` is `'1'`, the engine's convention default. That value is a routing
2913
+ * placeholder, not an observation — and read back later it is indistinguishable from a worker that
2914
+ * genuinely declared version `'1'`. Any check comparing an authored pin against it therefore passes
2915
+ * whenever the pin is `'1'` and fails whenever it is not, regardless of what the callee actually is.
2916
+ * A check that cannot fail is worse than no check, because it reads as a guarantee.
2917
+ *
2918
+ * The tag is the difference, carried ON the run so a retrospective check needs no second read: a
2919
+ * caller that pinned a version can see that the version it is comparing against was never stated,
2920
+ * and say so, instead of reporting a match it did not really make. It is deliberately a tag rather
2921
+ * than a new column — every store already persists and queries tags, so this needs no migration and
2922
+ * shows up in run listings and dashboards for free. Follows the `singleton:<key>` precedent for an
2923
+ * engine-stamped reserved tag.
2924
+ *
2925
+ * A run whose callee DOES declare a version never carries it; that run's version is the declared one
2926
+ * and a pin against it is genuinely checked.
2927
+ */
2928
+ declare const VERSION_UNDECLARED_TAG = "version:undeclared";
2336
2929
  interface WorkflowEngineDeps {
2337
2930
  store: StateStore;
2338
2931
  /** A single task transport. Shorthand for a one-entry `transports` pool (id `default`). */
@@ -2645,6 +3238,10 @@ declare class WorkflowEngine {
2645
3238
  * remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
2646
3239
  */
2647
3240
  workflowBody(name: string, version: string): WorkflowFn | undefined;
3241
+ /** The registered versions of `name`, for the pinned-start error — "2 is not registered" is a
3242
+ * guess-the-typo error without them, and the answer (which versions this deploy actually carries)
3243
+ * is right here. `none` when the name is unknown entirely. */
3244
+ private registeredVersions;
2648
3245
  /**
2649
3246
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
2650
3247
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
@@ -2770,6 +3367,21 @@ declare class WorkflowEngine {
2770
3367
  * liveness changes between calls, exactly as {@link synthesizeRemoteChild} does.
2771
3368
  */
2772
3369
  private resolveRemoteByConvention;
3370
+ /**
3371
+ * The version the LIVE fleet declares for `workflow` on `group`, or `undefined` when it declares
3372
+ * none — which is a fact about the fleet, not a failure to read it.
3373
+ *
3374
+ * The read is the descriptor advertisement, scoped to the one group being resolved: the workers on
3375
+ * that queue, their `registrations`, the entry whose `name` matches. That is where a version can
3376
+ * come from at all — a heartbeat has no room for one, so a worker on an SDK too old to publish a
3377
+ * descriptor declares nothing and the caller is told so rather than handed a default.
3378
+ *
3379
+ * `undefined` when the announcers DISAGREE, and deliberately: two live workers claiming different
3380
+ * versions of one name is a deployment in the middle of something, and picking either would stamp
3381
+ * a run with a version half the fleet does not serve. Undeclared is the honest reading of a
3382
+ * contradiction, and it is the reading that refuses to let a pin pass on a coin flip.
3383
+ */
3384
+ private declaredVersion;
2773
3385
  /**
2774
3386
  * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
2775
3387
  * registration (or `undefined` if there is none). The parent of an awaited child is found via its
@@ -2800,7 +3412,32 @@ declare class WorkflowEngine {
2800
3412
  /**
2801
3413
  * Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
2802
3414
  * timer poller alongside {@link resumeDueTimers}. A timed-out run is moved to `cancelled` with an
2803
- * `execution_timeout` error (terminal, so a late step result can't resurrect it).
3415
+ * `execution_timeout` error (terminal, so a late step result can't resurrect it), AND its children
3416
+ * are cascaded exactly as an explicit {@link cancel} cascades them.
3417
+ *
3418
+ * WHY THE TERMINAL WRITE IS DIRECT, not a call to {@link cancel}. Two reasons, both still standing:
3419
+ * the run must land with the `execution_timeout` error `cancel` knows nothing about (`cancel` writes
3420
+ * a plain `cancelled`, and the code is what a dashboard/alert distinguishes a timeout by), and this
3421
+ * is a SCAN — it walks every in-flight run of every timed workflow on every poller tick, so it must
3422
+ * not pay `cancel`'s per-run re-read and singleton/control-plane work on runs it isn't cancelling.
3423
+ * The cascade below is charged only to the runs that ACTUALLY timed out, which are rare by
3424
+ * construction; the scan itself is unchanged.
3425
+ *
3426
+ * The subtree, though, has no reason to differ, and before this it did: a timed-out parent's
3427
+ * children kept running with the parent gone and nothing pointing at them — a silent orphan you
3428
+ * could only find by reading the runs table by hand. {@link cancelChildren} is the SAME recursive
3429
+ * walk `cancel` uses, so children of children go too, and it inherits that path's two guards:
3430
+ * `cancel` returns early on a run that is already `completed`/`cancelled`/`dead` (a child that beat
3431
+ * the sweep to the finish is never clobbered, and a re-cancel is a no-op), and it writes the
3432
+ * parent's terminal status BEFORE recursing, so a cyclic parent→child graph terminates on the
3433
+ * second visit instead of looping. Two pollers sweeping the same run concurrently therefore write
3434
+ * the same terminal state twice and cascade into already-cancelled children — idempotent, not
3435
+ * corrupting. The one window it does NOT close is the pre-existing check-then-write race an
3436
+ * explicit `cancel` has always had (a child that completes between `cancel`'s read and its write);
3437
+ * closing that needs a compare-and-set in the store contract, not a change here.
3438
+ *
3439
+ * Cascade failures are swallowed per subtree for the same reason `cancelChildren` swallows them per
3440
+ * child: one unreachable child must not abort the sweep and leave every LATER timed-out run alive.
2804
3441
  */
2805
3442
  sweepTimeouts(now?: number): Promise<void>;
2806
3443
  /**
@@ -3064,6 +3701,10 @@ declare class WorkflowEngine {
3064
3701
  * `child:<id>` waiter) and fire-and-forget (`ctx.startChild`, found via its `spawn:<id>`
3065
3702
  * checkpoint). Recursive, so a whole subtree is cancelled; the terminal guard in `cancel` stops it
3066
3703
  * at finished / already-cancelled runs (no loops, no re-cancel).
3704
+ *
3705
+ * Two callers, deliberately the same walk: an explicit {@link cancel}, and {@link sweepTimeouts}
3706
+ * when a parent hits its `executionTimeout` — a timed-out parent's subtree dies the same way an
3707
+ * explicitly cancelled one's does, or the children are orphans nobody is holding a reference to.
3067
3708
  */
3068
3709
  private cancelChildren;
3069
3710
  /**
@@ -3090,6 +3731,40 @@ declare class WorkflowEngine {
3090
3731
  * from registrations, shows once its workers beat). Empty when no transport can introspect health
3091
3732
  * (only the BullMQ transport implements `groupHealth`). */
3092
3733
  workerHealth(extra?: string[]): Promise<GroupHealth[]>;
3734
+ /**
3735
+ * What workflows the LIVE fleet announces it can execute — the deployment-wide registry a picker
3736
+ * ("call this existing workflow") needs, and the one question this engine's own registry cannot
3737
+ * answer. {@link workflowBody} answers only for this process, and a missing body is ambiguous: it
3738
+ * means "not registered here", but equally "registered via {@link registerRemote} against another
3739
+ * SDK" or "a group resolved by convention against a worker". An inferred answer would therefore
3740
+ * differ per replica. This one does not: every pod folds the same published announcements.
3741
+ *
3742
+ * Scoped like every other poll surface — an engine WITH a `namespace` sees only workers in it, and
3743
+ * an operator (namespace unset) sees the whole deployment ("ver tudo = ausência de namespace"). A
3744
+ * descriptor that states no namespace is `default`, matching how a single-tenant fleet publishes.
3745
+ *
3746
+ * Cost: one advertisement scan per CALL, on demand. Nothing is added to the poll loop and nothing
3747
+ * is retained — no pod holds a table of the fleet's registrations. Empty when no transport can
3748
+ * introspect the advertisement keyspace (a pure in-process transport advertises nothing).
3749
+ *
3750
+ * Workflows only, and deliberately: see the "steps are out of scope" note in `handshake/announced`.
3751
+ */
3752
+ announcedWorkflows(): Promise<AnnouncedWorkflow[]>;
3753
+ /**
3754
+ * {@link announcedWorkflows}, plus the thing a list of workflows can never say: why it is the
3755
+ * length it is. See {@link WorkflowDirectory} for the three states an empty array used to collapse
3756
+ * into one — "nobody asked", "nothing is live", and the partition mismatch that reads exactly like
3757
+ * the second from the caller's side.
3758
+ *
3759
+ * Two live reads, both on demand and neither retained: the descriptor advertisement (what workers
3760
+ * SAY they serve) and the heartbeat keyspace (what is demonstrably consuming a queue right now).
3761
+ * The second is why a worker on an SDK too old to publish a descriptor appears here at all — it
3762
+ * beats and never describes itself, and reading only descriptors reported such a fleet as empty
3763
+ * while `resolveRemoteByConvention`, a few lines away in this same class, was routing calls to it
3764
+ * off those very keys. Two answers out of one Redis, disagreeing. Now they agree, and the entry
3765
+ * says which of the two kinds of evidence it rests on rather than flattening them together.
3766
+ */
3767
+ workflowDirectory(): Promise<WorkflowDirectory>;
3093
3768
  /**
3094
3769
  * The routing groups of every in-flight run's still-`pending` remote step — the queues where work is
3095
3770
  * dispatched and awaiting a worker. Folded into {@link workerHealth} so a step waiting on a group
@@ -3550,140 +4225,6 @@ declare class ContinueAsNew extends Error {
3550
4225
  constructor(input: unknown);
3551
4226
  }
3552
4227
 
3553
- /**
3554
- * Bilateral compatibility negotiation between two handshake descriptors (design §7.3/§7.4). Pure
3555
- * logic: given a local and a remote {@link WorkerDescriptor}, compute the negotiated session (highest
3556
- * common protocol major + capability intersection) and classify it into one of three outcomes with a
3557
- * precise, structured reason — never a bare boolean (design §7.6).
3558
- */
3559
-
3560
- /**
3561
- * The three compatibility outcomes (design §7.4):
3562
- * - `compatible` — protocol ranges intersect + full capability parity → dispatch freely.
3563
- * - `degraded` — ranges intersect but a capability is missing on one side → dispatch, but route
3564
- * capability-requiring work only to capable workers (soft warning).
3565
- * - `incompatible` — no protocol-range overlap → do NOT dispatch; red flag with the exact reason.
3566
- */
3567
- type NegotiationOutcome = 'compatible' | 'degraded' | 'incompatible';
3568
- /** Structured failure/warning payload — carries the precise delta so a diagnostics event can render
3569
- * it on the timeline + dashboard health panel (design §7.6). Never a bare boolean. */
3570
- interface NegotiationReason {
3571
- /** Stable machine code for alerting/telescope. */
3572
- code: 'protocol.incompatible' | 'capability.unavailable';
3573
- /** Human-readable one-liner (dashboard/telescope copy). */
3574
- message: string;
3575
- /** The exact delta: which protocol ranges failed to overlap, or which capabilities are missing. */
3576
- detail: {
3577
- localRange?: [number, number];
3578
- remoteRange?: [number, number];
3579
- /** Capabilities the local side advertises that the remote lacks. */
3580
- missingOnRemote?: string[];
3581
- /** Capabilities the remote side advertises that the local lacks. */
3582
- missingOnLocal?: string[];
3583
- /** Explicitly-required capabilities not satisfied by the negotiated session. */
3584
- missingRequired?: string[];
3585
- };
3586
- }
3587
- /** The negotiated session + full delta between two sides. Carries both normalized descriptors so a
3588
- * downstream diagnostics event can attach them wholesale (design §7.6). */
3589
- interface NegotiationResult {
3590
- outcome: NegotiationOutcome;
3591
- /** Highest common protocol major, or `null` when ranges do not overlap (incompatible). */
3592
- negotiatedProtocol: number | null;
3593
- protocol: {
3594
- localRange: [number, number];
3595
- remoteRange: [number, number];
3596
- };
3597
- capabilities: {
3598
- /** Intersection — the capabilities the negotiated session can rely on everywhere. */
3599
- shared: string[];
3600
- /** Local advertises, remote lacks. */
3601
- missingOnRemote: string[];
3602
- /** Remote advertises, local lacks. */
3603
- missingOnLocal: string[];
3604
- };
3605
- /** Present for `degraded` + `incompatible`; absent for `compatible`. */
3606
- reason?: NegotiationReason;
3607
- local: WorkerDescriptor;
3608
- remote: WorkerDescriptor;
3609
- }
3610
- interface NegotiateOptions {
3611
- /**
3612
- * Capabilities the local side needs the negotiated session to provide. A required capability the
3613
- * remote lacks degrades the session (dispatch generally, but capability-requiring runs park via
3614
- * the router, design §7.5) — it never, on its own, makes the pair *incompatible*: only a protocol
3615
- * range gap does that (design §7.4).
3616
- */
3617
- required?: string[];
3618
- }
3619
- /**
3620
- * Negotiate compatibility between `local` and `remote` (design §7.3/§7.4). Accepts raw/partial
3621
- * descriptors (a missing `protocol`/`capabilities` is normalized to the legacy-v1 baseline, so a
3622
- * legacy peer negotiates as **assume-compatible**, design §7.7).
3623
- *
3624
- * Outcome logic:
3625
- * 1. no protocol-range overlap → `incompatible` (`protocol.incompatible`);
3626
- * 2. else if a `required` capability is missing on the remote → `degraded` (`capability.unavailable`);
3627
- * 3. else if the capability sets differ at all (either direction) → `degraded`;
3628
- * 4. else full parity → `compatible`.
3629
- *
3630
- * **Bilateral-symmetric** on outcome for the parity path: `negotiate(a, b).outcome ===
3631
- * negotiate(b, a).outcome` (the `missingOnLocal`/`missingOnRemote` delta swaps). `required` is the
3632
- * caller's own need and intentionally asymmetric.
3633
- */
3634
- declare function negotiate(localRaw: RawWorkerDescriptor | WorkerDescriptor, remoteRaw: RawWorkerDescriptor | WorkerDescriptor, opts?: NegotiateOptions): NegotiationResult;
3635
-
3636
- /**
3637
- * Capability-aware routing (design §7.5). A workflow/step handler may declare
3638
- * `requires: ['saga', 'search-attr-v2']`; the control-plane dispatches only to workers whose
3639
- * descriptor advertises every required capability. If no live capable worker exists the run must
3640
- * **park as `blocked`** with a precise reason (visible in the dashboard) — never a silent hang.
3641
- *
3642
- * Pure logic: no transport, no store. Consumers (the dispatcher/provider) feed live descriptors in.
3643
- */
3644
-
3645
- /** A handler's capability demand — either a bare list, or anything carrying a `requires` array
3646
- * (a workflow/step ref). An absent/empty list means "runs anywhere" (design §7.5). */
3647
- type CapabilityRequirement = string[] | {
3648
- requires?: string[];
3649
- };
3650
- /** Normalize the two accepted shapes to a de-duplicated list of required capability names. */
3651
- declare function requiredCapabilities(handler: CapabilityRequirement): string[];
3652
- /**
3653
- * Can `worker` run a handler with these requirements? True iff the worker's (normalized) descriptor
3654
- * advertises **every** required capability. A legacy worker (no `capabilities` field) is normalized
3655
- * to the v1 baseline first (design §7.7), so it can still run baseline-only work.
3656
- */
3657
- declare function canRoute(handler: CapabilityRequirement, worker: RawWorkerDescriptor | WorkerDescriptor): boolean;
3658
- /**
3659
- * The capabilities a handler requires that `worker` does NOT advertise — the precise routing delta.
3660
- * Empty iff {@link canRoute} is true.
3661
- */
3662
- declare function missingCapabilities(handler: CapabilityRequirement, worker: RawWorkerDescriptor | WorkerDescriptor): string[];
3663
- /** Filter a fleet down to the workers that can run a handler (design §7.5). */
3664
- declare function capableWorkers<T extends RawWorkerDescriptor | WorkerDescriptor>(handler: CapabilityRequirement, workers: readonly T[]): T[];
3665
- /** A run is dispatchable — at least one live worker can execute the handler. */
3666
- interface RoutableResolution {
3667
- status: 'routable';
3668
- /** The normalized subset of the fleet that can run it. */
3669
- workers: WorkerDescriptor[];
3670
- requires: string[];
3671
- }
3672
- /** No live capable worker — the run parks as `blocked` with a human reason (design §7.5). */
3673
- interface BlockedResolution {
3674
- status: 'blocked';
3675
- /** Exactly the dashboard copy: `blocked: no compatible worker (requires <caps>)`. */
3676
- reason: string;
3677
- requires: string[];
3678
- }
3679
- type RoutingResolution = RoutableResolution | BlockedResolution;
3680
- /**
3681
- * Decide whether a handler can be dispatched to the given live fleet (design §7.5). Returns
3682
- * `routable` with the capable subset, or `blocked` with a precise reason when nobody can run it —
3683
- * so the run parks visibly instead of hanging silently. An empty fleet always blocks.
3684
- */
3685
- declare function resolveRouting(handler: CapabilityRequirement, workers: readonly (RawWorkerDescriptor | WorkerDescriptor)[]): RoutingResolution;
3686
-
3687
4228
  /**
3688
4229
  * Wrappers around the **real execution** of a unit of durable work — a workflow turn, or a step
3689
4230
  * handler body. Distinct from {@link StepInterceptor} (`engine.use`), which only sees the in-process
@@ -4423,4 +4964,4 @@ declare class InMemoryTransport implements Transport, ControlPlane {
4423
4964
  onControl(handler: (msg: ControlMessage) => void): void;
4424
4965
  }
4425
4966
 
4426
- export { type AccumulatorHost, type Admission, type AdmissionBackend, type AdmissionItem, type AttributeFilter, type AttributeOp, type BackoffStrategy, type BlockedDispatch, type BlockedResolution, CONTROL_FLOW_SIGNAL, CURRENT_PROTOCOL_VERSION, type CapabilityRequirement, type ChildCallOptions, CodecStateStore, ContinueAsNew, type ControlMessage, type ControlPlane, DURABLE_OPTIONS, DURABLE_OPTIONS_CANONICAL, DURABLE_STEP_CONFIG, DURABLE_STEP_NAME, type DispatchDiagnostics, type DispatchPlan, type DurableExecution, type DurableExecutionWrapper, type DurableTopology, type DurableWebhook, DurableWorkflow, EVENT_BUFFER_SCAN_LIMIT, EVENT_TOKEN_PREFIX, type EngineEvent, type EngineEventType, type EngineListener, Entities, type EntityConfig, type EntityHandler, type EntityHost, EventAccumulators, type EventBatchConfig, FatalError, GatherError, type GroupHealth, type Heartbeat, type HeartbeatStatus, type HistoryEvent, InMemoryAdmissionBackend, InMemoryStateStore, InMemoryTransport, type InferSearchAttributes, LEGACY_V1_CAPABILITIES, LEGACY_V1_PROTOCOL, type MetricsCollector, type MetricsSnapshot, type NamedTransport, type NegotiateOptions, type NegotiationOutcome, type NegotiationReason, type NegotiationResult, NonDeterminismError, type PayloadCodec, type QueueConfig, QueueController, type RawWorkerDescriptor, RemoteStepError, RemoteStepTimeout, type RemoteTask, RemoteWorkflowExecutor, RemoteWorkflowTimeout, type RetentionPolicy, type RoutableDispatch, type RoutableResolution, type RoutingResolution, type RunAttributeRow, type RunDetail, type RunDispatcher, RunGateway, type RunListItem, type RunQuery, type RunReply, type RunReplyResult, type RunRequest, type RunRequestKind, type RunResult, type RunSchedulesOptions, type RunStatus, type RunWaiting, STATE_STORE, STATE_STORE_CANONICAL, type ScheduledWorkflow, type SearchAttributes, type SearchAttributesSchema, SignalTimeoutError, type SignalWaiter, type SingletonConfig, SingletonQueueFullError, StandardSchemaV1, type StartOptions, type StartRunMessage, type StateStore, type StepCheckpoint, type StepConfig, type StepDef, type StepDispatchOpts, type StepError, type StepEvent, type StepHandler, type StepInterceptor, type StepInvocation, type StepKind, type StepLogger, type StepOptions, type StepRef, type StepResult, type StepUndo, type StoreTransaction, type SubProcessHandle, TERMINAL_RUN_STATUSES, TRANSPORT, TRANSPORT_CANONICAL, type TenantEvent, type Transport, type UndoOf, type UpdateResult, type UpdateValidator, WORKFLOW_NAME_KEY, type WireDates, type WorkerAdjust, type WorkerConcurrencyStatus, type WorkerDescriptor, type WorkerHeartbeat, type WorkerLifecycle, type WorkerStatus, WorkflowBlocked, type WorkflowClass, type WorkflowCommand, type WorkflowCtx, type WorkflowDecision, WorkflowEngine, type WorkflowEngineDeps, type WorkflowExecutor, type WorkflowHandler, type WorkflowInputOf, type WorkflowOutputOf, type WorkflowRef, type WorkflowRun, type WorkflowStartClient, type WorkflowStepEvent, WorkflowSuspended, type WorkflowTask, applyAttributeQuery, attributeColumnFor, attributeOperand, bindWorkflowClass, breakpointToken, canRoute, capableWorkers, classifyWaiterToken, clearDurableExecutionWrappers, collectMetrics, controlPlaneDescriptor, createStepLogger, currentStep, currentWorkflowCtx, debug, descriptorHash, error, eventMatchOf, eventMatches, eventNameOf, eventPrefix, eventToken, hasDurableExecutionWrappers, heartbeatStatus, indexWaitersByRun, info, isEventToken, isLegacyDescriptor, isWorkflowControlFlowSignal, log, matchesAttributes, missingCapabilities, negotiate, normalizeAttributeRows, normalizeDescriptor, parseDuration, planDispatch, prevCronFireMs, readSearchAttributes, requiredCapabilities, resolveRouting, resolveRunWaiting, runDurableExecution, runInStepLogger, runInWorkflowCtx, runSchedules, runStepHandler, sanitizeQueueToken, scheduledRunId, sqlComparator, stepConfigOf, stepId, stepNameOf, sub, subEvent, subProcess, tenantGroup, useDurableExecution, warn, workflowName };
4967
+ export { type AccumulatorHost, type Admission, type AdmissionBackend, type AdmissionItem, type AnnouncedWorkflow, type AnnouncementEvidence, type AttributeFilter, type AttributeOp, type BackoffStrategy, type BlockedDispatch, type BlockedResolution, CONTROL_FLOW_SIGNAL, CURRENT_PROTOCOL_VERSION, type CapabilityRequirement, type ChildCallOptions, CodecStateStore, ContinueAsNew, type ControlMessage, type ControlPlane, DEFAULT_PARTITION, DURABLE_OPTIONS, DURABLE_OPTIONS_CANONICAL, DURABLE_STEP_CONFIG, DURABLE_STEP_NAME, type Disagreement, type DispatchDiagnostics, type DispatchPlan, type DurableExecution, type DurableExecutionWrapper, type DurableTopology, type DurableWebhook, DurableWorkflow, EVENT_BUFFER_SCAN_LIMIT, EVENT_TOKEN_PREFIX, type EngineEvent, type EngineEventType, type EngineListener, Entities, type EntityConfig, type EntityHandler, type EntityHost, EventAccumulators, type EventBatchConfig, FatalError, GatherError, type GroupHealth, type Heartbeat, type HeartbeatSighting, type HeartbeatStatus, type HistoryEvent, InMemoryAdmissionBackend, InMemoryStateStore, InMemoryTransport, type InferSearchAttributes, LEGACY_V1_CAPABILITIES, LEGACY_V1_PROTOCOL, type MetricsCollector, type MetricsSnapshot, type NamedTransport, type NegotiateOptions, type NegotiationOutcome, type NegotiationReason, type NegotiationResult, NonDeterminismError, type PartitionSighting, type PayloadCodec, type QueueConfig, QueueController, type RawWorkerDescriptor, RemoteStepError, RemoteStepTimeout, type RemoteTask, RemoteWorkflowExecutor, RemoteWorkflowTimeout, type RetentionPolicy, type RoutableDispatch, type RoutableResolution, type RoutingResolution, type RunAttributeRow, type RunDetail, type RunDispatcher, RunGateway, type RunListItem, type RunQuery, type RunReply, type RunReplyResult, type RunRequest, type RunRequestKind, type RunResult, type RunSchedulesOptions, type RunStatus, type RunWaiting, STATE_STORE, STATE_STORE_CANONICAL, type ScheduledWorkflow, type SearchAttributes, type SearchAttributesSchema, SignalTimeoutError, type SignalWaiter, type SingletonConfig, SingletonQueueFullError, StandardSchemaV1, type StartOptions, type StartRunMessage, type StateStore, type StepCheckpoint, type StepConfig, type StepDef, type StepDispatchOpts, type StepError, type StepEvent, type StepHandler, type StepInterceptor, type StepInvocation, type StepKind, type StepLogger, type StepOptions, type StepRef, type StepResult, type StepUndo, type StoreTransaction, type SubProcessHandle, TERMINAL_RUN_STATUSES, TRANSPORT, TRANSPORT_CANONICAL, type TenantEvent, type Transport, type UndoOf, type UpdateResult, type UpdateValidator, VERSION_UNDECLARED_TAG, WORKFLOW_NAME_KEY, type WireDates, type WorkerAdjust, type WorkerConcurrencyStatus, type WorkerDescriptor, type WorkerHeartbeat, type WorkerLifecycle, type WorkerStatus, type WorkflowAnnouncement, WorkflowBlocked, type WorkflowClass, type WorkflowCommand, type WorkflowCtx, type WorkflowDecision, type WorkflowDirectory, WorkflowEngine, type WorkflowEngineDeps, type WorkflowExecutor, type WorkflowHandler, type WorkflowInputOf, type WorkflowOutputOf, type WorkflowRef, type WorkflowRegistration, type WorkflowRun, type WorkflowStartClient, type WorkflowStepEvent, WorkflowSuspended, type WorkflowTask, aggregateAnnouncements, announcementsOf, applyAttributeQuery, attributeColumnFor, attributeOperand, bindWorkflowClass, breakpointToken, buildWorkflowDirectory, canRoute, capableWorkers, classifyWaiterToken, clearDurableExecutionWrappers, collectMetrics, controlPlaneDescriptor, coveredTokens, createStepLogger, currentStep, currentWorkflowCtx, debug, describeWorker, descriptorHash, error, eventMatchOf, eventMatches, eventNameOf, eventPrefix, eventToken, hasDurableExecutionWrappers, heartbeatStatus, indexWaitersByRun, info, isEventToken, isLegacyDescriptor, isWorkflowControlFlowSignal, log, matchesAttributes, missingCapabilities, negotiate, normalizeAttributeRows, normalizeDescriptor, observedAnnouncements, parseDuration, partitionOfToken, planDispatch, prevCronFireMs, readSearchAttributes, requiredCapabilities, resolveRouting, resolveRunWaiting, runDurableExecution, runInStepLogger, runInWorkflowCtx, runSchedules, runStepHandler, sanitizeQueueToken, scheduledRunId, sqlComparator, stepConfigOf, stepId, stepNameOf, sub, subEvent, subProcess, tenantGroup, useDurableExecution, warn, workflowName };