workflow 4.2.0-beta.76 → 4.2.0-beta.77
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/api-reference/workflow-ai/durable-agent.mdx +86 -1
- package/docs/api-reference/workflow-api/get-world.mdx +51 -0
- package/docs/api-reference/workflow-api/start.mdx +5 -0
- package/docs/api-reference/workflow-next/with-workflow.mdx +51 -0
- package/docs/changelog/meta.json +1 -1
- package/docs/changelog/resilient-start.mdx +327 -0
- package/docs/deploying/building-a-world.mdx +16 -6
- package/docs/deploying/index.mdx +2 -2
- package/docs/deploying/world/vercel-world.mdx +30 -1
- package/docs/errors/hook-conflict.mdx +1 -1
- package/docs/errors/node-js-module-in-workflow.mdx +1 -1
- package/docs/errors/start-invalid-workflow-function.mdx +83 -58
- package/docs/foundations/starting-workflows.mdx +1 -1
- package/docs/foundations/streaming.mdx +1 -1
- package/docs/getting-started/astro.mdx +17 -0
- package/docs/getting-started/express.mdx +17 -0
- package/docs/getting-started/fastify.mdx +17 -0
- package/docs/getting-started/hono.mdx +17 -0
- package/docs/getting-started/nestjs.mdx +83 -5
- package/docs/getting-started/next.mdx +16 -1
- package/docs/getting-started/nitro.mdx +17 -0
- package/docs/getting-started/nuxt.mdx +17 -0
- package/docs/getting-started/sveltekit.mdx +17 -0
- package/docs/getting-started/vite.mdx +17 -0
- package/docs/how-it-works/encryption.mdx +39 -2
- package/docs/how-it-works/event-sourcing.mdx +19 -2
- package/docs/how-it-works/framework-integrations.mdx +68 -11
- package/docs/observability/index.mdx +1 -1
- package/docs/testing/index.mdx +1 -1
- package/docs/testing/server-based.mdx +59 -16
- package/package.json +10 -10
|
@@ -213,7 +213,7 @@ export default OutputSpecification;`}
|
|
|
213
213
|
- Tools can use core library features like `sleep()` and Hooks within their `execute` functions
|
|
214
214
|
- The agent processes tool calls iteratively until completion or `maxSteps` is reached
|
|
215
215
|
- **Default `maxSteps` is unlimited** - set a value to limit the number of LLM calls
|
|
216
|
-
- The `stream()` method returns `{ messages, steps, experimental_output, uiMessages }` containing the full conversation history, step details, optional structured output, and optionally accumulated UI messages
|
|
216
|
+
- The `stream()` method returns `{ messages, steps, toolCalls, toolResults, experimental_output, uiMessages }` containing the full conversation history, step details, tool call details, optional structured output, and optionally accumulated UI messages
|
|
217
217
|
- Use `collectUIMessages: true` to accumulate `UIMessage[]` during streaming, useful for persisting conversation state without re-reading the stream
|
|
218
218
|
- The `prepareStep` callback runs before each step and can modify model, messages, generation settings, tool choice, and context
|
|
219
219
|
- Generation settings (temperature, maxOutputTokens, etc.) can be set on the constructor and overridden per-stream call
|
|
@@ -842,6 +842,91 @@ async function saveConversation(messages: UIMessage[]) {
|
|
|
842
842
|
The `uiMessages` property is only available when `collectUIMessages` is set to `true`. When disabled, `uiMessages` is `undefined`.
|
|
843
843
|
</Callout>
|
|
844
844
|
|
|
845
|
+
### Machine-Readable Tool Results
|
|
846
|
+
|
|
847
|
+
`stream()` returns tool call information you can inspect programmatically. Compare `toolCalls` with `toolResults` to find unresolved tool calls that need client-side handling:
|
|
848
|
+
|
|
849
|
+
```typescript lineNumbers
|
|
850
|
+
import { DurableAgent } from "@workflow/ai/agent";
|
|
851
|
+
import { getWritable } from "workflow";
|
|
852
|
+
import { z } from "zod";
|
|
853
|
+
import type { UIMessageChunk } from "ai";
|
|
854
|
+
|
|
855
|
+
async function checkOrderStatus({ orderId }: { orderId: string }) {
|
|
856
|
+
"use step";
|
|
857
|
+
return `Order ${orderId}: shipped`;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function agentWithToolInspection(userMessage: string) {
|
|
861
|
+
"use workflow";
|
|
862
|
+
|
|
863
|
+
const agent = new DurableAgent({
|
|
864
|
+
model: "anthropic/claude-haiku-4.5",
|
|
865
|
+
tools: {
|
|
866
|
+
checkOrderStatus: {
|
|
867
|
+
description: "Check order status",
|
|
868
|
+
inputSchema: z.object({ orderId: z.string() }),
|
|
869
|
+
execute: checkOrderStatus,
|
|
870
|
+
},
|
|
871
|
+
},
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
const result = await agent.stream({
|
|
875
|
+
messages: [{ role: "user", content: userMessage }],
|
|
876
|
+
writable: getWritable<UIMessageChunk>(),
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
const unresolved = result.toolCalls.filter( // [!code highlight]
|
|
880
|
+
(tc) => !result.toolResults.some((tr) => tr.toolCallId === tc.toolCallId) // [!code highlight]
|
|
881
|
+
); // [!code highlight]
|
|
882
|
+
|
|
883
|
+
if (unresolved.length > 0) {
|
|
884
|
+
return {
|
|
885
|
+
status: "needs-client-tools",
|
|
886
|
+
unresolved,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
status: "complete",
|
|
892
|
+
messages: result.messages,
|
|
893
|
+
toolResults: result.toolResults,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
<Callout type="info">
|
|
899
|
+
`toolCalls` and `toolResults` reflect the *last step* of the agent loop. Tools without an `execute` function will appear in `toolCalls` but not in `toolResults`, which is how you detect calls that need client-side handling.
|
|
900
|
+
</Callout>
|
|
901
|
+
|
|
902
|
+
### Aborting Long-Running Streams
|
|
903
|
+
|
|
904
|
+
Use `timeout` to abort a stream automatically after a fixed duration:
|
|
905
|
+
|
|
906
|
+
<Callout type="warn">
|
|
907
|
+
`abortSignal` is not yet supported and will be available in a future release. Use `timeout` for now.
|
|
908
|
+
</Callout>
|
|
909
|
+
|
|
910
|
+
```typescript lineNumbers
|
|
911
|
+
import { DurableAgent } from "@workflow/ai/agent";
|
|
912
|
+
import { getWritable } from "workflow";
|
|
913
|
+
import type { UIMessageChunk } from "ai";
|
|
914
|
+
|
|
915
|
+
async function agentWithTimeout(userMessage: string) {
|
|
916
|
+
"use workflow";
|
|
917
|
+
|
|
918
|
+
const agent = new DurableAgent({
|
|
919
|
+
model: "anthropic/claude-haiku-4.5",
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
await agent.stream({
|
|
923
|
+
messages: [{ role: "user", content: userMessage }],
|
|
924
|
+
writable: getWritable<UIMessageChunk>(),
|
|
925
|
+
timeout: 30_000, // [!code highlight]
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
```
|
|
929
|
+
|
|
845
930
|
## See Also
|
|
846
931
|
|
|
847
932
|
- [Building Durable AI Agents](/docs/ai) - Complete guide to creating durable agents
|
|
@@ -66,6 +66,57 @@ const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highl
|
|
|
66
66
|
|
|
67
67
|
See [Observability Utilities](/docs/api-reference/workflow-api/world/observability) for the full hydration, parsing, and encryption API.
|
|
68
68
|
|
|
69
|
+
### List Workflow Runs (Display Names)
|
|
70
|
+
|
|
71
|
+
List workflow runs and derive human-readable names from the `workflowName` field:
|
|
72
|
+
|
|
73
|
+
```typescript lineNumbers
|
|
74
|
+
import { getWorld } from "workflow/runtime";
|
|
75
|
+
import { parseWorkflowName } from "@workflow/utils/parse-name"; // [!code highlight]
|
|
76
|
+
|
|
77
|
+
export async function GET(req: Request) {
|
|
78
|
+
const url = new URL(req.url);
|
|
79
|
+
const cursor = url.searchParams.get("cursor") ?? undefined;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const world = getWorld(); // [!code highlight]
|
|
83
|
+
const runs = await world.runs.list({
|
|
84
|
+
pagination: { cursor },
|
|
85
|
+
resolveData: "none",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return Response.json({
|
|
89
|
+
data: runs.data.map((run) => {
|
|
90
|
+
const parsed = parseWorkflowName(run.workflowName); // [!code highlight]
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
runId: run.runId,
|
|
94
|
+
// Use shortName for UI display (e.g., "processOrder") // [!code highlight]
|
|
95
|
+
displayName: parsed?.shortName ?? run.workflowName, // [!code highlight]
|
|
96
|
+
// Module info available for debugging // [!code highlight]
|
|
97
|
+
module: parsed?.moduleSpecifier, // [!code highlight]
|
|
98
|
+
status: run.status,
|
|
99
|
+
startedAt: run.startedAt,
|
|
100
|
+
completedAt: run.completedAt,
|
|
101
|
+
};
|
|
102
|
+
}),
|
|
103
|
+
cursor: runs.cursor,
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return Response.json(
|
|
107
|
+
{ error: "Failed to list workflow runs" },
|
|
108
|
+
{ status: 500 }
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
<Callout type="info">
|
|
115
|
+
The `workflowName` field contains a machine-readable identifier like `workflow//./src/workflows/order//processOrder`.
|
|
116
|
+
Use `parseWorkflowName()` from `@workflow/utils/parse-name` to extract the `shortName` (e.g., `"processOrder"`)
|
|
117
|
+
and `moduleSpecifier` for display in your UI.
|
|
118
|
+
</Callout>
|
|
119
|
+
|
|
69
120
|
## Related Functions
|
|
70
121
|
|
|
71
122
|
- [`getRun()`](/docs/api-reference/workflow-api/get-run) - Higher-level API for working with individual runs by ID.
|
|
@@ -54,6 +54,11 @@ Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow-
|
|
|
54
54
|
* This is different from calling workflow functions directly, which is the typical pattern in Next.js applications.
|
|
55
55
|
* The function returns immediately after enqueuing the workflow - it doesn't wait for the workflow to complete.
|
|
56
56
|
* All arguments must be [serializable](/docs/foundations/serialization).
|
|
57
|
+
* When `deploymentId` is provided, the argument types and return type become `unknown` since there is no guarantee the workflow function's types will be consistent across different deployments.
|
|
58
|
+
|
|
59
|
+
<Callout type="info">
|
|
60
|
+
If `start()` throws `'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.`, the passed function was not transformed as a workflow. The two most common causes are a missing `"use workflow"` directive or missing framework integration. See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function).
|
|
61
|
+
</Callout>
|
|
57
62
|
|
|
58
63
|
## Examples
|
|
59
64
|
|
|
@@ -27,6 +27,57 @@ const workflowConfig = {}
|
|
|
27
27
|
export default withWorkflow(nextConfig, workflowConfig); // [!code highlight]
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
### Monorepos and Workspace Imports
|
|
31
|
+
|
|
32
|
+
By default, Next.js detects the correct workspace root automatically. If your Next.js app lives in a subdirectory such as `apps/web` and workspace resolution is not working correctly, you can set `outputFileTracingRoot` as a workaround:
|
|
33
|
+
|
|
34
|
+
```typescript title="apps/web/next.config.ts" lineNumbers
|
|
35
|
+
import { resolve } from "node:path";
|
|
36
|
+
import type { NextConfig } from "next";
|
|
37
|
+
import { withWorkflow } from "workflow/next";
|
|
38
|
+
|
|
39
|
+
const nextConfig: NextConfig = {
|
|
40
|
+
outputFileTracingRoot: resolve(process.cwd(), "../.."),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default withWorkflow(nextConfig);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
<Callout type="info">
|
|
47
|
+
Use the smallest directory that contains every workspace package imported by your workflows. If your app already lives at the repository root, you do not need to set `outputFileTracingRoot`.
|
|
48
|
+
</Callout>
|
|
49
|
+
|
|
50
|
+
## Options
|
|
51
|
+
|
|
52
|
+
`withWorkflow` accepts an optional second argument to configure the Next.js integration.
|
|
53
|
+
|
|
54
|
+
```typescript title="next.config.ts" lineNumbers
|
|
55
|
+
import type { NextConfig } from "next";
|
|
56
|
+
import { withWorkflow } from "workflow/next";
|
|
57
|
+
|
|
58
|
+
const nextConfig: NextConfig = {};
|
|
59
|
+
|
|
60
|
+
export default withWorkflow(nextConfig, {
|
|
61
|
+
workflows: {
|
|
62
|
+
lazyDiscovery: true,
|
|
63
|
+
local: {
|
|
64
|
+
port: 4000,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Option | Type | Default | Description |
|
|
71
|
+
| --- | --- | --- | --- |
|
|
72
|
+
| `workflows.lazyDiscovery` | `boolean` | `false` | When `true`, defers workflow discovery until files are requested instead of scanning eagerly at startup. Useful for large projects where startup time matters. |
|
|
73
|
+
| `workflows.local.port` | `number` | — | Overrides the `PORT` environment variable for local development. Has no effect when deployed to Vercel. |
|
|
74
|
+
|
|
75
|
+
<Callout type="info">
|
|
76
|
+
The `workflows.local` options only affect local development. When deployed to Vercel, the runtime ignores `local` settings and uses the Vercel world automatically.
|
|
77
|
+
</Callout>
|
|
78
|
+
|
|
79
|
+
## Exporting a Function
|
|
80
|
+
|
|
30
81
|
If you are exporting a function in your `next.config` you will need to ensure you call the function returned from `withWorkflow`.
|
|
31
82
|
|
|
32
83
|
```typescript title="next.config.ts" lineNumbers
|
package/docs/changelog/meta.json
CHANGED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Resilient run start
|
|
3
|
+
description: Overhaul run start logic to tolerate world storage unavailability, as long as the queue is healthy, and significantly speeds up run start.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Resilient `start()`
|
|
7
|
+
|
|
8
|
+
## Motivation
|
|
9
|
+
|
|
10
|
+
When `world` storage is unavailable but the queue is up,
|
|
11
|
+
`start()` previously failed entirely because `world.events.create(run_created)`
|
|
12
|
+
is called before `world.queue()`. This change decouples run creation from queue
|
|
13
|
+
dispatch so that runs can still be accepted when storage is degraded.
|
|
14
|
+
|
|
15
|
+
Additionally, the runtime previously called `world.runs.get(runId)` before
|
|
16
|
+
`run_started`, adding an extra round-trip. By always calling `run_started`
|
|
17
|
+
directly, we save that round-trip and can return pre-loaded events in the
|
|
18
|
+
response to skip the initial `events.list` call, reducing TTFB.
|
|
19
|
+
|
|
20
|
+
## Design
|
|
21
|
+
|
|
22
|
+
### `start()` changes (packages/core)
|
|
23
|
+
|
|
24
|
+
- `world.events.create` (run_created) and `world.queue` are now called **in parallel**
|
|
25
|
+
via `Promise.allSettled`.
|
|
26
|
+
- If `events.create` errors with **429 or 5xx**, we log a warning saying that run
|
|
27
|
+
creation failed but the run was accepted — creation will be re-tried async by the
|
|
28
|
+
runtime when it processes the queue message. The returned `Run` instance is marked
|
|
29
|
+
with `resilientStart = true`.
|
|
30
|
+
- If `events.create` errors with **409** (EntityConflictError), the run already exists
|
|
31
|
+
(e.g., the queue handler's resilient start path created it first due to a cold-start
|
|
32
|
+
race). This is treated as success.
|
|
33
|
+
- If `world.queue` fails, we still throw — the run truly failed and was not enqueued.
|
|
34
|
+
- The queue invocation now receives all the run inputs (`input`, `deploymentId`,
|
|
35
|
+
`workflowName`, `specVersion`, `executionContext`) via `runInput` so the runtime can
|
|
36
|
+
create the run later if needed.
|
|
37
|
+
- When the runtime re-enqueues itself, it does **not** pass these inputs — only the
|
|
38
|
+
first queue cycle carries them.
|
|
39
|
+
|
|
40
|
+
### `workflowEntrypoint` changes (packages/core)
|
|
41
|
+
|
|
42
|
+
- When calling `world.events.create` with `run_started`, we now also always pass the
|
|
43
|
+
run input that was sent through the queue, if available. The response will still be on off:
|
|
44
|
+
- **200 with event (now running)**: As usual, but the server could have used the run input to create the run if it didn't exist yet. The response will be opaque to the runtime.
|
|
45
|
+
- **200 without event (already running)**: As usual
|
|
46
|
+
- **409 or 410 (already finished)**: As usual
|
|
47
|
+
|
|
48
|
+
### `Run.returnValue` polling (packages/core)
|
|
49
|
+
|
|
50
|
+
- When `resilientStart` is true on the Run instance (run_created failed), the
|
|
51
|
+
`pollReturnValue` loop retries on `WorkflowRunNotFoundError` up to 3 times
|
|
52
|
+
(1s + 3s + 6s = 10s total) to give the queue time to deliver and the runtime
|
|
53
|
+
to create the run via `run_started`.
|
|
54
|
+
- When `resilientStart` is false (normal path), 404 fails immediately — no delay
|
|
55
|
+
for the common case of a wrong run ID.
|
|
56
|
+
|
|
57
|
+
### World / workflow-server changes
|
|
58
|
+
|
|
59
|
+
- Posting `run_started` to a **non-existent** run is now allowed when the run input is
|
|
60
|
+
sent along with the payload. The server:
|
|
61
|
+
1. Creates a `run_created` event first (so the event log is consistent).
|
|
62
|
+
2. Strips the input from the `run_started` event data (it lives on `run_created`).
|
|
63
|
+
3. Then creates the `run_started` event normally.
|
|
64
|
+
4. Emits a log and a Datadog metric (`workflow_server.resilient_start.run_created_via_run_started`)
|
|
65
|
+
to track when this fallback path is hit.
|
|
66
|
+
- When `run_started` encounters an **already-running** run, all worlds return `{ run }`
|
|
67
|
+
with `event: undefined` instead of throwing. No duplicate event is created.
|
|
68
|
+
|
|
69
|
+
### Queue transport changes
|
|
70
|
+
|
|
71
|
+
`Uint8Array` values (the serialized workflow input in `runInput`) don't survive plain
|
|
72
|
+
JSON serialization. Each world uses a transport that preserves binary data:
|
|
73
|
+
|
|
74
|
+
- **world-vercel**: CBOR transport — CBOR-encodes the entire queue payload into a
|
|
75
|
+
`Buffer` and uses `BufferTransport` from `@vercel/queue`. Uint8Array survives natively.
|
|
76
|
+
- **world-local**: `TypedJsonTransport` — uses the existing `jsonReplacer`/`jsonReviver`
|
|
77
|
+
from `fs.ts` that encode Uint8Array as `{ __type: 'Uint8Array', data: '<base64>' }`.
|
|
78
|
+
- **world-postgres**: Inline typed JSON transport — same tagged-envelope approach as
|
|
79
|
+
world-local, inlined since world-postgres doesn't import from world-local.
|
|
80
|
+
|
|
81
|
+
## Decisions
|
|
82
|
+
|
|
83
|
+
1. **Parallel not sequential**: We chose `Promise.allSettled` over sequential calls to
|
|
84
|
+
minimize latency in the happy path.
|
|
85
|
+
|
|
86
|
+
2. **Already-running returns run without event**: When `run_started` encounters an
|
|
87
|
+
already-running run, all worlds return `{ run }` with `event: undefined` (no
|
|
88
|
+
`events` array) instead of throwing. The runtime detects this by checking for
|
|
89
|
+
`result.event === undefined`. This avoids an extra `world.runs.get` round-trip.
|
|
90
|
+
|
|
91
|
+
3. **Events in 200 response**: We only return events on the 200 path (first caller).
|
|
92
|
+
On the already-running path, we fall back to the normal `events.list` call. This is
|
|
93
|
+
correct because only on 200 can we be certain we know the full event history.
|
|
94
|
+
|
|
95
|
+
4. **Conditional 404 retry on Run.returnValue**: Only when `resilientStart = true`
|
|
96
|
+
(run_created failed). Normal runs fail fast on 404.
|
|
97
|
+
|
|
98
|
+
## Known concerns
|
|
99
|
+
|
|
100
|
+
### Cold-start race on Vercel (observed in CI)
|
|
101
|
+
|
|
102
|
+
On Vercel, the parallel dispatch can cause the queue message to be processed before
|
|
103
|
+
`run_created` completes, if `run_created` hits a cold-start lambda. Confirmed via
|
|
104
|
+
Datadog: the `run_started` request hit a warm lambda (23ms) while `run_created` hit
|
|
105
|
+
a cold lambda (727ms), even though `run_created` arrived at the edge 116ms earlier.
|
|
106
|
+
When this happens:
|
|
107
|
+
|
|
108
|
+
1. The runtime's resilient start path creates the run from `run_started`.
|
|
109
|
+
2. The original `run_created` arrives and gets 409 (EntityConflictError).
|
|
110
|
+
3. `start()` treats the 409 as success (the run exists).
|
|
111
|
+
|
|
112
|
+
This is handled correctly. The `resilientStart` flag is NOT set on the Run instance
|
|
113
|
+
in this case (409 is not a retryable error), so `returnValue` fails fast on 404.
|
|
114
|
+
|
|
115
|
+
### Local Prod test flakiness (resolved)
|
|
116
|
+
|
|
117
|
+
On world-local, the queue's async IIFE can deliver the message before
|
|
118
|
+
`events.create(run_created)` finishes writing to the shared filesystem. The
|
|
119
|
+
resilient start path should handle this, but Local Prod tests showed occasional
|
|
120
|
+
runs stuck at `pending` (no `run_started` event), and Windows CI showed
|
|
121
|
+
"Unconsumed event in event log" errors from duplicate `run_created` events.
|
|
122
|
+
|
|
123
|
+
**Root cause:** A TOCTOU race between the normal `run_created` path and the
|
|
124
|
+
resilient start path. Both used `writeJSON` which checks existence with
|
|
125
|
+
`fs.access()` (non-atomic), so both could pass the check and write separate
|
|
126
|
+
`run_created` events with different event IDs. Fixed by switching both paths to
|
|
127
|
+
`writeExclusive` (O_CREAT|O_EXCL) — see retrospective items 12 and 16.
|
|
128
|
+
|
|
129
|
+
## Follow-up work
|
|
130
|
+
|
|
131
|
+
- [x] ~~Investigate Local Prod test flakiness~~ — resolved via `writeExclusive`
|
|
132
|
+
for run entity creation (retrospective items 12, 16).
|
|
133
|
+
- [ ] Monitor the Datadog metric in production to understand how often the fallback is hit.
|
|
134
|
+
- [x] ~~Events optimization for re-enqueue cycles~~ — decided against. The
|
|
135
|
+
already-running path returns early without writing an event, so preloading
|
|
136
|
+
events there would require an extra filesystem/DB query on every re-enqueue.
|
|
137
|
+
More importantly, on Vercel with at-least-once delivery, multiple lambdas can
|
|
138
|
+
process the same run concurrently — the event snapshot could be stale or
|
|
139
|
+
incomplete. The runtime's fallback to `events.list` is the correct behavior
|
|
140
|
+
for re-enqueue cycles.
|
|
141
|
+
- [x] ~~CborTransport pass-through~~ — refactored. `encode()`/`decode()` now
|
|
142
|
+
live inside `CborTransport.serialize()`/`deserialize()`, matching the pattern
|
|
143
|
+
used by TypedJsonTransport (world-local) and the inline transport
|
|
144
|
+
(world-postgres). Call sites pass plain objects instead of pre-encoded buffers.
|
|
145
|
+
|
|
146
|
+
## Development retrospective
|
|
147
|
+
|
|
148
|
+
Chronological log of mistakes, misunderstandings, and reverted approaches during
|
|
149
|
+
development. Included for future reference when working on similar cross-cutting
|
|
150
|
+
runtime changes.
|
|
151
|
+
|
|
152
|
+
### 1. Uint8Array corruption through JSON queue transport
|
|
153
|
+
|
|
154
|
+
The initial implementation passed `runInput.input` (a `Uint8Array`) directly through
|
|
155
|
+
the queue payload. `Uint8Array` doesn't survive `JSON.stringify` — it becomes
|
|
156
|
+
`{"0":72,"1":101,...}`. This corrupted the workflow input when the resilient start
|
|
157
|
+
path tried to recreate the run from the queue-delivered data.
|
|
158
|
+
|
|
159
|
+
Caught by the `spawnWorkflowFromStepWorkflow` e2e test and the `world-testing`
|
|
160
|
+
embedded tests, which failed with "Invalid input" from devalue's `unflatten()`.
|
|
161
|
+
|
|
162
|
+
Three approaches were tried before landing on the final solution:
|
|
163
|
+
|
|
164
|
+
1. **Base64 encoding** (`btoa`/`atob`) — worked but fragile. The decode side used
|
|
165
|
+
`typeof runInput.input === 'string'` as a discriminant, which was flagged as
|
|
166
|
+
dangerous since non-binary inputs could also be strings.
|
|
167
|
+
2. **`Array.from()`/`new Uint8Array()`** — replaced base64 with a plain number array.
|
|
168
|
+
Two problems: (a) 3x JSON size regression vs base64, and (b) `Array.isArray()`
|
|
169
|
+
false-positives on v1Compat runs where `dehydrateWorkflowArguments` returns
|
|
170
|
+
devalue's flat Array format.
|
|
171
|
+
3. **CBOR + BufferTransport** (final) — world-vercel CBOR-encodes the queue payload;
|
|
172
|
+
world-local and world-postgres use a `TypedJsonTransport` with a tagged envelope.
|
|
173
|
+
|
|
174
|
+
### 2. Forgot to commit world-postgres transport fix (twice)
|
|
175
|
+
|
|
176
|
+
After fixing world-local and world-vercel queue transports, the same `JsonTransport`
|
|
177
|
+
corruption bug existed in world-postgres. The fix was written during a session but
|
|
178
|
+
never committed — lost when the working directory was reset via stash/checkout. This
|
|
179
|
+
happened twice. The fix only landed on the third attempt when it was committed and
|
|
180
|
+
pushed immediately. All 14 Postgres e2e jobs failed each time.
|
|
181
|
+
|
|
182
|
+
### 3. Incorrect diagnosis of Vercel Prod 409 errors
|
|
183
|
+
|
|
184
|
+
Multiple Vercel Prod e2e tests failed with `EntityConflictError: Workflow run with
|
|
185
|
+
ID wrun_... already exists` on `run_created`. The initial assumption was that VQS
|
|
186
|
+
couldn't deliver the queue message fast enough to beat the `run_created` call.
|
|
187
|
+
|
|
188
|
+
Datadog logs showed otherwise: the `run_created` request arrived at Vercel's edge
|
|
189
|
+
116ms before `run_started`, but `run_created` hit a cold-start lambda (727ms) while
|
|
190
|
+
`run_started` hit a warm one (23ms). Cold starts can invert expected execution order.
|
|
191
|
+
|
|
192
|
+
### 4. Removed EntityConflictError catch, then had to restore it
|
|
193
|
+
|
|
194
|
+
The `workflowEntrypoint` error handler originally caught both `EntityConflictError`
|
|
195
|
+
and `RunExpiredError`. When adding the "already-running returns run without event"
|
|
196
|
+
behavior, `EntityConflictError` was removed from the catch since the new worlds
|
|
197
|
+
wouldn't throw it. Reviewer flagged this: old worlds or world-vercel hitting an
|
|
198
|
+
older workflow-server could still throw it. The catch was restored.
|
|
199
|
+
|
|
200
|
+
### 5. Duplicate `startedAt` check
|
|
201
|
+
|
|
202
|
+
After refactoring the `run_started` flow, a `workflowRun.startedAt` null check
|
|
203
|
+
existed both inside the `try` block and after the `catch` block. The second was
|
|
204
|
+
unreachable. Removed after review.
|
|
205
|
+
|
|
206
|
+
### 6. WORKFLOW_SERVER_URL_OVERRIDE left set
|
|
207
|
+
|
|
208
|
+
During development, `WORKFLOW_SERVER_URL_OVERRIDE` was set to a test URL pointing
|
|
209
|
+
at the workflow-server preview deployment and accidentally committed. The Vercel
|
|
210
|
+
bot flagged this. Reset to empty string.
|
|
211
|
+
|
|
212
|
+
### 7. e2e test assertion was wrong
|
|
213
|
+
|
|
214
|
+
The resilient start e2e test stubbed `world.events.create` and asserted
|
|
215
|
+
`createCallCount >= 2`. But the stub only intercepts calls from the test runner
|
|
216
|
+
process — the server uses its own world. `createCallCount` was always 1. Changed
|
|
217
|
+
to `expect(createCallCount).toBe(1)`.
|
|
218
|
+
|
|
219
|
+
### 8. Misattributed Local Prod timeouts as "pre-existing"
|
|
220
|
+
|
|
221
|
+
Local Prod tests showed 60-second timeouts across various tests. Initially dismissed
|
|
222
|
+
as CI flakes. Checking main's CI showed all Local Prod tests pass on main — the
|
|
223
|
+
timeouts are caused by our changes. Should have compared against main immediately.
|
|
224
|
+
|
|
225
|
+
### 9. Attempted to revert parallel dispatch
|
|
226
|
+
|
|
227
|
+
After identifying Local Prod timeouts, `start()` was partially reverted back to
|
|
228
|
+
sequential dispatch. The user pointed out that parallel dispatch is the core value
|
|
229
|
+
proposition of the PR. The revert was undone.
|
|
230
|
+
|
|
231
|
+
### 10. WorkflowRunNotFoundError retry was unconditional
|
|
232
|
+
|
|
233
|
+
The initial `pollReturnValue` retry on `WorkflowRunNotFoundError` applied to all
|
|
234
|
+
`Run` instances. A user calling `getRun()` with a wrong ID would wait 10 seconds
|
|
235
|
+
before getting a 404. Fixed by adding a `resilientStart` flag: only retries when
|
|
236
|
+
`run_created` actually failed.
|
|
237
|
+
|
|
238
|
+
### 11. Changeset `minor` vs `patch`
|
|
239
|
+
|
|
240
|
+
The changeset was created with `"@workflow/core": minor`. Reviewer flagged this as
|
|
241
|
+
violating repo rules ("all changes should be patch"). Changed after discussion.
|
|
242
|
+
|
|
243
|
+
### 12. world-local TOCTOU race causing duplicate `run_created` events (Windows CI)
|
|
244
|
+
|
|
245
|
+
The resilient start path AND the normal `run_created` path in `world-local/events-storage.ts`
|
|
246
|
+
both used `writeJSON` to create the run entity. `writeJSON` checks file existence with
|
|
247
|
+
`fs.access()` then writes via temp+rename — a classic TOCTOU race. On the local world,
|
|
248
|
+
the queue delivers via an async IIFE in the same event loop, so `events.create(run_created)`
|
|
249
|
+
and `events.create(run_started)` (with resilient start) run concurrently:
|
|
250
|
+
|
|
251
|
+
1. Both paths call `fs.access(runPath)` → ENOENT (file doesn't exist yet)
|
|
252
|
+
2. Both proceed to write → the last `fs.rename` wins
|
|
253
|
+
3. Both succeed → both write their own `run_created` event with different event IDs
|
|
254
|
+
4. During replay, the consumer sees two `run_created` events → "Unconsumed event" error
|
|
255
|
+
|
|
256
|
+
This caused consistent failures in `world-testing` embedded tests on Windows CI (`hooks`,
|
|
257
|
+
`supports null bytes in step results`, `retriable and fatal errors` — all timing out at
|
|
258
|
+
60s with "Unconsumed event in event log" errors). Linux CI was not affected because the
|
|
259
|
+
timing was different enough that the race window was rarely hit.
|
|
260
|
+
|
|
261
|
+
Fixed by switching BOTH paths to `writeExclusive` (O_CREAT|O_EXCL), which is atomic at
|
|
262
|
+
the OS level — exactly one writer wins, the other gets EEXIST. The normal `run_created`
|
|
263
|
+
path throws `EntityConflictError` on conflict (handled by `start()` as 409). The resilient
|
|
264
|
+
start path re-reads the run from disk on conflict. Either way, only one `run_created`
|
|
265
|
+
event is written.
|
|
266
|
+
|
|
267
|
+
### 13. Non-atomic run + run_created event in world-postgres resilient path
|
|
268
|
+
|
|
269
|
+
The resilient start path in `world-postgres/storage.ts` did two separate writes (run
|
|
270
|
+
insert, then event insert) without a transaction. If the process crashed between them,
|
|
271
|
+
the run would exist without a `run_created` event — an inconsistent event log.
|
|
272
|
+
|
|
273
|
+
A `drizzle.transaction()` wrapper was attempted but dropped due to TypeScript inference
|
|
274
|
+
issues with drizzle's transaction callback and the insert builder's overloads. The current
|
|
275
|
+
fix keeps the two writes sequential but adds the same conflict-aware re-read pattern as
|
|
276
|
+
world-local: when `onConflictDoNothing` produces no result (run already existed), the run
|
|
277
|
+
is re-read so downstream logic sees the real state. The narrow crash window between the
|
|
278
|
+
two writes is acceptable — if the run insert succeeds but the event insert crashes, the
|
|
279
|
+
run exists and `run_started` will still proceed normally (the event log will be missing a
|
|
280
|
+
`run_created` entry, but the run itself is functional).
|
|
281
|
+
|
|
282
|
+
### 14. Missing `WorkflowRunStatus` span attribute after parallel refactor
|
|
283
|
+
|
|
284
|
+
The `start()` span previously set `Attribute.WorkflowRunStatus(result.run.status)`, but
|
|
285
|
+
this was dropped in the parallel refactor because `result.run` is only available when
|
|
286
|
+
`runCreatedResult` fulfilled. The attribute is now conditionally set when the result is
|
|
287
|
+
available. In the resilient start case (run_created failed), the attribute is omitted
|
|
288
|
+
rather than erroring.
|
|
289
|
+
|
|
290
|
+
### 15. `run_started` eventData leak in world-postgres result
|
|
291
|
+
|
|
292
|
+
The `...data` spread in the result construction leaked `eventData` from `run_started`
|
|
293
|
+
into the returned event object. Storage was already correct (`storedEventData` is
|
|
294
|
+
`undefined` for `run_started`), but the returned result carried the input data. While
|
|
295
|
+
harmless (the runtime doesn't use `result.event.eventData`), it was restored to match
|
|
296
|
+
the pre-refactor behavior where eventData was explicitly stripped from the result.
|
|
297
|
+
|
|
298
|
+
### 16. Normal `run_created` path also needed `writeExclusive` (Windows CI)
|
|
299
|
+
|
|
300
|
+
The initial TOCTOU fix (item 12) only changed the resilient start path to use
|
|
301
|
+
`writeExclusive`. The normal `run_created` entity write still used `writeJSON` which
|
|
302
|
+
checks existence with `fs.access()` then writes via temp+rename — not atomic. On
|
|
303
|
+
Windows CI, the local queue's async IIFE delivered fast enough for both paths to pass
|
|
304
|
+
their existence checks simultaneously, producing two `run_created` events with different
|
|
305
|
+
event IDs. The events consumer saw the duplicate as "Unconsumed event in event log,"
|
|
306
|
+
causing `hooks`, `supports null bytes in step results`, and `retriable and fatal errors`
|
|
307
|
+
tests to time out at 60s. Fixed by also switching the normal `run_created` entity write to
|
|
308
|
+
`writeExclusive`, making both paths use the same atomic gate.
|
|
309
|
+
|
|
310
|
+
### 17. CborTransport was a pass-through wrapper
|
|
311
|
+
|
|
312
|
+
`world-vercel/queue.ts` had `CborTransport` implementing `Transport<Buffer>` with a
|
|
313
|
+
no-op `serialize` (identity function) and a `deserialize` that reassembled chunks into
|
|
314
|
+
a Buffer without decoding. The actual CBOR `encode()`/`decode()` calls happened at the
|
|
315
|
+
call sites — `queue()` pre-encoded before calling `client.send()`, and the handler
|
|
316
|
+
post-decoded after receiving from `client.handleCallback()`. This violated the transport
|
|
317
|
+
abstraction (every other transport does its encoding inside serialize/deserialize) and
|
|
318
|
+
meant the call site had to remember to pre-encode. Refactored to move `encode()`/`decode()`
|
|
319
|
+
into the transport methods and changed the type from `Transport<Buffer>` to
|
|
320
|
+
`Transport<unknown>`.
|
|
321
|
+
|
|
322
|
+
## Follow-up work (additional)
|
|
323
|
+
|
|
324
|
+
- [x] ~~**CborTransport is a pass-through**~~ — Resolved. Moved `encode()`/`decode()`
|
|
325
|
+
into `CborTransport.serialize()`/`CborTransport.deserialize()`. The transport is now
|
|
326
|
+
self-contained: call sites pass plain objects, and the handler receives decoded objects.
|
|
327
|
+
See retrospective item 17.
|
|
@@ -34,10 +34,13 @@ A World connects workflows to the infrastructure that powers them. The World int
|
|
|
34
34
|
```typescript
|
|
35
35
|
interface World extends Storage, Queue, Streamer {
|
|
36
36
|
start?(): Promise<void>;
|
|
37
|
+
close?(): Promise<void>;
|
|
38
|
+
getEncryptionKeyForRun?(run: WorkflowRun): Promise<Uint8Array | undefined>;
|
|
39
|
+
getEncryptionKeyForRun?(runId: string, context?: Record<string, unknown>): Promise<Uint8Array | undefined>;
|
|
37
40
|
}
|
|
38
41
|
```
|
|
39
42
|
|
|
40
|
-
The optional `start()` method initializes
|
|
43
|
+
The optional `start()` method initializes background tasks (for example, queue polling). The optional `close()` method releases resources like connection pools and listeners. The optional `getEncryptionKeyForRun()` method returns the AES-256 key used to encrypt data for a run; if it is not implemented, encryption is disabled.
|
|
41
44
|
|
|
42
45
|
## The Event Log Model
|
|
43
46
|
|
|
@@ -63,8 +66,8 @@ interface Storage {
|
|
|
63
66
|
};
|
|
64
67
|
|
|
65
68
|
events: {
|
|
66
|
-
// Create a new workflow run (runId
|
|
67
|
-
create(runId: null, data: RunCreatedEventRequest, params?: CreateEventParams): Promise<EventResult>;
|
|
69
|
+
// Create a new workflow run (runId may be client-provided or null for server generation)
|
|
70
|
+
create(runId: string | null, data: RunCreatedEventRequest, params?: CreateEventParams): Promise<EventResult>;
|
|
68
71
|
|
|
69
72
|
// Create an event for an existing run
|
|
70
73
|
create(runId: string, data: CreateEventRequest, params?: CreateEventParams): Promise<EventResult>;
|
|
@@ -88,7 +91,7 @@ interface Storage {
|
|
|
88
91
|
2. Atomically update the affected entity (run, step, or hook)
|
|
89
92
|
3. Return both the created event and the updated entity
|
|
90
93
|
|
|
91
|
-
**Run Creation:** For `run_created` events, the `runId` parameter
|
|
94
|
+
**Run Creation:** For `run_created` events, the `runId` parameter may be a client-provided string or `null`. When `null`, your World generates and returns a new `runId`.
|
|
92
95
|
|
|
93
96
|
**Hook Tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an existing token, return a `hook_conflict` event instead.
|
|
94
97
|
|
|
@@ -165,13 +168,19 @@ The Streamer interface enables real-time data streaming:
|
|
|
165
168
|
interface Streamer {
|
|
166
169
|
writeToStream(
|
|
167
170
|
name: string,
|
|
168
|
-
runId: string
|
|
171
|
+
runId: string,
|
|
169
172
|
chunk: string | Uint8Array
|
|
170
173
|
): Promise<void>;
|
|
171
174
|
|
|
175
|
+
writeToStreamMulti?(
|
|
176
|
+
name: string,
|
|
177
|
+
runId: string,
|
|
178
|
+
chunks: (string | Uint8Array)[]
|
|
179
|
+
): Promise<void>;
|
|
180
|
+
|
|
172
181
|
closeStream(
|
|
173
182
|
name: string,
|
|
174
|
-
runId: string
|
|
183
|
+
runId: string
|
|
175
184
|
): Promise<void>;
|
|
176
185
|
|
|
177
186
|
readFromStream(
|
|
@@ -202,6 +211,7 @@ interface Streamer {
|
|
|
202
211
|
```
|
|
203
212
|
|
|
204
213
|
Streams are identified by a combination of `runId` and `name`. Each workflow run can have multiple named streams.
|
|
214
|
+
`writeToStreamMulti()` is an optional optimization for batching multiple writes.
|
|
205
215
|
|
|
206
216
|
`getStreamChunks` returns a paginated snapshot of currently available chunks (unlike `readFromStream` which returns a live `ReadableStream` that waits for new chunks). `getStreamInfo` returns the tail index (last chunk index, 0-based, or `-1` when empty) and whether the stream is complete — useful for resolving negative `startIndex` values into absolute positions.
|
|
207
217
|
|
package/docs/deploying/index.mdx
CHANGED
|
@@ -73,7 +73,7 @@ For self-hosting or deploying to other cloud providers, you can use community-ma
|
|
|
73
73
|
To use a different World implementation, set the `WORKFLOW_TARGET_WORLD` environment variable:
|
|
74
74
|
|
|
75
75
|
```bash
|
|
76
|
-
export WORKFLOW_TARGET_WORLD=@workflow-
|
|
76
|
+
export WORKFLOW_TARGET_WORLD=@workflow/world-postgres
|
|
77
77
|
# Plus any world-specific configuration
|
|
78
78
|
export DATABASE_URL=postgres://...
|
|
79
79
|
```
|
|
@@ -89,7 +89,7 @@ The [Observability tools](/docs/observability) work with any World backend. By d
|
|
|
89
89
|
npx workflow inspect runs
|
|
90
90
|
|
|
91
91
|
# Inspect remote workflows
|
|
92
|
-
npx workflow inspect runs --backend @workflow-
|
|
92
|
+
npx workflow inspect runs --backend @workflow/world-postgres
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
Learn more about [Observability](/docs/observability) tools.
|