@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/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
|
-
/**
|
|
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
|
-
/**
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
|
|
549
|
-
declare
|
|
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 { }
|