workflow 5.0.0-beta.50 → 5.0.0-beta.52

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.
@@ -11,7 +11,7 @@ related:
11
11
  ---
12
12
 
13
13
  <CopyPrompt
14
- text="Set up Workflow in this Python project. In `pyproject.toml`, add `requires-python = &quot;&gt;=3.12&quot;` and `dependencies = [&quot;vercel-workflow&quot;]` under `[project]`, then add `[[tool.vercel.workflows]]` with `entrypoint = &quot;app.workflows:wf&quot;`. Create `app/workflow.py` with `from vercel import workflow` and `wf = workflow.Workflows()`. Create `app/steps/generate_draft.py`, import `wf`, and define async step functions such as `generate_draft` and `summarize_draft`, decorating each with `@wf.step`. Then create `app/workflows/ai_content_workflow.py`, import `wf` and those step functions, and define `@wf.workflow async def ai_content_workflow(*, topic: str)` to orchestrate them and return the result. In `app/workflows/__init__.py`, export `wf` and import the workflow module so its definitions are registered. From server-side code, start it with `await workflow.start(ai_content_workflow, topic=...)`; use the returned `Run` to access its ID, check its status, or await its return value. Where the workflow needs a durable delay, use `await workflow.sleep(timedelta(days=7))` after importing `timedelta` from `datetime`. Where it needs an external approval event, define a Pydantic model that also extends `workflow.BaseHook`, wait with `.wait(token=...)`, and resume it from server-side code with `.resume(token)`."
14
+ text="Set up Workflow in this Python project. In `pyproject.toml`, add `requires-python = &quot;&gt;=3.12&quot;` and `dependencies = [&quot;vercel-workflow&quot;]` under `[project]`, then add `[[tool.vercel.workflows]]` with `entrypoint = &quot;app.workflows:wf&quot;`. Create `app/workflow.py` with `from vercel import workflow` and `wf = workflow.Workflows(sandbox_policy=workflow.SandboxPolicy(share_sandboxes=True))`. Create `app/steps/generate_draft.py`, import `wf`, and define async step functions such as `generate_draft` and `summarize_draft`, decorating each with `@wf.step`. Then create `app/workflows/ai_content_workflow.py`, import `wf` and those step functions, and define `@wf.workflow async def ai_content_workflow(*, topic: str)` to orchestrate them and return the result. In `app/workflows/__init__.py`, export `wf` and import the workflow module so its definitions are registered. From server-side code, start it with `await workflow.start(ai_content_workflow, topic=...)`; use the returned `Run` to access its ID, check its status, or await its return value. Where the workflow needs a durable delay, use `await workflow.sleep(timedelta(days=7))` after importing `timedelta` from `datetime`. Where it needs an external approval event, define a Pydantic model that also extends `workflow.BaseHook`, wait with `.wait(token=...)`, and resume it from server-side code with `.resume(token)`."
15
15
  />
16
16
 
17
17
  <Callout type="warn">
@@ -42,9 +42,13 @@ A workflow is a stateful function that coordinates multi-step logic over time. C
42
42
  ```python filename="app/workflow.py"
43
43
  from vercel import workflow
44
44
 
45
- wf = workflow.Workflows() # [!code highlight]
45
+ wf = workflow.Workflows(
46
+ sandbox_policy=workflow.SandboxPolicy(share_sandboxes=True), # [!code highlight]
47
+ )
46
48
  ```
47
49
 
