@voltro/workflow 0.25.0 → 0.27.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.
@@ -0,0 +1,131 @@
1
+ import { Context as e, Duration as t, Effect as n, Layer as r, Option as i } from "effect";
2
+ import { ClusterCron as a, ClusterSchema as o, ClusterWorkflowEngine as s, EntityAddress as c, EntityId as l, EntityType as u, MessageStorage as d, RunnerAddress as f, Sharding as p, SingleRunner as m } from "@effect/cluster";
3
+ import { SqlClient as h } from "@effect/sql";
4
+ //#region src/clusterLayer.ts
5
+ var g = 34e3, _ = (e) => {
6
+ let t = e.runnerListenHost ?? process.env.POD_IP ?? process.env.VOLTRO_WORKFLOW_RUNNER_HOST ?? "localhost";
7
+ return {
8
+ host: t,
9
+ port: e.runnerListenPort ?? 34e3,
10
+ localhostRisk: e.runnerStorage === "sql" && (t === "localhost" || t === "127.0.0.1")
11
+ };
12
+ }, v = (e) => {
13
+ let t = e.sqliteFamily ? "memory" : "sql", n = e.override ?? process.env.VOLTRO_WORKFLOW_RUNNER_STORAGE;
14
+ if (n === void 0 || n.trim() === "") return t;
15
+ let r = n.trim().toLowerCase();
16
+ if (r !== "memory" && r !== "sql") throw Error(`VOLTRO_WORKFLOW_RUNNER_STORAGE must be 'memory' or 'sql', got '${n}'.`);
17
+ if (r === "sql" && e.sqliteFamily) throw Error("VOLTRO_WORKFLOW_RUNNER_STORAGE='sql' is unsupported on sqlite / turso — @effect/cluster has no sqlite advisory-lock branch. Use 'memory'.");
18
+ return r;
19
+ }, y = (e = process.env.VOLTRO_WORKFLOW_SHARD_LOCK) => {
20
+ if (e === void 0 || e.trim() === "") return "auto";
21
+ let t = e.trim().toLowerCase();
22
+ if (t === "auto" || t === "row" || t === "advisory") return t;
23
+ throw Error(`VOLTRO_WORKFLOW_SHARD_LOCK must be 'auto', 'row', or 'advisory', got '${e}'.`);
24
+ }, b = n.gen(function* () {
25
+ let e = yield* (yield* h.SqlClient)`SELECT @@wsrep_on AS wsrep`.pipe(n.catchAll(() => n.succeed([])));
26
+ if (e.length === 0) return !1;
27
+ let t = e[0].wsrep;
28
+ return t === 1 || t === "1" || t === !0 || t === "ON" || t === "on";
29
+ }), x = (e) => n.gen(function* () {
30
+ return e.runnerStorage === "memory" ? !1 : e.mode === "row" ? !0 : e.mode === "advisory" || e.dialectId !== "mysql" && e.dialectId !== "mariadb" ? !1 : yield* b;
31
+ }), S = () => {
32
+ let e = (e) => {
33
+ if (e === void 0) return;
34
+ let t = Number(e);
35
+ return Number.isFinite(t) && t > 0 ? t : void 0;
36
+ }, t = e(process.env.VOLTRO_WORKFLOW_FAILOVER_LEASE), n = e(process.env.VOLTRO_WORKFLOW_FAILOVER_HEARTBEAT), r = e(process.env.VOLTRO_WORKFLOW_POLL_INTERVAL);
37
+ return {
38
+ ...t === void 0 ? {} : { failoverLeaseSeconds: t },
39
+ ...n === void 0 ? {} : { failoverHeartbeatSeconds: n },
40
+ ...r === void 0 ? {} : { messagePollSeconds: r }
41
+ };
42
+ }, C = n.flatMap(p.Sharding, (e) => e.pollStorage), w = /* @__PURE__ */ new Set(), T = (e) => {
43
+ let a = _({
44
+ runnerStorage: e.runnerStorage,
45
+ runnerListenHost: e.runnerListenHost,
46
+ runnerListenPort: e.runnerListenPort
47
+ }), o = a.host, c = a.port;
48
+ a.localhostRisk && !w.has(o) && (w.add(o), process.stderr.write(`[voltro:workflow] runner host is '${o}' with SQL cluster storage — other pods cannot reach this runner, so a workflow cannot resume on another pod after a reschedule. Inject POD_IP via the K8s downward API (fieldRef: status.podIP) or set runnerListenHost / VOLTRO_WORKFLOW_RUNNER_HOST.
49
+ `));
50
+ let l = e.shardLockMode ?? y(), u = r.unwrapEffect(x({
51
+ dialectId: e.dialectId,
52
+ runnerStorage: e.runnerStorage,
53
+ mode: l
54
+ }).pipe(n.map((n) => (e.runnerStorage === "sql" && process.stderr.write(`[voltro:workflow] shard-lock coordination: ${n ? "row-based" : "advisory"} (dialect=${e.dialectId ?? "unknown"}, mode=${l}` + (n && l === "auto" ? ", wsrep/Galera cluster detected" : "") + ")\n"), m.layer({
55
+ runnerStorage: e.runnerStorage,
56
+ shardingConfig: {
57
+ runnerAddress: i.some(f.make(o, c)),
58
+ runnerListenAddress: i.some(f.make(o, c)),
59
+ ...n ? { shardLockDisableAdvisory: !0 } : {},
60
+ ...e.failoverLeaseSeconds === void 0 ? {} : { shardLockExpiration: t.seconds(e.failoverLeaseSeconds) },
61
+ ...e.failoverHeartbeatSeconds === void 0 ? {} : { shardLockRefreshInterval: t.seconds(e.failoverHeartbeatSeconds) },
62
+ ...e.messagePollSeconds === void 0 ? {} : { entityMessagePollInterval: t.seconds(e.messagePollSeconds) }
63
+ }
64
+ }))))).pipe(r.provide(e.sqlClientLayer)), d = e.extraShardingLayers ?? [];
65
+ return (d.length === 0 ? s.layer : r.mergeAll(s.layer, ...d)).pipe(r.provideMerge(u));
66
+ }, E = (e) => a.make({
67
+ name: e.name,
68
+ cron: e.cron,
69
+ execute: n.promise(() => e.execute()),
70
+ skipIfOlderThan: e.skipIfOlderThan ?? t.days(1)
71
+ }), D = (e, t) => `${e}/${t}`, O = (t, n, r) => {
72
+ let i = l.make(n), a = e.get(t.annotations, o.ShardGroup)(i);
73
+ return new c.EntityAddress({
74
+ entityType: u.EntityType.make(`Workflow/${t.name}`),
75
+ entityId: i,
76
+ shardId: r.getShardId(i, a)
77
+ });
78
+ }, k = (e) => {
79
+ let t = (e) => e && typeof e == "object" && "_tag" in e ? String(e._tag) : void 0, n = t(e);
80
+ if (n === "Failure" || n !== "Success") return "failed";
81
+ let r = e.value, i = t(r);
82
+ if (i === "Suspended") return "suspended";
83
+ if (i === "Complete") {
84
+ let e = r.exit;
85
+ return t(e) === "Success" ? "succeeded" : "failed";
86
+ }
87
+ return "failed";
88
+ }, A = (e) => n.gen(function* () {
89
+ let t = yield* p.Sharding, r = yield* d.MessageStorage, a = O(e.workflow, e.executionId, t), o = yield* r.requestIdForPrimaryKey({
90
+ address: a,
91
+ tag: "run",
92
+ id: ""
93
+ }).pipe(n.orElseSucceed(() => i.none()));
94
+ if (i.isNone(o)) return {
95
+ redriven: !1,
96
+ runReplyCleared: !1,
97
+ activitiesReset: 0,
98
+ reason: "no-journal"
99
+ };
100
+ let s = (yield* r.repliesForUnfiltered([o.value]).pipe(n.orElseSucceed(() => []))).filter((e) => e._tag === "WithExit").at(-1);
101
+ if (s === void 0) return {
102
+ redriven: !1,
103
+ runReplyCleared: !1,
104
+ activitiesReset: 0,
105
+ reason: "no-reply"
106
+ };
107
+ if (k(s.exit) === "succeeded") return {
108
+ redriven: !1,
109
+ runReplyCleared: !1,
110
+ activitiesReset: 0,
111
+ reason: "succeeded"
112
+ };
113
+ let c = 0;
114
+ for (let o of e.failedActivities) {
115
+ let e = yield* r.requestIdForPrimaryKey({
116
+ address: a,
117
+ tag: "activity",
118
+ id: D(o.name, o.attempt)
119
+ }).pipe(n.orElseSucceed(() => i.none()));
120
+ i.isSome(e) && (yield* t.reset(e.value)) && (c += 1);
121
+ }
122
+ let l = yield* t.reset(o.value);
123
+ return yield* t.pollStorage, {
124
+ redriven: !0,
125
+ runReplyCleared: l,
126
+ activitiesReset: c,
127
+ reason: null
128
+ };
129
+ });
130
+ //#endregion
131
+ export { b as a, S as c, x as d, T as f, g as i, _ as l, A as n, y as o, E as r, C as s, D as t, v as u };
package/dist/cluster.d.ts CHANGED
@@ -3,11 +3,22 @@ import { Cron } from 'effect';
3
3
  import { Duration } from 'effect';
