workflow 5.0.0-beta.5 → 5.0.0-beta.51
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 +68 -23
- package/dist/api-workflow.d.ts +1 -1
- package/dist/api-workflow.d.ts.map +1 -1
- package/dist/api-workflow.js +1 -1
- package/dist/api.d.ts +3 -3
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +5 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/internal/builtins.d.ts +20 -3
- package/dist/internal/builtins.d.ts.map +1 -1
- package/dist/internal/builtins.js +68 -4
- package/dist/internal/errors.d.ts +1 -1
- package/dist/internal/errors.d.ts.map +1 -1
- package/dist/internal/errors.js +2 -2
- package/dist/nest-builder.d.ts +2 -0
- package/dist/nest-builder.d.ts.map +1 -0
- package/dist/nest-builder.js +2 -0
- package/dist/nest-vercel-builder.d.ts +2 -0
- package/dist/nest-vercel-builder.d.ts.map +1 -0
- package/dist/nest-vercel-builder.js +2 -0
- package/dist/observability.d.ts +1 -1
- package/dist/observability.js +2 -2
- package/dist/runtime.d.ts +2 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +4 -1
- package/docs/ai/chat-session-modeling.mdx +176 -422
- package/docs/ai/defining-tools.mdx +6 -7
- package/docs/ai/human-in-the-loop.mdx +11 -11
- package/docs/ai/index.mdx +71 -75
- package/docs/ai/message-queueing.mdx +71 -110
- package/docs/ai/meta.json +1 -0
- package/docs/ai/resumable-streams.mdx +40 -28
- package/docs/ai/sleep-and-delays.mdx +10 -10
- package/docs/ai/streaming-updates-from-tools.mdx +6 -6
- package/docs/api-reference/index.mdx +25 -1
- package/docs/api-reference/meta.json +8 -0
- package/docs/api-reference/vitest/index.mdx +9 -15
- package/docs/api-reference/workflow/create-hook.mdx +89 -10
- package/docs/api-reference/workflow/create-webhook.mdx +16 -15
- package/docs/api-reference/workflow/define-hook.mdx +35 -33
- package/docs/api-reference/workflow/fatal-error.mdx +30 -8
- package/docs/api-reference/workflow/fetch.mdx +14 -10
- package/docs/api-reference/workflow/get-step-metadata.mdx +2 -2
- package/docs/api-reference/workflow/get-workflow-metadata.mdx +3 -3
- package/docs/api-reference/workflow/get-writable.mdx +7 -7
- package/docs/api-reference/workflow/index.mdx +4 -1
- package/docs/api-reference/workflow/retryable-error.mdx +1 -1
- package/docs/api-reference/workflow/set-attributes.mdx +61 -0
- package/docs/api-reference/workflow/sleep.mdx +4 -4
- package/docs/api-reference/workflow-ai/durable-agent.mdx +63 -101
- package/docs/api-reference/workflow-ai/index.mdx +5 -5
- package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +67 -24
- package/docs/api-reference/workflow-api/get-hook-by-token.mdx +26 -12
- package/docs/api-reference/workflow-api/get-run.mdx +43 -8
- package/docs/api-reference/workflow-api/index.mdx +6 -10
- package/docs/api-reference/workflow-api/resume-hook.mdx +73 -12
- package/docs/api-reference/workflow-api/resume-webhook.mdx +11 -9
- package/docs/api-reference/workflow-api/start.mdx +60 -13
- package/docs/api-reference/workflow-astro/index.mdx +18 -0
- package/docs/api-reference/workflow-astro/meta.json +4 -0
- package/docs/api-reference/workflow-astro/workflow.mdx +45 -0
- package/docs/api-reference/workflow-errors/entity-conflict-error.mdx +4 -4
- package/docs/api-reference/workflow-errors/hook-conflict-error.mdx +60 -0
- package/docs/api-reference/workflow-errors/hook-not-found-error.mdx +8 -8
- package/docs/api-reference/workflow-errors/index.mdx +88 -0
- package/docs/api-reference/workflow-errors/meta.json +6 -0
- package/docs/api-reference/workflow-errors/precondition-failed-error.mdx +68 -0
- package/docs/api-reference/workflow-errors/run-expired-error.mdx +2 -2
- package/docs/api-reference/workflow-errors/run-not-supported-error.mdx +58 -0
- package/docs/api-reference/workflow-errors/step-not-registered-error.mdx +5 -5
- package/docs/api-reference/workflow-errors/throttle-error.mdx +2 -2
- package/docs/api-reference/workflow-errors/too-early-error.mdx +2 -2
- package/docs/api-reference/workflow-errors/workflow-error.mdx +52 -0
- package/docs/api-reference/workflow-errors/workflow-not-registered-error.mdx +5 -6
- package/docs/api-reference/workflow-errors/workflow-run-cancelled-error.mdx +6 -6
- package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +5 -5
- package/docs/api-reference/workflow-errors/workflow-run-not-completed-error.mdx +58 -0
- package/docs/api-reference/workflow-errors/workflow-run-not-found-error.mdx +4 -4
- package/docs/api-reference/workflow-errors/workflow-runtime-error.mdx +58 -0
- package/docs/api-reference/workflow-errors/workflow-world-error.mdx +8 -8
- package/docs/api-reference/workflow-globals.mdx +14 -10
- package/docs/api-reference/workflow-nest/configure-workflow-controller.mdx +33 -0
- package/docs/api-reference/workflow-nest/index.mdx +31 -0
- package/docs/api-reference/workflow-nest/meta.json +9 -0
- package/docs/api-reference/workflow-nest/nest-local-builder.mdx +64 -0
- package/docs/api-reference/workflow-nest/workflow-controller.mdx +40 -0
- package/docs/api-reference/workflow-nest/workflow-module.mdx +74 -0
- package/docs/api-reference/workflow-next/with-workflow.mdx +39 -17
- package/docs/api-reference/workflow-nitro/index.mdx +60 -0
- package/docs/api-reference/workflow-nuxt/index.mdx +48 -0
- package/docs/api-reference/workflow-observability/hydrate-data.mdx +35 -0
- package/docs/api-reference/workflow-observability/hydrate-resource-io.mdx +62 -0
- package/docs/api-reference/workflow-observability/index.mdx +62 -0
- package/docs/api-reference/workflow-observability/meta.json +11 -0
- package/docs/api-reference/workflow-observability/observability-revivers.mdx +50 -0
- package/docs/api-reference/workflow-observability/parse-class-name.mdx +41 -0
- package/docs/api-reference/workflow-observability/parse-step-name.mdx +40 -0
- package/docs/api-reference/workflow-observability/parse-workflow-name.mdx +55 -0
- package/docs/api-reference/workflow-runtime/create-world.mdx +39 -0
- package/docs/api-reference/workflow-runtime/get-world-handlers.mdx +44 -0
- package/docs/api-reference/{workflow-api → workflow-runtime}/get-world.mdx +11 -14
- package/docs/api-reference/workflow-runtime/health-check.mdx +50 -0
- package/docs/api-reference/workflow-runtime/index.mdx +41 -0
- package/docs/api-reference/workflow-runtime/meta.json +12 -0
- package/docs/api-reference/workflow-runtime/set-world.mdx +51 -0
- package/docs/api-reference/workflow-runtime/workflow-entrypoint.mdx +43 -0
- package/docs/api-reference/workflow-runtime/world/analytics.mdx +315 -0
- package/docs/api-reference/workflow-runtime/world/index.mdx +60 -0
- package/docs/api-reference/workflow-runtime/world/meta.json +4 -0
- package/docs/api-reference/workflow-runtime/world/queue.mdx +88 -0
- package/docs/api-reference/{workflow-api → workflow-runtime}/world/storage.mdx +98 -34
- package/docs/api-reference/{workflow-api → workflow-runtime}/world/streams.mdx +8 -8
- package/docs/api-reference/workflow-serde/index.mdx +1 -2
- package/docs/api-reference/workflow-serde/workflow-deserialize.mdx +3 -4
- package/docs/api-reference/workflow-serde/workflow-serialize.mdx +8 -8
- package/docs/api-reference/workflow-sveltekit/index.mdx +18 -0
- package/docs/api-reference/workflow-sveltekit/meta.json +4 -0
- package/docs/api-reference/workflow-sveltekit/workflow-plugin.mdx +42 -0
- package/docs/api-reference/workflow-vite/index.mdx +18 -0
- package/docs/api-reference/workflow-vite/meta.json +4 -0
- package/docs/api-reference/workflow-vite/workflow.mdx +48 -0
- package/docs/changelog/attributes-mvp.mdx +380 -0
- package/docs/changelog/batched-event-writes.mdx +79 -0
- package/docs/changelog/eager-processing.mdx +110 -436
- package/docs/changelog/index.mdx +4 -2
- package/docs/changelog/lazy-event-creation.md +127 -0
- package/docs/changelog/lazy-hook-resume.mdx +78 -0
- package/docs/changelog/meta.json +11 -1
- package/docs/changelog/resilient-resume.mdx +32 -0
- package/docs/changelog/resilient-start.mdx +33 -285
- package/docs/changelog/step-message-ownership.mdx +360 -0
- package/docs/changelog/turbo-mode.md +87 -0
- package/docs/comparisons/index.mdx +66 -0
- package/docs/comparisons/meta.json +11 -0
- package/docs/comparisons/workflow-sdk-vs-aws-agentcore.mdx +55 -0
- package/docs/comparisons/workflow-sdk-vs-aws-step-functions.mdx +111 -0
- package/docs/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx +71 -0
- package/docs/comparisons/workflow-sdk-vs-inngest.mdx +102 -0
- package/docs/comparisons/workflow-sdk-vs-temporal.mdx +123 -0
- package/docs/comparisons/workflow-sdk-vs-trigger-dev.mdx +104 -0
- package/docs/configuration/build-and-diagnostics.mdx +70 -0
- package/docs/configuration/cli-and-web-ui.mdx +241 -0
- package/docs/configuration/framework-options.mdx +165 -0
- package/docs/configuration/index.mdx +32 -0
- package/docs/configuration/meta.json +12 -0
- package/docs/configuration/runtime-tuning.mdx +381 -0
- package/docs/configuration/worlds.mdx +313 -0
- package/docs/cookbook/advanced/child-workflows.mdx +211 -264
- package/docs/cookbook/advanced/meta.json +6 -1
- package/docs/cookbook/advanced/publishing-libraries.mdx +65 -56
- package/docs/cookbook/advanced/serializable-steps.mdx +48 -68
- package/docs/cookbook/advanced/upgrading-workflows.mdx +199 -0
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +78 -60
- package/docs/cookbook/agent-patterns/durable-agent.mdx +23 -131
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +180 -195
- package/docs/cookbook/common-patterns/batching.mdx +18 -14
- package/docs/cookbook/common-patterns/idempotency.mdx +41 -53
- package/docs/cookbook/common-patterns/rate-limiting.mdx +8 -4
- package/docs/cookbook/common-patterns/saga.mdx +23 -19
- package/docs/cookbook/common-patterns/scheduling.mdx +34 -22
- package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +29 -25
- package/docs/cookbook/common-patterns/timeouts.mdx +26 -21
- package/docs/cookbook/common-patterns/webhooks.mdx +10 -6
- package/docs/cookbook/common-patterns/workflow-composition.mdx +30 -27
- package/docs/cookbook/index.mdx +22 -21
- package/docs/cookbook/integrations/ai-sdk.mdx +86 -48
- package/docs/cookbook/integrations/chat-sdk.mdx +50 -33
- package/docs/cookbook/integrations/sandbox.mdx +62 -45
- package/docs/deploying.mdx +95 -0
- package/docs/errors/abort-signal-timeout-in-workflow.mdx +16 -12
- package/docs/errors/corrupted-event-log.mdx +39 -18
- package/docs/errors/deployment-mismatch.mdx +71 -0
- package/docs/errors/fetch-in-workflow.mdx +15 -14
- package/docs/errors/hook-conflict.mdx +69 -13
- package/docs/errors/index.mdx +2 -36
- package/docs/errors/node-js-module-in-workflow.mdx +9 -5
- package/docs/errors/replay-divergence.mdx +27 -0
- package/docs/errors/run-expired.mdx +85 -0
- package/docs/errors/runtime-decryption-failed.mdx +77 -0
- package/docs/errors/serialization-failed.mdx +44 -12
- package/docs/errors/start-invalid-workflow-function.mdx +9 -5
- package/docs/errors/step-executed-multiple-times.mdx +23 -0
- package/docs/errors/step-not-registered.mdx +6 -6
- package/docs/errors/timeout-in-workflow.mdx +12 -8
- package/docs/errors/webhook-invalid-respond-with-value.mdx +18 -18
- package/docs/errors/webhook-response-not-sent.mdx +20 -16
- package/docs/errors/workflow-not-registered.mdx +5 -5
- package/docs/foundations/cancellation.mdx +31 -32
- package/docs/foundations/errors-and-retries.mdx +42 -11
- package/docs/foundations/hooks.mdx +64 -35
- package/docs/foundations/idempotency.mdx +244 -12
- package/docs/foundations/index.mdx +1 -23
- package/docs/foundations/meta.json +2 -1
- package/docs/foundations/serialization.mdx +21 -22
- package/docs/foundations/starting-workflows.mdx +106 -30
- package/docs/foundations/streaming.mdx +108 -60
- package/docs/foundations/versioning.mdx +263 -0
- package/docs/foundations/workflows-and-steps.mdx +9 -9
- package/docs/getting-started/astro.mdx +22 -18
- package/docs/getting-started/express.mdx +15 -11
- package/docs/getting-started/fastify.mdx +15 -11
- package/docs/getting-started/hono.mdx +15 -11
- package/docs/getting-started/index.mdx +10 -3
- package/docs/getting-started/meta.json +3 -1
- package/docs/getting-started/nestjs.mdx +87 -20
- package/docs/getting-started/next.mdx +22 -16
- package/docs/getting-started/nitro.mdx +22 -18
- package/docs/getting-started/nuxt.mdx +15 -11
- package/docs/getting-started/python.mdx +190 -41
- package/docs/getting-started/react-router/index.mdx +33 -0
- package/docs/getting-started/react-router/meta.json +5 -0
- package/docs/getting-started/react-router/v7.mdx +237 -0
- package/docs/getting-started/react-router/v8.mdx +232 -0
- package/docs/getting-started/sveltekit.mdx +20 -16
- package/docs/getting-started/tanstack-start.mdx +17 -13
- package/docs/getting-started/vite.mdx +15 -11
- package/docs/how-it-works/cancellation.mdx +63 -63
- package/docs/how-it-works/code-transform.mdx +83 -67
- package/docs/how-it-works/encryption.mdx +30 -26
- package/docs/how-it-works/event-sourcing.mdx +125 -34
- package/docs/how-it-works/framework-integrations.mdx +96 -337
- package/docs/how-it-works/understanding-directives.mdx +22 -22
- package/docs/internal/index.mdx +6 -4
- package/docs/internal/meta.json +6 -1
- package/docs/internal/nitro-native-build.mdx +38 -0
- package/docs/internal/nitro-web-ui.mdx +24 -0
- package/docs/internal/serializable-abort-controller.mdx +7 -7
- package/docs/meta.json +3 -2
- package/docs/observability/attributes.mdx +134 -0
- package/docs/observability/index.mdx +32 -10
- package/docs/observability/meta.json +1 -1
- package/docs/observability/retention.mdx +95 -0
- package/docs/observability/tracing.mdx +124 -0
- package/docs/testing/index.mdx +36 -36
- package/docs/testing/server-based.mdx +10 -10
- package/docs/whats-new.mdx +186 -0
- package/package.json +17 -14
- package/docs/api-reference/workflow-api/world/index.mdx +0 -58
- package/docs/api-reference/workflow-api/world/meta.json +0 -4
- package/docs/api-reference/workflow-api/world/observability.mdx +0 -164
- package/docs/api-reference/workflow-api/world/queue.mdx +0 -86
- package/docs/deploying/building-a-world.mdx +0 -251
- package/docs/deploying/index.mdx +0 -95
- package/docs/deploying/meta.json +0 -4
- package/docs/deploying/world/local-world.mdx +0 -84
- package/docs/deploying/world/meta.json +0 -4
- package/docs/deploying/world/postgres-world.mdx +0 -224
- package/docs/deploying/world/vercel-world.mdx +0 -179
- package/docs/migration-guides/index.mdx +0 -34
- package/docs/migration-guides/meta.json +0 -9
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +0 -363
- package/docs/migration-guides/migrating-from-inngest.mdx +0 -314
- package/docs/migration-guides/migrating-from-temporal.mdx +0 -318
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +0 -337
package/docs/changelog/index.mdx
CHANGED
|
@@ -12,5 +12,7 @@ Stay up to date with the latest changes to Workflow SDK.
|
|
|
12
12
|
|
|
13
13
|
## 2026
|
|
14
14
|
|
|
15
|
-
- [
|
|
16
|
-
- [
|
|
15
|
+
- [Durable hook resume](/docs/changelog/lazy-hook-resume) (August 2026)
|
|
16
|
+
- [Resilient hook resume](/docs/changelog/resilient-resume) (July 2026)
|
|
17
|
+
- [Eager processing of steps and incremental event replay](/docs/changelog/eager-processing) (March 2026)
|
|
18
|
+
- Serializable AbortController and AbortSignal (March 12, 2026)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Lazy inline step start
|
|
3
|
+
description: Defer step_created for the inline step and fold it into a single step_started, saving one world round-trip per inline step.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lazy inline step start
|
|
7
|
+
|
|
8
|
+
## Motivation
|
|
9
|
+
|
|
10
|
+
The owned-inline runtime path used to write two separate world events for a step it already owns and is about to run inline:
|
|
11
|
+
|
|
12
|
+
1. `step_created`, written by the suspension handler (`suspension-handler.ts`)
|
|
13
|
+
2. `step_started`, written by `executeStep` (`step-executor.ts`)
|
|
14
|
+
3. `step_completed` / `step_failed`, written by `executeStep`
|
|
15
|
+
|
|
16
|
+
On the Vercel world each `world.events.create` is a network round-trip, so for a sequential `"use step"` workflow this is pure latency between steps. Steps (1) and (2) are two round-trips for a step we already own and are about to execute in the same invocation.
|
|
17
|
+
|
|
18
|
+
This change defers `step_created` for that one inline step: `executeStep` sends a single `step_started` carrying the step input, and the world creates the step on the fly. This materializes the step entity **and** a synthetic `step_created` event so replay still observes it. **Two writes per inline step instead of three.** It mirrors the existing [resilient `run_started` → `run_created`](./resilient-start) pattern.
|
|
19
|
+
|
|
20
|
+
Steps that are *queued* (not run inline) keep their eager `step_created` and are unchanged. Only the single inline step per suspension is made lazy.
|
|
21
|
+
|
|
22
|
+
## Design
|
|
23
|
+
|
|
24
|
+
### Suspension handler
|
|
25
|
+
|
|
26
|
+
- `handleSuspension` selects exactly one step to defer: the first uncreated step (`stepItems.find(item => stepsNeedingCreation.has(...))`), which matches the inline candidate the caller would have picked.
|
|
27
|
+
- For that step it **skips** the `step_created` write and instead returns it as `lazyInlineStep = { correlationId, stepName, dehydratedInput }`. It is **not** added to `createdStepCorrelationIds` because ownership is no longer decided here.
|
|
28
|
+
- A `lazyInlineStep` is designated only when there is no `hook.getConflict()` awaiter (`hasAwaitedHookCreation === false`). With an awaiter present nothing runs inline, so nothing is deferred.
|
|
29
|
+
|
|
30
|
+
### `executeStep`
|
|
31
|
+
|
|
32
|
+
- A new `lazyStepInput` parameter carries the already-dehydrated input. When present, the `step_started` event includes `input` so the world creates the step on the fly. When absent, `step_started` carries only `stepName` (the legacy contract).
|
|
33
|
+
- For an **unregistered** step on the lazy path, `executeStep` sends the lazy `step_started` first to materialize the step, *then* writes `step_failed` (see [Materialize before failing](#materialize-before-failing-unregistered-steps) below).
|
|
34
|
+
|
|
35
|
+
### World contract
|
|
36
|
+
|
|
37
|
+
- `step_started` accepts an optional `input`. When provided for a non-existent step, the world atomically creates the step entity plus a **synthetic `step_created`** event (so the event log reads `created → started → completed`), then records `step_started`.
|
|
38
|
+
- A `stepCreated` signal is added to the event result so callers can tell whether the lazy `step_started` created the step or attached to an existing one.
|
|
39
|
+
- Worlds updated: `world-local`, `world-postgres`, `world-vercel`.
|
|
40
|
+
|
|
41
|
+
## Interactions with inline execution
|
|
42
|
+
|
|
43
|
+
### Exactly-one-owner is preserved (the race moved)
|
|
44
|
+
|
|
45
|
+
The guarantee that exactly one handler runs a step's body inline is intact, but it resolves at a different event:
|
|
46
|
+
|
|
47
|
+
- **Before:** ownership was won at the atomic `step_created` claim in the suspension handler; the loser caught `EntityConflictError` and queued instead.
|
|
48
|
+
- **After:** two concurrent handlers may both *select* the same `lazyInlineStep` (selection is optimistic, before any race). The race is now the world's atomic create-claim inside the lazy `step_started` (lock file in world-local, `onConflictDoNothing` + unique index in world-postgres, `attribute_not_exists` on the server). The loser gets `EntityConflictError`, which `executeStep` maps to `{ type: 'skipped' }`, so it never runs the body.
|
|
49
|
+
|
|
50
|
+
This is safe **because a lazy `step_started` is only ever sent for a brand-new step**. The suspension handler defers only steps with no prior `step_created` (`!hasCreatedEvent`).
|
|
51
|
+
|
|
52
|
+
### Crash recovery is unchanged
|
|
53
|
+
|
|
54
|
+
On crash recovery the step already has a `step_created` event in the log (`hasCreatedEvent === true`), so it is **not** a lazy candidate. It is re-queued and re-run via a normal **non-lazy** `step_started`, which re-starts a `running` step. At-least-once execution is preserved exactly as before.
|
|
55
|
+
|
|
56
|
+
### Materialize before failing unregistered steps
|
|
57
|
+
|
|
58
|
+
The inline path previously assumed an invariant: *by the time `executeStep` runs, the step entity already exists* (the suspension handler created it). The unregistered-step branch relied on this and writes `step_failed` directly, with no preceding `step_started`.
|
|
59
|
+
|
|
60
|
+
With the `step_created` deferred, the entity no longer exists when `executeStep` bails for an unregistered step, so the `step_failed` write hits the world's "step must exist" ordering guard and is rejected, wedging the run until it times out. On the lazy path, send the lazy `step_started` first (creating the entity + synthetic `step_created`), then write `step_failed`. The lazy `step_started`'s atomic create-claim still preserves exactly-one-owner. A concurrent winner makes our create reject with `EntityConflictError` → `skipped`, so the failure is never written twice.
|
|
61
|
+
|
|
62
|
+
This is the general rule the deferral introduces: **any inline path that writes a terminal step event must first ensure the deferred step has been materialized.**
|
|
63
|
+
|
|
64
|
+
### Replay correctness and the inline-delta fast path
|
|
65
|
+
|
|
66
|
+
The client step consumer (`step.ts`) sets `hasCreatedEvent` only when it observes a `step_created` event, and checks step-name divergence against `stepName`. The lazy path stays replay-correct only because the world writes a **synthetic `step_created`**: replay still sees `created → started → completed`. The input lives on the synthetic `step_created`; the `step_started` row drops the input but keeps `stepName` for the divergence check.
|
|
67
|
+
|
|
68
|
+
This intersects with the inline-delta optimization. The delta returned on the `step_completed` write is consumed by the *next* replay in place of an `events.list`, diffed against `preInlineWriteCursor` (snapshotted before replay). Because the synthetic `step_created`, `step_started`, and `step_completed` are all written *after* that cursor, the world's "events since cursor" delta carries the full triple, so the next replay does not diverge. The delta gate (one step, no hooks/waits, the lone pending step is the inline one) is unchanged.
|
|
69
|
+
|
|
70
|
+
### Pre-emption by attributes / hook conflicts
|
|
71
|
+
|
|
72
|
+
When `attr_set` events force an immediate in-process replay, or a hook conflict forces a re-invocation, the handler skips the dispatch loop for that pass. The deferred step is therefore **neither created nor queued** on that pass; it is recreated and run on the following replay (where it is still a lazy candidate).
|
|
73
|
+
|
|
74
|
+
This is a small behavioral improvement: previously the eager `step_created` left an orphan "created but never started" event when a step lost an attribute/hook race (e.g. `Promise.race([setAttributes(), step()])` where the attribute write wins and completes the run). With deferral, a step that loses the race is never created, reducing event-log garbage.
|
|
75
|
+
|
|
76
|
+
### `hook.getConflict()` awaiter
|
|
77
|
+
|
|
78
|
+
When a `hook.getConflict()` awaiter is present, no `lazyInlineStep` is designated, nothing runs inline, every step gets its eager `step_created` and is queued, and the handler re-invokes immediately so replay resolves the awaiter. This is identical to the pre-change behavior. The deferral never serializes the awaiter's parallel continuation behind an inline step.
|
|
79
|
+
|
|
80
|
+
## Rollout and compatibility
|
|
81
|
+
|
|
82
|
+
Server-first. The matching world-vercel backend change must deploy before this ships; the Vercel world targets a single backend whose spec version is always at least the SDK's, so the new SDK only ever talks to an already-upgraded backend. An old SDK against a new backend is safe because the lazy path is strictly additive. It triggers only when `step_started` carries both `stepName` and `input`, which old SDKs never send. For `world-local` / `world-postgres` the world ships in the same package as the runtime, so there is no version skew. Detection is by `input` presence on the event, mirroring resilient `run_started`, so no capability negotiation is needed.
|
|
83
|
+
|
|
84
|
+
# Parallel inline steps + optimistic start
|
|
85
|
+
|
|
86
|
+
A follow-up builds two more latency wins on top of lazy inline start. Both are client-side only; they reuse the world's lazy create-on-`step_started` support and need no further world/backend changes.
|
|
87
|
+
|
|
88
|
+
## Inline up to N steps in parallel
|
|
89
|
+
|
|
90
|
+
Previously the owned-inline path ran **exactly one** step inline per suspension and queued the rest. For a `Promise.all([stepA(), stepB(), stepC()])` fan-out that meant one branch ran inline while the others paid a queue round-trip each before making any progress.
|
|
91
|
+
|
|
92
|
+
The suspension handler now defers `step_created` for up to **`WORKFLOW_MAX_INLINE_STEPS` (default 3)** steps and returns them as `lazyInlineSteps`. The runtime runs that batch inline **in parallel** (`Promise.all`), each via its own lazy `step_started`, and queues only the steps beyond the cap.
|
|
93
|
+
|
|
94
|
+
- **Selection:** the first N uncreated steps, matching the previous single-step inline candidate. Steps beyond N keep their eager `step_created` and are queued exactly as before.
|
|
95
|
+
- **Result aggregation:** `retry` steps (whose `step_started` succeeded, so the step exists) are re-queued per-step as background steps with their own delay. `throttled` steps are different: a throttle rejects the lazy `step_started` on the create-claim, so the step was *never created* and has no recoverable input. Re-queuing it as an input-less background step would make the world reject the bare `step_started` with "Step not found" and redeliver until it fails. Any throttle instead **defers redelivery of the orchestrator** (by the longest throttle backoff in the batch), which re-runs the throttled step inline *with its input* on replay. The runtime only loops back to replay in-process once every inline step has reached a terminal state.
|
|
96
|
+
- **Inline-delta fast path:** still used only for the single-step sequential case (`lazyInlineSteps.length === 1`). With more than one inline step each writes its own events, so a per-write delta would be partial; multi-step batches fall back to a normal incremental `events.list`.
|
|
97
|
+
- **Config:** `WORKFLOW_MAX_INLINE_STEPS` is clamped to 1..16. Setting it to `1` reproduces the previous single-inline-step behavior exactly (a useful kill-switch). Inline bodies run in parallel within one function invocation, so the cap also bounds per-handler memory/CPU fan-out.
|
|
98
|
+
|
|
99
|
+
## Optimistic inline start (opt-in, off by default)
|
|
100
|
+
|
|
101
|
+
Normally `executeStep` awaits `step_started` (the lazy create-claim round-trip) before running the body. Because the inline path already holds the step input locally, it doesn't actually need that round-trip to begin.
|
|
102
|
+
|
|
103
|
+
When `WORKFLOW_OPTIMISTIC_INLINE_START` is enabled (set it to `1`/`true`; it is **off by default**), an inline step fires `step_started` **without awaiting it** and starts running the body immediately against locally-synthesized state. A lazy step is always brand-new, so attempt is 1, there is no prior error, and `startedAt` is now. All values are known without the server. The in-flight `step_started` is reconciled immediately before the terminal write:
|
|
104
|
+
|
|
105
|
+
- **Lost the create-claim (409 / `EntityConflictError`)** → discard the body result and return `skipped`; the winning handler owns the terminal write.
|
|
106
|
+
- **Run gone / throttled / too-early** → discard the body result and surface `gone` / `throttled` / `retry`.
|
|
107
|
+
- **Transient (non-translatable) failure** → propagate it, so the queue redelivers exactly as the await path does today.
|
|
108
|
+
- **Success** → write `step_completed` / `step_failed` / `step_retrying` as usual. Awaiting `step_started` before the terminal write keeps the event log ordered (`created → started → completed`).
|
|
109
|
+
|
|
110
|
+
### Safety and the idempotency tradeoff
|
|
111
|
+
|
|
112
|
+
- **Exactly-one terminal write is preserved.** Optimistic start changes only *when the body runs*, never who writes the terminal event. The terminal event is still gated by the lazy `step_started` create-claim, which is awaited before the terminal write. Losers return `skipped`.
|
|
113
|
+
- **Bounded to attempt 1.** Only brand-new (`!hasCreatedEvent`) steps are lazy; a retried step already has a `step_created`, so it takes the normal await-then-run path with the real attempt counter. Synthesizing `attempt = 1` locally is therefore always correct.
|
|
114
|
+
- **Why wider double-execution is off by default.** Running the body before confirming ownership means two handlers racing for the same step's create-claim can *both* run the side effects before either wins (previously the loser 409'd on `step_created` and skipped before running anything). This is unsafe for non-idempotent steps. In particular, two concurrent runs of a step that writes to the **workflow stream** (e.g. an AI agent streaming tokens) can interleave and **corrupt the stream data**. The optimization therefore ships **disabled**; enable it (`WORKFLOW_OPTIMISTIC_INLINE_START=1`) only for deployments whose inline step bodies are idempotent and stream-safe.
|
|
115
|
+
|
|
116
|
+
## Queue messages: inline steps don't pay a round-trip
|
|
117
|
+
|
|
118
|
+
Inline steps that **complete** never enqueue a per-step flow-route message. When every step in an inline batch reaches a terminal state with no pending background ops, the runtime continues its in-process loop and replays. A sequential chain (or a clean parallel fan-out) of inline steps therefore runs entirely within one invocation with **zero** queue messages. A workflow whose only work is three parallel inline steps issues no `queue()` calls.
|
|
119
|
+
|
|
120
|
+
The only flow-route messages produced around an inline batch are:
|
|
121
|
+
|
|
122
|
+
- **Pre-batch dispatch:** The steps *beyond* the inline cap (and any pending wait/sleep continuation). Inline steps are explicitly excluded from this dispatch.
|
|
123
|
+
- **`retry` results:** One delayed message per retried step. A retry *is* the step becoming its own background invocation, so this is expected.
|
|
124
|
+
- **`throttled` results:** A single deferral of the orchestrator message (see above).
|
|
125
|
+
- **Pending background ops:** If any inline step left unflushed stream writes (e.g. output streams to blob storage), the loop breaks and enqueues **one** continuation (aggregated across the batch, not per-step) so `waitUntil` can flush before the next replay reads them.
|
|
126
|
+
|
|
127
|
+
In other words: completed inline steps cost no queue round-trips; only steps that genuinely run as their own background invocations create new flow-route messages.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Durable hook resume
|
|
3
|
+
description: resumeHook() durably writes hook_received and only then publishes the workflow wake, so a resolved call can never be lost to a disposal race.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Durable hook resume
|
|
7
|
+
|
|
8
|
+
## Motivation
|
|
9
|
+
|
|
10
|
+
The previous lazy path published the serialized hook payload on the workflow
|
|
11
|
+
queue and left the queue consumer to create `hook_received`. If the hook was
|
|
12
|
+
disposed after `resumeHook()` returned but before the consumer write committed,
|
|
13
|
+
that write was rejected and the acknowledged queue delivery could not resume the
|
|
14
|
+
workflow: the caller was told the resume succeeded, and it was lost.
|
|
15
|
+
|
|
16
|
+
`resumeHook()` now resolves only after both the durable event write and the
|
|
17
|
+
workflow wake have succeeded, in that order.
|
|
18
|
+
|
|
19
|
+
## Design
|
|
20
|
+
|
|
21
|
+
The dispatch is strictly serial:
|
|
22
|
+
|
|
23
|
+
1. The hook is resolved by token. An unknown token throws `HookNotFoundError`.
|
|
24
|
+
2. The producer writes `hook_received` durably into the run's event log. A
|
|
25
|
+
client-minted `resumeId` and payload digest ride the write when the backend
|
|
26
|
+
supports atomic resume claims, so transport-level retries of the same write
|
|
27
|
+
converge on exactly one committed event. A write refused because the hook
|
|
28
|
+
was disposed or the run ended throws `HookNotFoundError`.
|
|
29
|
+
3. Only after the write is acknowledged does the producer publish the workflow
|
|
30
|
+
wake. The wake carries no payload — the payload lives in the event log — so
|
|
31
|
+
nothing rides on the queue message but the trigger. Publication is retried
|
|
32
|
+
a bounded number of times.
|
|
33
|
+
|
|
34
|
+
Because the event is committed before the wake exists, a disposal or run
|
|
35
|
+
completion racing the queue delivery cannot erase a resume the caller was told
|
|
36
|
+
succeeded: the delivery replays the committed event from the log.
|
|
37
|
+
|
|
38
|
+
- `ResumedHook.resilientResume` remains on the type for source compatibility
|
|
39
|
+
and is no longer set. The internal `resumeHookDurable()` entry point is
|
|
40
|
+
removed; `resumeHook()` itself now provides the durable guarantee.
|
|
41
|
+
|
|
42
|
+
A resolved call proves that the event is durable and the wake was accepted.
|
|
43
|
+
`HookNotFoundError` proves this invocation committed no event. Any other thrown
|
|
44
|
+
error is ambiguous only in *dispatch*, never in durability: a wake failure
|
|
45
|
+
after the write leaves the event committed, and any later wake of the run
|
|
46
|
+
(from any source) delivers it. A fresh `resumeHook()` invocation mints a new
|
|
47
|
+
`resumeId`, so blindly retrying a failed call can append a second
|
|
48
|
+
`hook_received`; callers that need at-most-once behavior across separate
|
|
49
|
+
invocations must deduplicate on their own request key.
|
|
50
|
+
|
|
51
|
+
## Behavior change: resumes against an ended run
|
|
52
|
+
|
|
53
|
+
The lazy path never observed the server's rejection — it published a message
|
|
54
|
+
and resolved, so a resume against a run that had already ended reported
|
|
55
|
+
success (reachable whenever the hook record outlives its run, e.g. token
|
|
56
|
+
retention). The durable write restores the check: **a resume against an ended
|
|
57
|
+
run now throws `HookNotFoundError`**, and a late webhook delivery to a
|
|
58
|
+
finished run answers 404 where it previously answered 202. Senders that treat
|
|
59
|
+
4xx as terminal will stop retrying such deliveries; that is the correct
|
|
60
|
+
signal, since nothing can resume an ended run.
|
|
61
|
+
|
|
62
|
+
A transient write conflict (HTTP 409, e.g. an event-slot conflict that
|
|
63
|
+
escaped the server's internal retry budget under contention) is no longer
|
|
64
|
+
re-keyed to `HookNotFoundError`. It surfaces as a retryable error, and its
|
|
65
|
+
rejected transaction committed nothing, so retrying the resume is safe.
|
|
66
|
+
|
|
67
|
+
## Compatibility
|
|
68
|
+
|
|
69
|
+
Nothing about the queue message changes: the wake has the same shape the
|
|
70
|
+
sequential path always published, so no consumer, backend, or server
|
|
71
|
+
coordination is needed and either side can roll back independently.
|
|
72
|
+
|
|
73
|
+
Consumers continue to accept legacy `hookInput` messages from older producers,
|
|
74
|
+
materializing their payload before replay. This permits rolling upgrades
|
|
75
|
+
without a coordinated producer and consumer deployment.
|
|
76
|
+
|
|
77
|
+
`WORKFLOW_DISABLE_LAZY_HOOK_RESUME` no longer gates anything and is ignored:
|
|
78
|
+
there is no lazy path left to disable.
|
package/docs/changelog/meta.json
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"title": "Changelog",
|
|
3
|
-
"pages": [
|
|
3
|
+
"pages": [
|
|
4
|
+
"index",
|
|
5
|
+
"lazy-hook-resume",
|
|
6
|
+
"eager-processing",
|
|
7
|
+
"resilient-resume",
|
|
8
|
+
"resilient-start",
|
|
9
|
+
"lazy-event-creation",
|
|
10
|
+
"turbo-mode",
|
|
11
|
+
"step-message-ownership",
|
|
12
|
+
"batched-event-writes"
|
|
13
|
+
],
|
|
4
14
|
"defaultOpen": false
|
|
5
15
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Resilient hook resume
|
|
3
|
+
description: resumeHook() now tolerates transient event storage failures when the queue is healthy instead of failing the resume.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Resilient `resumeHook()`
|
|
7
|
+
|
|
8
|
+
<Callout type="info">
|
|
9
|
+
Superseded by [durable hook resume](/docs/changelog/lazy-hook-resume):
|
|
10
|
+
`resumeHook()` now writes the `hook_received` event durably and only then
|
|
11
|
+
publishes the workflow wake, so the two-writer design, the queue-carried
|
|
12
|
+
payload, and the `resilientResume` flag described below are historical. The
|
|
13
|
+
`(runId, resumeId)` constraint remains, converging transport-level retries
|
|
14
|
+
of the producer's own write (and legacy `hookInput` redeliveries from older
|
|
15
|
+
producers).
|
|
16
|
+
</Callout>
|
|
17
|
+
|
|
18
|
+
## Motivation
|
|
19
|
+
|
|
20
|
+
`resumeHook()` used to write the `hook_received` event and dispatch the workflow queue message strictly one after the other, so every resume paid two sequential round trips and a transient event-storage failure failed the whole resume even when the queue was healthy. This change runs both writes **concurrently** (cutting a round trip off resume latency) and, on the same path, brings `resumeHook()` to parity with [resilient `start()`](/docs/changelog/resilient-start): a transient event-write failure no longer fails the resume when the payload can still be delivered through the queue.
|
|
21
|
+
|
|
22
|
+
## Design
|
|
23
|
+
|
|
24
|
+
- On the fast path, `resumeHook()` writes the `hook_received` event and dispatches the workflow queue message **concurrently** (`Promise.allSettled`). The queue message carries a `hookInput` payload: the dehydrated hook payload plus a client-minted `resumeId` idempotency key, the hook token, and a payload digest.
|
|
25
|
+
- A `(runId, resumeId)` dedup constraint keeps the two writers converging on **exactly one** `hook_received` event: whichever lands first wins, and the other is resolved server-side as success rather than a duplicate. The queue consumer idempotently re-ensures the event from `hookInput` before replay, so the resume is guaranteed even if the direct write never commits.
|
|
26
|
+
- Replay also deduplicates: `hook_received` events sharing a `resumeId` belong to the same resume attempt, and only the first in the event log is delivered to workflow code. Even if a redelivery materializes the event twice, the payload reaches the workflow exactly once.
|
|
27
|
+
- **Queue dispatch failure is fatal**: The run was not retriggered, so no consumer will re-ensure the event, and `resumeHook()` throws. A transient event-write failure (429/5xx, a transport error, or an expected `(runId, resumeId)` conflict with the consumer's own re-ensure) is swallowed because the queue delivery still guarantees the resume; a terminal run surfaces as `HookNotFoundError`, and any other event-write error is rethrown.
|
|
28
|
+
- `resumeHook()` returns `ResumedHook` (exported from `workflow/api`), which extends `Hook` with an optional `resilientResume` flag. The flag is `true` only when the direct write failed transiently and the resume was recovered through the queue; on the happy path and the sequential fallback it is absent.
|
|
29
|
+
|
|
30
|
+
## Compatibility
|
|
31
|
+
|
|
32
|
+
The parallel fast path is gated per resume: it activates only when both the target run's queue consumer and the live backend independently attest dedup support (re-checked on every resume, so rollout and rollback both degrade safely). Otherwise (for oversized payloads or legacy runs), `resumeHook()` falls back to the original sequential write-then-dispatch path. Because runs keep executing on the deployment they were created on, a resume targeting a run from an older deployment uses the sequential path.
|