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