@voltro/workflow 0.29.0 → 0.30.1

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.
@@ -1,11 +1,52 @@
1
1
  import { DurableClock as durableClock } from '@effect/workflow';
2
2
  import { DurableQueue as durableQueueModule } from '@effect/workflow';
3
3
  import { DurableRateLimiter as durableRateLimiterModule } from '@effect/workflow';
4
+ import { Duration } from 'effect';
4
5
  import { Effect } from 'effect';
5
6
  import { Schema } from 'effect';
6
7
  import { Activity as stepModule } from '@effect/workflow';
7
8
  import { Workflow as workflowModule } from '@effect/workflow';
8
9
 
10
+ /**
11
+ * Assert every workflow sharing a `concurrency.pool` declares the SAME limit.
12
+ *
13
+ * Called at BOOT, with every registered workflow's resolved control — the one
14
+ * moment all declarations are visible in one place. `validateFlowControl` runs
15
+ * per declaration and structurally cannot see a sibling file, so without this
16
+ * check two limits for one budget would not fail anywhere: admission would
17
+ * enforce whichever workflow's limit the arriving start happened to carry, and
18
+ * the pool would bound to 5 or 10 depending on WHO asked. A limit that varies
19
+ * by asker is not a limit.
20
+ *
21
+ * A boot error rather than a warning, for the same reason `singleton.mode` has
22
+ * no default: both declared numbers are somebody's intent, and picking either
23
+ * one silently enforces a budget the other author never wrote.
24
+ */
25
+ export declare const assertConsistentPools: (controls: ReadonlyArray<ResolvedFlowControl | undefined>) => void;
26
+
27
+ /** The DECODED payload type. Mirrors `Workflow.make`'s own `idempotencyKey`
28
+ * parameter, so the two never disagree about what a payload is. */
29
+ declare type DecodedPayload<P> = P extends Schema.Struct.Fields ? Schema.Struct.Type<P> : P extends {
30
+ readonly Type: infer T;
31
+ } ? T : never;
32
+
33
+ /**
34
+ * Controls that can DEFER a start rather than answer it immediately.
35
+ *
36
+ * The distinction matters at exactly one place: `ctx.workflows.run(...)` and
37
+ * `start(..., { wait: true })` block for the run's RESULT. There is no coherent
38
+ * result to return for a start that was collapsed into a future run, so those
39
+ * two callers reject a deferring control loudly instead of doing something
40
+ * surprising. Admission controls that have a synchronous answer — singleton,
41
+ * rate limit — still apply on every path.
42
+ */
43
+ export declare const DEFERRING_CONTROLS: readonly ["debounce", "batch", "throttle", "concurrency"];
44
+
45
+ export declare type DeferringControl = (typeof DEFERRING_CONTROLS)[number];
46
+
47
+ /** Which deferring controls this workflow declares, in a stable order. */
48
+ export declare const deferringControlsOf: (control: ResolvedFlowControl | undefined) => ReadonlyArray<DeferringControl>;
49
+
9
50
  export { durableClock }
10
51
 
11
52
  /** Define a durable queue for concurrency-controlled side work. */
@@ -15,8 +56,32 @@ export { durableQueueModule }
15
56
 
16
57
  export { durableRateLimiterModule }
17
58
 
59
+ /** A duration in any shape Effect accepts: `'15 minutes'`, `900_000`,
60
+ * `Duration.minutes(15)`. Same input type `sleep({ duration })` takes, so
61
+ * there is one duration vocabulary in the whole workflow surface. */
62
+ export declare type FlowDuration = Duration.DurationInput;
63
+
64
+ /** The resolved flow-control declaration attached to a workflow definition, or
65
+ * `undefined` when it declares none — in which case every start takes exactly
66
+ * the path it took before this feature existed. */
67
+ export declare const getWorkflowFlowControl: (value: unknown) => ResolvedFlowControl | undefined;
68
+
18
69
  export declare const getWorkflowVersionMetadata: (value: unknown) => WorkflowVersionMetadata;
19
70
 
71
+ /** `true` when this workflow declares at least one `cancelOn` entry — i.e. when
72
+ * the cancellation sweep has anything to do for it. */
73
+ export declare const hasCancelOn: (control: ResolvedFlowControl | undefined) => boolean;
74
+
75
+ /** `true` when at least one control is declared — i.e. when this workflow needs
76
+ * the admission boundary at all. A workflow with none must take the exact same
77
+ * code path it took before this feature existed.
78
+ *
79
+ * `cancelOn` is deliberately NOT in this list: it is evaluated by a sweep
80
+ * against live runs, never at the start boundary. Including it would route
81
+ * every start of a cancel-only workflow through the admission ledger, paying
82
+ * two writes per start for a decision that is always "admit". */
83
+ export declare const hasFlowControl: (control: ResolvedFlowControl | undefined) => boolean;
84
+
20
85
  export declare const isWorkflowWorkerLayer: (value: unknown) => value is WorkflowWorkerLayerBrand;
