@voltro/workflow 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Cause } from 'effect';
2
2
  import { Context } from 'effect';
3
+ import { DataStore } from '@voltro/database';
3
4
  import { DurableClock as durableClock } from '@effect/workflow';
4
5
  import { DurableQueue as durableQueueModule } from '@effect/workflow';
5
6
  import { DurableRateLimiter as durableRateLimiterModule } from '@effect/workflow';
@@ -100,6 +101,15 @@ export declare interface AdmissionDrainDeps {
100
101
  readonly executionId: string;
101
102
  readonly startedAt: number;
102
103
  }>>;
104
+ /**
105
+ * EVERY registered workflow name — not just the controlled ones in
106
+ * `controls`. A `mode: 'delayed'` row is legitimate for a workflow with NO
107
+ * flow control, so "missing from `controls`" cannot mean "orphan" for it the
108
+ * way it does for a debounce row. Absent → a delayed row is assumed
109
+ * registered, and an unknown name surfaces as a per-row failure (attempts +
110
+ * lastError climb on the row) instead of a silent abandon.
111
+ */
112
+ readonly registeredWorkflows?: ReadonlySet<string>;
103
113
  readonly log?: DrainLogger;
104
114
  readonly now?: () => number;
105
115
  }
@@ -446,6 +456,54 @@ export declare interface AwaitUpdateOptions<A, B = A> {
446
456
  readonly timeoutMs?: number;
447
457
  }
448
458
 
459
+ export declare const BUDGET_HOLDS_TABLE = "_voltro_budget_holds";
460
+
461
+ /** Raised into the workflow when a hold outlived its ceiling. */
462
+ export declare class BudgetHoldExpired extends Error {
463
+ readonly budget: string;
464
+ readonly holdKey: string;
465
+ constructor(budget: string, holdKey: string, timeoutMs: number);
466
+ }
467
+
468
+ /** How many holds this (execution, step, budget) has already taken. */
469
+ export declare const budgetHoldGeneration: (store: DataStore, input: {
470
+ readonly executionId: string;
471
+ readonly stepName: string;
472
+ readonly budget: string;
473
+ }) => Promise<number>;
474
+
475
+ /**
476
+ * The replay-stable identity of one hold within one run.
477
+ *
478
+ * The `generation` is the part that is easy to leave out and fatal to leave out.
479
+ * A run can be held MORE THAN ONCE at the same step: it is released, the body
480
+ * replays, it re-reads the counter, and the budget is still over — because the
481
+ * window rolled for a tenant that immediately spent again, or because an
482
+ * operator lifted the wrong hold. With a generation-free key the second park
483
+ * would await the deferred the FIRST release had already completed, get an
484
+ * instant resolution, and spend straight through a budget that was still
485
+ * exceeded — the AI-Flows constant-signal-name collision, one level up and with
486
+ * money on the other side of it.
487
+ *
488
+ * `generation` is the number of holds already recorded for this
489
+ * (execution, step, budget), which is durable, monotone, and unchanged by a
490
+ * crash-replay that took no new hold — so it is stable exactly when replay
491
+ * safety needs it to be and different exactly when correctness needs it to be.
492
+ */
493
+ export declare const budgetHoldKey: (input: {
494
+ readonly executionId: string;
495
+ readonly stepName: string;
496
+ readonly budget: string;
497
+ readonly generation: number;
498
+ }) => string;
499
+
500
+ /**
501
+ * The signal name ONE hold parks on. Derived from the hold key, so the releaser
502
+ * reconstructs it from the row with no shared state — the same discipline
503
+ * `suspendingSignalDeferredName` and `humanResponseSignalName` follow.
504
+ */
505
+ export declare const budgetHoldSignalName: (holdKey: string) => string;
506
+
449
507
  export declare const CANCEL_ON_WATERMARK = "cancelOn";
450
508
 