4
4
  import { Effect } from 'effect';
5
5
  import { Layer } from 'effect';
6
+ import { MessageStorage } from '@effect/cluster';
6
7
  import { Sharding } from '@effect/cluster';
7
8
  import { SqlClient } from '@effect/sql';
8
9
  import { SqlError } from '@effect/sql';
10
+ import { Workflow } from '@effect/workflow';
9
11
  import { WorkflowEngine } from '@effect/workflow';
10
12
 
13
+ /**
14
+ * The cluster's own activity primary-key format — an activity RPC's
15
+ * `primaryKey` is `` `${name}/${attempt}` `` (`ClusterWorkflowEngine`'s
16
+ * `activityPrimaryKey`). Mirrored here so we can address a specific
17
+ * failed attempt's stored reply; the contract test pins this against the
18
+ * real engine so a format change is caught, not silently missed.
19
+ */
20
+ export declare const activityPrimaryKey: (activityName: string, attempt: number) => string;
21
+
11
22
  export declare interface ClusterCronSpec {
12
23
  /** Stable schedule name — used as the cluster cron's identity. */
13
24
  readonly name: string;
@@ -34,6 +45,12 @@ export declare const DEFAULT_RUNNER_LISTEN_PORT: 34000;
34
45
  * somehow can't be probed can still force `VOLTRO_WORKFLOW_SHARD_LOCK=row`. */
35
46
  export declare const detectWsrepCluster: Effect.Effect<boolean, never, SqlClient.SqlClient>;
36
47
 
48
+ /** A step attempt to reset so it re-executes on the next drive. */
49
+ export declare interface FailedActivityRef {
50
+ readonly name: string;
51
+ readonly attempt: number;
52
+ }
53
+
37
54
  /**
38
55
  * Build the cluster-cron layer for one schedule. Returns a
39
56
  * `Layer<never, never, Sharding>` — the framework provides `Sharding`
@@ -47,6 +64,94 @@ export declare const makeClusterCronLayer: (spec: ClusterCronSpec) => Layer.Laye
47
64
  * the advisory path that breaks cross-pod handoff on Galera. */
48
65
  export declare const parseShardLockMode: (raw?: string | undefined) => ShardLockMode;
49
66
 
67
+ /**
68
+ * Re-poll the cluster's durable storage NOW — the effect behind a cross-replica
69
+ * "wake". Run it on the workflow runtime (`workflowRuntime.runPromise`), which
70
+ * carries `Sharding`; it opens the storage-read latch so a message just enqueued
71
+ * by another replica is picked up immediately instead of on the next poll tick.
72
+ * Lives here so a boot path doesn't import `@effect/cluster` (and its ~420ms
73
+ * load) just to reach the `Sharding` tag.
74
+ */
75
+ export declare const pollWorkflowStorage: Effect.Effect<void, never, Sharding.Sharding>;
76
+
77
+ /**
78
+ * Re-drive a terminally-`failed` workflow run from the step it died on.
79
+ *
80
+ * Clears the failed step attempts' journaled replies (so they re-execute)
81
+ * and the terminal `run` reply, then re-polls cluster storage so the
82
+ * engine re-delivers the `run` request. Completed steps replay from the
83
+ * journal; only the cleared (failed) steps run again.
84
+ *
85
+ * Idempotent-safe to REFUSE: if the run has no journal, is still running,
86
+ * or already succeeded, it returns `redriven: false` with a `reason` and
87
+ * touches nothing.
88
+ *
89
+ * Requires the SAME cluster instance's `Sharding` + `MessageStorage` that
90
+ * the workflow engine was built from (see `workflowEngineLayer`, which
91
+ * re-exports both for exactly this).
92
+ */
93
+ export declare const redriveFailedRun: (target: RedriveTarget) => Effect.Effect<RedriveOutcome, never, Sharding.Sharding | MessageStorage.MessageStorage>;
94
+
95
+ export declare interface RedriveOutcome {
96
+ /** True iff the `run` reply was cleared and storage re-polled. */
97
+ readonly redriven: boolean;
98
+ /** True iff the terminal `run` reply was cleared. */
99
+ readonly runReplyCleared: boolean;
100
+ /** How many failed activity replies were cleared (so they re-execute). */
101
+ readonly activitiesReset: number;
102
+ /** Set when `redriven` is false. */
103
+ readonly reason: RedriveSkipReason | null;
104
+ }
105
+
106
+ /** Why a re-drive did nothing — surfaced so the caller reports honestly. */
107
+ export declare type RedriveSkipReason =
108
+ /** No `run` request found in the cluster journal — a memory engine, or
109
+ * a run that was never durably journaled. Nothing to re-drive. */
110
+ 'no-journal'
111
+ /** The `run` request exists but carries no terminal reply — the run is
112
+ * still processing. Re-driving would race the live fiber; refuse. */
113
+ | 'no-reply'
114
+ /** The stored reply is a SUCCESS — the run completed cleanly. Nothing
115
+ * to re-drive; refuse rather than resurrect a finished run. */
116
+ | 'succeeded';
117
+
118
+ export declare interface RedriveTarget {
119
+ /** The workflow definition — for its `name` (entity type) + `annotations` (shard group). */
120
+ readonly workflow: Workflow.Any;
121
+ /** The engine-assigned execution id (= cluster `entityId`) of the run to re-drive. */
122
+ readonly executionId: string;
123
+ /**
124
+ * Failed step attempts to reset so they re-execute fresh. Succeeded
125
+ * steps are intentionally NOT listed — their journaled replies stay,
126
+ * so they replay rather than re-run. Typically the `status='failed'`
127
+ * rows of `_voltro_workflow_run_steps` for this run.
128
+ */
129
+ readonly failedActivities: ReadonlyArray<FailedActivityRef>;
130
+ }
131
+
132
+ /**
133
+ * Read the failover-tuning knobs from the environment, for the boot paths to
134
+ * spread into {@link workflowEngineLayer}. Operators set:
135
+ *
136
+ * - `VOLTRO_WORKFLOW_FAILOVER_LEASE` (seconds → `failoverLeaseSeconds`) — how
137
+ * long after a crashed replica's last heartbeat its in-flight workflows
138
+ * become claimable by a survivor. Default 35. Lower = faster failover, at the
139
+ * cost of false-positive reclaims under a long GC / DB-latency stall.
140
+ * - `VOLTRO_WORKFLOW_FAILOVER_HEARTBEAT` (seconds → `failoverHeartbeatSeconds`)
141
+ * — the lease refresh cadence. Default 10. Keep it ≈ lease/3.
142
+ * - `VOLTRO_WORKFLOW_POLL_INTERVAL` (seconds → `messagePollSeconds`) — the
143
+ * NEW-message pickup latency for a workflow whose shard is owned by ANOTHER
144
+ * replica. Default 10. Lower for latency-sensitive multi-replica workloads,
145
+ * at the cost of more idle poll queries. No effect on a single replica.
146
+ *
147
+ * A malformed value is ignored (the default stands) rather than failing boot.
148
+ */
149
+ export declare const resolveFailoverTuning: () => {
150
+ failoverLeaseSeconds?: number;
151
+ failoverHeartbeatSeconds?: number;
152
+ messagePollSeconds?: number;
153
+ };
154
+
50
155
  /** Resolve the runner host/port the SAME way `workflowEngineLayer` does:
51
156
  * explicit option → `POD_IP` → `VOLTRO_WORKFLOW_RUNNER_HOST` → loopback;
52
157
  * port from option → `VOLTRO_WORKFLOW_RUNNER_PORT` → default. */
@@ -155,19 +260,7 @@ export declare type RunnerStorage = 'sql' | 'memory';
155
260
  */
156
261
  export declare type ShardLockMode = 'auto' | 'row' | 'advisory';
157
262
 
158
- /**
159
- * Compose the dialect's SqlClient → SingleRunner → ClusterWorkflowEngine
160
- * into a single layer that user code provides to its program to get
161
- * durable workflows.
162
- *
163
- * `extraShardingLayers` (e.g. `ClusterCron` layers from
164
- * `cluster`-coordinated schedules) are merged into the same runtime so
165
- * they share the runner identity + shard map.
166
- *
167
- * For dev / tests where no real engine is available, see
168
- * `inMemoryWorkflowEngineLayer`.
169
- */
170
- export declare const workflowEngineLayer: (options: WorkflowEngineLayerOptions) => Layer.Layer<WorkflowEngine.WorkflowEngine>;
263
+ export declare const workflowEngineLayer: (options: WorkflowEngineLayerOptions) => Layer.Layer<WorkflowEngine.WorkflowEngine | Sharding.Sharding | MessageStorage.MessageStorage>;
171
264
 
172
265
  export declare interface WorkflowEngineLayerOptions {
173
266
  /**
@@ -196,6 +289,40 @@ export declare interface WorkflowEngineLayerOptions {
196
289
  readonly shardLockMode?: ShardLockMode;
197
290
  readonly runnerListenHost?: string;
198
291
  readonly runnerListenPort?: number;
292
+ /**
293
+ * FAILOVER TUNING — the two knobs that govern how fast a SURVIVING replica
294
+ * takes over a CRASHED replica's in-flight workflows. Reclaim is a
295
+ * lease-expiry problem, NOT a polling one: a dead runner keeps its shards
296
+ * until its heartbeat goes stale, so the floor is the lease TTL.
297
+ *
298
+ * - `failoverLeaseSeconds` (default 35, `@effect/cluster`
299
+ * `shardLockExpiration`) — how long after a runner's last heartbeat its
300
+ * shards become claimable. Lower = faster failover on a hard crash, at the
301
+ * cost of false-positive reclaims if a healthy runner is paused longer than
302
+ * this by a GC pause or a DB-latency spike (two runners could then briefly
303
+ * both think they own a shard).
304
+ * - `failoverHeartbeatSeconds` (default 10, `shardLockRefreshInterval`) — how
305
+ * often a runner refreshes its lease + heartbeat. Keep it well below the
306
+ * lease (≈ lease/3) so a single slow refresh doesn't trip a false reclaim.
307
+ *
308
+ * Omit both for production defaults. A push wake (LISTEN/NOTIFY) does NOT help
309
+ * here — reclaim is time-bound, not notification-bound; only these two (or a
310
+ * K8s liveness probe) move the floor.
311
+ */
312
+ readonly failoverLeaseSeconds?: number;
313
+ readonly failoverHeartbeatSeconds?: number;
314
+ /**
315
+ * NEW-MESSAGE latency across replicas (default 10, `@effect/cluster`
316
+ * `entityMessagePollInterval`). DISTINCT from failover: this is how long a
317
+ * freshly-triggered workflow can wait before the replica that OWNS its shard
318
+ * picks it up. When the triggering replica owns the shard it's immediate
319
+ * (a same-process push); when ANOTHER replica owns it, Voltro's single-runner
320
+ * topology has no cross-runner push, so the owner sees it on its next poll —
321
+ * up to this interval. Lower it for latency-sensitive multi-replica workloads,
322
+ * at the cost of more idle poll queries. (The lower-load fix is a pg
323
+ * LISTEN/NOTIFY wake, not yet wired.) No effect on a single replica.
324
+ */
325
+ readonly messagePollSeconds?: number;
199
326
  /**
200
327
  * Extra layers that need the cluster's `Sharding` service — most
201
328
  * prominently `ClusterCron` layers for `cluster`-coordinated
package/dist/cluster.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as e, c as t, i as n, n as r, o as i, r as a, s as o, t as s } from "./cluster-DuFweKq0.js";
2
- export { r as DEFAULT_RUNNER_LISTEN_PORT, a as detectWsrepCluster, s as makeClusterCronLayer, n as parseShardLockMode, e as resolveRunnerIdentity, i as resolveRunnerStorage, o as resolveShardLockDisableAdvisory, t as workflowEngineLayer };
1
+ import { a as e, c as t, d as n, f as r, i, l as a, n as o, o as s, r as c, s as l, t as u, u as d } from "./cluster-BQ0H5M9n.js";
2
+ export { i as DEFAULT_RUNNER_LISTEN_PORT, u as activityPrimaryKey, e as detectWsrepCluster, c as makeClusterCronLayer, s as parseShardLockMode, l as pollWorkflowStorage, o as redriveFailedRun, t as resolveFailoverTuning, a as resolveRunnerIdentity, d as resolveRunnerStorage, n as resolveShardLockDisableAdvisory, r as workflowEngineLayer };
@@ -116,6 +116,40 @@ declare interface WorkflowEngineLayerOptions {
116
116
  readonly shardLockMode?: ShardLockMode;
117
117
  readonly runnerListenHost?: string;
118
118
  readonly runnerListenPort?: number;
119
+ /**
120
+ * FAILOVER TUNING — the two knobs that govern how fast a SURVIVING replica
121
+ * takes over a CRASHED replica's in-flight workflows. Reclaim is a
122
+ * lease-expiry problem, NOT a polling one: a dead runner keeps its shards
123
+ * until its heartbeat goes stale, so the floor is the lease TTL.
124
+ *
125
+ * - `failoverLeaseSeconds` (default 35, `@effect/cluster`
126
+ * `shardLockExpiration`) — how long after a runner's last heartbeat its
127
+ * shards become claimable. Lower = faster failover on a hard crash, at the
128
+ * cost of false-positive reclaims if a healthy runner is paused longer than
129
+ * this by a GC pause or a DB-latency spike (two runners could then briefly
130
+ * both think they own a shard).
131
+ * - `failoverHeartbeatSeconds` (default 10, `shardLockRefreshInterval`) — how
132
+ * often a runner refreshes its lease + heartbeat. Keep it well below the
133
+ * lease (≈ lease/3) so a single slow refresh doesn't trip a false reclaim.
134
+ *
135
+ * Omit both for production defaults. A push wake (LISTEN/NOTIFY) does NOT help
136
+ * here — reclaim is time-bound, not notification-bound; only these two (or a
137
+ * K8s liveness probe) move the floor.
138
+ */
139
+ readonly failoverLeaseSeconds?: number;
140
+ readonly failoverHeartbeatSeconds?: number;
141
+ /**
142
+ * NEW-MESSAGE latency across replicas (default 10, `@effect/cluster`
143
+ * `entityMessagePollInterval`). DISTINCT from failover: this is how long a
144
+ * freshly-triggered workflow can wait before the replica that OWNS its shard
145
+ * picks it up. When the triggering replica owns the shard it's immediate
146
+ * (a same-process push); when ANOTHER replica owns it, Voltro's single-runner
147
+ * topology has no cross-runner push, so the owner sees it on its next poll —
148
+ * up to this interval. Lower it for latency-sensitive multi-replica workloads,
149
+ * at the cost of more idle poll queries. (The lower-load fix is a pg
150
+ * LISTEN/NOTIFY wake, not yet wired.) No effect on a single replica.
151
+ */
152
+ readonly messagePollSeconds?: number;
119
153
  /**
120
154
  * Extra layers that need the cluster's `Sharding` service — most
121
155
  * prominently `ClusterCron` layers for `cluster`-coordinated
@@ -1,6 +1,6 @@
1
- import { C as e, m as t, p as n, v as r, x as i } from "./primitives-CWy1iu5w.js";
2
- import { i as a } from "./src-DQww25rP.js";
3
- import { c as o, t as s } from "./cluster-DuFweKq0.js";
1
+ import { C as e, m as t, p as n, v as r, x as i } from "./primitives-Dgu3O55Q.js";
2
+ import { i as a } from "./src-KDx7NTsa.js";
3
+ import { f as o, r as s } from "./cluster-BQ0H5M9n.js";
4
4
  import { Cron as c, Deferred as l, Effect as u, Fiber as d, Layer as f, Schema as p } from "effect";
5
5
  import { SqlClient as m } from "@effect/sql";
6
6
  import { beforeAll as h, describe as g, expect as _, test as v } from "vitest";
@@ -33,14 +33,14 @@ var y = async (e, t, n = 750) => {
33
33
  for (let t of b) yield* e.unsafe(`DELETE FROM ${t}`).pipe(u.ignore);
34
34
  }).pipe(u.scoped, u.provide(e.sqlClientLayer)));
35
35
  } catch {}
36
- }, S = (e, t) => u.gen(function* () {
36
+ }, S = "10 seconds", C = "30 seconds", w = (e, t) => u.gen(function* () {
37
37
  let n = yield* u.sync(() => Date.now() + t);
38
38
  for (; !e();) {
39
39
  if (Date.now() > n) return !1;
40
40
  yield* u.sleep("250 millis");
41
41
  }
42
42
  return !0;
43
- }), C = (m) => {
43
+ }), T = (m) => {
44
44
  let y = `${m.name}-${Date.now()}`, b = (e, t) => o({
45
45
  sqlClientLayer: m.sqlClientLayer,
46
46
  runnerStorage: m.runnerStorage,
@@ -104,7 +104,9 @@ var y = async (e, t, n = 750) => {
104
104
  _(await u.runPromise(u.scoped(u.gen(function* () {
105
105
  return yield* f.build(b(m.runnerPort + 1, [n])), yield* l.await(e).pipe(u.timeout("25 seconds"), u.orElseSucceed(() => !1));
106
106
  })))).toBe(!0), _(t).toBeGreaterThanOrEqual(1);
107
- }, 35e3), v.runIf(m.clusterResume)("a sleeping workflow resumes on a fresh runner after the first is torn down", async () => {
107
+ }, 35e3);
108
+ let g = m.clusterResume ? v : (() => {});
109
+ g("a sleeping workflow resumes on a fresh runner after the first is torn down", async () => {
108
110
  if (!o) return;
109
111
  let e = `dormancyResume_${y}`, i = [], a = [], s = [], c = () => r({
110
112
  name: e,
@@ -126,7 +128,7 @@ var y = async (e, t, n = 750) => {
126
128
  })
127
129
  }), yield* n({
128
130
  name: "nap",
129
- duration: "30 seconds"
131
+ duration: S
130
132
  }), yield* t({
131
133
  name: "step2",
132
134
  success: p.Void,
@@ -142,13 +144,13 @@ var y = async (e, t, n = 750) => {
142
144
  };
143
145
  }, v = { id: `resume-${y}` }, x = await u.runPromise(u.scoped(u.gen(function* () {
144
146
  let { wf: e, layer: t } = g("A"), n = yield* f.build(t), r = yield* u.forkDaemon(e.execute(v).pipe(u.provide(n), u.ignore));
145
- return (yield* S(() => s.length > 0, 6e4)) ? r : yield* u.die(/* @__PURE__ */ Error("runner A never journaled step1"));
147
+ return (yield* w(() => s.length > 0, 6e4)) ? r : yield* u.die(/* @__PURE__ */ Error("runner A never journaled step1"));
146
148
  })));