21
86
 
22
87
  declare type NormaliseWorkflowMessages<M extends WorkflowMessageSchemas | undefined> = {
@@ -31,6 +96,9 @@ declare type NormaliseWorkflowMessages<M extends WorkflowMessageSchemas | undefi
31
96
  } ? NonNullable<Queries> & Readonly<Record<string, WorkflowMessagePairSchemas>> : {};
32
97
  };
33
98
 
99
+ /** The SCHEMA a payload input denotes — `Fields` lifted into a `Struct`. */
100
+ declare type PayloadSchemaOf<P> = P extends Schema.Struct.Fields ? Schema.Struct<P> : P;
101
+
34
102
  /** Enqueue work and await the durable queue worker's result. */
35
103
  export declare const processQueue: typeof durableQueueModule.process;
36
104
 
@@ -46,6 +114,74 @@ export declare const queueWorker: typeof durableQueueModule.worker;
46
114
  /** Durable rate limiter activity. Delays through the workflow clock. */
47
115
  export declare const rateLimit: typeof durableRateLimiterModule.rateLimit;
48
116
 
117
+ /** One resolved `cancelOn` entry. The schema is kept (not pre-compiled into a
118
+ * decoder) so the sweeper can report a decode failure naming the event, which
119
+ * is the difference between "the predicate said no" and "we could not read the
120
+ * event at all". */
121
+ export declare interface ResolvedCancelOn {
122
+ readonly event: string;
123
+ readonly schema: Schema.Schema.Any;
124
+ readonly match: (event: unknown, payload: unknown) => boolean;
125
+ readonly withinMs?: number;
126
+ readonly reason: string;
127
+ }
128
+
129
+ /** The declaration with its durations resolved to milliseconds and its
130
+ * defaults filled in — what the decision engine actually reads. Key functions
131
+ * stay as functions; they are applied against a concrete payload at start. */
132
+ export declare interface ResolvedFlowControl {
133
+ readonly workflowName: string;
134
+ readonly debounce?: {
135
+ readonly key: (payload: unknown) => string;
136
+ readonly periodMs: number;
137
+ readonly timeoutMs?: number;
138
+ };
139
+ readonly singleton?: {
140
+ readonly key: (payload: unknown) => string;
141
+ readonly mode: 'skip' | 'cancel';
142
+ };
143
+ readonly concurrency?: {
144
+ readonly limit: number;
145
+ readonly key?: (payload: unknown) => string;
146
+ readonly pool?: string;
147
+ };
148
+ readonly throttle?: {
149
+ readonly limit: number;
150
+ readonly periodMs: number;
151
+ readonly key?: (payload: unknown) => string;
152
+ };
153
+ readonly rateLimit?: {
154
+ readonly limit: number;
155
+ readonly periodMs: number;
156
+ readonly key?: (payload: unknown) => string;
157
+ };
158
+ readonly batch?: {
159
+ readonly key?: (payload: unknown) => string;
160
+ readonly maxSize: number;
161
+ readonly timeoutMs: number;
162
+ /**
163
+ * Schema of ONE item — what a CALLER passes to `start()`, as opposed to the
164
+ * workflow's own `{ items: [...] }` payload.
165
+ *
166
+ * Carried through resolution because the facade validates an arriving start
167
+ * against it. It used to be declared, asserted at declaration time, and then
168
+ * dropped here — so every `start()` on a batching workflow was validated
169
+ * against the batch shape it is not, and threw `WorkflowPayloadError` before
170
+ * the gate ever saw it. A required option nothing reads is worse than an
171
+ * absent one: it reads as wired.
172
+ */
173
+ readonly item?: unknown;
174
+ };
175
+ readonly priority?: (payload: unknown) => number;
176
+ readonly timeouts?: {
177
+ readonly startMs?: number;
178
+ readonly finishMs?: number;
179
+ };
180
+ readonly onFailure?: string;
181
+ readonly encryptSteps?: boolean;
182
+ readonly cancelOn?: ReadonlyArray<ResolvedCancelOn>;
183
+ }
184
+
49
185
  /**
50
186
  * Durable sleep — wake time journaled in the cluster. Wrapper around
51
187
  * `DurableClock.sleep` that emits `timer-set` / `timer-fired` events
@@ -60,6 +196,32 @@ export declare const rateLimit: typeof durableRateLimiterModule.rateLimit;
60
196
  */
61
197
  export declare const sleep: typeof durableClock.sleep;
62
198
 