50
+ `share_sandboxes=True` reuses a sandbox across workflow runs for faster startup. Module globals are shared between those runs, so workflow code should not mutate global state.
51
+
48
52
  ```python filename="app/workflows/ai_content_workflow.py"
49
53
  from app.workflow import wf
50
54
  from app.steps.generate_draft import generate_draft, summarize_draft
@@ -96,6 +100,33 @@ async def summarize_draft(*, draft: str):
96
100
 
97
101
  Each step executes separately from the workflow orchestrator. While the step executes, the workflow suspends without consuming resources. When the step completes, the workflow resumes automatically where it left off.
98
102
 
103
+ ### Cancellable steps
104
+
105
+ Pass `cancellable=True` when a workflow may need to stop a running step. This makes normal Python `asyncio` cancellation apply to the step: if a task awaiting the step is cancelled in the workflow, then the task running the step will be cancelled as well. For example, `asyncio.timeout()` cancels the step if it does not finish within the allotted time:
106
+
107
+ ```python filename="app/workflows/report.py"
108
+ import asyncio
109
+ from datetime import timedelta
110
+
111
+ from app.workflow import wf
112
+
113
+
114
+ @wf.step(cancellable=True) # [!code highlight]
115
+ async def generate_report(*, account_id: str) -> dict[str, str]:
116
+ return await reporting_service.generate(account_id=account_id)
117
+
118
+
119
+ @wf.workflow
120
+ async def report_workflow(*, account_id: str) -> dict[str, str]:
121
+ try:
122
+ async with asyncio.timeout(timedelta(minutes=10).total_seconds()): # [!code highlight]
123
+ return await generate_report(account_id=account_id)
124
+ except TimeoutError:
125
+ return {"status": "timed_out"}
126
+ ```
127
+
128
+ Cancellation is a request, so the workflow waits for the step to terminate before continuing. If the step suppresses its cancellation, it can still return normally.
129
+
99
130
  ## Starting a workflow
100
131
 
101
132
  Call `workflow.start()` from server-side code to start a workflow. It returns a `Run` that you can use to identify the run, check its status, and wait for its result:
@@ -167,6 +198,18 @@ The string form accepts one or more `<value><unit>` pairs. Supported units:
167
198
 
168
199
  The sleep consumes no resources. The workflow resumes automatically when the time expires.
169
200
 
201
+ `asyncio.sleep()` may also be used to sleep.
202
+
203
+ ## Deterministic workflow helpers
204
+
205
+ Workflow bodies must be fully deterministic, and so are not allowed to perform operations like reading the system clock or generating randomness using the default generator. Deterministic replacements are provided for use in workflow bodies:
206
+
207
+ - `workflow.now()` is a deterministic substitute for `datetime.datetime.now`. It returns the time that the last workflow event occurred at.
208
+ - `workflow.time_ns()` is a deterministic substitute for `time.time_ns`.
209
+ - `workflow.random()` returns a `random.Random` instance with a seed based on the run id.
210
+
211
+ These helpers can only be called from a workflow body. Steps should use the normal system functions.
212
+
170
213
  ## Hooks
171
214
 
172
215
  A hook lets a workflow wait for external events such as user actions, webhooks, or third-party API responses.
@@ -187,7 +230,7 @@ class Approval(pydantic.BaseModel, workflow.BaseHook): # [!code highlight]
187
230
  notes: str | None = None
188
231
 
189
232
  @wf.workflow
190
- async def ai_approval_workflow(*, topic: str):
233
+ async def ai_approval_workflow(*, topic: str) -> None:
191
234
  draft = await generate_draft(topic=topic)
192
235
 
193
236
  # Wait for human approval events
@@ -209,7 +252,8 @@ from app.workflows.approval import Approval
209
252
  async def resume(approval: Approval): # [!code highlight]
210
253
  """Resume the workflow when an approval is received"""
211
254
 
212
- await approval.resume("draft-123") # [!code highlight]
255
+ hook = await approval.resume("draft-123") # [!code highlight]
256
+ print(f"Resumed workflow run: {hook.run_id}") # [!code highlight]
213
257
  return {"ok": True}
214
258
  ```
215
259
 
@@ -217,41 +261,51 @@ When a hook receives data, the workflow resumes automatically. You don&apos;t ne
217
261
 
218
262
  ## Streaming
219
263
 
220
- Steps can stream progress while a workflow is running. Get the run&apos;s writable stream inside a step, write values to it, and close it when no more values will be sent:
264
+ Steps can stream progress while a workflow is running. Streams are associated with workflow runs, can be acquired from inside a workflow or inside a step, and can be passed to workflows and steps as arguments. If a type is specified when getting the stream (or on the type annotation of a step or workflow it is passed to), then Pydantic will be used to encode and decode the type.
221
265
 
222
266
  ```python filename="app/workflows/streaming.py"
267
+ import pydantic
268
+
223
269
  from app.workflow import wf
224
270
  from vercel import workflow
225
271
 
226
- @wf.step
227
- async def write_progress():
228
- writable = workflow.get_writable() # [!code highlight]
229
272
 
273
+ class Progress(pydantic.BaseModel):
274
+ message: str
275
+
276
+
277
+ @wf.step
278
+ async def write_progress(
279
+ writable: workflow.WorkflowWritable[Progress], # [!code highlight]
280
+ ):
230
281
  for message in ["Drafting", "Reviewing", "Complete"]:
231
- await writable.write(message) # [!code highlight]
282
+ await writable.write(Progress(message=message)) # [!code highlight]
232
283
 
233
284
  await writable.close()
234
285
 
235
286
  @wf.workflow
236
287
  async def streaming_workflow():
237
- await write_progress()
288
+ writable = workflow.get_writable(type=Progress)
289
+ await write_progress(writable) # [!code highlight]
238
290
  ```
239
291
 