147
149
  await u.runPromise(d.interrupt(x).pipe(u.ignore)), _(i).toEqual(["A"]), _(a).toEqual([]), _(await u.runPromise(u.scoped(u.gen(function* () {
148
150
  let { wf: e, layer: t } = g("B"), n = yield* f.build(t);
149
151
  return yield* e.execute(v).pipe(u.provide(n), u.timeout("240 seconds"), u.orElseSucceed(() => "TIMEOUT"));
150
152
  })))).toBe("done"), _(i).toEqual(["A"]), _(a).toEqual(["B"]);
151
- }, 48e4), v.runIf(m.clusterResume)("dormancy loop: a wake signal survives teardown and is cleared on resume", async () => {
153
+ }, 48e4), g("dormancy loop: a wake signal survives teardown and is cleared on resume", async () => {
152
154
  if (!o) return;
153
155
  let s = `dormancyLoop_${y}`, c = `wr_${s}`, l = [], h = /* @__PURE__ */ new Map(), g = a({
154
156
  store: {
@@ -176,7 +178,7 @@ var y = async (e, t, n = 750) => {
176
178
  execute: u.void
177
179
  }), yield* n({
178
180
  name: "nap",
179
- duration: "30 seconds"
181
+ duration: C
180
182
  }), yield* t({
181
183
  name: "step2",
182
184
  success: p.Void,
@@ -184,22 +186,22 @@ var y = async (e, t, n = 750) => {
184
186
  l.push(a);
185
187
  })
186
188
  }), "done";
187
- }).pipe(u.locally(i, c), u.provideService(e, g))), C = m.runnerPort + 2, w = (e) => {
189
+ }).pipe(u.locally(i, c), u.provideService(e, g))), S = m.runnerPort + 2, T = (e) => {
188
190
  let t = v();
189
191
  return {
190
192
  wf: t,
191
- layer: x(t, e).pipe(f.provideMerge(b(C)))
193
+ layer: x(t, e).pipe(f.provideMerge(b(S)))
192
194
  };
193
- }, T = { id: `loop-${y}` }, E = await u.runPromise(u.scoped(u.gen(function* () {
194
- let { wf: e, layer: t } = w("A"), n = yield* f.build(t), r = yield* u.forkDaemon(e.execute(T).pipe(u.provide(n), u.ignore));
195
- return (yield* S(() => h.has(c), 6e4)) ? (yield* u.sleep("1 second"), r) : yield* u.die(/* @__PURE__ */ Error("workflow never registered a wakeup"));
195
+ }, E = { id: `loop-${y}` }, D = await u.runPromise(u.scoped(u.gen(function* () {
196
+ let { wf: e, layer: t } = T("A"), n = yield* f.build(t), r = yield* u.forkDaemon(e.execute(E).pipe(u.provide(n), u.ignore));
197
+ return (yield* w(() => h.has(c), 6e4)) ? (yield* u.sleep("1 second"), r) : yield* u.die(/* @__PURE__ */ Error("workflow never registered a wakeup"));
196
198
  })));
197
- await u.runPromise(d.interrupt(E).pipe(u.ignore)), _(h.has(c)).toBe(!0), _(l).toEqual([]), _(await u.runPromise(u.scoped(u.gen(function* () {
198
- let { wf: e, layer: t } = w("B"), n = yield* f.build(t);
199
- return yield* e.execute(T).pipe(u.provide(n), u.timeout("240 seconds"), u.orElseSucceed(() => "TIMEOUT"));
199
+ await u.runPromise(d.interrupt(D).pipe(u.ignore)), _(h.has(c)).toBe(!0), _(l).toEqual([]), _(await u.runPromise(u.scoped(u.gen(function* () {
200
+ let { wf: e, layer: t } = T("B"), n = yield* f.build(t);
201
+ return yield* e.execute(E).pipe(u.provide(n), u.timeout("600 seconds"), u.orElseSucceed(() => "TIMEOUT"));
200
202
  })))).toBe("done"), _(l).toEqual(["B"]), _(h.has(c)).toBe(!1);
201
- }, 48e4);
203
+ }, 9e5);
202
204
  });
203
205
  };
204
206
  //#endregion
205
- export { C as runClusterEngineSuite, y as tcpReachable };
207
+ export { T as runClusterEngineSuite, y as tcpReachable };
package/dist/index.d.ts CHANGED
@@ -426,42 +426,58 @@ declare type StepOptions<R, Success extends Schema.Schema.Any, Error extends Sch
426
426
  /** Structured value recorded into `_voltro_workflow_run_steps.input`.
427
427
  * Pass whatever makes the step debuggable in isolation. */
428
428
  readonly input?: unknown;
429
- /** Declarative retry-policy summary recorded into
430
- * `_voltro_workflow_run_steps.retryPolicy`. Pure metadata does
431
- * NOT change retry behavior on its own. Wire actual retries via
432
- * `Effect.retry` inside `execute:` or via `interruptRetryPolicy`
433
- * above; this field tells the dashboard what the intended policy
434
- * looks like so the per-attempt panel can show "attempt 2 of 5,
435
- * exponential 1s base" instead of just "attempt 2". */
429
+ /** Declarative retry policy ENFORCED. The framework compiles it to an
430
+ * Effect `Schedule` and retries `execute` accordingly (backoff, jitter,
431
+ * attempt cap, time budget, error classification, provider `Retry-After`).
432
+ * See {@link StepRetryPolicy}. Retries run INSIDE this one step and are
433
+ * transparent to the durable engine; the step's FINAL outcome is recorded.
434
+ * The serialisable knobs are also stored as `_voltro_workflow_run_steps.
435
+ * retryPolicy` for the dashboard. `retry: { maxAttempts: 5 }` is enough. */
436
436
  readonly retry?: StepRetryPolicy;
437
437
  };
