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.
- package/docs/ai/chat-session-modeling.mdx +170 -419
- package/docs/ai/index.mdx +27 -36
- package/docs/ai/message-queueing.mdx +61 -100
- package/docs/api-reference/index.mdx +1 -1
- package/docs/api-reference/workflow-ai/durable-agent.mdx +15 -15
- package/docs/api-reference/workflow-ai/index.mdx +3 -3
- package/docs/changelog/batched-event-writes.mdx +1 -1
- package/docs/configuration/runtime-tuning.mdx +5 -0
- package/docs/configuration/worlds.mdx +1 -1
- package/docs/cookbook/advanced/serializable-steps.mdx +33 -61
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +64 -54
- package/docs/cookbook/agent-patterns/durable-agent.mdx +21 -1
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +177 -200
- package/docs/cookbook/integrations/ai-sdk.mdx +2 -2
- package/docs/errors/corrupted-event-log.mdx +11 -1
- package/docs/errors/fetch-in-workflow.mdx +4 -7
- package/docs/errors/run-expired.mdx +6 -6
- package/docs/foundations/streaming.mdx +1 -1
- package/docs/getting-started/python.mdx +69 -15
- package/docs/how-it-works/event-sourcing.mdx +27 -0
- package/docs/observability/retention.mdx +8 -6
- package/package.json +10 -10
|
@@ -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 = ">=3.12"` and `dependencies = ["vercel-workflow"]` under `[project]`, then add `[[tool.vercel.workflows]]` with `entrypoint = "app.workflows:wf"`. 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 = ">=3.12"` and `dependencies = ["vercel-workflow"]` under `[project]`, then add `[[tool.vercel.workflows]]` with `entrypoint = "app.workflows:wf"`. 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(
|
|
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't ne
|
|
|
217
261
|
|
|
218
262
|
## Streaming
|
|
219
263
|
|
|
220
|
-
Steps can stream progress while a workflow is running.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
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.
|
|
62
|
-
"@workflow/cli": "5.0.0-beta.
|
|
63
|
-
"@workflow/core": "5.0.0-beta.
|
|
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.
|
|
69
|
-
"@workflow/nest": "5.0.0-beta.
|
|
70
|
-
"@workflow/nitro": "5.0.0-beta.
|
|
71
|
-
"@workflow/nuxt": "5.0.0-beta.
|
|
72
|
-
"@workflow/sveltekit": "5.0.0-beta.
|
|
73
|
-
"@workflow/rollup": "5.0.0-beta.
|
|
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",
|