240
- Read the values from the returned `Run` as they arrive:
292
+ Read and validate the typed values from the returned `Run` as they arrive:
241
293
 
242
294
  ```python filename="app/api/stream.py"
243
- from app.workflows.streaming import streaming_workflow
295
+ from app.workflows.streaming import Progress, streaming_workflow
244
296
  from vercel import workflow
245
297
 
246
298
  @app.post("/api/stream")
247
299
  async def stream_progress():
248
300
  run = await workflow.start(streaming_workflow)
249
301
 
250
- async for message in run.readable(): # [!code highlight]
251
- print(message)
302
+ async for progress in run.readable(type=Progress): # [!code highlight]
303
+ print(progress.message)
252
304
  ```
253
305
 
254
- Streams are not closed automatically. Close the writable in the last step that writes to it so readers know when the stream is complete.
306
+ Streams are not closed automatically. It can be manually closed when it is finished so that readers know to terminate.
307
+
308
+ `readable` and `get_writable` also take a `namespace` parameter, allowing each run to have many distinct streams.
255
309
 
256
310
  ## Next steps
257
311
 
@@ -267,6 +267,33 @@ Both kinds of skip are logged at `debug`, so neither reaches the console unless
267
267
 
268
268
  The observability UI grays out events it can identify this way and shows the reason on hover. Its set is narrower than the runtime's because it reads the log without consumer state. A consumer for an entity that is still open can legitimately claim a repeat because each step retry writes another `step_started`. The UI marks a repeat only when no consumer can remain for it: after a terminal event for the same entity or at a second `run_started`, of which the log records one per run. The UI marks nothing on a partial log view, such as one page of a paginated list or search results, because identifying the first copy requires the entire log.
269
269
 