451
509
  export declare interface CancelDecision {
@@ -659,6 +717,29 @@ export declare interface CompleteSuspendingSignalInput {
659
717
  * engine-assigned executionId, not our row id. */
660
718
  export declare const CurrentWorkflowExecutionId: FiberRef.FiberRef<string | undefined>;
661
719
 
720
+ /**
721
+ * FiberRef carrying the patch markers that were declared on the workflow at the
722
+ * moment THIS RUN STARTED — read back from `_voltro_workflow_runs.
723
+ * workflowPatches` on a resume, taken from the current declaration on a first
724
+ * start. This is what makes `patch('id')` (see `primitives.ts`) answer the
725
+ * Temporal question "was this marker in effect when this execution began?"
726
+ * rather than "is it in the code I am running now".
727
+ *
728
+ * Empty outside a recorded workflow body, so `patch()` is `false` there — the
729
+ * safe direction: an un-recorded context gets the PRE-patch branch.
730
+ */
731
+ export declare const CurrentWorkflowPatches: FiberRef.FiberRef<readonly string[]>;
732
+
733
+ /**
734
+ * The tracker for the CURRENT body entry, or `undefined` when there is nothing
735
+ * to compare against.
736
+ *
737
+ * Installed by `wrapWorkflowExecuteWithRunRecording` ONLY on a re-entry (a run
738
+ * whose row already exists). A first entry has no prior shape, so the whole
739
+ * mechanism costs one `FiberRef` read per step and nothing else.
740
+ */
741
+ export declare const CurrentWorkflowReplayShape: FiberRef.FiberRef<ReplayShapeTracker | undefined>;
742
+
662
743
  /** FiberRef carrying the active workflow run's id. The outer
663
744
  * `wrapWithRunRecording` in dev.ts sets it via
664
745
  * `Effect.locally(CurrentWorkflowRunId, runId)`. */
@@ -697,6 +778,11 @@ declare type DecodedPayload<P> = P extends Schema.Struct.Fields ? Schema.Struct.
697
778
  readonly Type: infer T;
698
779
  } ? T : never;
699
780
 
781
+ /** Default ceiling on a hold — 7 days, matching the HITL park bound. The wait
782
+ * itself is free (no worker is held), and a run that gives up on a monthly
783
+ * budget after an hour is a run somebody has to re-drive by hand. */
784
+ export declare const DEFAULT_BUDGET_HOLD_TIMEOUT_MS: number;
785
+
700
786
  /** How far back a COLD start looks when no watermark exists yet — one hour.
701
787
  * Not the whole journal: a first boot against an old deployment would
702
788
  * otherwise read every event ever published to decide about runs that mostly
@@ -727,6 +813,44 @@ export declare const DEFAULT_EVENT_BATCH = 500;
727
813
  */
728
814
  export declare const DEFAULT_LEASE_MS: number;
729
815
 
816
+ /** Consecutive runner deaths a single run may cost before it is parked. Three,
817
+ * not one: a single crash is frequently the node and not the payload, and
818
+ * parking on it would turn every spot-instance eviction into an operator
819
+ * ticket. Three in a row is a property of the work. */
820
+ export declare const DEFAULT_MAX_RUN_RECLAIMS = 3;
821
+
822
+ /** Recorded step rows the replay-shape snapshot will load for one run. */
823
+ export declare const DEFAULT_REPLAY_SHAPE_LIMIT = 2000;
824
+
825
+ /** How long a live run may make no progress before it is reported.
826
+ *
827
+ * Thirty minutes, not five: the population this must not drown in is
828
+ * long-running steps (a big import, a slow provider), and a threshold under
829
+ * the longest legitimate step turns the feature into noise. Runs that wait
830
+ * legitimately for HOURS wait on a durable timer, which is excluded outright,
831
+ * so the threshold only has to clear the slowest single step. */
832
+ export declare const DEFAULT_STALL_AFTER_MS: number;
833
+
834
+ /** Lifecycle events read per candidate. Enough to see the timer handshake and
835
+ * a prior `run-stalled` on a run that is, by construction, not doing much. */
836
+ export declare const DEFAULT_STALL_EVENT_LOOKBACK = 20;
837
+
838
+ /** Live runs examined per tick. Bounded for the reason every sweep here is:
839
+ * oldest-first, so a deployment past the bound makes progress on the next
840
+ * tick instead of re-reading the same page. */
841
+ export declare const DEFAULT_STALL_RUN_PAGE = 200;
842
+
843
+ /**
844
+ * Default threshold: a wait DECLARED longer than this suggests the suspending
845
+ * variant. Five minutes — well above any interactive approval, well below the
846
+ * hours-long HITL waits the suspending variant is for; the poll's own backoff
847
+ * (≤ 5 s) makes the polling cost of a shorter wait negligible.
848
+ */
849
+ export declare const DEFAULT_SUSPEND_HINT_MS: number;
850
+
851
+ /** 24 h — see {@link WorkflowScheduleDeclaration.maxRuntime}. */
852
+ export declare const DEFAULT_WORKFLOW_SCHEDULE_MAX_RUNTIME_MS: number;
853
+
730
854
  export declare type DeferMode = DeferringControl | 'paused';
731
855
 
732
856
  /**
@@ -901,6 +1025,9 @@ export declare const getCurrentWorkflowRunId: () => Effect.Effect<string | undef
901
1025
  * the path it took before this feature existed. */
902
1026
  export declare const getWorkflowFlowControl: (value: unknown) => ResolvedFlowControl | undefined;
903
1027
 
1028
+ /** The resolved schedule attached to a workflow definition, or undefined. */
1029
+ export declare const getWorkflowSchedule: (value: unknown) => ResolvedWorkflowSchedule | undefined;
1030
+
904
1031
  export declare const getWorkflowVersionMetadata: (value: unknown) => WorkflowVersionMetadata;
905
1032
 
906
1033
  /** `true` when this workflow declares at least one `cancelOn` entry — i.e. when
@@ -917,6 +1044,13 @@ export declare const hasCancelOn: (control: ResolvedFlowControl | undefined) =>
917
1044
  * two writes per start for a decision that is always "admit". */
918
1045
  export declare const hasFlowControl: (control: ResolvedFlowControl | undefined) => boolean;
919
1046
 
1047
+ /** The distinct budgets currently holding at least one run — what a poller
1048
+ * iterates so it asks about nothing else. */
1049
+ export declare const heldBudgets: (store: DataStore) => Promise<ReadonlyArray<{
1050
+ readonly budget: string;
1051
+ readonly tenantId: string | null;
1052
+ }>>;
1053
+
920
1054
  export declare interface InMemoryRecorder {
921
1055
  readonly layer: Layer.Layer<WorkflowRunRecorder>;
922
1056
  readonly readSteps: () => ReadonlyArray<RecordedStep>;
@@ -1019,10 +1153,28 @@ export declare const linkExecution: (store: AdmissionDataStore, ledgerId: string
1019
1153
 
1020
1154
  export declare const makeInMemoryRecorder: () => InMemoryRecorder;
1021
1155
 
1156
+ /**
1157
+ * Build a tracker from the step rows this run recorded on EARLIER body entries.
1158
+ *
1159
+ * Pass the rows as they are: one row per step ATTEMPT, so a step that retried
1160
+ * three times contributes three. Counting rows rather than distinct names is
1161
+ * what makes the per-name sequence rule work, and the asymmetry is deliberate —
1162
+ * only reaching a name MORE often than recorded is a finding. Fewer is normal
1163
+ * (a crash mid-step records two rows for one logical reach).
1164
+ */
1165
+ export declare const makeReplayShapeTracker: (recordedRows: ReadonlyArray<RecordedStepShape>) => ReplayShapeTracker;
1166
+
1022
1167
  export declare const makeWorkflowRunRecorder: (options: WorkflowRunRecorderOptions) => WorkflowRunRecorderService;
1023
1168
 
1024
1169
  export declare const makeWorkflowUpdateId: () => string;
1025
1170
 
1171
+ /**
1172
+ * Emit the steering hint when a polling `awaitSignal` declares a wait longer
1173
+ * than the threshold. Returns `true` when it hinted — the testable fact; the
1174
+ * once-per-name set is module state on purpose (one process, one hint).
1175
+ */
1176
+ export declare const maybeHintSuspendingSignal: (input: SuspendHintInput) => boolean;
1177
+
1026
1178
  /** The newest event instant in a batch, or `undefined` for an empty one.
1027
1179
  * The watermark advances to THIS, never to `now`: advancing to wall-clock
1028
1180
  * would skip anything written with an older `occurredAt` by a transaction
@@ -1051,6 +1203,23 @@ export declare const newestEventAt: (events: ReadonlyArray<DomainEventRow>) => D
1051
1203
  */
1052
1204
  export declare const nextSlotAt: (recentAdmissions: ReadonlyArray<number>, limit: number, periodMs: number, now: number) => number | undefined;
1053
1205
 
1206
+ /** The `nondeterminism-suspected` event body. Kept here so the two emit sites
1207
+ * (the step wrapper, and the run wrapper's `settle`) cannot drift. */
1208
+ export declare const nondeterminismEventPayload: (finding: NondeterminismFinding) => Record<string, unknown>;
1209
+
1210
+ export declare interface NondeterminismFinding {
1211
+ readonly kind: NondeterminismKind;
1212
+ readonly stepName: string;
1213
+ /** How many rows this run recorded for `stepName` on EARLIER body entries. */
1214
+ readonly recorded: number;
1215
+ /** How many times the CURRENT body entry reached it. */
1216
+ readonly reached: number;
1217
+ /** One sentence an operator can act on, without opening the code. */
1218
+ readonly detail: string;
1219
+ }
1220
+
1221
+ export declare type NondeterminismKind = 'unreached-step' | 'extra-step-occurrence';
1222
+
1054
1223
  declare type NormaliseWorkflowMessages<M extends WorkflowMessageSchemas | undefined> = {
1055
1224
  readonly signals: M extends {
1056
1225
  readonly signals: infer Signals;
@@ -1058,13 +1227,99 @@ declare type NormaliseWorkflowMessages<M extends WorkflowMessageSchemas | undefi
1058
1227
  readonly updates: M extends {
1059
1228
  readonly updates: infer Updates;
1060
1229
  } ? NonNullable<Updates> & Readonly<Record<string, WorkflowMessagePairSchemas>> : {};
1061
- readonly queries: M extends {
1062
- readonly queries: infer Queries;
1063
- } ? NonNullable<Queries> & Readonly<Record<string, WorkflowMessagePairSchemas>> : {};
1064
1230
  };
1065
1231
 
1232
+ /** `workflowPatches` comes back off a `json()` column, so it is whatever the
1233
+ * dialect's driver decoded — an array, a JSON string, or null. Anything that
1234
+ * is not a list of strings means "no markers", which is the safe answer:
1235
+ * `patch()` then reports the PRE-patch branch. */
1236
+ export declare const normaliseWorkflowPatches: (value: unknown) => ReadonlyArray<string> | undefined;
1237
+
1066
1238
  export declare const noteIntentAttempt: (store: AdmissionDataStore, intent: PendingIntent, error: string, now: number) => Promise<void>;
1067
1239
 
1240
+ /**
1241
+ * Park a `start(name, payload, { at })` as a DURABLE pending row.
1242
+ *
1243
+ * A delayed start that only exists in a `setTimeout` dies with the process, so
1244
+ * it is a row in `_voltro_workflow_pending` (`mode: 'delayed'`) that the
1245
+ * coordinated drainer fires when `dueAt` arrives. Each call is its OWN row —
1246
+ * two delayed starts never collapse, because unlike debounce nothing about
1247
+ * `{ at }` says the second supersedes the first.
1248
+ *
1249
+ * What happens at `dueAt` is an ARRIVAL, not a bypass: the drainer hands the
1250
+ * row to the same `admitStart` a live `ctx.workflows.start` goes through, so a
1251
+ * workflow's declared debounce/singleton/rateLimit judge the start as of the
1252
+ * moment it comes due. `{ at }` delays the arrival; it never outranks a control.
1253
+ */
1254
+ export declare const parkDelayedStart: (input: ParkDelayedStartInput) => Promise<{
1255
+ readonly intentId: string;
1256
+ readonly dueAt: number;
1257
+ }>;
1258
+
1259
+ export declare interface ParkDelayedStartInput {
1260
+ readonly store: AdmissionDataStore;
1261
+ readonly workflowName: string;
1262
+ readonly payload: unknown;
1263
+ readonly callerContext: unknown;
1264
+ readonly tenantId: string | null;
1265
+ /** Epoch ms the start becomes an ARRIVAL. Must be in the future — the facade
1266
+ * starts a past-`at` immediately and never calls in here. */
1267
+ readonly at: number;
1268
+ readonly now: number;
1269
+ }
1270
+
1271
+ /**
1272
+ * Is a named change marker in effect for the run currently executing?
1273
+ *
1274
+ * This is the escape valve for the versioning trap. `compatibleWith` is
1275
+ * all-or-nothing: leave an old version out and every in-flight run on it is
1276
+ * terminally failed; leave it in and the old runs replay against the new body
1277
+ * with no detection. `patch` is the third option — the body itself branches, so
1278
+ * runs that started BEFORE the change keep taking the old path to completion
1279
+ * while new runs take the new one.
1280
+ *
1281
+ * ```ts
1282
+ * export const Charge = workflow({
1283
+ * name: 'billing.charge',
1284
+ * payload: { orderId: Schema.String },
1285
+ * idempotencyKey: ({ orderId }) => `billing.charge:${orderId}`,
1286
+ * patches: ['split-tax-calculation'], // ← declare it
1287
+ * })
1288
+ *
1289
+ * // inside the body:
1290
+ * if (yield* patch('split-tax-calculation')) {
1291
+ * const net = yield* step({ name: 'net-total', execute })
1292
+ * const tax = yield* step({ name: 'tax', execute })
1293
+ * return net + tax
1294
+ * }
1295
+ * return yield* step({ name: 'total', execute }) // the pre-patch path
1296
+ * ```
1297
+ *
1298
+ * ── The answer is pinned to the RUN, not to the code ────────────────────────
1299
+ *
1300
+ * `patches` is stamped onto `_voltro_workflow_runs.workflowPatches` when the
1301
+ * run starts and read back from that row on every resume. So a run started
1302
+ * before you added the marker answers `false` for the rest of its life,
1303
+ * however many times it replays and whatever the deployed code says — which is
1304
+ * the property that makes the branch deterministic across a redeploy.
1305
+ *
1306
+ * Under `@effect/workflow` the naive implementation does the opposite: an
1307
+ * activity that is ABSENT from an old run's journal EXECUTES on replay (the
1308
+ * journal is keyed name/attempt), so a marker journaled by an activity would
1309
+ * answer `true` for exactly the old runs it must answer `false` for. Reading
1310
+ * the run row sidesteps that entirely.
1311
+ *
1312
+ * ── Retiring a patch ───────────────────────────────────────────────────────
1313
+ *
1314
+ * Once no run predating the marker can still be in flight, delete the old
1315
+ * branch and the entry from `patches`. Runs that stamped it keep the marker on
1316
+ * their row for the audit trail; `patch()` simply stops being called.
1317
+ *
1318
+ * Outside a recorded workflow body (a unit test, a bare `step()` call) this is
1319
+ * `false` — the pre-patch branch, which is the safe direction.
1320
+ */
1321
+ export declare const patch: (id: string) => Effect.Effect<boolean>;
1322
+
1068
1323
  export declare const PAUSES_TABLE = "_voltro_workflow_pauses";
1069
1324
 
1070
1325
  export declare const pauseWorkflow: (store: AdmissionDataStore, workflowName: string, by: string | null, reason: string | null) => Promise<void>;
@@ -1074,13 +1329,27 @@ declare type PayloadSchemaOf<P> = P extends Schema.Struct.Fields ? Schema.Struct
1074
1329
 
1075
1330
  export declare const PENDING_TABLE = "_voltro_workflow_pending";
1076
1331
 
1332
+ /** Every hold currently parked — the operator's view, and the poller's input. */
1333
+ export declare const pendingBudgetHolds: (store: DataStore, options?: {
1334
+ readonly budget?: string;
1335
+ }) => Promise<ReadonlyArray<{
1336
+ readonly id: string;
1337
+ readonly holdKey: string;
1338
+ readonly budget: string;
1339
+ readonly tenantId: string | null;
1340
+ readonly executionId: string;
1341
+ readonly workflowName: string;
1342
+ readonly stepName: string;
1343
+ readonly heldAt: string;
1344
+ }>>;
1345
+
1077
1346
  /** A pending row in domain shape. */
1078
1347
  export declare interface PendingIntent {
1079
1348
  readonly id: string;
1080
1349
  readonly tenantId: string | null;
1081
1350
  readonly workflowName: string;
1082
1351
  readonly controlKey: string;
1083
- readonly mode: 'debounce' | 'batch' | 'throttle' | 'concurrency' | 'paused';
1352
+ readonly mode: 'debounce' | 'batch' | 'throttle' | 'concurrency' | 'paused' | 'delayed';
1084
1353
  readonly payload: unknown;
1085
1354
  readonly callerContext: unknown;
1086
1355
  readonly priority: number;
@@ -1295,6 +1564,44 @@ export declare interface RecordedStep {
1295
1564
  durationMs: number | null;
1296
1565
  }
1297
1566
 
1567
+ /** The slice of a `_voltro_workflow_run_steps` row this needs. */
1568
+ export declare interface RecordedStepShape {
1569
+ readonly stepName: string;
1570
+ }
1571
+
1572
+ /**
1573
+ * Resume every run parked on `budget`.
1574
+ *
1575
+ * Requires the `WorkflowEngine` — run it through the same runner that executes
1576
+ * workflows (`workflowRuntime.runPromise`), exactly like `completeSuspendingSignal`.
1577
+ *
1578
+ * A release WAKES a run; it does not authorise a spend. The resumed body
1579
+ * re-reads the budget and parks again on a fresh generation if it is still over
1580
+ * — see `suspendForBudget`. So this is safe to call optimistically (on a
1581
+ * `recovered` signal, or from an operator's console) without knowing whether
1582
+ * every held run will actually proceed.
1583
+ *
1584
+ * The row is closed BEFORE the deferred is completed, deliberately: the resumed
1585
+ * body counts existing holds to derive its next generation, and a row still
1586
+ * reading `releasedAt: null` would make the re-park collide with the hold it was
1587
+ * just released from.
1588
+ */
1589
+ export declare const releaseBudgetHolds: (input: ReleaseBudgetHoldsInput) => Effect.Effect<{
1590
+ readonly released: number;
1591
+ }, never, WorkflowEngine.WorkflowEngine>;
1592
+
1593
+ export declare interface ReleaseBudgetHoldsInput {
1594
+ readonly store: DataStore;
1595
+ /** Which budget regained headroom. */
1596
+ readonly budget: string;
1597
+ /** Restrict to one tenant. Omitted → every tenant held on this budget (a
1598
+ * window rollover is per-tenant in the accountant but the counter key rotates
1599
+ * for everyone, so both shapes are legitimate). */
1600
+ readonly tenantId?: string | null | undefined;
1601
+ /** `released` = the budget itself recovered; `lifted` = an operator decided. */
1602
+ readonly outcome?: 'released' | 'lifted';
1603
+ }
1604
+
1298
1605
  /**
1299
1606
  * Free the slot an execution holds. Called when a run reaches a terminal state.
1300
1607
  *
@@ -1304,6 +1611,37 @@ export declare interface RecordedStep {
1304
1611
  */
1305
1612
  export declare const releaseLease: (store: AdmissionDataStore, executionId: string, now: number) => Promise<boolean>;
1306
1613
 
1614
+ export declare interface ReplayShapeTracker {
1615
+ /**
1616
+ * The body reached `stepName`. Returns a finding the FIRST time that reach
1617
+ * is anomalous, `undefined` otherwise — so a step in a hot loop reports once
1618
+ * rather than once per iteration.
1619
+ */
1620
+ readonly reach: (stepName: string) => NondeterminismFinding | undefined;
1621
+ /**
1622
+ * The body ran to a COMPLETE outcome. Returns the set-membership findings —
1623
+ * recorded names the body never reached.
1624
+ *
1625
+ * Call this ONLY on success/failure, never on a suspend: a suspended body
1626
+ * stopped partway through on purpose, so everything after the suspension
1627
+ * point is legitimately unreached.
1628
+ */
1629
+ readonly settle: () => ReadonlyArray<NondeterminismFinding>;
1630
+ }
1631
+
1632
+ /**
1633
+ * Emit findings from INSIDE the workflow body, where the run id and the
1634
+ * recorder are only reachable through the fiber context.
1635
+ *
1636
+ * Best-effort in the strongest sense: no recorder, no run id, or a rejecting
1637
+ * write all resolve to "nothing happened". A tripwire that can break the run it
1638
+ * is watching is not a tripwire.
1639
+ */
1640
+ export declare const reportNondeterminism: (findings: ReadonlyArray<NondeterminismFinding>) => Effect.Effect<void>;
1641
+
1642
+ /** Test seam: forget which (workflow, signal) pairs have been hinted. */
1643
+ export declare const resetSuspendHintsForTest: () => void;
1644
+
1307
1645
  /** Resolve every declared key against one payload. Pure; throws only
1308
1646
  * {@link FlowControlKeyError}. */
1309
1647
  export declare const resolveAdmissionKeys: (control: ResolvedFlowControl, payload: unknown) => AdmissionKeys;
@@ -1376,6 +1714,35 @@ export declare interface ResolvedFlowControl {
1376
1714
  readonly cancelOn?: ReadonlyArray<ResolvedCancelOn>;
1377
1715
  }
1378
1716
 
1717
+ /** The declaration with defaults filled in — what the CLI lowering reads. */
1718
+ export declare interface ResolvedWorkflowSchedule {
1719
+ readonly cron: string;
1720
+ readonly timezone: string;
1721
+ readonly payload?: unknown | ((fire: {
1722
+ readonly scheduledAt: Date;
1723
+ }) => unknown | Promise<unknown>);
1724
+ readonly onOverlap: WorkflowScheduleOverlap;
1725
+ readonly backfill: 'skip' | 'latest' | 'all';
1726
+ readonly maxRuntimeMs: number;
1727
+ }
1728
+
1729
+ /**
1730
+ * Read the run-guard knobs from the environment, for the boot paths to spread
1731
+ * into {@link wrapWorkflowExecuteWithRunRecording}. Mirrors
1732
+ * `resolveFailoverTuning` in `clusterLayer.ts` — same shape, same
1733
+ * malformed-value-is-ignored rule, so an operator learns one convention:
1734
+ *
1735
+ * - `VOLTRO_WORKFLOW_MAX_RECLAIMS` (→ `maxRunReclaims`, default 3)
1736
+ * - `VOLTRO_WORKFLOW_REPLAY_SHAPE_LIMIT` (→ `replayShapeLimit`, default 2000)
1737
+ */
1738
+ export declare const resolveRunGuardTuning: () => {
1739
+ maxRunReclaims?: number;
1740
+ replayShapeLimit?: number;
1741
+ };
1742
+
1743
+ /** Env (operator, no rebuild) → config (project) → default. */
1744
+ export declare const resolveSuspendHintMs: () => number;
1745
+
1379
1746
  export declare const resolveWorkflowMessageRun: (store: WorkflowMessageStore, target: WorkflowMessageTarget) => Promise<WorkflowResolvedRun>;
1380
1747
 
1381
1748
  export declare const resumeWorkflow: (store: AdmissionDataStore, workflowName: string) => Promise<boolean>;
@@ -1401,6 +1768,15 @@ export declare const sendWorkflowUpdate: (input: {
1401
1768
 
1402
1769
  export declare const serialiseWorkflowRowForWire: (row: Record<string, unknown>) => Record<string, unknown>;
1403
1770
 
1771
+ /**
1772
+ * Set the threshold from `app.config.ts` (`workflows.suspendSignalHintMs`).
1773
+ * Called by the framework boot (both paths, through `wireFlowControl`); the
1774
+ * env override below still wins, per the standing tunables rule.
1775
+ * Non-positive/non-finite values are ignored; `0` cannot silence the hint —
1776
+ * pass a very large threshold to effectively disable it.
1777
+ */
1778
+ export declare const setSuspendSignalHintMs: (ms: number | undefined) => void;
1779
+
1404
1780
  /**
1405
1781
  * Durable sleep — wake time journaled in the cluster. Wrapper around
1406
1782
  * `DurableClock.sleep` that emits `timer-set` / `timer-fired` events
@@ -1441,6 +1817,82 @@ export declare const sleepUntil: (options: {
1441
1817
  readonly until: Date | number;
1442
1818
  }) => Effect.Effect<void, never, never>;
1443
1819
 
1820
+ /** The narrow store slice this needs. Structural, like `CancelOnDataStore`. */
1821
+ export declare interface StalenessDataStore {
1822
+ query(descriptor: QueryDescriptor): Promise<ReadonlyArray<Row>>;
1823
+ }
1824
+
1825
+ export declare interface StalenessSweepDeps {
1826
+ readonly store: StalenessDataStore;
1827
+ /**
1828
+ * Record the `run-stalled` event. Wired to the run recorder's `recordEvent`.
1829
+ * Absent → detection without a durable trace (the handler still fires), which
1830
+ * is the right shape for a `voltro doctor` style one-shot check.
1831
+ */
1832
+ readonly recordEvent?: (input: {
1833
+ readonly runId: string;
1834
+ readonly eventType: 'run-stalled';
1835
+ readonly payload: Record<string, unknown>;
1836
+ }) => Promise<unknown>;
1837
+ /** App-supplied reaction — page someone, open a ticket. Must not throw;
1838
+ * a rejection is collected as a failure and the sweep continues. */
1839
+ readonly onStalled?: (run: StalledRun) => Promise<void>;
1840
+ readonly now?: () => number;
1841
+ readonly log?: {
1842
+ readonly info: (message: string, fields?: Record<string, unknown>) => void;
1843
+ readonly warn: (message: string, fields?: Record<string, unknown>) => void;
1844
+ };
1845
+ }
1846
+
1847
+ export declare interface StalenessSweepOptions {
1848
+ readonly stallAfterMs?: number;
1849
+ readonly runPage?: number;
1850
+ readonly eventLookback?: number;
1851
+ }
1852
+
1853
+ export declare interface StalenessSweepResult {
1854
+ /** Live runs old enough to be worth examining. */
1855
+ readonly examined: number;
1856
+ /** Newly reported this tick. */
1857
+ readonly stalled: ReadonlyArray<StalledRun>;
1858
+ /** Stalled, but already reported since their last progress — reported
1859
+ * separately rather than folded into `stalled`, so a caller can tell "the
1860
+ * wedge persists" from "nothing is stuck". */
1861
+ readonly alreadyReported: number;
1862
+ /** Excluded because they are inside a durable timer that has not come due. */
1863
+ readonly waitingOnTimer: number;
1864
+ /** The candidate page filled up: more live old runs exist and the next tick
1865
+ * will reach them. Reported rather than inferred — the counts alone cannot
1866
+ * distinguish it from "nothing else was old". */
1867
+ readonly sawFullRunPage: boolean;
1868
+ readonly failures: ReadonlyArray<{
1869
+ readonly subject: string;
1870
+ readonly detail: string;
1871
+ }>;
1872
+ }
1873
+
1874
+ export declare interface StalledRun {
1875
+ readonly runId: string;
1876
+ readonly tag: string;
1877
+ readonly executionId: string;
1878
+ readonly status: string;
1879
+ readonly lastProgressAt: Date;
1880
+ readonly idleMs: number;
1881
+ readonly reason: StallReason;
1882
+ }
1883
+
1884
+ /** Why the sweep believes this run is not moving. Diagnostic, not a taxonomy
1885
+ * the caller should branch on for correctness. */
1886
+ export declare type StallReason =
1887
+ /** Suspended with a `signal-awaited` outstanding — the classic wedge: the
1888
+ * sender never came. */
1889
+ 'awaiting-signal'
1890
+ /** Suspended, but not on a signal and not on a timer. */
1891
+ | 'suspended'
1892
+ /** Status `running` with no step or event movement — a step that never
1893
+ * returns, or a runner that vanished without the reclaim path noticing. */
1894
+ | 'no-progress';
1895
+
1444
1896
  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>>;
1445
1897
 
1446
1898
  /**
@@ -1543,6 +1995,72 @@ export declare interface StepRetryPolicy {
1543
1995
  readonly note?: string;
1544
1996
  }
1545
1997
 
1998
+ /**
1999
+ * Park the run until the budget has headroom.
2000
+ *
2001
+ * Returns (successfully) only when `hasHeadroom` answers true — so the caller
2002
+ * can spend immediately afterwards without a second check of its own. Fails with
2003
+ * `BudgetHoldExpired` once the total `timeoutMs` is used up, having spent
2004
+ * nothing.
2005
+ *
2006
+ * ── Why it is a LOOP, and why the loop is not a spin ─────────────────────────
2007
+ *
2008
+ * Two independent things can end one park: an external release, and the
2009
+ * recheck clock. Neither proves there is headroom (a window can roll over for a
2010
+ * tenant that immediately spends again; an operator can lift the wrong hold), so
2011
+ * each wake re-reads and — if still over — parks AGAIN, on a FRESH generation.
2012
+ *
2013
+ * It is not a spin because every iteration is a real `Workflow.suspend`: the
2014
+ * fiber is released, the wake is a `DurableClock`, and the minimum time between
2015
+ * two iterations is `recheckEveryMs`. The generation is what makes that true —
2016
+ * re-parking on the SAME key would await a deferred the previous release had
2017
+ * already completed, return instantly, and turn this loop into exactly the
2018
+ * busy-wait it looks like.
2019
+ *
2020
+ * The deadline is evaluated against the WALL CLOCK at each wake, from the FIRST
2021
+ * hold's durable `heldAt` rather than a captured `Date.now()`. A replay reading a
2022
+ * later clock therefore gives up sooner-or-equal, never later — which is the
2023
+ * safe direction for a control whose purpose is to stop spending.
2024
+ */
2025
+ export declare const suspendForBudget: (options: SuspendForBudgetOptions) => Effect.Effect<void, BudgetHoldExpired>;
2026
+
2027
+ export declare interface SuspendForBudgetOptions {
2028
+ readonly store: DataStore;
2029
+ readonly budget: string;
2030
+ readonly tenantId: string | null;
2031
+ readonly executionId: string;
2032
+ readonly workflowName: string;
2033
+ readonly stepName: string;
2034
+ readonly spentMicroUsd?: number | undefined;
2035
+ readonly limitMicroUsd?: number | undefined;
2036
+ /** Total time the run may stay held across ALL parks. Default 7 days;
2037
+ * `Number.POSITIVE_INFINITY` waits forever (a legitimate choice for a
2038
+ * monthly cap). */
2039
+ readonly timeoutMs?: number | undefined;
2040
+ /** Self-recheck cadence. Default 15 minutes. */
2041
+ readonly recheckEveryMs?: number | undefined;
2042
+ /**
2043
+ * Does the budget have headroom NOW?
2044
+ *
2045
+ * Re-read on every wake, and it is the only thing that ends the hold. A
2046
+ * release — from an operator, or from the finops accountant's `recovered`
2047
+ * signal — WAKES the run; it does not authorise the spend. Those are different
2048
+ * facts, and conflating them is how a run resumes into a budget that is still
2049
+ * exceeded and spends anyway.
2050
+ */
2051
+ readonly hasHeadroom: Effect.Effect<boolean>;
2052
+ }
2053
+
2054
+ export declare interface SuspendHintInput {
2055
+ /** Undefined outside an engine context — the hint then keys on the signal
2056
+ * name alone, which still fires once rather than never. */
2057
+ readonly workflowName: string | undefined;
2058
+ readonly signalName: string;
2059
+ /** The wait's DECLARED timeout, ms. */
2060
+ readonly timeoutMs: number;
2061
+ readonly warn?: (message: string) => void;
2062
+ }
2063
+
1546
2064
  /**
1547
2065
  * The stable `DurableDeferred` name for a suspending signal wait. It is a pure
1548
2066
  * function of the workflow name + the signal name, so the awaiting body and an
@@ -1562,6 +2080,15 @@ export declare const suspendingSignalDeferredName: (workflowName: string, signal
1562
2080
  */
1563
2081
  export declare const sweepCancelOn: (deps: CancelOnSweepDeps, options: CancelOnSweepOptions) => Promise<CancelOnSweepResult>;
1564
2082
 
2083
+ /**
2084
+ * One staleness tick. Never throws.
2085
+ *
2086
+ * Same contract as `sweepCancelOn`: one malformed row must not take the whole
2087
+ * scheduled tick down and stop detection for every workflow until somebody
2088
+ * notices. Failures are collected and reported.
2089
+ */
2090
+ export declare const sweepStalledRuns: (deps: StalenessSweepDeps, options?: StalenessSweepOptions) => Promise<StalenessSweepResult>;
2091
+
1565
2092
  export declare const truncateWorkflowValue: (value: unknown, bytes?: number) => unknown;
1566
2093
 
1567
2094
  /**
@@ -1581,6 +2108,22 @@ export declare interface ValidateFlowControlInput {
1581
2108
  readonly control: (WorkflowFlowControl<never> & WorkflowCancelOnDeclared) | undefined;
1582
2109
  }
1583
2110
 
2111
+ /**
2112
+ * Validate at DEFINITION time — module load, i.e. boot — with a message naming
2113
+ * the workflow and the field. Same posture as `validateFlowControl` and
2114
+ * `defineSchedule`: a cron typo that surfaces as "never fired" three days in
2115
+ * is the failure class this repo keeps paying for.
2116
+ */
2117
+ export declare const validateWorkflowSchedule: (workflowName: string, declaration: WorkflowScheduleDeclaration<never> | undefined) => ResolvedWorkflowSchedule | undefined;
2118
+
2119
+ /**
2120
+ * One parked run, waiting on budget headroom.
2121
+ *
2122
+ * Reactive so a dashboard can show "3 runs held on `ai-usd`" without polling —
2123
+ * which is the operator's entry point to lifting one.
2124
+ */
2125
+ export declare const _voltroBudgetHoldsTable: TableLike;
2126
+
1584
2127
  /**
1585
2128
  * `_voltro_workflow_admissions` — append-only ledger, and the lease register.
1586
2129
  *
@@ -1904,6 +2447,37 @@ export declare interface WorkflowExecuteRecordingOptions extends WorkflowRunReco
1904
2447
  readonly runId?: string;
1905
2448
  readonly status: 'succeeded' | 'failed';
1906
2449
  }) => Promise<void>;
2450
+ /**
2451
+ * CRASH-LOOP BREAKER — how many CONSECUTIVE runner deaths this run may cost
2452
+ * the fleet before it is parked instead of reclaimed again.
2453
+ *
2454
+ * The failure it bounds has no error to catch: a step that kills the process
2455
+ * (OOM, a native crash, a `process.exit` in a dependency) leaves the shard
2456
+ * lease to age out, a survivor claims it, and executes the same payload —
2457
+ * forever, across every replica in turn. Poison handling exists at ADMISSION
2458
+ * (`admissionTables.ts`), which is the wrong side of the boundary: the run
2459
+ * was admitted long ago and dies on the way out.
2460
+ *
2461
+ * At the cap the run becomes `suspended` with a `run-crashlooped` event, the
2462
+ * body is NOT entered, and the fleet stops rotating. An operator resume
2463
+ * re-arms it — and any clean re-entry (a durable sleep waking, a redrive)
2464
+ * resets the counter to 0, so this measures a loop, not a lifetime.
2465
+ *
2466
+ * Default {@link DEFAULT_MAX_RUN_RECLAIMS}; `VOLTRO_WORKFLOW_MAX_RECLAIMS`
2467
+ * overrides it via {@link resolveRunGuardTuning}.
2468
+ */
2469
+ readonly maxRunReclaims?: number;
2470
+ /**
2471
+ * Step rows read to build the replay-shape snapshot for the nondeterminism
2472
+ * tripwire. A run with more recorded steps than this SKIPS the tripwire
2473
+ * entirely rather than comparing against a truncated history — a partial
2474
+ * prior shape would report the steps it failed to load as `unreached-step`,
2475
+ * which is a false positive manufactured by the bound itself.
2476
+ *
2477
+ * Default {@link DEFAULT_REPLAY_SHAPE_LIMIT};
2478
+ * `VOLTRO_WORKFLOW_REPLAY_SHAPE_LIMIT` overrides it.
2479
+ */
2480
+ readonly replayShapeLimit?: number;
1907
2481
  }
1908
2482
 
1909
2483
  /**
@@ -2051,6 +2625,12 @@ declare type WorkflowFn = <const Name extends string, Payload extends WorkflowPa
2051
2625
  * type comes from its OWN `schema` — see {@link WorkflowCancelOnList} for
2052
2626
  * why that needs a tuple type parameter rather than a plain array. */
2053
2627
  readonly cancelOn?: WorkflowCancelOnList<Cancels, DecodedPayload<Payload>>;
2628
+ /** Run this workflow on a cron — the workflow-side spelling of a
2629
+ * `defineSchedule({ workflow })` target, with Temporal's overlap
2630
+ * vocabulary (`skip` / `buffer` / `cancelOther`). Lowered by the CLI into
2631
+ * a real schedule named `workflow:<name>`; see
2632
+ * {@link WorkflowScheduleDeclaration}. */
2633
+ readonly schedule?: WorkflowScheduleDeclaration<DecodedPayload<Payload>>;
2054
2634
  } & Omit<WorkflowBaseOptions, 'name' | 'payload' | 'idempotencyKey' | 'success' | 'error'> & WorkflowVersionOptions & {
2055
2635
  readonly messages?: Messages;
2056
2636
  } & WorkflowFlowControl<DecodedPayload<Payload>>) => workflowModule.Workflow<Name, PayloadSchemaOf<Payload>, Success, Error> & WorkflowMessagesCarrier<NormaliseWorkflowMessages<Messages>>;
@@ -2066,16 +2646,24 @@ export declare interface WorkflowMessagesCarrier<M extends WorkflowMessagesMetad
2066
2646
  readonly [WorkflowMessagesProperty]: M;
2067
2647
  }
2068
2648
 
2649
+ /**
2650
+ * The message channels a workflow declares.
2651
+ *
2652
+ * TWO, not three. `queries` used to sit here as a third channel and there was
2653
+ * never a send path for it — no `sendWorkflowQuery`, no `awaitQuery`, nothing
2654
+ * to receive one. It was normalised into metadata, projected into the generated
2655
+ * rpcGroup and carried on the client's `WorkflowState`, so a user got a fully
2656
+ * typed record they could not invoke from anywhere. Signals (fire-and-forget)
2657
+ * and updates (synchronous, with a result) are the channels that exist.
2658
+ */
2069
2659
  export declare interface WorkflowMessageSchemas {
2070
2660
  readonly signals?: Readonly<Record<string, Schema.Schema.Any>>;
2071
2661
  readonly updates?: Readonly<Record<string, WorkflowMessagePairSchemas>>;
2072
- readonly queries?: Readonly<Record<string, WorkflowMessagePairSchemas>>;
2073
2662
  }
2074
2663
 
2075
2664
  export declare interface WorkflowMessagesMetadata {
2076
2665
  readonly signals: Readonly<Record<string, Schema.Schema.Any>>;
2077
2666
  readonly updates: Readonly<Record<string, WorkflowMessagePairSchemas>>;
2078
- readonly queries: Readonly<Record<string, WorkflowMessagePairSchemas>>;
2079
2667
  }
2080
2668
 
2081
2669
  export declare const WorkflowMessagesProperty = "__voltroWorkflowMessages";
@@ -2224,7 +2812,23 @@ export declare type WorkflowRunEventType = 'run-started' | 'run-suspended' | 'ru
2224
2812
  /** Emitted by `awaitUpdate(...)` when schema validation or the
2225
2813
  * update handler fails. `payload` carries `{ updateId, updateName,
2226
2814
  * errorTag, errorMessage }`; the caller's update promise rejects. */
2227
- | 'update-failed';
2815
+ | 'update-failed'
2816
+ /** The replay tripwire fired: this run is re-executing against a body whose
2817
+ * step shape no longer matches what the run journaled. `payload` carries
2818
+ * `{ kind, stepName, recorded, reached, detail }` — see
2819
+ * `nondeterminism.ts`. NEVER accompanied by a state change: the run keeps
2820
+ * going, because a false positive that killed a run would be worse than the
2821
+ * divergence it suspects. */
2822
+ | 'nondeterminism-suspected'
2823
+ /** The crash-loop breaker parked this run: its runner died mid-body
2824
+ * `maxRunReclaims` times in a row and a survivor reclaimed it each time.
2825
+ * `payload` carries `{ reclaimCount, maxRunReclaims }`. The run row is
2826
+ * `suspended` — an operator resume re-arms it with a fresh budget. */
2827
+ | 'run-crashlooped'
2828
+ /** The staleness sweep saw no progress on a live run for longer than the
2829
+ * configured threshold. `payload` carries `{ idleMs, stallAfterMs,
2830
+ * lastProgressAt, reason }`. Diagnostic only — the run is untouched. */
2831
+ | 'run-stalled';
2228
2832
 
2229
2833
  /** A workflow run's TERMINAL outcome, handed to the metrics hook. Kept local to
2230
2834
  * this package (structurally matching `@voltro/runtime`'s `WorkflowRunRecord`) so
@@ -2277,6 +2881,23 @@ export declare interface WorkflowRunRecorderOptions {
2277
2881
  * rather than as garbage — see `inspectWorkflow`.
2278
2882
  */
2279
2883
  readonly encryptStepPayload?: (value: unknown) => unknown;
2884
+ /**
2885
+ * How much this recorder writes per STEP — `'full'` (default) or `'coarse'`.
2886
+ *
2887
+ * `'coarse'` makes `startStep` answer "not recording this step" (the
2888
+ * contract callers already handle) and `endStep*` no-ops, removing the two
2889
+ * fire-and-forget store writes every step otherwise costs. Run rows and
2890
+ * `recordEvent` (signals, timers, cancels, stall reports) are DELIBERATELY
2891
+ * unaffected: they are per-run, not per-step, and they are what the
2892
+ * dead-letter view and the cancel/staleness sweeps read. The engine's own
2893
+ * durable journal is not touched either way — this is the introspection
2894
+ * copy, never replay state.
2895
+ *
2896
+ * App-facing spelling: `workflows.recording` in `app.config.ts`, env
2897
+ * override `VOLTRO_WORKFLOW_RECORDING` (resolved by the CLI, threaded to
2898
+ * BOTH boot paths through one resolver).
2899
+ */
2900
+ readonly recording?: 'full' | 'coarse';
2280
2901
  }
2281
2902
 
2282
2903
  /** Per-step lifecycle hooks. Implementations should not throw —
@@ -2349,6 +2970,68 @@ export declare interface WorkflowRunRecorderService {
2349
2970
  }>;
2350
2971
  }
2351
2972
 
2973
+ export declare interface WorkflowScheduleDeclaration<Payload> {
2974
+ /** Standard cron expression — 5-field or 6-field (leading seconds). */
2975
+ readonly cron: string;
2976
+ /** IANA timezone the expression is interpreted in. REQUIRED, like
2977
+ * `defineSchedule` — server-local time in a container is a bug factory. */
2978
+ readonly timezone: string;
2979
+ /**
2980
+ * The payload each firing starts the workflow with. A value, or a function
2981
+ * of the firing (`({ scheduledAt }) => …`) for payloads that carry the slot
2982
+ * — a backfilled firing then computes against ITS instant, not "now".
2983
+ * Omitted ⇒ `{}`, which only typechecks for workflows whose payload has no
2984
+ * required fields; a mismatch fails the start with `WorkflowPayloadError`
2985
+ * naming the missing fields, same as any other start.
2986
+ */
2987
+ readonly payload?: Payload | ((fire: {
2988
+ readonly scheduledAt: Date;
2989
+ }) => Payload | Promise<Payload>);
2990
+ /** Default `'skip'` — Temporal's default too, and the only answer that is
2991
+ * safe for every job shape. */
2992
+ readonly onOverlap?: WorkflowScheduleOverlap;
2993
+ /** Boot catch-up policy for firings missed during downtime — the scheduler's
2994
+ * own vocabulary, unchanged. Default `'skip'`. For a RANGE older than boot
2995
+ * backfill reaches, use `voltro schedule backfill`. */
2996
+ readonly backfill?: 'skip' | 'latest' | 'all';
2997
+ /**
2998
+ * Watchdog on ONE firing — which, for a workflow schedule, INCLUDES the
2999
+ * awaited run (that await is what makes `onOverlap` bind on the run's
3000
+ * duration). Default 24 hours rather than `defineSchedule`'s 30 minutes,
3001
+ * because a durable run legitimately outlives a handler; set it above your
3002
+ * slowest expected run. Past it the FIRING records `failed`/timeout and
3003
+ * releases the overlap guard — the workflow run itself is NOT cancelled
3004
+ * (declare flow-control `timeouts.finish` to bound the run).
3005
+ */
3006
+ readonly maxRuntime?: FlowDuration;
3007
+ }
3008
+
3009
+ /**
3010
+ * What happens when a firing arrives while the RUN from the previous firing is
3011
+ * still going — Temporal Schedules' overlap vocabulary, applied to the
3012
+ * workflow run (not merely to the handler invocation):
3013
+ *
3014
+ * `'skip'` — the new firing stands down; recorded as `skipped`.
3015
+ * `'buffer'` — the new firing waits and runs after the previous one
3016
+ * finishes; firings serialize, none is lost.
3017
+ * `'cancelOther'` — the new firing CANCELS the still-running previous run
3018
+ * and starts fresh — for "recompute the latest state"
3019
+ * jobs where the old run's partial work is worthless.
3020
+ *
3021
+ * The first two lower into the scheduler's own `onOverlap: 'skip' | 'queue'`;
3022
+ * what makes them bind on the RUN's duration is that the synthesised handler
3023
+ * AWAITS the workflow run to completion, so the schedule-run row is `running`
3024
+ * for exactly as long as the workflow is.
3025
+ */
3026
+ export declare type WorkflowScheduleOverlap = 'skip' | 'buffer' | 'cancelOther';
3027
+
3028
+ /** Attached via `defineProperty` like the flow-control carrier — the value's
3029
+ * TYPE does not know about it, and the web bundle never reads it. A STRING
3030
+ * property rather than a symbol for the same reason as
3031
+ * `WorkflowFlowControlProperty`: the CLI's discovery reads it off a module
3032
+ * that crossed a bundler boundary, and symbols do not survive every one. */
3033
+ export declare const WorkflowScheduleProperty = "__voltroWorkflowSchedule";
3034
+
2352
3035
  /**
2353
3036
  * At most one run per key. The newcomer either stands down or evicts.
2354
3037
  *
@@ -2461,6 +3144,17 @@ export declare interface WorkflowVersionMetadata {
2461
3144
  export declare interface WorkflowVersionOptions {
2462
3145
  readonly version?: string | number;
2463
3146
  readonly compatibleWith?: ReadonlyArray<string | number>;
3147
+ /**
3148
+ * Named change markers this workflow's body may branch on, via
3149
+ * {@link patch}. Declaring one here is what makes `yield* patch('id')`
3150
+ * answer `true` for runs started from now on and `false` for runs that were
3151
+ * already in flight — see {@link patch} for the semantics and the worked
3152
+ * example.
3153
+ *
3154
+ * The declared set is stamped onto `_voltro_workflow_runs.workflowPatches`
3155
+ * at run start and read back from THERE on every resume, so the answer is
3156
+ * pinned to the run and cannot change under a redeploy.
3157
+ */
2464
3158
  readonly patches?: ReadonlyArray<string>;
2465
3159
  /**
2466
3160
  * When `true`, a workflow whose top-level body FAILS does not become a