@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.
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ import { Context } from 'effect';
3
3
  import { DurableClock as durableClock } from '@effect/workflow';
4
4
  import { DurableQueue as durableQueueModule } from '@effect/workflow';
5
5
  import { DurableRateLimiter as durableRateLimiterModule } from '@effect/workflow';
6
+ import { Duration } from 'effect';
6
7
  import { Effect } from 'effect';
7
8
  import { FiberRef } from 'effect';
8
9
  import { Layer } from 'effect';
@@ -18,6 +19,309 @@ import { WorkflowEngine } from '@effect/workflow';
18
19
  import { Workflow as workflowModule } from '@effect/workflow';
19
20
  import { WorkflowParentClosePolicy } from '@voltro/protocol';
20
21
 
22
+ /** The narrow slice of `DataStore` flow control needs. */
23
+ export declare interface AdmissionDataStore {
24
+ query(descriptor: QueryDescriptor): Promise<ReadonlyArray<Row>>;
25
+ insert(table: string, row: Row): Promise<Row>;
26
+ update(table: string, primaryKey: string, patch: Readonly<Record<string, unknown>>): Promise<Row | null>;
27
+ delete(table: string, primaryKey: string): Promise<boolean>;
28
+ upsert(table: string, row: Row, options: {
29
+ conflictColumns: ReadonlyArray<string>;
30
+ update?: ReadonlyArray<string> | ((existing: Row) => Readonly<Record<string, unknown>>);
31
+ }): Promise<Row>;
32
+ }
33
+
34
+ export declare type AdmissionDecision = {
35
+ readonly kind: 'admit';
36
+ readonly reason: AdmitReason;
37
+ readonly keys: AdmissionKeys;
38
+ /** Cancel this run as part of admitting — `singleton: { mode: 'cancel' }`.
39
+ * Only ever present on an `admit`; see the module header. */
40
+ readonly evict?: AdmissionIncumbent;
41
+ } | {
42
+ readonly kind: 'defer';
43
+ readonly mode: DeferMode;
44
+ /** Epoch ms: the earliest moment this may be admitted. */
45
+ readonly dueAt: number;
46
+ /** Epoch ms: debounce's hard cap, measured from the first start. */
47
+ readonly deadlineAt?: number;
48
+ /** `'replace'` — one pending row per key, newest payload wins (debounce).
49
+ * `'append'` — one row per item, they accumulate (batch).
50
+ * `'hold'` — the row already exists and only its `dueAt` moves
51
+ * (throttle / concurrency / paused re-checks). */
52
+ readonly collapse: 'replace' | 'append' | 'hold';
53
+ readonly keys: AdmissionKeys;
54
+ } | {
55
+ readonly kind: 'drop';
56
+ readonly reason: 'rate-limit';
57
+ readonly retryAfterMs: number;
58
+ readonly keys: AdmissionKeys;
59
+ } | {
60
+ readonly kind: 'skip';
61
+ readonly incumbent: AdmissionIncumbent;
62
+ readonly keys: AdmissionKeys;
63
+ };
64
+
65
+ export declare interface AdmissionDrainDeps {
66
+ readonly store: AdmissionDataStore;
67
+ /** Resolved flow control per workflow name. A name missing from this map has
68
+ * no controls, so any pending row under it is an orphan — see `drainTick`. */
69
+ readonly controls: ReadonlyMap<string, ResolvedFlowControl>;
70
+ /** Actually start the workflow. Injected, because the engine lives in the CLI
71
+ * layer and this package must not reach for it. */
72
+ readonly startAdmitted: (workflowName: string, payload: unknown, callerContext: unknown) => Promise<AdmittedStart>;
73
+ /** Cancel a run — used by singleton eviction and by `timeouts.finish`. */
74
+ readonly cancelRun: (workflowName: string, executionId: string) => Promise<void>;
75
+ /**
76
+ * A start that will never happen: `timeouts.start` elapsed, or the workflow
77
+ * was deleted while its intents were queued. Wired to the same `onFailure`
78
+ * path an exhausted retry takes — a job that silently did not happen is the
79
+ * failure this whole feature exists to remove, so it must not be the one
80
+ * outcome with no notification.
81
+ */
82
+ readonly onAbandoned: (input: {
83
+ readonly workflowName: string;
84
+ readonly payload: unknown;
85
+ readonly callerContext: unknown;
86
+ readonly reason: string;
87
+ }) => Promise<void>;
88
+ /** Runs of a controlled workflow that are still running, for `timeouts.finish`. */
89
+ readonly listRunningRuns?: (workflowName: string) => Promise<ReadonlyArray<{
90
+ readonly executionId: string;
91
+ readonly startedAt: number;
92
+ }>>;
93
+ readonly log?: DrainLogger;
94
+ readonly now?: () => number;
95
+ }
96
+
97
+ /** The run currently holding a singleton key. */
98
+ export declare interface AdmissionIncumbent {
99
+ readonly executionId: string;
100
+ readonly runId: string;
101
+ /** Epoch ms, for the "how long has it held this" the ledger reports. */
102
+ readonly admittedAt: number;
103
+ }
104
+
105
+ /** Every flow-control key resolved against ONE concrete payload. */
106
+ export declare interface AdmissionKeys {
107
+ readonly debounceKey?: string;
108
+ readonly singletonKey?: string;
109
+ readonly concurrencyKey?: string;
110
+ readonly throttleKey?: string;
111
+ readonly rateLimitKey?: string;
112
+ readonly batchKey?: string;
113
+ readonly priority: number;
114
+ }
115
+
116
+ export declare const ADMISSIONS_TABLE = "_voltro_workflow_admissions";
117
+
118
+ /** What the store observed for these keys. Every field optional: the store only
119
+ * queries for controls this workflow actually declares, so an undeclared
120
+ * control costs no round trip. */
121
+ export declare interface AdmissionState {
122
+ /** Operator pause (`voltro workflows pause <name>`). */
123
+ readonly paused?: boolean;
124
+ /** An existing pending row under `debounceKey`. */
125
+ readonly debouncePending?: {
126
+ readonly firstSeenAt: number;
127
+ readonly collapsed: number;
128
+ /** Already-computed hard cap, carried on the row so the deadline is
129
+ * measured from the FIRST start and survives a replica change. */
130
+ readonly deadlineAt?: number;
131
+ };
132
+ /** Items already accumulated under `batchKey`. */
133
+ readonly batchPending?: {
134
+ readonly count: number;
135
+ readonly firstSeenAt: number;
136
+ };
137
+ readonly singletonHolder?: AdmissionIncumbent;
138
+ /** Runs in flight under `concurrencyKey` (leases held, not yet released). */
139
+ readonly inFlight?: number;
140
+ /** Admission timestamps inside the throttle / rate-limit window,
141
+ * NEWEST FIRST. Derived from the admissions ledger — see the note on
142
+ * {@link nextSlotAt} for why a ledger read beats a counter column. */
143
+ readonly recentAdmissions?: ReadonlyArray<number>;
144
+ }
145
+
146
+ /** What the caller learns about a start that went through flow control. */
147
+ export declare type AdmissionVerdict = {
148
+ /** Start it now. The ledger row is already written; call `linkExecution`
149
+ * once the engine hands back an execution id. */
150
+ readonly kind: 'start';
151
+ readonly ledgerId: string | undefined;
152
+ readonly keys: AdmissionKeys;
153
+ /**
154
+ * What to start the workflow WITH.
155
+ *
156
+ * Almost always the caller's own payload — except for a batch that filled
157
+ * up on THIS arrival, where it is `{ items: [...] }` carrying every item
158
+ * already accumulated plus the arriving one. Returning it explicitly rather
159
+ * than letting the caller assume its own payload is the fix for a real
160
+ * defect: the arriving item alone would start the run, and the three items
161
+ * already queued would sit in the table until their timeout fired a SECOND
162
+ * run. One batch in, two runs out, and the first one short.
163
+ */
164
+ readonly payload: unknown;
165
+ /** Pending rows this start consumes — delete them once it succeeds. Empty
166
+ * for an ordinary start. */
167
+ readonly consume: ReadonlyArray<string>;
168
+ /** Starts folded into this run, for the ledger. */
169
+ readonly collapsed: number;
170
+ /** Cancel this run first — `singleton: { mode: 'cancel' }`. */
171
+ readonly evict?: {
172
+ readonly executionId: string;
173
+ readonly runId: string;
174
+ };
175
+ } | {
176
+ /** Queued. The run will happen later; `start()` returns a handle whose
177
+ * status says so rather than a fake execution id. */
178
+ readonly kind: 'queued';
179
+ readonly intentId: string;
180
+ readonly mode: string;
181
+ readonly dueAt: number;
182
+ } | {
183
+ /** Over a `rateLimit` cap. The work will NOT happen. */
184
+ readonly kind: 'dropped';
185
+ readonly retryAfterMs: number;
186
+ } | {
187
+ /** `singleton: { mode: 'skip' }` — the incumbent's handle is the answer. */
188
+ readonly kind: 'skipped';
189
+ readonly executionId: string;
190
+ readonly runId: string;
191
+ };
192
+
193
+ export declare type AdmitReason = 'no-controls' | 'under-limits' | 'debounce-elapsed' | 'debounce-deadline' | 'batch-full' | 'batch-timeout';
194
+
195
+ /**
196
+ * Decide, persist, and report — the whole arrival path in one call.
197
+ *
198
+ * Note what it does NOT do: start the workflow. The engine call stays with the
199
+ * facade, because the facade is the only thing that knows how to talk to it,
200
+ * and because a `kind: 'start'` verdict with an eviction attached has to
201
+ * cancel the incumbent in the right order relative to the new start.
202
+ */
203
+ export declare const admitStart: (input: AdmitStartInput) => Promise<AdmissionVerdict>;
204
+
205
+ export declare interface AdmitStartInput {
206
+ readonly store: AdmissionDataStore;
207
+ readonly control: ResolvedFlowControl;
208
+ readonly payload: unknown;
209
+ readonly callerContext: unknown;
210
+ readonly tenantId: string | null;
211
+ readonly paused: boolean;
212
+ readonly now: number;
213
+ /**
214
+ * `true` when the caller is BLOCKING for the result (`ctx.workflows.run`, or
215
+ * `start({ wait: true })`).
216
+ *
217
+ * A deferring control has no coherent answer for such a caller — there is no
218
+ * result to return for a start that was collapsed into a future run — so the
219
+ * facade rejects that combination before calling in here. This flag is
220
+ * carried so the ledger records WHY a run bypassed the queue, not to change
221
+ * the decision.
222
+ */
223
+ readonly waiting: boolean;
224
+ }
225
+
226
+ export declare interface AdmittedStart {
227
+ readonly executionId: string;
228
+ readonly runId?: string;
229
+ }
230
+
231
+ /** Every pending row, for the inspect surface — including not-yet-due ones,
232
+ * because "what is waiting and for how long" is the question being asked. */
233
+ export declare const allIntents: (store: AdmissionDataStore, limit: number) => Promise<ReadonlyArray<PendingIntent>>;
234
+
235
+ /**
236
+ * Assert every workflow sharing a `concurrency.pool` declares the SAME limit.
237
+ *
238
+ * Called at BOOT, with every registered workflow's resolved control — the one
239
+ * moment all declarations are visible in one place. `validateFlowControl` runs
240
+ * per declaration and structurally cannot see a sibling file, so without this
241
+ * check two limits for one budget would not fail anywhere: admission would
242
+ * enforce whichever workflow's limit the arriving start happened to carry, and
243
+ * the pool would bound to 5 or 10 depending on WHO asked. A limit that varies
244
+ * by asker is not a limit.
245
+ *
246
+ * A boot error rather than a warning, for the same reason `singleton.mode` has
247
+ * no default: both declared numbers are somebody's intent, and picking either
248
+ * one silently enforces a budget the other author never wrote.
249
+ */
250
+ export declare const assertConsistentPools: (controls: ReadonlyArray<ResolvedFlowControl | undefined>) => void;
251
+
252
+ /**
253
+ * Wait for a domain event correlated with this run.
254
+ *
255
+ * ```ts
256
+ * const change = yield* awaitEvent(ctx, {
257
+ * event: 'jira.issue.changed',
258
+ * schema: JiraIssueChanged,
259
+ * match: (e) => e.issueKey === input.issueKey,
260
+ * since: 'run-start',
261
+ * timeoutMs: 30 * 60_000,
262
+ * })
263
+ * ```
264
+ *
265
+ * Journaled like every other activity: once it has matched, a replay returns the
266
+ * matched event from the journal without re-reading anything.
267
+ */
268
+ export declare const awaitEvent: <A>(ctx: AwaitEventCtx, options: AwaitEventOptions<A>) => Effect.Effect<A, Cause.UnknownException | ParseResult.ParseError>;
269
+
270
+ /** The slice of `ctx.store` this needs — the same shape `awaitSignal` takes, so
271
+ * a full `AppContext` store is assignable without a cast. */
272
+ export declare interface AwaitEventCtx {
273
+ readonly store: {
274
+ readonly query: (descriptor: QueryDescriptor) => Promise<ReadonlyArray<unknown>>;
275
+ };
276
+ }
277
+
278
+ export declare interface AwaitEventOptions<A> {
279
+ /**
280
+ * The event NAME, exactly as `ctx.events.publish` / `emit` writes it.
281
+ *
282
+ * Also used to name the journal entry, so two `awaitEvent` calls for
283
+ * different events in one body are distinct activities and replay
284
+ * independently.
285
+ */
286
+ readonly event: string;
287
+ /** Parsed against the event's payload. A parse failure fails the activity
288
+ * rather than matching — a correlated event whose SHAPE is wrong is a bug in
289
+ * the publisher, and silently skipping it would hide that. */
290
+ readonly schema: Schema.Schema<A, any>;
291
+ /**
292
+ * Does this event belong to this run?
293
+ *
294
+ * Ordinary JavaScript over the decoded event payload. Return `true` for the
295
+ * one you are waiting for. Called for every candidate event in the window, so
296
+ * keep it cheap and pure — it may run many times across polls, and a
297
+ * predicate with side effects would perform them repeatedly.
298
+ */
299
+ readonly match: (event: A) => boolean;
300
+ /**
301
+ * Which events are eligible.
302
+ *
303
+ * `'wait-start'` (default) — only events that arrived after this call. Matches
304
+ * what every durable-execution engine does, and carries the same RACE: an
305
+ * event published between the previous step finishing and this call beginning
306
+ * is missed, and the wait then runs to its timeout. That window is small but
307
+ * real, and it is the single most likely reason an `awaitEvent` "never fires".
308
+ *
309
+ * `'run-start'` — every event since the run began. Closes the race at the cost
310
+ * of possibly matching something that arrived while an earlier step was still
311
+ * running. Prefer it whenever the event is a REPLY to something an earlier
312
+ * step in this same workflow sent, which is most of the time.
313
+ */
314
+ readonly since?: 'wait-start' | 'run-start';
315
+ readonly pollIntervalMs?: number;
316
+ readonly maxPollIntervalMs?: number;
317
+ /** Default 24 h. `Number.POSITIVE_INFINITY` for unbounded. */
318
+ readonly timeoutMs?: number;
319
+ /** How many candidate events one poll reads. Default 500. A correlation that
320
+ * needs more than this in one window is one whose `event` name is too broad
321
+ * — narrow the name rather than raising this. */
322
+ readonly take?: number;
323
+ }
324
+
21
325
  /**
22
326
  * Wait inside a workflow body for an externally-injected signal.
23
327
  *
@@ -132,6 +436,144 @@ export declare interface AwaitUpdateOptions<A, B = A> {
132
436
  readonly timeoutMs?: number;
133
437
  }
134
438
 
439
+ export declare const CANCEL_ON_WATERMARK = "cancelOn";
440
+
441
+ export declare interface CancelDecision {
442
+ readonly runId: string;
443
+ readonly executionId: string;
444
+ readonly reason: string;
445
+ readonly event: string;
446
+ readonly eventId: string;
447
+ }
448
+
449
+ /** A start still sitting in the admission queue. */
450
+ export declare interface CancellableIntent {
451
+ readonly intentId: string;
452
+ readonly payload: unknown;
453
+ readonly firstSeenAt: Date;
454
+ }
455
+
456
+ /** A live run that a matching event could cancel. */
457
+ export declare interface CancellableRun {
458
+ readonly runId: string;
459
+ readonly executionId: string;
460
+ /** As stored in `_voltro_workflow_runs.payload` — ENCODED. Decoded with the
461
+ * workflow's own payload schema before `match` sees it. */
462
+ readonly payload: unknown;
463
+ readonly startedAt: Date;
464
+ }
465
+
466
+ /** The narrow store slice this needs. Structural, like `AdmissionDataStore`. */
467
+ export declare interface CancelOnDataStore {
468
+ query(descriptor: QueryDescriptor): Promise<ReadonlyArray<Row>>;
469
+ upsert(table: string, row: Row, options: {
470
+ conflictColumns: ReadonlyArray<string>;
471
+ update?: ReadonlyArray<string> | ((existing: Row) => Readonly<Record<string, unknown>>);
472
+ }): Promise<Row>;
473
+ }
474
+
475
+ export declare interface CancelOnPlan {
476
+ readonly cancels: ReadonlyArray<CancelDecision>;
477
+ readonly discards: ReadonlyArray<DiscardDecision>;
478
+ readonly problems: ReadonlyArray<CancelProblem>;
479
+ }
480
+
481
+ export declare interface CancelOnPlanInput {
482
+ readonly workflowName: string;
483
+ readonly entries: ReadonlyArray<ResolvedCancelOn>;
484
+ /** The workflow's own payload schema. When absent the stored payload is
485
+ * handed to `match` as-is — correct for JSON-shaped payloads and the honest
486
+ * fallback when a definition cannot be resolved. */
487
+ readonly payloadSchema?: Schema.Schema.Any | undefined;
488
+ readonly events: ReadonlyArray<DomainEventRow>;
489
+ readonly runs: ReadonlyArray<CancellableRun>;
490
+ readonly intents: ReadonlyArray<CancellableIntent>;
491
+ }
492
+
493
+ export declare interface CancelOnSweepDeps {
494
+ readonly store: CancelOnDataStore;
495
+ /** Cancel a live run. MUST be idempotent — the boundary instant is re-read
496
+ * every tick, so a cancel can legitimately be issued twice. */
497
+ readonly cancelRun: (input: {
498
+ readonly workflowName: string;
499
+ readonly runId: string;
500
+ readonly executionId: string;
501
+ readonly reason: string;
502
+ readonly event: string;
503
+ }) => Promise<void>;
504
+ /** Discard a queued admission intent. Also idempotent. */
505
+ readonly discardIntent: (input: {
506
+ readonly workflowName: string;
507
+ readonly intentId: string;
508
+ readonly reason: string;
509
+ readonly event: string;
510
+ }) => Promise<void>;
511
+ readonly now?: () => number;
512
+ readonly log?: {
513
+ readonly info: (message: string, fields?: Record<string, unknown>) => void;
514
+ readonly warn: (message: string, fields?: Record<string, unknown>) => void;
515
+ };
516
+ }
517
+
518
+ export declare interface CancelOnSweepOptions {
519
+ readonly workflows: ReadonlyArray<CancelOnWorkflow>;
520
+ readonly coldLookbackMs?: number;
521
+ readonly batch?: number;
522
+ readonly runPage?: number;
523
+ }
524
+
525
+ export declare interface CancelOnSweepResult {
526
+ /**
527
+ * `false` when nothing declares `cancelOn` — the sweep did not look.
528
+ *
529
+ * Distinct from an all-zero sweep that examined a hundred events and matched
530
+ * nothing. Rendering the two identically is the recurring way a dashboard
531
+ * turns "this feature is not wired" into "this feature found nothing".
532
+ */
533
+ readonly examined: boolean;
534
+ readonly events: number;
535
+ readonly cancelled: number;
536
+ readonly discarded: number;
537
+ /** The batch filled up: more events are waiting and the next tick will read
538
+ * them. Reported because a permanently-catching-up sweep means cancellation
539
+ * is running late, which is invisible from the counts alone. */
540
+ readonly catchingUp: boolean;
541
+ /** A workflow's live-run page filled up, so this tick examined only its
542
+ * oldest `runPage` runs. Reported rather than inferred from the counts,
543
+ * which cannot distinguish it from "nothing else matched". */
544
+ readonly sawFullRunPage: boolean;
545
+ readonly problems: ReadonlyArray<CancelProblem>;
546
+ readonly failures: ReadonlyArray<{
547
+ readonly subject: string;
548
+ readonly detail: string;
549
+ }>;
550
+ readonly watermark: Date | undefined;
551
+ }
552
+
553
+ /** One workflow's cancellation declaration, as the sweep needs it. */
554
+ export declare interface CancelOnWorkflow {
555
+ readonly workflowName: string;
556
+ readonly entries: ReadonlyArray<ResolvedCancelOn>;
557
+ readonly payloadSchema?: Schema.Schema.Any | undefined;
558
+ }
559
+
560
+ /**
561
+ * Something the sweep could not evaluate.
562
+ *
563
+ * Reported rather than swallowed, and NEVER treated as a match. A `cancelOn`
564
+ * that silently stops firing because a publisher changed the event's shape is
565
+ * the failure this type exists to make visible — the run keeps going, which is
566
+ * the safe direction, but nothing about the run says the cancellation was
567
+ * attempted and failed.
568
+ */
569
+ export declare interface CancelProblem {
570
+ readonly kind: 'event-decode' | 'payload-decode' | 'match-threw';
571
+ readonly workflowName: string;
572
+ readonly event: string;
573
+ readonly subject: string;
574
+ readonly detail: string;
575
+ }
576
+
135
577
  export declare const closeWorkflowChildrenForParent: (options: CloseWorkflowChildrenForParentOptions) => Promise<ReadonlyArray<WorkflowParentCloseDecision>>;