199
+ /**
200
+ * Durable sleep until an ABSOLUTE instant.
201
+ *
202
+ * Not sugar for `sleep({ duration: target - Date.now() })`, and the difference
203
+ * is the whole reason it exists.
204
+ *
205
+ * A delta is computed from the clock of whichever attempt computed it. Compute
206
+ * it in the body and the value is baked into a replay: a run that suspends for
207
+ * six hours and resumes replays the body, recomputes `target - Date.now()`
208
+ * against the NEW now, and sleeps the full period again — from a moment that is
209
+ * already past the target. The wait doubles, silently, and only for runs that
210
+ * happened to be interrupted.
211
+ *
212
+ * So the INSTANT is journaled first, by an activity, and the sleep is derived
213
+ * from the journaled value on every attempt. A replay after the target has
214
+ * passed sleeps zero and continues, which is what "until" means.
215
+ *
216
+ * ```ts
217
+ * yield* sleepUntil({ name: 'until-window-opens', until: order.deliverAfter })
218
+ * ```
219
+ */
220
+ export declare const sleepUntil: (options: {
221
+ readonly name: string;
222
+ readonly until: Date | number;
223
+ }) => Effect.Effect<void, never, never>;
224
+
63
225
  export declare const step: <R, Success extends Schema.Schema.Any = typeof Schema.Void, Error extends Schema.Schema.All = typeof Schema.Never>(options: StepOptions<R, Success, Error>) => ReturnType<typeof stepModule.make<R, Success, Error>>;
64
226
 
65
227
  /**
@@ -123,7 +285,7 @@ declare type StepOptions<R, Success extends Schema.Schema.Any, Error extends Sch
123
285
  * JSON-serialisable EXCEPT `retryable` (a predicate), which the recorder drops
124
286
  * when it stores the policy as dashboard metadata.
125
287
  */