438
438
 
439
439
  /**
440
- * Declarative retry-policy summary. Pure metadata for the dashboard;
441
- * the framework does NOT apply this. Users wire actual retry behavior
442
- * via `Effect.retry(Schedule.*)` inside `execute:` or
443
- * `Activity.make`'s `interruptRetryPolicy`.
444
- *
445
- * Shape is intentionally close to Effect's Schedule combinators so a
446
- * future framework version can auto-translate declarative policies
447
- * into a Schedule + apply it. For now: record + display.
448
- *
449
- * { strategy: 'exponential', maxAttempts: 5, baseDelay: '1 second', maxDelay: '30 seconds' }
450
- * { strategy: 'fixed', maxAttempts: 3, baseDelay: '500 millis' }
451
- * { strategy: 'linear', maxAttempts: 4, baseDelay: '1 second', step: '2 seconds' }
440
+ * A step's retry policy. Every field is optional — `retry: {}` is already a
441
+ * sensible policy (3 attempts, exponential backoff, jittered). All fields are
442
+ * JSON-serialisable EXCEPT `retryable` (a predicate), which the recorder drops
443
+ * when it stores the policy as dashboard metadata.
452
444
  */
453
445
  export declare interface StepRetryPolicy {
454
- readonly strategy: 'exponential' | 'fixed' | 'linear';
455
- readonly maxAttempts: number;
456
- /** Effect Duration string, e.g. `'1 second'`, `'500 millis'`. */
446
+ /** Total attempts INCLUDING the first. Default `3`. `1` disables retry. */
447
+ readonly maxAttempts?: number;
448
+ /** Backoff shape between attempts. Default `'exponential'`. */
449
+ readonly strategy?: 'exponential' | 'fixed' | 'linear';
450
+ /** First delay (Effect Duration string, e.g. `'500 millis'`). Default `'200 millis'`. */
457
451
  readonly baseDelay?: string;
458
- /** Cap on exponential / linear growth, e.g. `'30 seconds'`. */
459
- readonly maxDelay?: string;
460
- /** Increment for `linear` strategy. */
452
+ /** Growth factor for `'exponential'`. Default `2`. */
453
+ readonly factor?: number;
454
+ /** Increment per attempt for `'linear'`. Default = `baseDelay`. */
461
455
  readonly step?: string;
462
- /** Free-form note rendered as a chip in the dashboard. Useful when
463
- * the retry behavior is partially implemented in the user's
464
- * Effect chain and the summary is just documentation. */
456
+ /** Ceiling on any single delay, so exponential growth can't run away
457
+ * (e.g. `'30 seconds'`). Applied before jitter. */
458
+ readonly maxDelay?: string;
459
+ /** Full jitter on each delay — spreads retries so a fleet doesn't
460
+ * re-hit a recovering dependency in lockstep. Default `true`. */
461
+ readonly jitter?: boolean;
462
+ /** A total wall-clock BUDGET across all attempts: stop retrying once
463
+ * this much time has elapsed since the first attempt, even if attempts
464
+ * remain (e.g. `'5 minutes'`). A deadline, not a count. */
465
+ readonly maxElapsed?: string;
466
+ /** Retry ONLY failures whose typed-error `_tag` is in this list; every
467
+ * other error fails fast. The declarative "retry transient, fail
468
+ * permanent" — e.g. `['ProviderDown', 'RateLimited']`. */
469
+ readonly retryableErrors?: ReadonlyArray<string>;
470
+ /** Retry-predicate on the raw error (wins over `retryableErrors` when
471
+ * both are set). Not serialised into dashboard metadata. Default:
472
+ * retry every failure. */
473
+ readonly retryable?: (error: unknown) => boolean;
474
+ /** Honor a provider's own backoff: if a retryable error carries a
475
+ * `retryAfterMillis` number (or `retryAfter` in seconds), use exactly that as
476
+ * the delay before the next attempt — REPLACING the computed backoff for
477
+ * that attempt (a 429 `Retry-After`, say). Falls back to the normal backoff
478
+ * when the error carries no hint. Default `false`. */
479
+ readonly respectRetryAfter?: boolean;
480
+ /** Free-form note rendered as a chip in the dashboard. */
465
481
  readonly note?: string;
466
482
  }