136
578
 
137
579
  export declare interface CloseWorkflowChildrenForParentOptions {
@@ -145,6 +587,26 @@ export declare interface CloseWorkflowChildrenForParentOptions {
145
587
  readonly interruptChild: (workflowName: string, executionId: string, policy: Exclude<WorkflowParentClosePolicy, 'abandon'>) => Promise<void>;
146
588
  }
147
589
 
590
+ /**
591
+ * Order pending rows for admission.
592
+ *
593
+ * `priority` DESC, then arrival ASC. The arrival tiebreak is not cosmetic: with
594
+ * every workflow on the default priority of 0 the whole queue is one tie, and
595
+ * without a second key the order would be whatever the dialect's planner felt
596
+ * like — reproducible on postgres, different on MariaDB, and the kind of
597
+ * difference that only shows up as "our staging box drains fairly and
598
+ * production does not".
599
+ */
600
+ export declare const comparePendingOrder: (a: {
601
+ readonly priority: number;
602
+ readonly firstSeenAt: number;
603
+ readonly id: string;
604
+ }, b: {
605
+ readonly priority: number;
606
+ readonly firstSeenAt: number;
607
+ readonly id: string;
608
+ }) => number;
609
+
148
610
  /**
149
611
  * Externally complete a suspending signal wait, resuming the parked run with
150
612
  * `payload`. The counterpart to {@link awaitSignalSuspending} and the
@@ -192,6 +654,173 @@ export declare const CurrentWorkflowExecutionId: FiberRef.FiberRef<string | unde
192
654
  * `Effect.locally(CurrentWorkflowRunId, runId)`. */
193
655
  export declare const CurrentWorkflowRunId: FiberRef.FiberRef<string | undefined>;
194
656
 
657
+ /**
658
+ * Decide what happens to one start. Pure.
659
+ *
660
+ * Called on arrival with `reconsidering: false`, and by the drainer with
661
+ * `reconsidering: true` and freshly-read state. Both get the same answer for
662
+ * the same inputs, by construction.
663
+ */
664
+ export declare const decideAdmission: (input: DecideAdmissionInput) => AdmissionDecision;
665
+
666
+ declare interface DecideAdmissionInput {
667
+ readonly control: ResolvedFlowControl;
668
+ readonly keys: AdmissionKeys;
669
+ readonly state: AdmissionState;
670
+ readonly now: number;
671
+ /**
672
+ * `true` when this call is the drainer reconsidering an already-pending row,
673
+ * `false` on first arrival.
674
+ *
675
+ * It changes exactly one thing, and only for debounce: on ARRIVAL the quiet
676
+ * period restarts (that is what debounce is); on a DRAIN pass it must not,
677
+ * or a row would push its own deadline out every time the drainer looked at
678
+ * it and never run. This flag existing is the reason the two paths can share
679
+ * one function at all.
680
+ */
681
+ readonly reconsidering: boolean;
682
+ }
683
+
684
+ /** The DECODED payload type. Mirrors `Workflow.make`'s own `idempotencyKey`
685
+ * parameter, so the two never disagree about what a payload is. */
686
+ declare type DecodedPayload<P> = P extends Schema.Struct.Fields ? Schema.Struct.Type<P> : P extends {
687
+ readonly Type: infer T;
688
+ } ? T : never;
689
+
690
+ /** How far back a COLD start looks when no watermark exists yet — one hour.
691
+ * Not the whole journal: a first boot against an old deployment would
692
+ * otherwise read every event ever published to decide about runs that mostly
693
+ * finished long ago. */
694
+ export declare const DEFAULT_COLD_LOOKBACK_MS: number;
695
+
696
+ /** How many pending rows one tick will process. Bounded because the tick is
697
+ * serial by necessity (see the header); the order guarantees the overflow is
698
+ * the tail, not a random sample. */
699
+ export declare const DEFAULT_DRAIN_BATCH = 200;
700
+
701
+ /** Events read per tick. A deployment publishing more than this per tick falls
702
+ * behind by design rather than by surprise: the watermark advances to the
703
+ * newest event in the batch, so the next tick continues where this one
704
+ * stopped. `catchingUp` in the result says when that is happening. */
705
+ export declare const DEFAULT_EVENT_BATCH = 500;
706
+
707
+ /**
708
+ * How long an admission lease survives with nothing releasing it — 24 hours.
709
+ *
710
+ * This is a CRASH BACKSTOP, not a run deadline: a slot is normally freed the
711
+ * moment the run reaches a terminal state. It is deliberately generous, because
712
+ * the two failure modes are not symmetric. Expiring early hands a live run's
713
+ * concurrency slot to a second run — the exact thing the limit was declared to
714
+ * prevent, and silent. Expiring late leaves a dead replica's slot occupied for
715
+ * a while, which is visible in the dashboard and self-heals. Declare
716
+ * `timeouts.finish` to make the backstop tight; that is what it is for.
717
+ */
718
+ export declare const DEFAULT_LEASE_MS: number;
719
+
720
+ export declare type DeferMode = DeferringControl | 'paused';
721
+
722
+ /**
723
+ * Controls that can DEFER a start rather than answer it immediately.
724
+ *
725
+ * The distinction matters at exactly one place: `ctx.workflows.run(...)` and
726
+ * `start(..., { wait: true })` block for the run's RESULT. There is no coherent
727
+ * result to return for a start that was collapsed into a future run, so those
728
+ * two callers reject a deferring control loudly instead of doing something
729
+ * surprising. Admission controls that have a synchronous answer — singleton,
730
+ * rate limit — still apply on every path.
731
+ */
732
+ export declare const DEFERRING_CONTROLS: readonly ["debounce", "batch", "throttle", "concurrency"];
733
+
734
+ export declare type DeferringControl = (typeof DEFERRING_CONTROLS)[number];
735
+
736
+ /** Which deferring controls this workflow declares, in a stable order. */
737
+ export declare const deferringControlsOf: (control: ResolvedFlowControl | undefined) => ReadonlyArray<DeferringControl>;
738
+
739
+ /** Remove a pending row that has been ADMITTED. The admission is already in the
740
+ * ledger, so this deletion is a bookkeeping step and needs no record of its
741
+ * own. Use {@link discardIntent} for a start that is being dropped. */
742
+ export declare const deleteIntent: (store: AdmissionDataStore, id: string) => Promise<boolean>;
743
+
744
+ export declare interface DiscardDecision {
745
+ readonly intentId: string;
746
+ readonly reason: string;
747
+ readonly event: string;
748
+ readonly eventId: string;
749
+ }
750
+
751
+ /**
752
+ * Drop a queued start WITHOUT running it, and write down that it happened.
753
+ *
754
+ * The ledger row is the whole point. A pending row deleted on purpose and a
755
+ * pending row that silently vanished are the same observation from the outside
756
+ * — a row that is no longer there — and "every admission decision is recorded"
757
+ * is the claim the whole panel rests on. Both discard paths (an operator's
758
+ * button, a `cancelOn` event) go through here so neither can be the one that
759
+ * leaves no trace.
760
+ *
761
+ * The row is written BEFORE the delete: the other order leaves a window where
762
+ * the start is gone and nothing says why, which is precisely the state being
763
+ * ruled out. A duplicate ledger row from a retry is a far cheaper failure.
764
+ */
765
+ export declare const discardIntent: (store: AdmissionDataStore, intentId: string, reason: string, now: number) => Promise<boolean>;
766
+
767
+ /** A row of `_voltro_workflow_events` — a published DOMAIN event. */
768
+ export declare interface DomainEventRow {
769
+ readonly id: string;
770
+ readonly name: string;
771
+ readonly payload: unknown;
772
+ readonly occurredAt: Date;
773
+ }
774
+
775
+ declare interface DrainLogger {
776
+ readonly info?: (message: string, meta?: Record<string, unknown>) => void;
777
+ readonly warn?: (message: string, meta?: Record<string, unknown>) => void;
778
+ }
779
+
780
+ export declare interface DrainOutcome {
781
+ readonly scanned: number;
782
+ readonly admitted: number;
783
+ readonly deferred: number;
784
+ readonly dropped: number;
785
+ readonly skipped: number;
786
+ readonly expired: number;
787
+ readonly evicted: number;
788
+ readonly failed: number;
789
+ /** Runs cancelled by `timeouts.finish` on this tick. */
790
+ readonly timedOut: number;
791
+ /**
792
+ * `true` when the tick genuinely examined the queue.
793
+ *
794
+ * A drain that read nothing and a drain that found nothing due produce the
795
+ * same counters, and the framework has been bitten enough times by a check
796
+ * that reports success about a non-event. The inspect surface renders these
797
+ * differently because of this flag.
798
+ */
799
+ readonly examined: boolean;
800
+ }
801
+
802
+ /**
803
+ * One drain pass. Safe to call on a timer; never throws.
804
+ */
805
+ export declare const drainTick: (deps: AdmissionDrainDeps, options?: DrainTickOptions) => Promise<DrainOutcome>;
806
+
807
+ declare interface DrainTickOptions {
808
+ readonly batchSize?: number;
809
+ }
810
+
811
+ /**
812
+ * The drainer's read: pending rows whose `dueAt` has arrived, in admission
813
+ * order.
814
+ *
815
+ * Ordered in SQL by `(priority DESC, firstSeenAt ASC)` AND re-sorted in JS by
816
+ * `comparePendingOrder`. The second pass is not redundant — it adds the id
817
+ * tiebreak, which SQL cannot express portably without making `id` part of every
818
+ * index, and without which two rows created in the same millisecond have an
819
+ * order the dialect picks. Fair on postgres, arbitrary on MariaDB, and visible
820
+ * only as "staging drains fairly and production does not".
821
+ */
822
+ export declare const dueIntents: (store: AdmissionDataStore, now: number, limit: number) => Promise<ReadonlyArray<PendingIntent>>;
823
+
195
824
  export { durableClock }
196
825
 
197
826
  /** Define a durable queue for concurrency-controlled side work. */
@@ -201,6 +830,42 @@ export { durableQueueModule }
201
830
 
202
831
  export { durableRateLimiterModule }
203
832
 
833
+ /**
834
+ * Read the state and decide — the ONE composition both callers use.
835
+ *
836
+ * Exported as a pair rather than left to each caller because the failure it
837
+ * prevents has no symptom: an arrival path that read one column set and a drain
838
+ * path that read another would produce different answers for the same row, and
839
+ * the disagreement surfaces as a workflow that ran twice under a limit of one,
840
+ * on a replica nobody was watching, under load.
841
+ */
842
+ export declare const evaluateAdmission: (input: EvaluateInput) => Promise<AdmissionDecision>;
843
+
844
+ declare interface EvaluateInput {
845
+ readonly store: AdmissionDataStore;
846
+ readonly control: ResolvedFlowControl;
847
+ readonly keys: AdmissionKeys;
848
+ readonly paused: boolean;
849
+ readonly now: number;
850
+ readonly reconsidering: boolean;
851
+ }
852
+
853
+ export declare const EVENTS_TABLE = "_voltro_workflow_events";
854
+
855
+ /** Raised when a user-supplied key function throws. Named separately because
856
+ * the alternative — letting it escape — surfaces as an opaque failure inside
857
+ * `start()` with no hint that the app's own lambda produced it. */
858
+ export declare class FlowControlKeyError extends Error {
859
+ readonly workflowName: string;
860
+ readonly field: string;
861
+ constructor(workflowName: string, field: string, cause: unknown);
862
+ }
863
+
864
+ /** A duration in any shape Effect accepts: `'15 minutes'`, `900_000`,
865
+ * `Duration.minutes(15)`. Same input type `sleep({ duration })` takes, so
866
+ * there is one duration vocabulary in the whole workflow surface. */
867
+ export declare type FlowDuration = Duration.DurationInput;
868
+
204
869
  /** Try to read the current workflow execution id. Returns undefined
205
870
  * when called outside a recorded workflow body. */
206
871
  export declare const getCurrentWorkflowExecutionId: () => Effect.Effect<string | undefined>;
@@ -210,8 +875,27 @@ export declare const getCurrentWorkflowExecutionId: () => Effect.Effect<string |
210
875
  * framework's wrapper, e.g. in a unit test). */
211
876
  export declare const getCurrentWorkflowRunId: () => Effect.Effect<string | undefined>;
212
877
 
878
+ /** The resolved flow-control declaration attached to a workflow definition, or
879
+ * `undefined` when it declares none — in which case every start takes exactly
880
+ * the path it took before this feature existed. */
881
+ export declare const getWorkflowFlowControl: (value: unknown) => ResolvedFlowControl | undefined;
882
+
213
883
  export declare const getWorkflowVersionMetadata: (value: unknown) => WorkflowVersionMetadata;
214
884
 
885
+ /** `true` when this workflow declares at least one `cancelOn` entry — i.e. when
886
+ * the cancellation sweep has anything to do for it. */
887
+ export declare const hasCancelOn: (control: ResolvedFlowControl | undefined) => boolean;
888
+
889
+ /** `true` when at least one control is declared — i.e. when this workflow needs
890
+ * the admission boundary at all. A workflow with none must take the exact same
891
+ * code path it took before this feature existed.
892
+ *
893
+ * `cancelOn` is deliberately NOT in this list: it is evaluated by a sweep
894
+ * against live runs, never at the start boundary. Including it would route
895
+ * every start of a cancel-only workflow through the admission ledger, paying
896
+ * two writes per start for a decision that is always "admit". */
897
+ export declare const hasFlowControl: (control: ResolvedFlowControl | undefined) => boolean;
898
+
215
899
  export declare interface InMemoryRecorder {
216
900
  readonly layer: Layer.Layer<WorkflowRunRecorder>;
217
901
  readonly readSteps: () => ReadonlyArray<RecordedStep>;
@@ -273,14 +957,79 @@ export declare interface InspectStore {
273
957
  */
274
958
  export declare const inspectWorkflow: (idOrExecutionId: string, store: InspectStore) => Promise<InspectedWorkflow | null>;
275
959
 
960
+ /**
961
+ * Has an admitted run outlived `timeouts.finish`?
962
+ *
963
+ * Deliberately NOT symmetrical with the pending-row check: a run's clock starts
964
+ * when it was admitted, and a run that has already reached a terminal state is
965
+ * never overdue no matter how long it took. The caller passes `startedAt` from
966
+ * the run row, so a run adopted by a different replica is measured on the same
967
+ * basis as one that stayed put.
968
+ */
969
+ export declare const isFinishExpired: (control: ResolvedFlowControl, run: {
970
+ readonly startedAt: number;
971
+ readonly terminal: boolean;
972
+ }, now: number) => boolean;
973
+
974
+ /**
975
+ * Has a pending row outlived `timeouts.start`?
976
+ *
977
+ * Measured from `firstSeenAt` — the moment the FIRST start in the group
978
+ * arrived, not the last. A debounce that keeps collapsing new starts is exactly
979
+ * the case this bound exists for; measuring from the most recent arrival would
980
+ * make an unbounded burst immortal, which is the thing being bounded.
981
+ */
982
+ export declare const isStartExpired: (control: ResolvedFlowControl, pending: {
983
+ readonly firstSeenAt: number;
984
+ }, now: number) => boolean;
985
+
276
986
  export declare const isWorkflowWorkerLayer: (value: unknown) => value is WorkflowWorkerLayerBrand;
277
987
 
988
+ /**
989
+ * Attach the engine's execution id to the ledger row written by
990
+ * {@link admitStart}.
991
+ *
992
+ * Best-effort by design: the run has already started, and failing the caller
993
+ * because an audit column could not be filled in would be the wrong trade. But
994
+ * an unlinked row is a real (small) cost — its lease expires on the backstop
995
+ * rather than on the run's terminal state — so the caller logs it.
996
+ */
997
+ export declare const linkExecution: (store: AdmissionDataStore, ledgerId: string, executionId: string, runId: string | null) => Promise<void>;
998
+
278
999
  export declare const makeInMemoryRecorder: () => InMemoryRecorder;
279
1000
 
280
1001
  export declare const makeWorkflowRunRecorder: (options: WorkflowRunRecorderOptions) => WorkflowRunRecorderService;
281
1002
 
282
1003
  export declare const makeWorkflowUpdateId: () => string;
283
1004
 
1005
+ /** The newest event instant in a batch, or `undefined` for an empty one.
1006
+ * The watermark advances to THIS, never to `now`: advancing to wall-clock
1007
+ * would skip anything written with an older `occurredAt` by a transaction
1008
+ * that committed late. */
1009
+ export declare const newestEventAt: (events: ReadonlyArray<DomainEventRow>) => Date | undefined;
1010
+
1011
+ /**
1012
+ * When does a window with `limit` slots over `periodMs` free up next, given the
1013
+ * admissions inside it (newest first)?
1014
+ *
1015
+ * A SLIDING window, computed from the admissions ledger rather than from a
1016
+ * counter column, and that choice is deliberate:
1017
+ *
1018
+ * • A counter drifts. It is incremented next to the admission, not by it, so
1019
+ * a crash between the two leaves the two facts disagreeing forever — and the
1020
+ * disagreement is invisible, because the counter is the only thing anyone
1021
+ * reads.
1022
+ * • The ledger is the audit trail anyway. Deriving the limit from the same
1023
+ * rows the dashboard shows means "why was this dropped" is answerable by
1024
+ * looking, not by trusting.
1025
+ * • It is exact. A fixed window lets 2×limit through across a boundary; this
1026
+ * does not.
1027
+ *
1028
+ * The cost is a `COUNT`-shaped read bounded by `limit` rows, which is why
1029
+ * `limit` is documented as a throughput knob and not a place to put 10^6.
1030
+ */
1031
+ export declare const nextSlotAt: (recentAdmissions: ReadonlyArray<number>, limit: number, periodMs: number, now: number) => number | undefined;
1032
+
284
1033
  declare type NormaliseWorkflowMessages<M extends WorkflowMessageSchemas | undefined> = {
285
1034
  readonly signals: M extends {
286
1035
  readonly signals: infer Signals;
@@ -293,9 +1042,111 @@ declare type NormaliseWorkflowMessages<M extends WorkflowMessageSchemas | undefi
293
1042
  } ? NonNullable<Queries> & Readonly<Record<string, WorkflowMessagePairSchemas>> : {};
294
1043
  };
295
1044
 
1045
+ export declare const noteIntentAttempt: (store: AdmissionDataStore, intent: PendingIntent, error: string, now: number) => Promise<void>;
1046
+
1047
+ export declare const PAUSES_TABLE = "_voltro_workflow_pauses";
1048
+
1049
+ export declare const pauseWorkflow: (store: AdmissionDataStore, workflowName: string, by: string | null, reason: string | null) => Promise<void>;
1050
+
1051
+ /** The SCHEMA a payload input denotes — `Fields` lifted into a `Struct`. */
1052
+ declare type PayloadSchemaOf<P> = P extends Schema.Struct.Fields ? Schema.Struct<P> : P;
1053
+
1054
+ export declare const PENDING_TABLE = "_voltro_workflow_pending";
1055
+
1056
+ /** A pending row in domain shape. */
1057
+ export declare interface PendingIntent {
1058
+ readonly id: string;
1059
+ readonly tenantId: string | null;
1060
+ readonly workflowName: string;
1061
+ readonly controlKey: string;
1062
+ readonly mode: 'debounce' | 'batch' | 'throttle' | 'concurrency' | 'paused';
1063
+ readonly payload: unknown;
1064
+ readonly callerContext: unknown;
1065
+ readonly priority: number;
1066
+ readonly dueAt: number;
1067
+ readonly deadlineAt: number | undefined;
1068
+ readonly collapsed: number;
1069
+ readonly firstSeenAt: number;
1070
+ readonly attempts: number;
1071
+ /** Last drain error. A row whose attempts climb with a repeated message is a
1072
+ * poison intent, and a poison intent that only ever manifested as "nothing
1073
+ * ran" is the failure this column removes. */
1074
+ readonly lastError: string | null;
1075
+ }
1076
+
1077
+ /**
1078
+ * The deterministic pending-row id for a COLLAPSING control.
1079
+ *
1080
+ * A hash, and the reason is length rather than secrecy: `id()` is VARCHAR(64) on
1081
+ * MySQL/MariaDB, and a key built by concatenating a workflow name with an
1082
+ * app-supplied control key would silently exceed it — an insert that fails on
1083
+ * one dialect and not another, discovered in production. A hash is fixed-width
1084
+ * whatever the app puts in a key.
1085
+ *
1086
+ * sha256 rather than a cheap non-crypto hash because a collision here MERGES
1087
+ * TWO UNRELATED DEBOUNCE GROUPS: one row, one run, and the other group's work
1088
+ * silently never happens. That is not a class of bug worth trading for a few
1089
+ * microseconds.
1090
+ */
1091
+ export declare const pendingSlotId: (workflowName: string, mode: string, controlKey: string) => string;
1092
+
1093
+ /**
1094
+ * Write (or collapse into) the pending row a `defer` decision calls for.
1095
+ * Returns the row id, so the caller can report it and the drainer can find it.
1096
+ */
1097
+ export declare const persistDefer: (input: PersistDeferInput) => Promise<string>;
1098
+
1099
+ declare interface PersistDeferInput {
1100
+ readonly store: AdmissionDataStore;
1101
+ readonly control: ResolvedFlowControl;
1102
+ readonly decision: Extract<AdmissionDecision, {
1103
+ kind: 'defer';
1104
+ }>;
1105
+ readonly payload: unknown;
1106
+ readonly callerContext: unknown;
1107
+ readonly tenantId: string | null;
1108
+ readonly now: number;
1109
+ /** Set when the drainer is re-deferring a row it already holds. */
1110
+ readonly existingId?: string;
1111
+ }
1112
+
1113
+ /**
1114
+ * Which runs and intents a batch of events cancels.
1115
+ *
1116
+ * Deterministic and total: every event is compared against every entry naming
1117
+ * it, and a run is cancelled at most once even when several entries match it —
1118
+ * the first match wins, so the recorded reason is stable rather than dependent
1119
+ * on iteration order across dialects.
1120
+ */
1121
+ export declare const planCancellations: (input: CancelOnPlanInput) => CancelOnPlan;
1122
+
1123
+ /**
1124
+ * The stored spelling of a concurrency key, pool-aware.
1125
+ *
1126
+ * `\u0000` separators, because the prefix must be unforgeable BY ACCIDENT: an
1127
+ * app's key function returning a string that collides with a pooled spelling
1128
+ * would silently count that run into another pool's budget. No app key
1129
+ * legitimately contains NUL, and a key function constructing this exact shape
1130
+ * on purpose is sabotage, not a typo. Kept human-readable (not hashed) so a
1131
+ * ledger row's key still SAYS which pool it held — "why was this dropped" must
1132
+ * stay answerable by looking.
1133
+ */
1134
+ export declare const pooledConcurrencyKey: (pool: string | undefined, rawKey: string) => string;
1135
+
296
1136
  /** Enqueue work and await the durable queue worker's result. */
297
1137
  export declare const processQueue: typeof durableQueueModule.process;
298
1138
 
1139
+ /**
1140
+ * Prune ledger rows past a retention window.
1141
+ *
1142
+ * Only rows whose lease is RELEASED — an unreleased `admitted` row is a live
1143
+ * concurrency slot and a live singleton holder, and deleting it silently raises
1144
+ * both limits. That is the one deletion in this feature that could corrupt
1145
+ * behaviour rather than merely lose history, which is why it is a predicate and
1146
+ * not a date range alone.
1147
+ */
1148
+ export declare const pruneAdmissions: (store: AdmissionDataStore, olderThan: Date, limit?: number) => Promise<number>;
1149
+
299
1150
  /** Start a durable queue worker layer with bounded concurrency.
300
1151
  *
301
1152
  * `DurableQueue.worker` is itself generic (`<Payload, Success, Error, R>`), so
@@ -308,6 +1159,68 @@ export declare const queueWorker: typeof durableQueueModule.worker;
308
1159
  /** Durable rate limiter activity. Delays through the workflow clock. */
309
1160
  export declare const rateLimit: typeof durableRateLimiterModule.rateLimit;
310
1161
 
1162
+ /**
1163
+ * Read exactly the state the declared controls need — no more.
1164
+ *
1165
+ * An undeclared control costs ZERO round trips. That is not an optimisation
1166
+ * detail: a workflow that declares only `debounce` must not pay for a
1167
+ * concurrency-lease scan, or adopting one cheap control would quietly buy the
1168
+ * cost of all six.
1169
+ */
1170
+ export declare const readAdmissionState: (input: ReadStateInput) => Promise<AdmissionState>;
1171
+
1172
+ /**
1173
+ * Every workflow name currently paused.
1174
+ *
1175
+ * Read as a SET, on the drainer's tick, rather than per start. A pause is an
1176
+ * operator action measured in minutes; paying a database round trip on every
1177
+ * single start to notice one a second sooner is the wrong trade, and it would
1178
+ * put a query on the hot path of workflows that declare no controls at all —
1179
+ * which must keep costing exactly what they cost before this feature existed.
1180
+ *
1181
+ * The consequence is stated rather than hidden: a pause takes effect on the
1182
+ * pausing replica immediately and on the others within one drainer tick
1183
+ * (`VOLTRO_WORKFLOW_DRAIN_MS`, default 1000).
1184
+ */
1185
+ export declare const readPausedWorkflows: (store: AdmissionDataStore) => Promise<ReadonlySet<string>>;
1186
+
1187
+ declare interface ReadStateInput {
1188
+ readonly store: AdmissionDataStore;
1189
+ readonly control: ResolvedFlowControl;
1190
+ readonly keys: AdmissionKeys;
1191
+ readonly now: number;
1192
+ readonly paused: boolean;
1193
+ }
1194
+
1195
+ /** Read the stored position for a sweep consumer. */
1196
+ export declare const readWatermark: (store: CancelOnDataStore, name: string) => Promise<Date | undefined>;
1197
+
1198
+ /**
1199
+ * Append one ledger row. Also the lease, when the outcome is `admitted`.
1200
+ *
1201
+ * Never throws into the caller: a start must not fail because its audit row
1202
+ * could not be written. But it must not be SILENT either — the caller passes an
1203
+ * `onError` so the failure lands in the log rather than in nothing. A ledger
1204
+ * that quietly stops recording is a rate limiter that quietly stops limiting,
1205
+ * because the limit is derived from these rows.
1206
+ */
1207
+ export declare const recordAdmission: (input: RecordAdmissionInput) => Promise<string | undefined>;
1208
+
1209
+ declare interface RecordAdmissionInput {
1210
+ readonly store: AdmissionDataStore;
1211
+ readonly control: ResolvedFlowControl;
1212
+ readonly keys: AdmissionKeys;
1213
+ readonly outcome: 'admitted' | 'dropped' | 'skipped' | 'evicted' | 'expired' | 'discarded';
1214
+ readonly mode: string | null;
1215
+ readonly reason: string | null;
1216
+ readonly collapsed: number;
1217
+ readonly waitedMs: number | null;
1218
+ readonly executionId: string | null;
1219
+ readonly runId: string | null;
1220
+ readonly tenantId: string | null;
1221
+ readonly now: number;
1222
+ }
1223
+
311
1224
  /** One recorded run-event — mirrors a `_voltro_workflow_run_events` row. */
312
1225
  export declare interface RecordedEvent {
313
1226
  readonly id: string;
@@ -342,8 +1255,79 @@ export declare interface RecordedStep {
342
1255
  durationMs: number | null;
343
1256
  }
344
1257
 
1258
+ /**
1259
+ * Free the slot an execution holds. Called when a run reaches a terminal state.
1260
+ *
1261
+ * Idempotent by construction: setting `releasedAt` twice is the same as setting
1262
+ * it once, and a run that never took a lease finds no row. That matters because
1263
+ * the terminal transition is recorded by the run recorder, which retries.
1264
+ */
1265
+ export declare const releaseLease: (store: AdmissionDataStore, executionId: string, now: number) => Promise<boolean>;
1266
+
1267
+ /** Resolve every declared key against one payload. Pure; throws only
1268
+ * {@link FlowControlKeyError}. */
1269
+ export declare const resolveAdmissionKeys: (control: ResolvedFlowControl, payload: unknown) => AdmissionKeys;
1270
+
1271
+ /** One resolved `cancelOn` entry. The schema is kept (not pre-compiled into a
1272
+ * decoder) so the sweeper can report a decode failure naming the event, which
1273
+ * is the difference between "the predicate said no" and "we could not read the
1274
+ * event at all". */
1275
+ export declare interface ResolvedCancelOn {
1276
+ readonly event: string;
1277
+ readonly schema: Schema.Schema.Any;
1278
+ readonly match: (event: unknown, payload: unknown) => boolean;
1279
+ readonly withinMs?: number;
1280
+ readonly reason: string;
1281
+ }
1282
+
1283
+ /** The declaration with its durations resolved to milliseconds and its
1284
+ * defaults filled in — what the decision engine actually reads. Key functions
1285
+ * stay as functions; they are applied against a concrete payload at start. */
1286
+ export declare interface ResolvedFlowControl {
1287
+ readonly workflowName: string;
1288
+ readonly debounce?: {
1289
+ readonly key: (payload: unknown) => string;
1290
+ readonly periodMs: number;
1291
+ readonly timeoutMs?: number;
1292
+ };
1293
+ readonly singleton?: {
1294
+ readonly key: (payload: unknown) => string;
1295
+ readonly mode: 'skip' | 'cancel';
1296
+ };
1297
+ readonly concurrency?: {
1298
+ readonly limit: number;
1299
+ readonly key?: (payload: unknown) => string;
1300
+ readonly pool?: string;
1301
+ };
1302
+ readonly throttle?: {
1303
+ readonly limit: number;
1304
+ readonly periodMs: number;
1305
+ readonly key?: (payload: unknown) => string;
1306
+ };
1307
+ readonly rateLimit?: {
1308
+ readonly limit: number;
1309
+ readonly periodMs: number;
1310
+ readonly key?: (payload: unknown) => string;
1311
+ };
1312
+ readonly batch?: {
1313
+ readonly key?: (payload: unknown) => string;
1314
+ readonly maxSize: number;
1315
+ readonly timeoutMs: number;
1316
+ };
1317
+ readonly priority?: (payload: unknown) => number;
1318
+ readonly timeouts?: {
1319
+ readonly startMs?: number;
1320
+ readonly finishMs?: number;
1321
+ };
1322
+ readonly onFailure?: string;
1323
+ readonly encryptSteps?: boolean;
1324
+ readonly cancelOn?: ReadonlyArray<ResolvedCancelOn>;
1325
+ }
1326
+
345
1327
  export declare const resolveWorkflowMessageRun: (store: WorkflowMessageStore, target: WorkflowMessageTarget) => Promise<WorkflowResolvedRun>;
346
1328
 
1329
+ export declare const resumeWorkflow: (store: AdmissionDataStore, workflowName: string) => Promise<boolean>;
1330
+
347
1331
  export declare const sendWorkflowSignal: (input: {
348
1332
  readonly store: WorkflowMessageStore;
349
1333
  readonly recorder: WorkflowRunRecorderService;
@@ -379,6 +1363,32 @@ export declare const serialiseWorkflowRowForWire: (row: Record<string, unknown>)
379
1363
  */
380
1364
  export declare const sleep: typeof durableClock.sleep;
381
1365
 
1366
+ /**
1367
+ * Durable sleep until an ABSOLUTE instant.
1368
+ *
1369
+ * Not sugar for `sleep({ duration: target - Date.now() })`, and the difference
1370
+ * is the whole reason it exists.
1371
+ *
1372
+ * A delta is computed from the clock of whichever attempt computed it. Compute
1373
+ * it in the body and the value is baked into a replay: a run that suspends for
1374
+ * six hours and resumes replays the body, recomputes `target - Date.now()`
1375
+ * against the NEW now, and sleeps the full period again — from a moment that is
1376
+ * already past the target. The wait doubles, silently, and only for runs that
1377
+ * happened to be interrupted.
1378
+ *
1379
+ * So the INSTANT is journaled first, by an activity, and the sleep is derived
1380
+ * from the journaled value on every attempt. A replay after the target has
1381
+ * passed sleeps zero and continues, which is what "until" means.
1382
+ *
1383
+ * ```ts
1384
+ * yield* sleepUntil({ name: 'until-window-opens', until: order.deliverAfter })
1385
+ * ```
1386
+ */
1387
+ export declare const sleepUntil: (options: {
1388
+ readonly name: string;
1389
+ readonly until: Date | number;
1390
+ }) => Effect.Effect<void, never, never>;
1391
+
382
1392
  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>>;
383
1393
 
384
1394
  /**
@@ -489,8 +1499,69 @@ export declare interface StepRetryPolicy {
489
1499
  */
490
1500
  export declare const suspendingSignalDeferredName: (workflowName: string, signalName: string) => string;
491
1501
 
1502
+ /**
1503
+ * One cancellation tick. Never throws.
1504
+ *
1505
+ * A sweep that can throw takes the whole scheduled tick down with it, which
1506
+ * means one malformed event stops cancellation for every workflow until someone
1507
+ * notices. Failures are collected and reported instead — and the watermark
1508
+ * still advances, because re-reading an event that has already failed to
1509
+ * evaluate would fail identically forever.
1510
+ */
1511
+ export declare const sweepCancelOn: (deps: CancelOnSweepDeps, options: CancelOnSweepOptions) => Promise<CancelOnSweepResult>;
1512
+
492
1513
  export declare const truncateWorkflowValue: (value: unknown, bytes?: number) => unknown;
493
1514
 
1515
+ /**
1516
+ * Validate + resolve a declaration. Throws at DEFINITION time (module load,
1517
+ * i.e. boot) with a message naming the workflow and the field.
1518
+ *
1519
+ * Boot is the only honest place for these errors. A `limit: 0` that surfaces as
1520
+ * "nothing ever runs" three hours into a deployment is the same defect class as
1521
+ * a rate cap that drops silently — the system is behaving exactly as configured
1522
+ * and nothing says so.
1523
+ */
1524
+ export declare const validateFlowControl: (input: ValidateFlowControlInput) => ResolvedFlowControl | undefined;
1525
+
1526
+ export declare interface ValidateFlowControlInput {
1527
+ readonly workflowName: string;
1528
+ readonly payloadSchema?: unknown;
1529
+ readonly control: (WorkflowFlowControl<never> & WorkflowCancelOnDeclared) | undefined;
1530
+ }
1531
+
1532
+ /**
1533
+ * `_voltro_workflow_admissions` — append-only ledger, and the lease register.
1534
+ *
1535
+ * Never updated except to set `releasedAt` (a run reached a terminal state) —
1536
+ * so a row is evidence of what the deployment decided, at the moment it decided
1537
+ * it.
1538
+ */
1539
+ export declare const _voltroWorkflowAdmissionsTable: SchemaTable;
1540
+
1541
+ /**
1542
+ * `_voltro_workflow_pauses` — one row per PAUSED workflow. Absent ⇒ running.
1543
+ *
1544
+ * A pause makes starts COLLECT rather than fail, so the operator who paused a
1545
+ * workflow to ship a fix finds a backlog waiting rather than a hole in the
1546
+ * data. That is the only behaviour that makes pause safe to reach for.
1547
+ *
1548
+ * The workflow name is the primary key directly — the same shape
1549
+ * `_voltro_wakeups` uses for its natural key. Workflow names are identifiers
1550
+ * (they are also cluster entity types), so they are comfortably inside the
1551
+ * VARCHAR(64) an `id()` column is on MySQL/MariaDB; the bound is pinned by test
1552
+ * rather than assumed.
1553
+ */
1554
+ export declare const _voltroWorkflowPausesTable: SchemaTable;
1555
+
1556
+ /**
1557
+ * `_voltro_workflow_pending` — starts that have been accepted but not admitted.
1558
+ *
1559
+ * Deleted on admission. A row here is an intent the deployment OWES someone; if
1560
+ * rows accumulate, work is not happening, and that is exactly what the
1561
+ * `firstSeenAt` / `collapsed` / `attempts` columns exist to make visible.
1562
+ */
1563
+ export declare const _voltroWorkflowPendingTable: SchemaTable;
1564
+
494
1565
  export declare const _voltroWorkflowRunEventsTable: SchemaTable;
495
1566
 
496
1567
  export declare const _voltroWorkflowRunsTable: SchemaTable;
@@ -499,6 +1570,10 @@ export declare const _voltroWorkflowRunStepsTable: SchemaTable;
499
1570
 
500
1571
  export declare const _voltroWorkflowStartContextsTable: TableLike;
501
1572
 
1573
+ export declare const _voltroWorkflowWatermarksTable: SchemaTable;
1574
+
1575
+ export declare const WATERMARKS_TABLE = "_voltro_workflow_watermarks";
1576
+
502
1577
  /**
503
1578
  * Add compensating (rollback) logic to a top-level effect in a workflow
504
1579
  * body. The finalizer runs if the WHOLE workflow later fails — use it for
@@ -518,9 +1593,228 @@ export declare const _voltroWorkflowStartContextsTable: TableLike;
518
1593
  */
519
1594
  export declare const withCompensation: typeof workflowModule.withCompensation;
520
1595
 
521
- /** Define a durable workflow. Adds Voltro version metadata on top of `Workflow.make`. */
1596
+ /**
1597
+ * Define a durable workflow.
1598
+ *
1599
+ * ── `idempotencyKey` is the execution's IDENTITY, permanently ──────────────
1600
+ *
1601
+ * Read this before choosing one. It is the single most expensive
1602
+ * misunderstanding this API has produced — a downstream team lost a day and
1603
+ * shipped a design around the wrong model, and the wording they read
1604
+ * ("deduplicates concurrent invocations with the same input") is what led them
1605
+ * there. It sounds like a WINDOW: dedupe while a run is in flight. It is not.
1606
+ *
1607
+ * The key IS the execution. A second start with the same key REPLAYS the first
1608
+ * run's journaled result — forever, not just while it is running. Once the run
1609
+ * completes the key is SPENT: a later, genuinely new invocation under that key
1610
+ * is a silent no-op that returns the old output. Nothing errors and nothing
1611
+ * logs, because from the engine's point of view you asked for a run it already
1612
+ * has.
1613
+ *
1614
+ * ```ts
1615
+ * const a = yield* wf.execute({ id: 'same' })
1616
+ * const b = yield* wf.execute({ id: 'same' }) // ← does NOT run; replays `a`
1617
+ * ```
1618
+ *
1619
+ * So a key must be **unique per unit of work you want to happen**, not per
1620
+ * logical subject. `tour:${rowId}` is wrong if the tour can be re-narrated;
1621
+ * `tour:${rowId}:${updatedAt}` is right, because every edit is a new unit of
1622
+ * work with its own identity.
1623
+ *
1624
+ * ── And a flow-control key is a DIFFERENT thing ────────────────────────────
1625
+ *
1626
+ * The two get conflated, and conflating them is what makes "I need to re-arm a
1627
+ * key" feel like a missing feature. It is not missing; it is two fields:
1628
+ *
1629
+ * • `idempotencyKey` — the execution's identity. Vary it per unit of work.
1630
+ * • `debounce.key` / `singleton.key` / `concurrency.key` — the RESOURCE runs
1631
+ * compete for. Keep it stable.
1632
+ *
1633
+ * "One job, fifteen minutes after the last edit, latest state wins" is then:
1634
+ *
1635
+ * ```ts
1636
+ * workflow({
1637
+ * name: 'tourNarration',
1638
+ * payload: TourPayload,
1639
+ * idempotencyKey: ({ rowId, editedAt }) => `tour:${rowId}:${editedAt}`, // varies
1640
+ * debounce: { key: (p) => `tour:${p.rowId}`, period: '15 minutes' }, // stable
1641
+ * })
1642
+ * ```
1643
+ *
1644
+ * Twenty edits mint twenty identities; exactly one is ever admitted. There is
1645
+ * no `restart: true` and no generation counter in this API because separating
1646
+ * the two keys is the mechanism those would have been.
1647
+ */
522
1648
  export declare const workflow: WorkflowFn;
523
1649
 
1650
+ declare type WorkflowBaseOptions = Parameters<WorkflowMake>[0];
1651
+
1652
+ /**
1653
+ * Collect many starts into ONE run over an array of items.
1654
+ *
1655
+ * A hundred issue-changed events in a minute become one run over a hundred
1656
+ * keys, not a hundred runs. The workflow's `payload` must be the BATCH shape —
1657
+ * a struct with an `items` array — while callers `start()` it with a SINGLE
1658
+ * item. `validateFlowControl` asserts that shape at declaration time, because
1659
+ * the alternative is a decode failure at admission on a replica somebody else
1660
+ * is watching.
1661
+ */
1662
+ export declare interface WorkflowBatch<Item> {
1663
+ /** Schema of ONE item, i.e. what a caller passes to `start()`. The workflow's
1664
+ * own `payload` schema describes `{ items: Array<item> }`. */
1665
+ readonly item: unknown;
1666
+ /** Groups items. Omit to batch everything for this workflow together. */
1667
+ readonly key?: (item: Item) => string;
1668
+ /** Admit as soon as this many items have accumulated. */
1669
+ readonly maxSize: number;
1670
+ /** Admit this long after the FIRST item, however few arrived. Unlike
1671
+ * debounce's period this does NOT reset — a batch is a deadline, not a quiet
1672
+ * period, or a steady trickle would never flush. */
1673
+ readonly timeout: FlowDuration;
1674
+ }
1675
+
1676
+ /**
1677
+ * Cancel this workflow's in-flight work when a correlated domain event arrives.
1678
+ *
1679
+ * ── Why this is a DECLARATION and not `awaitEvent` + a race ─────────────────
1680
+ *
1681
+ * You can express "stop when the issue is deleted" inside the body: race the
1682
+ * real work against an `awaitEvent`, and interrupt on the first to settle. That
1683
+ * works while the body is RUNNING. It does not work while the run is sleeping
1684
+ * for six hours, suspended on a signal, or still sitting in the admission
1685
+ * queue — which is the whole reason you wanted cancellation. The event has to
1686
+ * be able to reach a run whose fiber is not currently executing anything, and
1687
+ * only something OUTSIDE the body can do that.
1688
+ *
1689
+ * So it is swept: a coordinated tick reads events published since a durable
1690
+ * watermark, resolves each declaring workflow's live runs, evaluates `match`
1691
+ * against the run's own decoded payload, and cancels the ones that correlate.
1692
+ *
1693
+ * ── What it cancels, which is more than the run ─────────────────────────────
1694
+ *
1695
+ * A matching event also DISCARDS queued intents of that workflow. Cancelling
1696
+ * only the running one leaves a debounced or concurrency-queued duplicate to
1697
+ * start seconds later, against the row that just got deleted — the exact
1698
+ * outcome the declaration was meant to prevent, arriving late enough that
1699
+ * nobody connects the two.
1700
+ *
1701
+ * ```ts
1702
+ * cancelOn: [{
1703
+ * event: 'jira.issue.deleted',
1704
+ * schema: JiraIssueDeleted,
1705
+ * match: (e, payload) => e.issueKey === payload.issueKey,
1706
+ * }],
1707
+ * ```
1708
+ */
1709
+ export declare interface WorkflowCancelOn<EventSchema extends Schema.Schema.Any, Payload> {
1710
+ /** The event NAME, exactly as `ctx.events.publish` writes it. */
1711
+ readonly event: string;
1712
+ /** Decoded before `match` sees it, so the predicate reads the domain type
1713
+ * rather than whatever JSON came off the wire. A publisher shipping the
1714
+ * wrong shape is reported, and does NOT count as a match — cancelling on an
1715
+ * event you could not read is cancelling blind. */
1716
+ readonly schema: EventSchema;
1717
+ /**
1718
+ * Does this event cancel THIS run?
1719
+ *
1720
+ * Required, with no default, for the same reason `singleton.mode` has none:
1721
+ * the omitted case is not harmless. A missing predicate would mean "cancel
1722
+ * every live run of this workflow", which is a legitimate thing to want and a
1723
+ * catastrophic thing to get by forgetting a line. Write `match: () => true`
1724
+ * when you mean it.
1725
+ */
1726
+ readonly match: (event: Schema.Schema.Type<EventSchema>, payload: Payload) => boolean;
1727
+ /**
1728
+ * Only cancel runs that started within this window before the event.
1729
+ *
1730
+ * Bounds the blast radius of a broad predicate — an event correlating on a
1731
+ * tenant id would otherwise reach a run that has been alive for a week.
1732
+ */
1733
+ readonly within?: FlowDuration;
1734
+ /** Recorded on the run's `run-cancelled` event and shown in the dashboard.
1735
+ * Defaults to `cancelOn:<event>`. */
1736
+ readonly reason?: string;
1737
+ }
1738
+
1739
+ /** The erased `cancelOn` shape the implementation reads. The DECLARED type is
1740
+ * the per-entry-inferred {@link WorkflowCancelOnList}; this is what survives
1741
+ * erasure, and it is deliberately not the public spelling. */
1742
+ export declare interface WorkflowCancelOnDeclared {
1743
+ readonly cancelOn?: ReadonlyArray<WorkflowCancelOn<Schema.Schema.Any, never>>;
1744
+ }
1745
+
1746
+ /** The `cancelOn` array as declared, with each entry's event type inferred
1747
+ * from its own `schema`. The tuple type parameter is what makes that
1748
+ * per-entry inference work — a plain `ReadonlyArray<WorkflowCancelOn<…>>`
1749
+ * collapses every entry's event to the constraint (`any`), which is how the
1750
+ * predicate silently stops being checked. */
1751
+ export declare type WorkflowCancelOnList<Cancels extends ReadonlyArray<Schema.Schema.Any>, Payload> = {
1752
+ readonly [I in keyof Cancels]: WorkflowCancelOn<Cancels[I], Payload>;
1753
+ };
1754
+
1755
+ /**
1756
+ * At most `limit` runs in flight at once, per key.
1757
+ *
1758
+ * The limit spans every replica — it is enforced through the shared admissions
1759
+ * ledger, so three replicas with `limit: 5` are five runs, not fifteen. (An
1760
+ * earlier draft offered `scope: 'replica'` for a per-process budget; it is
1761
+ * gone, and deliberately: excess starts queue in the SHARED pending table and
1762
+ * are drained by whichever replica has capacity, so a per-process count has no
1763
+ * coherent meaning in this model — the replica that counted is not the replica
1764
+ * that drains.)
1765
+ */
1766
+ export declare interface WorkflowConcurrency<Payload> {
1767
+ readonly limit: number;
1768
+ /** Partitions the limit. Omit for one deployment-wide pool. */
1769
+ readonly key?: (payload: Payload) => string;
1770
+ /**
1771
+ * Share the limit ACROSS workflows.
1772
+ *
1773
+ * Without `pool`, the limit bounds THIS workflow's runs. With it, every
1774
+ * workflow declaring the same pool name competes for ONE budget — the shape
1775
+ * a rate-limited provider forces: five workflows that each call OpenAI must
1776
+ * share ten slots, not hold ten each. `key` still partitions within the
1777
+ * pool (all members' key functions feed the same buckets, so
1778
+ * `(p) => p.tenantId` in each member yields a per-tenant shared budget).
1779
+ *
1780
+ * Every member of a pool must declare the SAME `limit` — the boot fails
1781
+ * otherwise, because two limits for one budget is a contradiction, and
1782
+ * silently picking one would enforce a number somebody did not write.
1783
+ */
1784
+ readonly pool?: string;
1785
+ }
1786
+
1787
+ /**
1788
+ * Collapse a burst of starts into ONE run, `period` after the last one.
1789
+ *
1790
+ * The LATEST payload wins. That is what debounce means — "fifteen minutes after
1791
+ * the last edit, narrate what settled" wants the state as of the last edit, not
1792
+ * the first. Stated explicitly because "first wins" is a defensible alternative
1793
+ * and a caller must not have to guess which they got.
1794
+ */
1795
+ export declare interface WorkflowDebounce<Payload> {
1796
+ /** Groups the burst. Starts sharing a key collapse into one pending run. */
1797
+ readonly key: (payload: Payload) => string;
1798
+ /** Quiet period after the last start before the run is admitted. */
1799
+ readonly period: FlowDuration;
1800
+ /**
1801
+ * Hard cap measured from the FIRST start in the burst — the run is admitted
1802
+ * at `timeout` even if starts keep arriving.
1803
+ *
1804
+ * Optional, and uncapped when unset. That is deliberate and it is a real
1805
+ * trade: an unbroken stream of starts arriving faster than `period` will
1806
+ * defer the run forever. We do not invent a default cap (any number would be
1807
+ * arbitrary), and we do not warn (a warning at boot rots into noise). Instead
1808
+ * the starvation is a NUMBER you can see: the pending row carries
1809
+ * `firstSeenAt` and `collapsed`, both surfaced by
1810
+ * `/_voltro/inspect/workflows/flow-control` and the dashboard's Flow Control
1811
+ * panel. A pending age climbing past a few multiples of `period` is the
1812
+ * signal; set `timeout` when you see it, or from the start if the burst is
1813
+ * user-driven and unbounded.
1814
+ */
1815
+ readonly timeout?: FlowDuration;
1816
+ }
1817
+
524
1818
  export declare interface WorkflowExecuteRecordingOptions extends WorkflowRunRecorderOptions {
525
1819
  readonly name: string;
526
1820
  readonly subject?: unknown;
@@ -539,14 +1833,175 @@ export declare interface WorkflowExecuteRecordingOptions extends WorkflowRunReco
539
1833
  }) => Promise<void>;
540
1834
  readonly recorder?: WorkflowRunRecorderService;
541
1835
  readonly pluginWorkflowStepLayer?: Layer.Layer<any, never, never>;
1836
+ /**
1837
+ * A run reached a TERMINAL state — supplied by the CLI wiring so this package
1838
+ * needs no knowledge of flow control.
1839
+ *
1840
+ * It exists to free the run's admission lease. That lease is what a
1841
+ * `concurrency` limit counts, so a terminal transition that does not release
1842
+ * it does not error, log, or fail a test — the limit simply admits one fewer
1843
+ * run, permanently, and then one fewer again. A deployment that slowly stops
1844
+ * doing work with nothing pointing at why.
1845
+ *
1846
+ * Called on success AND on failure, and DELIBERATELY not on suspend: a
1847
+ * suspended run is still holding the resource it was admitted for.
1848
+ */
1849
+ readonly onTerminal?: (input: {
1850
+ readonly workflowName: string;
1851
+ readonly executionId: string;
1852
+ readonly runId?: string;
1853
+ readonly status: 'succeeded' | 'failed';
1854
+ }) => Promise<void>;
542
1855
  }
543
1856
 
544
- /** Callable shape of `workflow`: the raw `Workflow.make` surface PLUS the
545
- * Voltro-typed overload that threads the message-schema metadata onto the
546
- * returned definition. Explicit alias so the `.d.ts` names this type by its
547
- * own alias the inferred `Workflow.make` type references internal `@effect`
548
- * modules via `.pnpm` paths and is not portable (TS2742) when re-emitted. */
549
- declare type WorkflowFn = WorkflowMake & (<const Options extends WorkflowOptions>(options: Options) => ReturnType<WorkflowMake> & WorkflowMessagesCarrier<NormaliseWorkflowMessages<Options['messages']>>);
1857
+ /**
1858
+ * What an `onFailure` workflow receives. Declare it as that workflow's
1859
+ * `payload` schema the framework validates against it like any other start,
1860
+ * so a mismatch is a decode error naming the field rather than a silent drop.
1861
+ */
1862
+ export declare interface WorkflowFailureReport {
1863
+ /** The workflow that failed. */
1864
+ readonly workflow: string;
1865
+ /** Its payload, as it was started with. Null when the failure happened before
1866
+ * a payload existed (a `timeouts.finish` sweep reads the run row, not the
1867
+ * intent). */
1868
+ readonly payload: unknown;
1869
+ /** Tagged error name, when the failure carried one. */
1870
+ readonly errorTag: string | null;
1871
+ readonly errorMessage: string | null;
1872
+ /** `_voltro_workflow_runs.id`, when a run existed. */
1873
+ readonly runId: string | null;
1874
+ readonly executionId: string | null;
1875
+ /** Human-readable, always present: which of the four paths this was. */
1876
+ readonly reason: string;
1877
+ /** Epoch ms. */
1878
+ readonly failedAt: number;
1879
+ }
1880
+
1881
+ /** Every flow-control field, as declared on `workflow({...})`. */
1882
+ export declare interface WorkflowFlowControl<Payload> {
1883
+ readonly debounce?: WorkflowDebounce<Payload>;
1884
+ readonly singleton?: WorkflowSingleton<Payload>;
1885
+ readonly concurrency?: WorkflowConcurrency<Payload>;
1886
+ readonly throttle?: WorkflowThrottle<Payload>;
1887
+ readonly rateLimit?: WorkflowRateLimit<Payload>;
1888
+ readonly batch?: WorkflowBatch<Payload>;
1889
+ /** Higher runs first out of the pending queue. Ties break by arrival, so an
1890
+ * all-default deployment is FIFO rather than nondeterministic. */
1891
+ readonly priority?: (payload: Payload) => number;
1892
+ readonly timeouts?: WorkflowTimeouts;
1893
+ /**
1894
+ * The NAME of a workflow to run when this one does not complete.
1895
+ *
1896
+ * A durable run that exhausts its retries currently ends and says nothing.
1897
+ * The alternative people reach for is a cron over
1898
+ * `listRuns({ status: 'failed' })` — a sweep where a signal belongs, and the
1899
+ * shape this framework keeps trying to remove.
1900
+ *
1901
+ * A NAME, not a function, and that is the whole design decision. A closure
1902
+ * cannot be journaled: the failure may be noticed by a different replica than
1903
+ * the one that ran the workflow, minutes later, after the process that held
1904
+ * the closure is gone. A workflow name resolves anywhere, is itself durable,
1905
+ * retries on its own terms, and shows up in the run list like everything else.
1906
+ *
1907
+ * It fires for EVERY way a run fails to deliver, not only an exhausted retry:
1908
+ * • the body failed and retries are spent
1909
+ * • `timeouts.finish` cancelled an overrunning run
1910
+ * • `timeouts.start` expired an intent that never got a slot
1911
+ * • the workflow was renamed away while intents were queued
1912
+ *
1913
+ * Its payload is {@link WorkflowFailureReport}. Declare that as its `payload`
1914
+ * schema. A handler that itself fails is recorded and NOT re-notified —
1915
+ * there is no `onFailure` for the `onFailure`, deliberately: the alternative
1916
+ * is a loop that produces one run per failure per level, forever.
1917
+ */
1918
+ readonly onFailure?: string;
1919
+ /**
1920
+ * Encrypt this workflow's journaled step `input` / `output` / `errorCause`.
1921
+ *
1922
+ * `step({ input })` is written to `_voltro_workflow_run_steps` and rendered in
1923
+ * the dashboard — a feature, and the reason people pass rich input. For a step
1924
+ * carrying personal data it is also a SECOND COPY, outside the `.encrypted()`
1925
+ * boundary the governance plugin establishes for tables. An app that carefully
1926
+ * encrypted a column and then handed the same value to a step has it in
1927
+ * plaintext one table over.
1928
+ *
1929
+ * Reuses the cipher `governancePlugin({ fieldEncryption })` already registers,
1930
+ * so there is ONE key and one rotation story rather than a second scheme that
1931
+ * only workflows know about. It is therefore a BOOT ERROR to declare this
1932
+ * without that plugin configured: silently falling back to plaintext would be
1933
+ * the worst outcome available — the declaration reads as protection and the
1934
+ * rows are readable.
1935
+ *
1936
+ * Only the journal is affected. The values the workflow BODY sees are
1937
+ * unchanged, and the durable execution journal `@effect/workflow` keeps is
1938
+ * untouched (encrypting that would break replay).
1939
+ */
1940
+ readonly encryptSteps?: boolean;
1941
+ }
1942
+
1943
+ /** Where the RESOLVED flow-control declaration hangs off a definition. A
1944
+ * string property rather than a symbol, matching `WorkflowMessagesProperty`,
1945
+ * because the CLI's discovery reads it off a module that crossed a bundler
1946
+ * boundary and symbols do not survive every one of those. */
1947
+ export declare const WorkflowFlowControlProperty = "__voltroWorkflowFlowControl";
1948
+
1949
+ /**
1950
+ * Callable shape of `workflow` — ONE signature mirroring `Workflow.make`'s own
1951
+ * generics, plus Voltro's version metadata, message schemas and flow control.
1952
+ *
1953
+ * ── Why one signature and not an intersection ─────────────────────────────
1954
+ *
1955
+ * This used to be `WorkflowMake & (<Options>(options: Options) => …)`: the raw
1956
+ * effect signature first for a precise return type, ours second for the extras.
1957
+ * That shape cannot carry flow control, and the way it fails is silent.
1958
+ *
1959
+ * `debounce.key: (payload) => string` must receive the workflow's own payload
1960
+ * type. That is most of what the declaration buys over a hand-rolled debounce:
1961
+ * a lambda typed `any` compiles, runs, and is wrong only once somebody renames
1962
+ * a field — at which point the key becomes the string `"undefined"` and every
1963
+ * row in the deployment collapses into one bucket, which looks exactly like a
1964
+ * working debounce until you count the runs.
1965
+ *
1966
+ * Three attempts got there and were measured, not assumed:
1967
+ *
1968
+ * 1. `<Options extends WorkflowOptions>(o: Options & WorkflowFlowControl<
1969
+ * PayloadTypeOf<Options>>)` — TypeScript types the key lambdas while it is
1970
+ * still inferring `Options`, so the payload type resolves against the
1971
+ * CONSTRAINT. With `WorkflowFlowControl<never>` in it, `never` wins the
1972
+ * contextual-type merge and the parameters fall back to implicit `any`.
1973
+ * 2. Taking it out of the constraint was not enough: `Options` is a NAKED
1974
+ * type parameter inside the parameter's intersection, and TypeScript does
1975
+ * not push contextual types through an intersection it cannot decompose.
1976
+ * 3. Spelling the parameter out concretely fixed THAT, and the call still
1977
+ * typed the lambda as `any` — because `workflow` was still an
1978
+ * INTERSECTION of two call signatures, and overload resolution types
1979
+ * context-sensitive arguments before it fixes type parameters.
1980
+ *
1981
+ * So: no intersection. The generics below are `Workflow.make`'s own, which is
1982
+ * what makes a single signature able to accept everything it accepted. The
1983
+ * return type improves as a side effect — it was `ReturnType<WorkflowMake>`
1984
+ * (i.e. `Workflow<any, any, any>`) for any workflow that declared `messages`,
1985
+ * because such a call fell through to the second overload.
1986
+ *
1987
+ * `flowControl.test-d.ts` pins the result, including a `@ts-expect-error` on a
1988
+ * misspelled payload field. That assertion is the one that silently stops
1989
+ * meaning anything if this ever regresses to `any` — which is precisely what
1990
+ * all three failed attempts did.
1991
+ */
1992
+ 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: {
1993
+ readonly name: Name;
1994
+ readonly payload: Payload;
1995
+ readonly idempotencyKey: (payload: DecodedPayload<Payload>) => string;
1996
+ readonly success?: Success;
1997
+ readonly error?: Error;
1998
+ /** Cancel live runs when a correlated event arrives. Each entry's event
1999
+ * type comes from its OWN `schema` — see {@link WorkflowCancelOnList} for
2000
+ * why that needs a tuple type parameter rather than a plain array. */
2001
+ readonly cancelOn?: WorkflowCancelOnList<Cancels, DecodedPayload<Payload>>;
2002
+ } & Omit<WorkflowBaseOptions, 'name' | 'payload' | 'idempotencyKey' | 'success' | 'error'> & WorkflowVersionOptions & {
2003
+ readonly messages?: Messages;
2004
+ } & WorkflowFlowControl<DecodedPayload<Payload>>) => workflowModule.Workflow<Name, PayloadSchemaOf<Payload>, Success, Error> & WorkflowMessagesCarrier<NormaliseWorkflowMessages<Messages>>;
550
2005
 
551
2006
  declare type WorkflowMake = typeof workflowModule.make;
552
2007
 
@@ -589,10 +2044,6 @@ export declare interface WorkflowMessageTarget {
589
2044
 
590
2045
  export { workflowModule }
591
2046
 
592
- declare type WorkflowOptions = Parameters<WorkflowMake>[0] & WorkflowVersionOptions & {
593
- readonly messages?: WorkflowMessageSchemas;
594
- };
595
-
596
2047
  export declare interface WorkflowParentCloseDecision {
597
2048
  readonly runId: string;
598
2049
  readonly workflowName: string;
@@ -620,6 +2071,26 @@ export declare interface WorkflowParentCloseStore {
620
2071
  update(table: string, id: string, patch: unknown): Promise<unknown | null>;
621
2072
  }
622
2073
 
2074
+ /** Everything `Workflow.make` accepts for `payload`: a struct Schema, or bare
2075
+ * `Fields`. Both stay supported — a key lambda must not silently degrade to
2076
+ * `any` because the workflow spelled its payload the shorter way. */
2077
+ declare type WorkflowPayloadInput = WorkflowBaseOptions['payload'];
2078
+
2079
+ /**
2080
+ * Hard cap: starts beyond `limit` per `period` are DROPPED, not queued.
2081
+ *
2082
+ * A dropped start is reported — `start()` resolves to a handle with
2083
+ * `status: 'dropped'` and a `retryAfterMs`, and the drop is a row in the
2084
+ * admissions ledger with its key and reason. It is never silent. A cap that
2085
+ * discards work without saying so is indistinguishable from a bug, which is
2086
+ * exactly the failure this framework keeps writing guards against.
2087
+ */
2088
+ export declare interface WorkflowRateLimit<Payload> {
2089
+ readonly limit: number;
2090
+ readonly period: FlowDuration;
2091
+ readonly key?: (payload: Payload) => string;
2092
+ }
2093
+
623
2094
  export declare interface WorkflowRecordingEmitter {
624
2095
  emit(channel: 'workflowRuns' | 'workflowSteps' | 'workflowEvents', event: {
625
2096
  readonly op: 'insert' | 'update';
@@ -734,6 +2205,26 @@ export declare interface WorkflowRunRecorderOptions {
734
2205
  * per run at a TERMINAL outcome (succeeded/failed), never on suspend. Absent →
735
2206
  * no metrics, zero overhead. */
736
2207
  readonly recordRun?: (outcome: WorkflowRunOutcome) => void;
2208
+ /**
2209
+ * Encrypt the `input` / `output` / `errorCause` a step journals.
2210
+ *
2211
+ * `step({ input })` is written to `_voltro_workflow_run_steps` and shown in
2212
+ * the dashboard, which is a feature and the reason people pass rich input. For
2213
+ * a step carrying personal data it is also a SECOND COPY, outside the
2214
+ * `.encrypted()` boundary the governance plugin establishes for tables — so an
2215
+ * app that carefully encrypted a column then handed the same value to a step
2216
+ * has it in plaintext one table over.
2217
+ *
2218
+ * Supplied by the CLI from the cipher `governancePlugin({ fieldEncryption })`
2219
+ * already registers, so there is ONE key and one rotation story rather than a
2220
+ * second scheme for workflows. Absent → plaintext, exactly as before.
2221
+ *
2222
+ * Encryption happens on the WRITE and decryption on the inspect read, so the
2223
+ * dashboard is unchanged for a viewer who is allowed to see it and the value
2224
+ * at rest is not. A ciphertext that cannot be decrypted renders as a marker
2225
+ * rather than as garbage — see `inspectWorkflow`.
2226
+ */
2227
+ readonly encryptStepPayload?: (value: unknown) => unknown;
737
2228
  }
738
2229
 
739
2230
  /** Per-step lifecycle hooks. Implementations should not throw —
@@ -806,6 +2297,29 @@ export declare interface WorkflowRunRecorderService {
806
2297
  }>;
807
2298
  }
808
2299
 
2300
+ /**
2301
+ * At most one run per key. The newcomer either stands down or evicts.
2302
+ *
2303
+ * `key` is the RESOURCE, not the identity — see the module header. Two starts
2304
+ * with different `idempotencyKey`s can absolutely collide here, and that is the
2305
+ * point.
2306
+ */
2307
+ export declare interface WorkflowSingleton<Payload> {
2308
+ readonly key: (payload: Payload) => string;
2309
+ /**
2310
+ * `'skip'` — a start arriving while the key is held returns the INCUMBENT's
2311
+ * handle. No new run, no error. Use when the work in flight is
2312
+ * as good as the work you were about to ask for.
2313
+ * `'cancel'` — the incumbent is cancelled and the newcomer runs. Use when the
2314
+ * newcomer carries fresher input and the incumbent's partial
2315
+ * work is worthless.
2316
+ *
2317
+ * There is no default: the two answers are opposite and picking one for you
2318
+ * would silently discard either the new request or the running one.
2319
+ */
2320
+ readonly mode: 'skip' | 'cancel';
2321
+ }
2322
+
809
2323
  /**
810
2324
  * Per-step plugin interceptor. Plugins can wrap every `step()`
811
2325
  * (== `Activity.make`) invocation with observability, suppress /
@@ -843,6 +2357,37 @@ export declare class WorkflowStepInterceptorTag extends WorkflowStepInterceptorT
843
2357
 
844
2358
  declare const WorkflowStepInterceptorTag_base: Context.TagClass<WorkflowStepInterceptorTag, "@voltro/WorkflowStepInterceptor", WorkflowStepInterceptorService>;
845
2359
 
2360
+ /**
2361
+ * Smooth throughput: excess starts QUEUE and are admitted as capacity frees up.
2362
+ *
2363
+ * The counterpart to {@link WorkflowRateLimit}, which DROPS the excess. Reach
2364
+ * for throttle when every start must eventually run and you only care that they
2365
+ * do not run all at once; reach for rate limit when the excess is genuinely
2366
+ * surplus and running it late is worse than not running it.
2367
+ */
2368
+ export declare interface WorkflowThrottle<Payload> {
2369
+ readonly limit: number;
2370
+ readonly period: FlowDuration;
2371
+ readonly key?: (payload: Payload) => string;
2372
+ }
2373
+
2374
+ /**
2375
+ * Wall-clock bounds on a run, distinct from a step's retry policy.
2376
+ *
2377
+ * `start` bounds how long a run may sit in the admission queue — a debounced or
2378
+ * concurrency-queued run that never gets a slot is a job that silently did not
2379
+ * happen. `finish` bounds the run itself once admitted.
2380
+ *
2381
+ * Both expire into the SAME path as an exhausted retry: the run is recorded
2382
+ * `failed` with an `errorTag` naming which bound was hit, and `onFailure` fires.
2383
+ * A timeout that expired quietly would be the sweep-instead-of-signal shape this
2384
+ * whole feature exists to remove.
2385
+ */
2386
+ export declare interface WorkflowTimeouts {
2387
+ readonly start?: FlowDuration;
2388
+ readonly finish?: FlowDuration;
2389
+ }
2390
+
846
2391
  export declare interface WorkflowUpdateResult {
847
2392
  readonly eventId: string;
848
2393
  readonly updateId: string;
@@ -920,4 +2465,15 @@ export declare const WorkflowWorkerLayerTypeId: unique symbol;
920
2465
 
921
2466
  export declare const wrapWorkflowExecuteWithRunRecording: (options: WorkflowExecuteRecordingOptions, userExecute: (payload: unknown, executionId: string) => Effect.Effect<unknown, unknown, never>) => (payload: unknown, executionId: string) => Effect.Effect<unknown, unknown, never>;
922
2467
 
2468
+ /**
2469
+ * Advance the stored position.
2470
+ *
2471
+ * Never moves BACKWARDS. The update function compares against the row as it is
2472
+ * at write time, so a slow replica finishing an old batch cannot rewind a
2473
+ * faster one and cause the window in between to be swept twice — or, worse,
2474
+ * cause a `discardIntent` on an intent that has since been legitimately
2475
+ * re-queued.
2476
+ */
2477
+ export declare const writeWatermark: (store: CancelOnDataStore, name: string, at: Date) => Promise<void>;
2478
+
923
2479
  export { }