126
- declare interface StepRetryPolicy {
288
+ export declare interface StepRetryPolicy {
127
289
  /** Total attempts INCLUDING the first. Default `3`. `1` disables retry. */
128
290
  readonly maxAttempts?: number;
129
291
  /** Backoff shape between attempts. Default `'exponential'`. */
@@ -162,6 +324,23 @@ declare interface StepRetryPolicy {
162
324
  readonly note?: string;
163
325
  }
164
326
 
327
+ /**
328
+ * Validate + resolve a declaration. Throws at DEFINITION time (module load,
329
+ * i.e. boot) with a message naming the workflow and the field.
330
+ *
331
+ * Boot is the only honest place for these errors. A `limit: 0` that surfaces as
332
+ * "nothing ever runs" three hours into a deployment is the same defect class as
333
+ * a rate cap that drops silently — the system is behaving exactly as configured
334
+ * and nothing says so.
335
+ */
336
+ export declare const validateFlowControl: (input: ValidateFlowControlInput) => ResolvedFlowControl | undefined;
337
+
338
+ export declare interface ValidateFlowControlInput {
339
+ readonly workflowName: string;
340
+ readonly payloadSchema?: unknown;
341
+ readonly control: (WorkflowFlowControl<never> & WorkflowCancelOnDeclared) | undefined;
342
+ }
343
+
165
344
  /**
166
345
  * Add compensating (rollback) logic to a top-level effect in a workflow
167
346
  * body. The finalizer runs if the WHOLE workflow later fails — use it for
@@ -181,15 +360,376 @@ declare interface StepRetryPolicy {
181
360
  */
182
361
  export declare const withCompensation: typeof workflowModule.withCompensation;
183
362
 
184
- /** Define a durable workflow. Adds Voltro version metadata on top of `Workflow.make`. */
363
+ /**
364
+ * Define a durable workflow.
365
+ *
366
+ * ── `idempotencyKey` is the execution's IDENTITY, permanently ──────────────
367
+ *
368
+ * Read this before choosing one. It is the single most expensive
369
+ * misunderstanding this API has produced — a downstream team lost a day and
370
+ * shipped a design around the wrong model, and the wording they read
371
+ * ("deduplicates concurrent invocations with the same input") is what led them
372
+ * there. It sounds like a WINDOW: dedupe while a run is in flight. It is not.
373
+ *
374
+ * The key IS the execution. A second start with the same key REPLAYS the first
375
+ * run's journaled result — forever, not just while it is running. Once the run
376
+ * completes the key is SPENT: a later, genuinely new invocation under that key
377
+ * is a silent no-op that returns the old output. Nothing errors and nothing
378
+ * logs, because from the engine's point of view you asked for a run it already
379
+ * has.
380
+ *
381
+ * ```ts
382
+ * const a = yield* wf.execute({ id: 'same' })
383
+ * const b = yield* wf.execute({ id: 'same' }) // ← does NOT run; replays `a`
384
+ * ```
385
+ *
386
+ * So a key must be **unique per unit of work you want to happen**, not per
387
+ * logical subject. `tour:${rowId}` is wrong if the tour can be re-narrated;
388
+ * `tour:${rowId}:${updatedAt}` is right, because every edit is a new unit of
389
+ * work with its own identity.
390
+ *
391
+ * ── And a flow-control key is a DIFFERENT thing ────────────────────────────
392
+ *
393
+ * The two get conflated, and conflating them is what makes "I need to re-arm a
394
+ * key" feel like a missing feature. It is not missing; it is two fields:
395
+ *
396
+ * • `idempotencyKey` — the execution's identity. Vary it per unit of work.
397
+ * • `debounce.key` / `singleton.key` / `concurrency.key` — the RESOURCE runs
398
+ * compete for. Keep it stable.
399
+ *
400
+ * "One job, fifteen minutes after the last edit, latest state wins" is then:
401
+ *
402
+ * ```ts
403
+ * workflow({
404
+ * name: 'tourNarration',
405
+ * payload: TourPayload,
406
+ * idempotencyKey: ({ rowId, editedAt }) => `tour:${rowId}:${editedAt}`, // varies
407
+ * debounce: { key: (p) => `tour:${p.rowId}`, period: '15 minutes' }, // stable
408
+ * })
409
+ * ```
410
+ *
411
+ * Twenty edits mint twenty identities; exactly one is ever admitted. There is
412
+ * no `restart: true` and no generation counter in this API because separating
413
+ * the two keys is the mechanism those would have been.
414
+ */
185
415
  export declare const workflow: WorkflowFn;
186
416
 
187
- /** Callable shape of `workflow`: the raw `Workflow.make` surface PLUS the
188
- * Voltro-typed overload that threads the message-schema metadata onto the
189
- * returned definition. Explicit alias so the `.d.ts` names this type by its
190
- * own alias the inferred `Workflow.make` type references internal `@effect`
191
- * modules via `.pnpm` paths and is not portable (TS2742) when re-emitted. */
192
- declare type WorkflowFn = WorkflowMake & (<const Options extends WorkflowOptions>(options: Options) => ReturnType<WorkflowMake> & WorkflowMessagesCarrier<NormaliseWorkflowMessages<Options['messages']>>);
417
+ declare type WorkflowBaseOptions = Parameters<WorkflowMake>[0];
418
+
419
+ /**
420
+ * Collect many starts into ONE run over an array of items.
421
+ *
422
+ * A hundred issue-changed events in a minute become one run over a hundred
423
+ * keys, not a hundred runs. The workflow's `payload` must be the BATCH shape —
424
+ * a struct with an `items` array — while callers `start()` it with a SINGLE
425
+ * item. `validateFlowControl` asserts that shape at declaration time, because
426
+ * the alternative is a decode failure at admission on a replica somebody else
427
+ * is watching.
428
+ */
429
+ export declare interface WorkflowBatch<Item> {
430
+ /** Schema of ONE item, i.e. what a caller passes to `start()`. The workflow's
431
+ * own `payload` schema describes `{ items: Array<item> }`. */
432
+ readonly item: unknown;
433
+ /** Groups items. Omit to batch everything for this workflow together. */
434
+ readonly key?: (item: Item) => string;
435
+ /** Admit as soon as this many items have accumulated. */
436
+ readonly maxSize: number;
437
+ /** Admit this long after the FIRST item, however few arrived. Unlike
438
+ * debounce's period this does NOT reset — a batch is a deadline, not a quiet
439
+ * period, or a steady trickle would never flush. */
440
+ readonly timeout: FlowDuration;
441
+ }
442
+
443
+ /**
444
+ * Cancel this workflow's in-flight work when a correlated domain event arrives.
445
+ *
446
+ * ── Why this is a DECLARATION and not `awaitEvent` + a race ─────────────────
447
+ *
448
+ * You can express "stop when the issue is deleted" inside the body: race the
449
+ * real work against an `awaitEvent`, and interrupt on the first to settle. That
450
+ * works while the body is RUNNING. It does not work while the run is sleeping
451
+ * for six hours, suspended on a signal, or still sitting in the admission
452
+ * queue — which is the whole reason you wanted cancellation. The event has to
453
+ * be able to reach a run whose fiber is not currently executing anything, and
454
+ * only something OUTSIDE the body can do that.
455
+ *
456
+ * So it is swept: a coordinated tick reads events published since a durable
457
+ * watermark, resolves each declaring workflow's live runs, evaluates `match`
458
+ * against the run's own decoded payload, and cancels the ones that correlate.
459
+ *
460
+ * ── What it cancels, which is more than the run ─────────────────────────────
461
+ *
462
+ * A matching event also DISCARDS queued intents of that workflow. Cancelling
463
+ * only the running one leaves a debounced or concurrency-queued duplicate to
464
+ * start seconds later, against the row that just got deleted — the exact
465
+ * outcome the declaration was meant to prevent, arriving late enough that
466
+ * nobody connects the two.
467
+ *
468
+ * ```ts
469
+ * cancelOn: [{
470
+ * event: 'jira.issue.deleted',
471
+ * schema: JiraIssueDeleted,
472
+ * match: (e, payload) => e.issueKey === payload.issueKey,
473
+ * }],
474
+ * ```
475
+ */
476
+ export declare interface WorkflowCancelOn<EventSchema extends Schema.Schema.Any, Payload> {
477
+ /** The event NAME, exactly as `ctx.events.publish` writes it. */
478
+ readonly event: string;
479
+ /** Decoded before `match` sees it, so the predicate reads the domain type
480
+ * rather than whatever JSON came off the wire. A publisher shipping the
481
+ * wrong shape is reported, and does NOT count as a match — cancelling on an
482
+ * event you could not read is cancelling blind. */
483
+ readonly schema: EventSchema;
484
+ /**
485
+ * Does this event cancel THIS run?
486
+ *
487
+ * Required, with no default, for the same reason `singleton.mode` has none:
488
+ * the omitted case is not harmless. A missing predicate would mean "cancel
489
+ * every live run of this workflow", which is a legitimate thing to want and a
490
+ * catastrophic thing to get by forgetting a line. Write `match: () => true`
491
+ * when you mean it.
492
+ */
493
+ readonly match: (event: Schema.Schema.Type<EventSchema>, payload: Payload) => boolean;
494
+ /**
495
+ * Only cancel runs that started within this window before the event.
496
+ *
497
+ * Bounds the blast radius of a broad predicate — an event correlating on a
498
+ * tenant id would otherwise reach a run that has been alive for a week.
499
+ */
500
+ readonly within?: FlowDuration;
501
+ /** Recorded on the run's `run-cancelled` event and shown in the dashboard.
502
+ * Defaults to `cancelOn:<event>`. */
503
+ readonly reason?: string;
504
+ }
505
+
506
+ /** The erased `cancelOn` shape the implementation reads. The DECLARED type is
507
+ * the per-entry-inferred {@link WorkflowCancelOnList}; this is what survives
508
+ * erasure, and it is deliberately not the public spelling. */
509
+ export declare interface WorkflowCancelOnDeclared {
510
+ readonly cancelOn?: ReadonlyArray<WorkflowCancelOn<Schema.Schema.Any, never>>;
511
+ }
512
+
513
+ /** The `cancelOn` array as declared, with each entry's event type inferred
514
+ * from its own `schema`. The tuple type parameter is what makes that
515
+ * per-entry inference work — a plain `ReadonlyArray<WorkflowCancelOn<…>>`
516
+ * collapses every entry's event to the constraint (`any`), which is how the
517
+ * predicate silently stops being checked. */
518
+ export declare type WorkflowCancelOnList<Cancels extends ReadonlyArray<Schema.Schema.Any>, Payload> = {
519
+ readonly [I in keyof Cancels]: WorkflowCancelOn<Cancels[I], Payload>;
520
+ };
521
+
522
+ /**
523
+ * At most `limit` runs in flight at once, per key.
524
+ *
525
+ * The limit spans every replica — it is enforced through the shared admissions
526
+ * ledger, so three replicas with `limit: 5` are five runs, not fifteen. (An
527
+ * earlier draft offered `scope: 'replica'` for a per-process budget; it is
528
+ * gone, and deliberately: excess starts queue in the SHARED pending table and
529
+ * are drained by whichever replica has capacity, so a per-process count has no
530
+ * coherent meaning in this model — the replica that counted is not the replica
531
+ * that drains.)
532
+ */
533
+ export declare interface WorkflowConcurrency<Payload> {
534
+ readonly limit: number;
535
+ /** Partitions the limit. Omit for one deployment-wide pool. */
536
+ readonly key?: (payload: Payload) => string;
537
+ /**
538
+ * Share the limit ACROSS workflows.
539
+ *
540
+ * Without `pool`, the limit bounds THIS workflow's runs. With it, every
541
+ * workflow declaring the same pool name competes for ONE budget — the shape
542
+ * a rate-limited provider forces: five workflows that each call OpenAI must
543
+ * share ten slots, not hold ten each. `key` still partitions within the
544
+ * pool (all members' key functions feed the same buckets, so
545
+ * `(p) => p.tenantId` in each member yields a per-tenant shared budget).
546
+ *
547
+ * Every member of a pool must declare the SAME `limit` — the boot fails
548
+ * otherwise, because two limits for one budget is a contradiction, and
549
+ * silently picking one would enforce a number somebody did not write.
550
+ */
551
+ readonly pool?: string;
552
+ }
553
+
554
+ /**
555
+ * Collapse a burst of starts into ONE run, `period` after the last one.
556
+ *
557
+ * The LATEST payload wins. That is what debounce means — "fifteen minutes after
558
+ * the last edit, narrate what settled" wants the state as of the last edit, not
559
+ * the first. Stated explicitly because "first wins" is a defensible alternative
560
+ * and a caller must not have to guess which they got.
561
+ */
562
+ export declare interface WorkflowDebounce<Payload> {
563
+ /** Groups the burst. Starts sharing a key collapse into one pending run. */
564
+ readonly key: (payload: Payload) => string;
565
+ /** Quiet period after the last start before the run is admitted. */
566
+ readonly period: FlowDuration;
567
+ /**
568
+ * Hard cap measured from the FIRST start in the burst — the run is admitted
569
+ * at `timeout` even if starts keep arriving.
570
+ *
571
+ * Optional, and uncapped when unset. That is deliberate and it is a real
572
+ * trade: an unbroken stream of starts arriving faster than `period` will
573
+ * defer the run forever. We do not invent a default cap (any number would be
574
+ * arbitrary), and we do not warn (a warning at boot rots into noise). Instead
575
+ * the starvation is a NUMBER you can see: the pending row carries
576
+ * `firstSeenAt` and `collapsed`, both surfaced by
577
+ * `/_voltro/inspect/workflows/flow-control` and the dashboard's Flow Control
578
+ * panel. A pending age climbing past a few multiples of `period` is the
579
+ * signal; set `timeout` when you see it, or from the start if the burst is
580
+ * user-driven and unbounded.
581
+ */
582
+ readonly timeout?: FlowDuration;
583
+ }
584
+
585
+ /**
586
+ * What an `onFailure` workflow receives. Declare it as that workflow's
587
+ * `payload` schema — the framework validates against it like any other start,
588
+ * so a mismatch is a decode error naming the field rather than a silent drop.
589
+ */
590
+ export declare interface WorkflowFailureReport {
591
+ /** The workflow that failed. */
592
+ readonly workflow: string;
593
+ /** Its payload, as it was started with. Null when the failure happened before
594
+ * a payload existed (a `timeouts.finish` sweep reads the run row, not the
595
+ * intent). */
596
+ readonly payload: unknown;
597
+ /** Tagged error name, when the failure carried one. */
598
+ readonly errorTag: string | null;
599
+ readonly errorMessage: string | null;
600
+ /** `_voltro_workflow_runs.id`, when a run existed. */
601
+ readonly runId: string | null;
602
+ readonly executionId: string | null;
603
+ /** Human-readable, always present: which of the four paths this was. */
604
+ readonly reason: string;
605
+ /** Epoch ms. */
606
+ readonly failedAt: number;
607
+ }
608
+
609
+ /** Every flow-control field, as declared on `workflow({...})`. */
610
+ export declare interface WorkflowFlowControl<Payload> {
611
+ readonly debounce?: WorkflowDebounce<Payload>;
612
+ readonly singleton?: WorkflowSingleton<Payload>;
613
+ readonly concurrency?: WorkflowConcurrency<Payload>;
614
+ readonly throttle?: WorkflowThrottle<Payload>;
615
+ readonly rateLimit?: WorkflowRateLimit<Payload>;
616
+ readonly batch?: WorkflowBatch<Payload>;
617
+ /** Higher runs first out of the pending queue. Ties break by arrival, so an
618
+ * all-default deployment is FIFO rather than nondeterministic. */
619
+ readonly priority?: (payload: Payload) => number;
620
+ readonly timeouts?: WorkflowTimeouts;
621
+ /**
622
+ * The NAME of a workflow to run when this one does not complete.
623
+ *
624
+ * A durable run that exhausts its retries currently ends and says nothing.
625
+ * The alternative people reach for is a cron over
626
+ * `listRuns({ status: 'failed' })` — a sweep where a signal belongs, and the
627
+ * shape this framework keeps trying to remove.
628
+ *
629
+ * A NAME, not a function, and that is the whole design decision. A closure
630
+ * cannot be journaled: the failure may be noticed by a different replica than
631
+ * the one that ran the workflow, minutes later, after the process that held
632
+ * the closure is gone. A workflow name resolves anywhere, is itself durable,
633
+ * retries on its own terms, and shows up in the run list like everything else.
634
+ *
635
+ * It fires for EVERY way a run fails to deliver, not only an exhausted retry:
636
+ * • the body failed and retries are spent
637
+ * • `timeouts.finish` cancelled an overrunning run
638
+ * • `timeouts.start` expired an intent that never got a slot
639
+ * • the workflow was renamed away while intents were queued
640
+ *
641
+ * Its payload is {@link WorkflowFailureReport}. Declare that as its `payload`
642
+ * schema. A handler that itself fails is recorded and NOT re-notified —
643
+ * there is no `onFailure` for the `onFailure`, deliberately: the alternative
644
+ * is a loop that produces one run per failure per level, forever.
645
+ */
646
+ readonly onFailure?: string;
647
+ /**
648
+ * Encrypt this workflow's journaled step `input` / `output` / `errorCause`.
649
+ *
650
+ * `step({ input })` is written to `_voltro_workflow_run_steps` and rendered in
651
+ * the dashboard — a feature, and the reason people pass rich input. For a step
652
+ * carrying personal data it is also a SECOND COPY, outside the `.encrypted()`
653
+ * boundary the governance plugin establishes for tables. An app that carefully
654
+ * encrypted a column and then handed the same value to a step has it in
655
+ * plaintext one table over.
656
+ *
657
+ * Reuses the cipher `governancePlugin({ fieldEncryption })` already registers,
658
+ * so there is ONE key and one rotation story rather than a second scheme that
659
+ * only workflows know about. It is therefore a BOOT ERROR to declare this
660
+ * without that plugin configured: silently falling back to plaintext would be
661
+ * the worst outcome available — the declaration reads as protection and the
662
+ * rows are readable.
663
+ *
664
+ * Only the journal is affected. The values the workflow BODY sees are
665
+ * unchanged, and the durable execution journal `@effect/workflow` keeps is
666
+ * untouched (encrypting that would break replay).
667
+ */
668
+ readonly encryptSteps?: boolean;
669
+ }
670
+
671
+ /** Where the RESOLVED flow-control declaration hangs off a definition. A
672
+ * string property rather than a symbol, matching `WorkflowMessagesProperty`,
673
+ * because the CLI's discovery reads it off a module that crossed a bundler
674
+ * boundary and symbols do not survive every one of those. */
675
+ export declare const WorkflowFlowControlProperty = "__voltroWorkflowFlowControl";
676
+
677
+ /**
678
+ * Callable shape of `workflow` — ONE signature mirroring `Workflow.make`'s own
679
+ * generics, plus Voltro's version metadata, message schemas and flow control.
680
+ *
681
+ * ── Why one signature and not an intersection ─────────────────────────────
682
+ *
683
+ * This used to be `WorkflowMake & (<Options>(options: Options) => …)`: the raw
684
+ * effect signature first for a precise return type, ours second for the extras.
685
+ * That shape cannot carry flow control, and the way it fails is silent.
686
+ *
687
+ * `debounce.key: (payload) => string` must receive the workflow's own payload
688
+ * type. That is most of what the declaration buys over a hand-rolled debounce:
689
+ * a lambda typed `any` compiles, runs, and is wrong only once somebody renames
690
+ * a field — at which point the key becomes the string `"undefined"` and every
691
+ * row in the deployment collapses into one bucket, which looks exactly like a
692
+ * working debounce until you count the runs.
693
+ *
694
+ * Three attempts got there and were measured, not assumed:
695
+ *
696
+ * 1. `<Options extends WorkflowOptions>(o: Options & WorkflowFlowControl<
697
+ * PayloadTypeOf<Options>>)` — TypeScript types the key lambdas while it is
698
+ * still inferring `Options`, so the payload type resolves against the
699
+ * CONSTRAINT. With `WorkflowFlowControl<never>` in it, `never` wins the
700
+ * contextual-type merge and the parameters fall back to implicit `any`.
701
+ * 2. Taking it out of the constraint was not enough: `Options` is a NAKED
702
+ * type parameter inside the parameter's intersection, and TypeScript does
703
+ * not push contextual types through an intersection it cannot decompose.
704
+ * 3. Spelling the parameter out concretely fixed THAT, and the call still
705
+ * typed the lambda as `any` — because `workflow` was still an
706
+ * INTERSECTION of two call signatures, and overload resolution types
707
+ * context-sensitive arguments before it fixes type parameters.
708
+ *
709
+ * So: no intersection. The generics below are `Workflow.make`'s own, which is
710
+ * what makes a single signature able to accept everything it accepted. The
711
+ * return type improves as a side effect — it was `ReturnType<WorkflowMake>`
712
+ * (i.e. `Workflow<any, any, any>`) for any workflow that declared `messages`,
713
+ * because such a call fell through to the second overload.
714
+ *
715
+ * `flowControl.test-d.ts` pins the result, including a `@ts-expect-error` on a
716
+ * misspelled payload field. That assertion is the one that silently stops
717
+ * meaning anything if this ever regresses to `any` — which is precisely what
718
+ * all three failed attempts did.
719
+ */
720
+ declare type WorkflowFn = <const Name extends string, Payload extends WorkflowPayloadInput, Success extends Schema.Schema.Any = typeof Schema.Void, Error extends Schema.Schema.All = typeof Schema.Never, const Messages extends WorkflowMessageSchemas | undefined = undefined, Cancels extends ReadonlyArray<Schema.Schema.Any> = readonly []>(options: {
721
+ readonly name: Name;
722
+ readonly payload: Payload;
723
+ readonly idempotencyKey: (payload: DecodedPayload<Payload>) => string;
724
+ readonly success?: Success;
725
+ readonly error?: Error;
726
+ /** Cancel live runs when a correlated event arrives. Each entry's event
727
+ * type comes from its OWN `schema` — see {@link WorkflowCancelOnList} for
728
+ * why that needs a tuple type parameter rather than a plain array. */
729
+ readonly cancelOn?: WorkflowCancelOnList<Cancels, DecodedPayload<Payload>>;
730
+ } & Omit<WorkflowBaseOptions, 'name' | 'payload' | 'idempotencyKey' | 'success' | 'error'> & WorkflowVersionOptions & {
731
+ readonly messages?: Messages;
732
+ } & WorkflowFlowControl<DecodedPayload<Payload>>) => workflowModule.Workflow<Name, PayloadSchemaOf<Payload>, Success, Error> & WorkflowMessagesCarrier<NormaliseWorkflowMessages<Messages>>;
193
733
 
194
734
  declare type WorkflowMake = typeof workflowModule.make;
195
735
 
@@ -218,9 +758,79 @@ export declare const WorkflowMessagesProperty = "__voltroWorkflowMessages";
218
758
 
219
759
  export { workflowModule }
220
760
 
221
- declare type WorkflowOptions = Parameters<WorkflowMake>[0] & WorkflowVersionOptions & {
222
- readonly messages?: WorkflowMessageSchemas;
223
- };
761
+ /** Everything `Workflow.make` accepts for `payload`: a struct Schema, or bare
762
+ * `Fields`. Both stay supported — a key lambda must not silently degrade to
763
+ * `any` because the workflow spelled its payload the shorter way. */
764
+ declare type WorkflowPayloadInput = WorkflowBaseOptions['payload'];
765
+
766
+ /**
767
+ * Hard cap: starts beyond `limit` per `period` are DROPPED, not queued.
768
+ *
769
+ * A dropped start is reported — `start()` resolves to a handle with
770
+ * `status: 'dropped'` and a `retryAfterMs`, and the drop is a row in the
771
+ * admissions ledger with its key and reason. It is never silent. A cap that
772
+ * discards work without saying so is indistinguishable from a bug, which is
773
+ * exactly the failure this framework keeps writing guards against.
774
+ */
775
+ export declare interface WorkflowRateLimit<Payload> {
776
+ readonly limit: number;
777
+ readonly period: FlowDuration;
778
+ readonly key?: (payload: Payload) => string;
779
+ }
780
+
781
+ /**
782
+ * At most one run per key. The newcomer either stands down or evicts.
783
+ *
784
+ * `key` is the RESOURCE, not the identity — see the module header. Two starts
785
+ * with different `idempotencyKey`s can absolutely collide here, and that is the
786
+ * point.
787
+ */
788
+ export declare interface WorkflowSingleton<Payload> {
789
+ readonly key: (payload: Payload) => string;
790
+ /**
791
+ * `'skip'` — a start arriving while the key is held returns the INCUMBENT's
792
+ * handle. No new run, no error. Use when the work in flight is
793
+ * as good as the work you were about to ask for.
794
+ * `'cancel'` — the incumbent is cancelled and the newcomer runs. Use when the
795
+ * newcomer carries fresher input and the incumbent's partial
796
+ * work is worthless.
797
+ *
798
+ * There is no default: the two answers are opposite and picking one for you
799
+ * would silently discard either the new request or the running one.
800
+ */
801
+ readonly mode: 'skip' | 'cancel';
802
+ }
803
+
804
+ /**
805
+ * Smooth throughput: excess starts QUEUE and are admitted as capacity frees up.
806
+ *
807
+ * The counterpart to {@link WorkflowRateLimit}, which DROPS the excess. Reach
808
+ * for throttle when every start must eventually run and you only care that they
809
+ * do not run all at once; reach for rate limit when the excess is genuinely
810
+ * surplus and running it late is worse than not running it.
811
+ */
812
+ export declare interface WorkflowThrottle<Payload> {
813
+ readonly limit: number;
814
+ readonly period: FlowDuration;
815
+ readonly key?: (payload: Payload) => string;
816
+ }
817
+
818
+ /**
819
+ * Wall-clock bounds on a run, distinct from a step's retry policy.
820
+ *
821
+ * `start` bounds how long a run may sit in the admission queue — a debounced or
822
+ * concurrency-queued run that never gets a slot is a job that silently did not
823
+ * happen. `finish` bounds the run itself once admitted.
824
+ *
825
+ * Both expire into the SAME path as an exhausted retry: the run is recorded
826
+ * `failed` with an `errorTag` naming which bound was hit, and `onFailure` fires.
827
+ * A timeout that expired quietly would be the sweep-instead-of-signal shape this
828
+ * whole feature exists to remove.
829
+ */
830
+ export declare interface WorkflowTimeouts {
831
+ readonly start?: FlowDuration;
832
+ readonly finish?: FlowDuration;
833
+ }
224
834
 
225
835
  export declare interface WorkflowVersionMetadata {
226
836
  readonly version: string;