@voltro/workflow 0.28.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/CHANGELOG.md +655 -0
- package/dist/cluster-CyIyBO39.js +159 -0
- package/dist/cluster.d.ts +41 -0
- package/dist/cluster.js +2 -2
- package/dist/clusterTestSuite.d.ts +29 -0
- package/dist/clusterTestSuite.js +35 -35
- package/dist/index.d.ts +1567 -11
- package/dist/index.js +3 -3
- package/dist/primitives-Cwy2zQpU.js +322 -0
- package/dist/primitives.d.ts +609 -11
- package/dist/primitives.js +2 -2
- package/dist/src-CMapid4w.js +1989 -0
- package/package.json +3 -3
- package/dist/cluster-BQ0H5M9n.js +0 -131
- package/dist/primitives-Dgu3O55Q.js +0 -164
- package/dist/src-KDx7NTsa.js +0 -854
package/dist/primitives.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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;
|