467
483
 
@@ -637,7 +653,13 @@ export declare interface WorkflowResolvedRun {
637
653
  * open-text on purpose (future phases add new types without a column
638
654
  * migration) — but at emit time we still want type-safety.
639
655
  */
640
- export declare type WorkflowRunEventType = 'run-started' | 'run-suspended' | 'run-resumed' | 'run-cancelled' | 'run-succeeded' | 'run-failed'
656
+ export declare type WorkflowRunEventType = 'run-started' | 'run-suspended' | 'run-resumed'
657
+ /** Emitted when an operator re-drives a terminally-FAILED run from its
658
+ * durable journal (`voltro workflows redrive` / `ctx.workflows.redrive`).
659
+ * `payload` carries `{ reason, activitiesReset }` — how many failed step
660
+ * attempts were reset so they re-execute (completed steps replay). Distinct
661
+ * from `run-resumed`, which continues a *suspended* run. */
662
+ | 'run-redriven' | 'run-cancelled' | 'run-succeeded' | 'run-failed'
641
663
  /** Emitted when `sleep({ name, duration })` enters the wait — `payload`
642
664
  * carries `{ name, durationMs, scheduledWakeAt }`. The dashboard
643
665
  * Gantt renders these as a hatched bar in the timeline so a
@@ -843,6 +865,25 @@ export declare interface WorkflowVersionOptions {
843
865
  readonly version?: string | number;
844
866
  readonly compatibleWith?: ReadonlyArray<string | number>;
845
867
  readonly patches?: ReadonlyArray<string>;
868
+ /**
869
+ * When `true`, a workflow whose top-level body FAILS does not become a
870
+ * terminal `failed` run — it **suspends** with its durable journal intact, so
871
+ * `voltro workflows resume <id>` (or `ctx.workflows.resume`) re-drives it from
872
+ * the point of failure: every completed activity replays from the journal
873
+ * (NOT re-executed) and only the failed activity runs again. This is the
874
+ * durable-execution way to make a workflow recoverable across a transient
875
+ * downstream outage without re-doing prior work.
876
+ *
877
+ * Default `false` — a failure is terminal (the dead-letter state; see
878
+ * `voltro workflows list --dead-letter`). Choose per workflow: `true` for a
879
+ * long multi-step pipeline where re-doing step 1..N-1 is expensive or unsafe;
880
+ * `false` for a short idempotent job where a fresh `retry` is simpler. A
881
+ * suspended-on-failure run shows up under `--status suspended`, NOT in the
882
+ * dead-letter view — it is recoverable, not dead.
883
+ *
884
+ * Maps to `@effect/workflow`'s `SuspendOnFailure` annotation.
885
+ */
886
+ readonly suspendOnFailure?: boolean;
846
887
  }
