octaflow 0.15.0 → 0.17.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/README.md CHANGED
@@ -9,7 +9,8 @@
9
9
  **Durable workflows for TypeScript that run on the Postgres you already have.**
10
10
 
11
11
  Declare a DAG of Zod-typed steps. The engine runs each step as soon as its dependencies
12
- complete, persists every transition, retries failures, and resumes after a crash. There is
12
+ complete, persists every transition, retries failures, and picks up where it left off after a
13
+ restart. There is
13
14
  no workflow server to operate, no control plane, and no vendor — it's a library you import,
14
15
  not a platform you adopt.
15
16
 
@@ -64,11 +65,12 @@ of what it did. Both models are durable — they trade off differently, and
64
65
 
65
66
  A fuller pipeline than the snippet above — dynamic fan-out over two `map` steps at once, a
66
67
  concurrency cap so only 2 of 6 images encode at a time, a flaky step retried, a sub-workflow,
67
- a step suspended on an external event, and a durable sleep. Then a second workflow fails and
68
- its completed steps roll back in reverse.
68
+ a step suspended on an external event under a deadline, a durable sleep, and a branch where
69
+ the arm the review didn't choose is skipped but the join still fires. Then a second workflow
70
+ fails and its completed steps roll back in reverse.
69
71
 
70
72
  <p align="center">
71
- <img src="./docs/demo.svg" alt="Terminal trace of a publishing pipeline: parallel fan-out over locales and images with a concurrency cap, a retried step, a sub-workflow, a suspend-and-resume on an external event, a durable sleep, then a saga rollback in reverse order" width="760">
73
+ <img src="./docs/demo.svg" alt="Terminal trace of a publishing pipeline: parallel fan-out over locales and images with a concurrency cap, a retried step, a sub-workflow, a suspend-and-resume on an external event with a deadline armed, a durable sleep, a skipped branch converging on a join, then a saga rollback in reverse order" width="760">
72
74
  </p>
73
75
 