270
+ ## Events returned on a write
271
+
272
+ Concurrent invocations of one run write to a shared log, and a write is decided against the log the writer had loaded. When the write lands above other events the writer had not seen, the World can hand those events back on the write's own success response, so the writer merges them and continues rather than discovering the gap on its next read. Two parameters of `events.create()` ask for this:
273
+
274
+ - **`eventCount`** says how many events the writer held. When the write commits at a higher position than `eventCount + 1`, the World returns the events on the positions in between (the *skipped-slot report*).
275
+ - **`sinceCursor`** is the `events.list()` cursor the writer had read to. The World returns everything after it, the write itself included (the *inline delta*), in the same `events` / `cursor` / `hasMore` shape as a list page.
276
+
277
+ Both save a reload, so the rule for which writes send them is not about the event type. It is about whether the process that writes **holds a loaded log it will keep deciding from**: its own next writes, or the replay it resumes after the write. A writer with no log has nothing to merge a page into, and whoever decides next is a fresh replay that loads the log anyway. Three things must hold for a page to come back: the writer named a position, the World reads a page for that event type (a backend may decline for types whose writer never holds a log), and there is something to return (the report only when positions were skipped; the delta always).
278
+
279
+ | Event | Writer | Sends | Why |
280
+ |-------|--------|-------|-----|
281
+ | `run_created` | `start()` | Nothing | No log exists yet. |
282
+ | `run_started` | Replay, first write of a delivery | `eventCount` when a log is loaded; no cursor | The `run_started` response already carries the full log as a preload. |
283
+ | `step_created`, `wait_created`, `hook_disposed`, `attr_set` | Suspension handler | `eventCount` | The handler keeps writing from this log and the replay resumes from it. |
284
+ | `hook_created` | Suspension handler | `eventCount` and `sinceCursor` (only when the suspension creates exactly one hook) | The delta is how a hook whose payload already arrived resolves without another invocation. Two creates diffing against one cursor would give two deltas of which only the first could be taken. |
285
+ | `hook_received` | Replay resuming a hook | `eventCount` | The replay continues from the log right after. |
286
+ | `hook_received` | Webhook or `resumeHook()` | Nothing | Out-of-band. It enqueues a delivery that loads the log. |
287
+ | `wait_completed` | Replay completing an elapsed wait | `eventCount` | The replay continues from the log right after. |
288
+ | `step_started`, `step_retrying` | Step executor | Nothing | The executor holds no log. The next decision is a replay that reloads, or receives the delta on the step's terminal write. |
289
+ | `step_completed`, `step_failed` | Step executor | `sinceCursor` when a single step runs inline; nothing from a queued delivery | Inline, this is where the invocation gets its log back without a list. From a queue, a fresh replay loads the log anyway. |
290
+ | `attr_set` | `setAttributes()` outside a workflow | Nothing | Out-of-band. |
291
+ | `run_completed`, `run_failed`, `run_cancelled` | Replay, or `cancel()` | Never `sinceCursor` | Terminal. Nothing replays the log afterwards, so a page would be read by no one. |
292
+
293
+ Batched writes (`events.createBatch()`) carry no position and get no page. Both runtime engines follow this table: the `node:vm` engine merges a returned page into the log it replays from, and the QuickJS engine queues it for delivery to its live VM, in position order, ahead of its next `events.list()`.
294
+
295
+ A World that supports this returns the page in `events`, `cursor`, and `hasMore` on the `EventResult`. A World that does not simply returns the created event, and the runtime lists. See [Building a World](/worlds/building-a-world#event-id-allocation) for the World-side contract.
296
+
270
297
  ## Sealed positions (noop events)
271
298
 
272
299
  Runs at `specVersion` 7 and above use a *sealed log*. The backend gives each write its position from a per-run sequencer **before** the write commits, so concurrent writers hold distinct positions and never race for a slot. New runs use this behavior by default. [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) returns a deployment to the previous scheme. Every runtime reads a sealed log regardless, and a run's version is fixed at creation, so changing the setting never affects an in-flight run. However, a writer can claim a position and then stop because of a crashed process or canceled transaction. This leaves a hole that no writer will fill, and a hole looks like an event the reader failed to load.
@@ -72,7 +72,8 @@ only in how the absence is labelled.
72
72
  [`RunExpiredError`](/docs/errors/run-expired) instead of resolving.
73
73
 
74
74
  This is a known limitation. If you need the result, send it somewhere you
75
- control, e.g. a step that writes it to your own store, rather than reading it back off the run.
75
+ control, e.g. a step that writes it to your own store, rather than reading
76
+ it back off the run.
76
77
  </Callout>
77
78
 
78
79
  `RunExpiredError` is not specific to `experimental_retention: 0`. Any run read
@@ -86,8 +87,9 @@ missing and you get `WorkflowRunNotFoundError` instead.
86
87
 
87
88
  The SDK records your preference; it does not enforce it. `start()` writes the
88
89
  value onto the run as the reserved `$retention` attribute, and the World
89
- decides what to do when the run ends.
90
- A World that does not implement retention
91
- **keeps the data**. If you need certainty that a specific World deletes your data,
92
- confirm it against that World's own documentation rather than the presence of this
93
- option.
90
+ decides what to do when the run ends. Attributes are a spec version 4 feature,
91
+ so `experimental_retention: 0` throws against an older World rather than being
92
+ recorded and ignored. A World that does implement spec version 4 but not
93
+ retention **keeps the data**. If you need certainty that a specific World
94
+ deletes your data, confirm it against that World's own documentation rather
95
+ than the presence of this option.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow",
3
- "version": "5.0.0-beta.50",
3
+ "version": "5.0.0-beta.52",
4
4
  "description": "Workflow SDK - Build durable, resilient, and observable workflows",
5
5
  "main": "dist/typescript-plugin.cjs",
6
6
  "type": "module",
@@ -58,19 +58,19 @@
58
58
  }
59
59
  },
60
60
  "dependencies": {
61
- "@workflow/astro": "5.0.0-beta.50",
62
- "@workflow/cli": "5.0.0-beta.50",
63
- "@workflow/core": "5.0.0-beta.50",
61
+ "@workflow/astro": "5.0.0-beta.52",
62
+ "@workflow/cli": "5.0.0-beta.52",
63
+ "@workflow/core": "5.0.0-beta.52",
64
64
  "@workflow/errors": "5.0.0-beta.21",
65
65
  "@workflow/typescript-plugin": "5.0.0-beta.5",
66
66
  "@workflow/utils": "5.0.0-beta.10",
67
67
  "ms": "2.1.3",
68
- "@workflow/next": "5.0.0-beta.50",
69
- "@workflow/nest": "5.0.0-beta.50",
70
- "@workflow/nitro": "5.0.0-beta.50",
71
- "@workflow/nuxt": "5.0.0-beta.50",
72
- "@workflow/sveltekit": "5.0.0-beta.50",
73
- "@workflow/rollup": "5.0.0-beta.50"
68
+ "@workflow/next": "5.0.0-beta.52",
69
+ "@workflow/nest": "5.0.0-beta.52",
70
+ "@workflow/nitro": "5.0.0-beta.52",
71
+ "@workflow/nuxt": "5.0.0-beta.52",
72
+ "@workflow/sveltekit": "5.0.0-beta.52",
73
+ "@workflow/rollup": "5.0.0-beta.52"
74
74
  },
75
75
  "devDependencies": {
76
76
  "@types/ms": "2.1.0",