847
888
 
848
889
  export declare const WorkflowVersionTypeId: unique symbol;
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { C as e, E as t, T as n, _ as r, a as i, b as a, c as o, d as s, f as c, g as l, h as u, i as d, l as f, m as p, n as m, o as h, p as g, r as _, s as v, t as y, u as b, v as x, w as S, x as C, y as w } from "./primitives-CWy1iu5w.js";
2
- import { _ as T, a as E, b as D, c as O, d as k, f as A, g as j, h as M, i as N, l as P, m as F, n as I, o as L, p as R, r as z, s as B, t as V, u as H, v as U, x as W, y as G } from "./src-DQww25rP.js";
1
+ import { C as e, E as t, T as n, _ as r, a as i, b as a, c as o, d as s, f as c, g as l, h as u, i as d, l as f, m as p, n as m, o as h, p as g, r as _, s as v, t as y, u as b, v as x, w as S, x as C, y as w } from "./primitives-Dgu3O55Q.js";
2
+ import { _ as T, a as E, b as D, c as O, d as k, f as A, g as j, h as M, i as N, l as P, m as F, n as I, o as L, p as R, r as z, s as B, t as V, u as H, v as U, x as W, y as G } from "./src-KDx7NTsa.js";
3
3
  export { a as CurrentWorkflowExecutionId, C as CurrentWorkflowRunId, y as WorkflowMessagesProperty, e as WorkflowRunRecorder, S as WorkflowStepInterceptorTag, m as WorkflowVersionTypeId, _ as WorkflowWorkerLayerTypeId, U as _voltroWorkflowRunEventsTable, G as _voltroWorkflowRunStepsTable, D as _voltroWorkflowRunsTable, T as _voltroWorkflowStartContextsTable, j as awaitSignal, P as awaitSignalSuspending, O as awaitUpdate, z as closeWorkflowChildrenForParent, H as completeSuspendingSignal, d as durableClock, i as durableQueue, h as durableQueueModule, v as durableRateLimiterModule, n as getCurrentWorkflowExecutionId, t as getCurrentWorkflowRunId, o as getWorkflowVersionMetadata, W as inMemoryWorkflowEngineLayer, I as inspectWorkflow, f as isWorkflowWorkerLayer, V as makeInMemoryRecorder, N as makeWorkflowRunRecorder, A as makeWorkflowUpdateId, b as processQueue, s as queueWorker, c as rateLimit, R as resolveWorkflowMessageRun, F as sendWorkflowSignal, M as sendWorkflowUpdate, E as serialiseWorkflowRowForWire, g as sleep, p as step, u as stepIdempotencyKey, l as stepModule, k as suspendingSignalDeferredName, L as truncateWorkflowValue, r as withCompensation, x as workflow, w as workflowModule, B as wrapWorkflowExecuteWithRunRecording };