74
76
  Every line is an engine transition emitted through the [`FlowObserver`](https://octabits-io.github.io/octaflow/running/observability/) seam —
@@ -118,10 +120,12 @@ that the others have earned.
118
120
  Reach for something else if:
119
121
 
120
122
  - **Your control flow is genuinely dynamic.** A declarative DAG is fixed at definition time.
121
- Flow softens this with [`defineMapStep`](https://octabits-io.github.io/octaflow/core/fan-out-and-map/) (runtime-sized fan-out),
122
- [sub-workflows](https://octabits-io.github.io/octaflow/core/sub-workflows/), and [`waitForEvent`](https://octabits-io.github.io/octaflow/core/signals/) — but if your
123
- process is "loop until a human approves, branching on whatever they typed," an imperative
124
- durable function will express it more naturally.
123
+ Flow softens this a lot — [`when` guards and joins](https://octabits-io.github.io/octaflow/core/branching/) (if/else over a static graph),
124
+ [`defineMapStep`](https://octabits-io.github.io/octaflow/core/fan-out-and-map/) (runtime-sized fan-out),
125
+ [sub-workflows](https://octabits-io.github.io/octaflow/core/sub-workflows/), and [`waitForEvent`](https://octabits-io.github.io/octaflow/core/signals/) — but the
126
+ set of steps is still fixed up front. If your process is "**loop** until a human approves,
127
+ branching on whatever they typed," an imperative durable function will express it more
128
+ naturally: Flow can pick a branch, not invent a step.
125
129
  - **You want a UI out of the box.** Flow ships a wire-safe projection
126
130
  ([`toPublicWorkflow`](https://octabits-io.github.io/octaflow/extending/http/)) and lifecycle events, not a
127
131
  dashboard. You build it.
@@ -187,13 +191,18 @@ next to what your steps actually do.
187
191
  | ⚡ | **Auto-parallelism** — dependency-free steps run concurrently; a step starts when all its deps complete |
188
192
  | 🔁 | **Retry & timeout** — per-step `maxAttempts`, fixed/exponential backoff, wall-clock timeout |
189
193
  | 💤 | **Durable sleep** — hold a step in the queue for N ms (survives restarts) |
194
+ | 🔀 | **Conditional branching** — `when` guards skip a step and its branch; `join: 'any'` converges |
195
+ | ⏳ | **Deadlines** — a budget on a suspended step (fail, or continue with a stand-in answer) and on a whole run |
196
+ | ♻️ | **Retry a failed run** — `retryWorkflow` resumes from the failure point; completed steps keep their output |
190
197
  | 🚦 | **Concurrency & rate limiting** — per-step-type caps and token buckets via a pluggable gate |
191
198
  | ⏰ | **Cron / scheduled starts** — fire workflows on a schedule (pg-boss) |
192
199
  | 🔑 | **Start idempotency** — a dedup key collapses double-clicks / overlapping ticks |
193
200
  | 🗺️ | **Dynamic fan-out / map** — spawn one child step per item of a runtime-sized list |
194
- | ⏸️ | **Signals / waitForEvent** — suspend a step until an external event (`resumeStep`) |
201
+ | ⏸️ | **Signals / waitForEvent** — suspend a step until an external event (`resumeStep`), with an optional deadline |
195
202
  | 🪆 | **Sub-workflows** — a step starts a child workflow and awaits its result |
196
203
  | ↩️ | **Saga compensation** — run rollback handlers in reverse order on failure |
204
+ | 🚑 | **Crash recovery** — a step whose worker died is re-queued while its attempt budget lasts |
205
+ | 💓 | **Heartbeats** — a long step proves it is alive, so a dead one is caught in seconds, not minutes |
197
206
  | 🔭 | **Observability** — lifecycle events (run history) + per-step spans, both pluggable |
198
207
  | 🤖 | **AI add-on** — instrumented models, token/cost capture, quota, daily rollups |
199
208
  | 🧱 | **Pluggable everything** — `WorkflowStore`, `Dispatcher`, `StepGate`, `FlowObserver`, hooks |
@@ -239,9 +248,13 @@ Full docs — concepts, every feature, production wiring and the API reference
239
248
  | [Defining steps](https://octabits-io.github.io/octaflow/core/defining-steps/) | `defineStep` and its variants |
240
249
  | [Retry & timeout](https://octabits-io.github.io/octaflow/core/retry-and-timeout/) | attempt budgets, backoff, and how a failure is classified |
241
250
  | [Fan-out & map](https://octabits-io.github.io/octaflow/core/fan-out-and-map/) | one child step per item of a runtime list |
251
+ | [Branching](https://octabits-io.github.io/octaflow/core/branching/) | `when` guards and join rules — if/else over a static DAG |
242
252
  | [Signals](https://octabits-io.github.io/octaflow/core/signals/) · [Sub-workflows](https://octabits-io.github.io/octaflow/core/sub-workflows/) · [Saga](https://octabits-io.github.io/octaflow/core/saga-compensation/) | suspend, nest, and roll back |
253
+ | [Deadlines](https://octabits-io.github.io/octaflow/core/deadlines/) | budgets for a suspended step and for a whole run |
254
+ | [Heartbeats](https://octabits-io.github.io/octaflow/core/heartbeats/) | liveness for long steps, and interrupting one that was cancelled |
243
255
  | [Postgres & pg-boss](https://octabits-io.github.io/octaflow/running/postgres-and-pg-boss/) | production wiring, workers, DLQ, cron |
244
- | [Observability](https://octabits-io.github.io/octaflow/running/observability/) | lifecycle events and spans |
256
+ | [Cancellation & recovery](https://octabits-io.github.io/octaflow/running/cancellation-and-recovery/) | cancelling a run, sweeping steps a crash left behind, and retrying a failed run |
257
+ | [Observability](https://octabits-io.github.io/octaflow/running/observability/) · [Live progress](https://octabits-io.github.io/octaflow/running/live-progress/) | lifecycle events, spans, and streaming them to a browser |
245
258
  | [The AI add-on](https://octabits-io.github.io/octaflow/extending/ai/) | token/cost capture, quota, usage rollups |
246
259
  | [Extending](https://octabits-io.github.io/octaflow/extending/interfaces/) | custom stores, dispatchers and gates |
247
260
  | [API reference](https://octabits-io.github.io/octaflow/reference/api/) | every export, by entry point |
@@ -267,8 +280,11 @@ Runnable, focused examples live in [`examples/`](./examples) — see [`examples/
267
280
  | 11 | `11-observability.ts` | observer events + tracer spans |
268
281
  | 12 | `12-postgres-pgboss-production.ts` | full pg store + gate + event sink + pg-boss + cron |
269
282
  | 13 | `13-ai-workflow.ts` | AI add-on (instrumented model + cost) |
283
+ | 14 | `14-live-progress.ts` | `FlowObserver` → SSE fan-out (build your own dashboard) |
284
+ | 15 | `15-conditional-branching.ts` | `when` guards + a `join: 'any'` convergence |
285
+ | 16 | `16-deadlines-and-retry.ts` | wait deadlines, run deadlines, `retryWorkflow`, heartbeats |
270
286
 
271
- The in-memory examples (01–11) share a small driver, [`examples/runtime.ts`](./examples/runtime.ts),
287
+ The in-memory examples (01–11, 14–16) share a small driver, [`examples/runtime.ts`](./examples/runtime.ts),
272
288
  that builds an engine over the in-memory store and an in-process queue you drain.
273
289
 
274
290
  ---
@@ -1,6 +1,6 @@
1
- import { K as FlowErrorShape, T as RetryPolicy, h as TypedWorkflow, m as TypedStepContext, p as TypedStep, q as Result } from "../index-D_fOYfmf.js";
1
+ import { E as RetryPolicy, g as TypedWorkflow, h as TypedStepContext, m as TypedStep, nt as Result, tt as FlowErrorShape } from "../index-BURAYZui.js";
2
2
  import { n as Logger } from "../logger-BCus3AOa.js";
3
- import { i as BuildStepContextArgs, o as WorkflowHooks } from "../hooks-CDRFQZbT.js";
3
+ import { i as BuildStepContextArgs, o as WorkflowHooks } from "../hooks-BdxIcc92.js";
4
4
  import { z } from "zod";
5
5
  import { EmbeddingModelV4, LanguageModelV4 } from "@ai-sdk/provider";
6
6
  //#region src/ai/instrumented-model.d.ts
package/dist/ai/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { S as ok, b as noopLogger, f as defineStep, l as buildWorkflow, x as err } from "../core-ERYGlYsS.js";
1
+ import { S as ok, b as noopLogger, f as defineStep, l as buildWorkflow, x as err } from "../core-DNWPwFaE.js";
2
2
  import { wrapEmbeddingModel, wrapLanguageModel } from "ai";
3
3
  //#region src/ai/instrumented-model.ts
4
4
  function createUsageAccumulator() {
@@ -171,33 +171,61 @@ const emptyObjectSchema = z.object({});
171
171
  * Validation errors are non-retryable (a schema mismatch is a programming error).
172
172
  */
173
173
  function defineStep(config) {
174
- const { type, workflowInputSchema, outputSchema, dependencies, handler, retry, isRetryable, timeoutMs, delayMs, waitForEvent } = config;
174
+ const { type, workflowInputSchema, outputSchema, dependencies, handler, retry, isRetryable, timeoutMs, delayMs, waitForEvent, join } = config;
175
175
  const deps = dependencies ?? {};
176
- const wrappedHandler = async (ctx) => {
177
- try {
178
- const inputResult = workflowInputSchema.safeParse(ctx.workflowInput);
179
- if (!inputResult.success) return {
176
+ /**
177
+ * Classify a thrown error. Precedence: an explicit marker on the error
178
+ * (following `cause`), then this step's predicate, then the default
179
+ * classifier. `retryableFrom` records which one answered, so the engine's
180
+ * `defaultRetryable` can override a guess without overriding a decision
181
+ * someone actually made.
182
+ */
183
+ const toStepError = (error) => {
184
+ const message = error instanceof Error ? error.message : "Unknown step error";
185
+ const marked = explicitRetryability(error);
186
+ return {
187
+ key: "step_error",
188
+ message,
189
+ retryable: marked ?? (isRetryable ? isRetryable(error) : isRetryableError(error)),
190
+ retryableFrom: marked !== void 0 ? "explicit" : isRetryable ? "predicate" : "heuristic"
191
+ };
192
+ };
193
+ /**
194
+ * Phases 1–3 of the pipeline: validate the workflow input, validate each
195
+ * dependency's output, and assemble the typed context. Shared so the `when`
196
+ * guard decides on exactly the same values the handler would receive.
197
+ */
198
+ const buildTypedContext = (ctx) => {
199
+ const inputResult = workflowInputSchema.safeParse(ctx.workflowInput);
200
+ if (!inputResult.success) return {
201
+ ok: false,
202
+ error: {
203
+ key: "step_error",
204
+ message: `[${type}] Invalid workflow input: ${inputResult.error.message}`,
205
+ retryable: false
206
+ }
207
+ };
208
+ const parsedDeps = {};
209
+ for (const [depKey, depStep] of Object.entries(deps)) {
210
+ const raw = ctx.dependencyOutputs[depKey];
211
+ if (join === "any" && raw === void 0) {
212
+ parsedDeps[depKey] = void 0;
213
+ continue;
214
+ }
215
+ const depResult = depStep.outputSchema.safeParse(raw);
216
+ if (!depResult.success) return {
180
217
  ok: false,
181
218
  error: {
182
219
  key: "step_error",
183
- message: `[${type}] Invalid workflow input: ${inputResult.error.message}`,
220
+ message: `[${type}] Invalid dependency output '${depKey}': ${depResult.error.message}`,
184
221
  retryable: false
185
222
  }
186
223
  };
187
- const parsedDeps = {};
188
- for (const [depKey, depStep] of Object.entries(deps)) {
189
- const depResult = depStep.outputSchema.safeParse(ctx.dependencyOutputs[depKey]);
190
- if (!depResult.success) return {
191
- ok: false,
192
- error: {
193
- key: "step_error",
194
- message: `[${type}] Invalid dependency output '${depKey}': ${depResult.error.message}`,
195
- retryable: false
196
- }
197
- };
198
- parsedDeps[depKey] = depResult.data;
199
- }
200
- const typedCtx = {
224
+ parsedDeps[depKey] = depResult.data;
225
+ }
226
+ return {
227
+ ok: true,
228
+ value: {
201
229
  workflowId: ctx.workflowId,
202
230
  stepId: ctx.stepId,
203
231
  stepKey: ctx.stepKey,
@@ -206,9 +234,16 @@ function defineStep(config) {
206
234
  stepInput: ctx.stepInput,
207
235
  deps: parsedDeps,
208
236
  signal: ctx.signal,
237
+ heartbeat: ctx.heartbeat,
209
238
  context: ctx.context
210
- };
211
- const output = await handler(typedCtx);
239
+ }
240
+ };
241
+ };
242
+ const wrappedHandler = async (ctx) => {
243
+ try {
244
+ const built = buildTypedContext(ctx);
245
+ if (!built.ok) return built;
246
+ const output = await handler(built.value);
212
247
  const outputResult = outputSchema.safeParse(output);
213
248
  if (!outputResult.success) return {
214
249
  ok: false,
@@ -223,19 +258,31 @@ function defineStep(config) {
223
258
  value: outputResult.data
224
259
  };
225
260
  } catch (error) {
226
- const message = error instanceof Error ? error.message : "Unknown step error";
227
- const marked = explicitRetryability(error);
228
261
  return {
229
262
  ok: false,
230
- error: {
231
- key: "step_error",
232
- message,
233
- retryable: marked ?? (isRetryable ? isRetryable(error) : isRetryableError(error)),
234
- retryableFrom: marked !== void 0 ? "explicit" : isRetryable ? "predicate" : "heuristic"
235
- }
263
+ error: toStepError(error)
236
264
  };
237
265
  }
238
266
  };
267
+ let wrappedCondition;
268
+ if (config.when) {
269
+ const when = config.when;
270
+ wrappedCondition = async (ctx) => {
271
+ try {
272
+ const built = buildTypedContext(ctx);
273
+ if (!built.ok) return built;
274
+ return {
275
+ ok: true,
276
+ value: await when(built.value) === true
277
+ };
278
+ } catch (error) {
279
+ return {
280
+ ok: false,
281
+ error: toStepError(error)
282
+ };
283
+ }
284
+ };
285
+ }
239
286
  let wrappedCompensate;
240
287
  if (config.compensate) {
241
288
  const compensate = config.compensate;
@@ -256,6 +303,7 @@ function defineStep(config) {
256
303
  stepInput: ctx.stepInput,
257
304
  deps: parsedDeps,
258
305
  signal: ctx.signal,
306
+ heartbeat: ctx.heartbeat,
259
307
  context: ctx.context,
260
308
  output: out.success ? out.data : ctx.output
261
309
  });
@@ -269,9 +317,14 @@ function defineStep(config) {
269
317
  handler: wrappedHandler,
270
318
  retry,
271
319
  timeoutMs,
320
+ onTimeout: config.onTimeout,
321
+ heartbeatTimeoutMs: config.heartbeatTimeoutMs,
322
+ heartbeat: config.heartbeat,
272
323
  delayMs,
273
324
  waitForEvent,
274
- compensate: wrappedCompensate
325
+ compensate: wrappedCompensate,
326
+ condition: wrappedCondition,
327
+ join
275
328
  };
276
329
  }
277
330
  /**
@@ -281,6 +334,21 @@ function defineStep(config) {
281
334
  * (validated by dependents against `outputSchema`), then the DAG advances.
282
335
  *
283
336
  * Use for human-in-the-loop approvals, webhooks, or any async external dependency.
337
+ *
338
+ * Give it a `timeoutMs` so the wait cannot last forever. What the expiry does is
339
+ * `onTimeout`'s call: `'fail'` (the default) ends the run, while `{ output }`
340
+ * completes the step with a stand-in answer and lets the DAG carry on — which,
341
+ * paired with a `when` guard downstream, is how "approve within 48 hours,
342
+ * otherwise escalate" is expressed:
343
+ *
344
+ * ```ts
345
+ * const approval = defineWaitStep({
346
+ * type: 'await-approval',
347
+ * outputSchema: z.object({ approved: z.boolean() }),
348
+ * timeoutMs: 48 * 60 * 60 * 1000,
349
+ * onTimeout: { output: { approved: false } },
350
+ * });
351
+ * ```
284
352
  */
285
353
  function defineWaitStep(config) {
286
354
  return defineStep({
@@ -289,6 +357,10 @@ function defineWaitStep(config) {
289
357
  outputSchema: config.outputSchema,
290
358
  dependencies: config.dependencies,
291
359
  waitForEvent: true,
360
+ timeoutMs: config.timeoutMs,
361
+ onTimeout: config.onTimeout,
362
+ when: config.when,
363
+ join: config.join,
292
364
  handler: async () => ({})
293
365
  });
294
366
  }
@@ -308,6 +380,7 @@ function defineMapStep(config) {
308
380
  workflowInputSchema: config.workflowInputSchema,
309
381
  outputSchema: z.object({ items: z.array(z.unknown()) }),
310
382
  dependencies: config.dependencies,
383
+ when: config.when,
311
384
  handler: async (ctx) => ({ items: await config.items(ctx) })
312
385
  });
313
386
  const child = defineStep({
@@ -317,6 +390,7 @@ function defineMapStep(config) {
317
390
  retry: config.itemRetry,
318
391
  isRetryable: config.itemIsRetryable,
319
392
  timeoutMs: config.itemTimeoutMs,
393
+ heartbeatTimeoutMs: config.itemHeartbeatTimeoutMs,
320
394
  handler: async (ctx) => config.each(ctx.stepInput.item, {
321
395
  index: Number(ctx.stepInput.index ?? 0),
322
396
  context: ctx.context,
@@ -333,7 +407,8 @@ function defineMapStep(config) {
333
407
  type: childType,
334
408
  handler: child.handler,
335
409
  retry: child.retry,
336
- timeoutMs: child.timeoutMs
410
+ timeoutMs: child.timeoutMs,
411
+ heartbeatTimeoutMs: child.heartbeatTimeoutMs
337
412
  }
338
413
  };
339
414
  }
@@ -355,6 +430,7 @@ function defineSubWorkflowStep(config) {
355
430
  workflowInputSchema: config.workflowInputSchema,
356
431
  outputSchema: recordSchema,
357
432
  dependencies: config.dependencies,
433
+ when: config.when,
358
434
  handler: async (ctx) => config.input(ctx)
359
435
  }),
360
436
  outputSchema: config.outputSchema ?? recordSchema,
@@ -409,18 +485,24 @@ function buildWorkflow(config) {
409
485
  registry.register(step.type, step.handler, {
410
486
  retry: step.retry,
411
487
  timeoutMs: step.timeoutMs,
488
+ onTimeout: step.onTimeout,
489
+ heartbeatTimeoutMs: step.heartbeatTimeoutMs,
490
+ heartbeat: step.heartbeat,
412
491
  delayMs: step.delayMs,
413
492
  waitForEvent: step.waitForEvent,
414
493
  map: step.map,
415
494
  childType: step.childType,
416
495
  subWorkflowDefinition: step.subWorkflowDefinition,
417
- compensate: step.compensate
496
+ compensate: step.compensate,
497
+ condition: step.condition,
498
+ join: step.join
418
499
  });
419
500
  if (step.childRegistration) {
420
501
  const c = step.childRegistration;
421
502
  registry.register(c.type, c.handler, {
422
503
  retry: c.retry,
423
- timeoutMs: c.timeoutMs
504
+ timeoutMs: c.timeoutMs,
505
+ heartbeatTimeoutMs: c.heartbeatTimeoutMs
424
506
  });
425
507
  }
426
508
  if (step.subWorkflowRegister) step.subWorkflowRegister(registry);
@@ -441,7 +523,7 @@ function buildWorkflow(config) {
441
523
  * The engine's records ({@link WorkflowRecord}, {@link StepRecord}) are the
442
524
  * store's data model: they carry fields that must not leak onto a public API
443
525
  * (`partitionKey`, `idempotencyKey`, sub-workflow linkage, `metadata`, retry
444
- * `attempts`) and step statuses that are engine mechanics rather than display
526
+ * `attempts`, heartbeats) and step statuses that are engine mechanics rather than display
445
527
  * states. Serving them verbatim also couples the consumer's API contract to
446
528
  * flow's record shape. This module owns the boundary instead:
447
529
  *
@@ -531,8 +613,8 @@ const PUBLIC_WORKFLOW_SCHEMA = z.object({
531
613
  });
532
614
  /**
533
615
  * Project a step record to its public view. Drops `workflowId` (redundant
534
- * under the parent), `metadata`, `attempts`, and `parentStepId`; folds the
535
- * status.
616
+ * under the parent), `metadata`, `attempts`, `heartbeatAt`, and `parentStepId`;
617
+ * folds the status.
536
618
  */
537
619
  function toPublicStep(step) {
538
620
  return {
@@ -1,11 +1,24 @@
1
- import { F as StepId, K as FlowErrorShape, V as WorkflowId, q as Result } from "./index-D_fOYfmf.js";
1
+ import { L as StepId, W as WorkflowId, nt as Result, tt as FlowErrorShape } from "./index-BURAYZui.js";
2
2
  //#region src/core/dispatcher.d.ts
3
+ /**
4
+ * What a step job is for. A queue carries two kinds of work for a step, and
5
+ * they differ only in this field:
6
+ *
7
+ * - `'execute'` (default) — run the step. Every job before wait deadlines
8
+ * existed was this, which is why it is also what a payload without a `kind`
9
+ * means: an in-flight job enqueued by an older version still routes correctly.
10
+ * - `'timeout'` — the step's wait budget has elapsed; settle it if it is still
11
+ * suspended. Delivered by the same durable delay that powers a sleep step.
12
+ */
13
+ type DispatchKind = 'execute' | 'timeout';
3
14
  /** Payload handed to the dispatcher to schedule a single step for execution. */
4
15
  interface DispatchStepPayload {
5
16
  workflowId: WorkflowId;
6
17
  stepId: StepId;
7
18
  stepKey: string;
8
19
  stepType: string;
20
+ /** Defaults to `'execute'` when absent. */
21
+ kind?: DispatchKind;
9
22
  }
10
23
  interface EnqueueOptions {
11
24
  /**
@@ -34,4 +47,4 @@ interface Dispatcher {
34
47
  enqueueStepIn?(handle: unknown, payload: DispatchStepPayload, options?: EnqueueOptions): Promise<Result<void, FlowErrorShape>>;
35
48
  }
36
49
  //#endregion
37
- export { Dispatcher as n, EnqueueOptions as r, DispatchStepPayload as t };
50
+ export { EnqueueOptions as i, DispatchStepPayload as n, Dispatcher as r, DispatchKind as t };
@@ -1,13 +1,13 @@
1
- import "../index-D_fOYfmf.js";
1
+ import "../index-BURAYZui.js";
2
2
  import { n as Logger } from "../logger-BCus3AOa.js";
3
- import { n as Dispatcher } from "../dispatcher-CnMTwfCc.js";
3
+ import { r as Dispatcher } from "../dispatcher-Cbb-KjY_.js";
4
4
  import { z } from "zod";
5
5
  import { PgBoss } from "pg-boss";
6
6
  //#region src/dispatcher-pgboss/payload.d.ts
7
7
  /**
8
8
  * The wire payload for a step job. It is the flow-core `DispatchStepPayload`
9
9
  * plus the `partitionKey` — the worker needs the partition to reconstruct a
10
- * partition-scoped engine before calling `executeStep`.
10
+ * partition-scoped engine before handing the job to `engine.handleStepJob`.
11
11
  */
12
12
  declare const WIRE_STEP_PAYLOAD_SCHEMA: z.ZodObject<{
13
13
  partitionKey: z.ZodString;
@@ -15,6 +15,10 @@ declare const WIRE_STEP_PAYLOAD_SCHEMA: z.ZodObject<{
15
15
  stepId: z.ZodNumber;
16
16
  stepKey: z.ZodString;
17
17
  stepType: z.ZodString;
18
+ kind: z.ZodDefault<z.ZodEnum<{
19
+ execute: "execute";
20
+ timeout: "timeout";
21
+ }>>;
18
22
  }, z.core.$strip>;
19
23
  type WireStepPayload = z.infer<typeof WIRE_STEP_PAYLOAD_SCHEMA>;
20
24
  interface StepQueueConfig {
@@ -29,8 +33,15 @@ declare const DEFAULT_STEP_QUEUE_CONFIG: Required<StepQueueConfig>;
29
33
  /**
30
34
  * The wire payload for a scheduled (or ad-hoc) workflow **start**. Carries the
31
35
  * partition plus what `engine.startWorkflow` needs; the host resolves `workflowType`
32
- * to a definition. `idempotencyKey` is forwarded to the start so overlapping
33
- * cron ticks don't double-start.
36
+ * to a definition.
37
+ *
38
+ * Two mutually exclusive ways to ask for start idempotency, because a cron schedule
39
+ * stores its payload **once** and pg-boss redelivers that same payload on every tick:
40
+ *
41
+ * - `idempotencyKeyPrefix` — the usual choice. The start worker appends the job id,
42
+ * so each tick gets a distinct key while a redelivery of the *same* tick reuses it.
43
+ * - `idempotencyKey` — used verbatim. On a schedule this means "start exactly one
44
+ * workflow, ever"; every later tick returns the first one.
34
45
  */
35
46
  declare const WIRE_START_PAYLOAD_SCHEMA: z.ZodObject<{
36
47
  partitionKey: z.ZodString;
@@ -38,8 +49,22 @@ declare const WIRE_START_PAYLOAD_SCHEMA: z.ZodObject<{
38
49
  input: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
39
50
  entityRef: z.ZodOptional<z.ZodString>;
40
51
  idempotencyKey: z.ZodOptional<z.ZodString>;
52
+ idempotencyKeyPrefix: z.ZodOptional<z.ZodString>;
41
53
  }, z.core.$strip>;
42
54
  type WireStartPayload = z.infer<typeof WIRE_START_PAYLOAD_SCHEMA>;
55
+ /**
56
+ * What a {@link WireStartPayload} becomes at delivery time: the same fields, plus the
57
+ * pg-boss job id and the **resolved** `idempotencyKey` to hand `engine.startWorkflow`.
58
+ *
59
+ * `idempotencyKey` is verbatim when the payload set one, `${idempotencyKeyPrefix}:${jobId}`
60
+ * when it set a prefix, and `undefined` when it set neither.
61
+ */
62
+ interface StartJobContext extends WireStartPayload {
63
+ /** pg-boss job id — unique per cron tick, stable across that job's retries. */
64
+ jobId: string;
65
+ }
66
+ /** Resolve the per-delivery idempotency key. Explicit key wins over a prefix. */
67
+ declare function resolveStartIdempotencyKey(payload: WireStartPayload, jobId: string): string | undefined;
43
68
  //#endregion
44
69
  //#region src/dispatcher-pgboss/queue.d.ts
45
70
  /**
@@ -136,7 +161,20 @@ interface ScheduleStartInput {
136
161
  workflowType: string;
137
162
  input?: Record<string, unknown>;
138
163
  entityRef?: string;
139
- /** Forwarded to the start so overlapping ticks don't double-start. */
164
+ /**
165
+ * Per-tick start idempotency. The start worker resolves this to
166
+ * `${idempotencyKeyPrefix}:${jobId}`, so each cron tick gets a distinct key while a
167
+ * redelivery of the same tick reuses it — the usual thing you want on a schedule.
168
+ */
169
+ idempotencyKeyPrefix?: string;
170
+ /**
171
+ * Verbatim start idempotency key.
172
+ *
173
+ * A schedule stores its payload **once**, so this same key rides every tick — and
174
+ * start keys never expire. On a cron schedule that means **exactly one workflow,
175
+ * ever**; every later tick returns the first one. That is occasionally what you
176
+ * want ("run this once and never again"); if it isn't, use `idempotencyKeyPrefix`.
177
+ */
140
178
  idempotencyKey?: string;
141
179
  /** Optional IANA time zone (default UTC). */
142
180
  tz?: string;
@@ -150,8 +188,20 @@ declare function createPgBossScheduler(deps: PgBossSchedulerDeps): {
150
188
  /** List the schedules registered on this queue. */
151
189
  list(): Promise<import("pg-boss").Schedule[]>;
152
190
  };
153
- /** Invoked for each start job — typically resolves the type and calls `engine.startWorkflow`. */
154
- type StartJobProcessor = (payload: WireStartPayload) => Promise<void>;
191
+ /**
192
+ * Invoked for each start job — typically resolves the type and calls `engine.startWorkflow`.
193
+ *
194
+ * The context's `idempotencyKey` is already resolved for **this delivery** (see
195
+ * {@link resolveStartIdempotencyKey}), so forwarding it is the right default:
196
+ *
197
+ * ```ts
198
+ * await starter.start(async (job) => {
199
+ * const wf = workflowsByType[job.workflowType];
200
+ * await engine.startWorkflow(wf.definition, job.input, { idempotencyKey: job.idempotencyKey });
201
+ * });
202
+ * ```
203
+ */
204
+ type StartJobProcessor = (payload: StartJobContext) => Promise<void>;
155
205
  interface PgBossStartWorkerDeps {
156
206
  boss: PgBoss;
157
207
  queueName: string;
@@ -167,4 +217,4 @@ declare function createPgBossStartWorker(deps: PgBossStartWorkerDeps): {
167
217
  stop: () => Promise<void>;
168
218
  };
169
219
  //#endregion
170
- export { DEFAULT_STEP_QUEUE_CONFIG, DlqProcessor, PgBossDispatcherDeps, PgBossDlqWorkerDeps, PgBossSchedulerDeps, PgBossStartWorkerDeps, PgBossStepWorkerDeps, PgBossStepWorkerOptions, ScheduleStartInput, StartJobProcessor, StepJobProcessor, StepQueueConfig, WIRE_START_PAYLOAD_SCHEMA, WIRE_STEP_PAYLOAD_SCHEMA, WireStartPayload, WireStepPayload, createPgBossDispatcher, createPgBossDlqWorker, createPgBossScheduler, createPgBossStartWorker, createPgBossStepWorker, ensureStartQueue, ensureStepQueue };
220
+ export { DEFAULT_STEP_QUEUE_CONFIG, DlqProcessor, PgBossDispatcherDeps, PgBossDlqWorkerDeps, PgBossSchedulerDeps, PgBossStartWorkerDeps, PgBossStepWorkerDeps, PgBossStepWorkerOptions, ScheduleStartInput, StartJobContext, StartJobProcessor, StepJobProcessor, StepQueueConfig, WIRE_START_PAYLOAD_SCHEMA, WIRE_STEP_PAYLOAD_SCHEMA, WireStartPayload, WireStepPayload, createPgBossDispatcher, createPgBossDlqWorker, createPgBossScheduler, createPgBossStartWorker, createPgBossStepWorker, ensureStartQueue, ensureStepQueue, resolveStartIdempotencyKey };
@@ -3,14 +3,20 @@ import { z } from "zod";
3
3
  /**
4
4
  * The wire payload for a step job. It is the flow-core `DispatchStepPayload`
5
5
  * plus the `partitionKey` — the worker needs the partition to reconstruct a
6
- * partition-scoped engine before calling `executeStep`.
6
+ * partition-scoped engine before handing the job to `engine.handleStepJob`.
7
7
  */
8
8
  const WIRE_STEP_PAYLOAD_SCHEMA = z.object({
9
9
  partitionKey: z.string().min(1),
10
10
  workflowId: z.number().int().positive(),
11
11
  stepId: z.number().int().positive(),
12
12
  stepKey: z.string().min(1),
13
- stepType: z.string().min(1)
13
+ stepType: z.string().min(1),
14
+ /**
15
+ * What the job asks for: run the step, or settle it because its wait deadline
16
+ * elapsed. Defaulted rather than required, so a job enqueued before wait
17
+ * deadlines existed still parses — and still means "run the step".
18
+ */
19
+ kind: z.enum(["execute", "timeout"]).default("execute")
14
20
  });
15
21
  const DEFAULT_STEP_QUEUE_CONFIG = {
16
22
  retryLimit: 2,
@@ -20,16 +26,31 @@ const DEFAULT_STEP_QUEUE_CONFIG = {
20
26
  /**
21
27
  * The wire payload for a scheduled (or ad-hoc) workflow **start**. Carries the
22
28
  * partition plus what `engine.startWorkflow` needs; the host resolves `workflowType`
23
- * to a definition. `idempotencyKey` is forwarded to the start so overlapping
24
- * cron ticks don't double-start.
29
+ * to a definition.
30
+ *
31
+ * Two mutually exclusive ways to ask for start idempotency, because a cron schedule
32
+ * stores its payload **once** and pg-boss redelivers that same payload on every tick:
33
+ *
34
+ * - `idempotencyKeyPrefix` — the usual choice. The start worker appends the job id,
35
+ * so each tick gets a distinct key while a redelivery of the *same* tick reuses it.
36
+ * - `idempotencyKey` — used verbatim. On a schedule this means "start exactly one
37
+ * workflow, ever"; every later tick returns the first one.
25
38
  */
26
39
  const WIRE_START_PAYLOAD_SCHEMA = z.object({
27
40
  partitionKey: z.string().min(1),
28
41
  workflowType: z.string().min(1),
29
42
  input: z.record(z.string(), z.unknown()).default({}),
30
43
  entityRef: z.string().optional(),
31
- idempotencyKey: z.string().optional()
44
+ /** Verbatim dedup key — collapses *every* delivery, including future cron ticks. */
45
+ idempotencyKey: z.string().optional(),
46
+ /** Per-tick dedup: the start worker resolves this to `${prefix}:${jobId}`. */
47
+ idempotencyKeyPrefix: z.string().optional()
32
48
  });
49
+ /** Resolve the per-delivery idempotency key. Explicit key wins over a prefix. */
50
+ function resolveStartIdempotencyKey(payload, jobId) {
51
+ if (payload.idempotencyKey !== void 0) return payload.idempotencyKey;
52
+ if (payload.idempotencyKeyPrefix !== void 0) return `${payload.idempotencyKeyPrefix}:${jobId}`;
53
+ }
33
54
  //#endregion
34
55
  //#region src/dispatcher-pgboss/queue.ts
35
56
  function dlqName(queueName) {
@@ -85,8 +106,9 @@ function createPgBossDispatcher(deps) {
85
106
  async function send(payload, options, handle) {
86
107
  try {
87
108
  const wire = {
88
- partitionKey,
89
- ...payload
109
+ kind: "execute",
110
+ ...payload,
111
+ partitionKey
90
112
  };
91
113
  const parsed = WIRE_STEP_PAYLOAD_SCHEMA.safeParse(wire);
92
114
  if (!parsed.success) return {
@@ -275,7 +297,8 @@ function createPgBossScheduler(deps) {
275
297
  workflowType: input.workflowType,
276
298
  input: input.input ?? {},
277
299
  entityRef: input.entityRef,
278
- idempotencyKey: input.idempotencyKey
300
+ idempotencyKey: input.idempotencyKey,
301
+ idempotencyKeyPrefix: input.idempotencyKeyPrefix
279
302
  };
280
303
  await boss.schedule(queueName, input.cron, payload, {
281
304
  key: input.key,
@@ -309,7 +332,11 @@ function createPgBossStartWorker(deps) {
309
332
  logger?.error("Invalid start payload", void 0, { jobId: job.id });
310
333
  continue;
311
334
  }
312
- await process(parsed.data);
335
+ await process({
336
+ ...parsed.data,
337
+ jobId: job.id,
338
+ idempotencyKey: resolveStartIdempotencyKey(parsed.data, job.id)
339
+ });
313
340
  }
314
341
  });
315
342
  }
@@ -325,4 +352,4 @@ function createPgBossStartWorker(deps) {
325
352
  };
326
353
  }
327
354
  //#endregion
328
- export { DEFAULT_STEP_QUEUE_CONFIG, WIRE_START_PAYLOAD_SCHEMA, WIRE_STEP_PAYLOAD_SCHEMA, createPgBossDispatcher, createPgBossDlqWorker, createPgBossScheduler, createPgBossStartWorker, createPgBossStepWorker, ensureStartQueue, ensureStepQueue };
355
+ export { DEFAULT_STEP_QUEUE_CONFIG, WIRE_START_PAYLOAD_SCHEMA, WIRE_STEP_PAYLOAD_SCHEMA, createPgBossDispatcher, createPgBossDlqWorker, createPgBossScheduler, createPgBossStartWorker, createPgBossStepWorker, ensureStartQueue, ensureStepQueue, resolveStartIdempotencyKey };
@@ -1,4 +1,4 @@
1
- import { B as WorkflowDefinition, E as StartOptions, F as StepId, I as StepRecord, K as FlowErrorShape, U as WorkflowRecord, V as WorkflowId, q as Result } from "./index-D_fOYfmf.js";
1
+ import { D as StartOptions, L as StepId, R as StepRecord, U as WorkflowDefinition, W as WorkflowId, nt as Result, q as WorkflowRecord, tt as FlowErrorShape } from "./index-BURAYZui.js";
2
2
  //#region src/core/hooks.d.ts
3
3
  interface BeforeStartArgs {
4
4
  definition: WorkflowDefinition;