Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion docs/content/docs/v5/api-reference/workflow-api/start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow-
* In v5, `start()` can also be called directly from a workflow function to spawn a child run or continue work in a new run. See [Workflow Composition](/cookbook/common-patterns/workflow-composition) and [Versioning](/docs/foundations/versioning).
* This is different from calling workflow functions directly, which is the typical pattern in Next.js applications.
* The function returns immediately after enqueuing the workflow - it doesn't wait for the workflow to complete.
* Each call to `start()` creates a new workflow run. If retried requests must route to one active workflow, have the workflow create a deterministic hook token and use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) to reuse an already-registered active hook. The lookup is not atomic with `start()`, so concurrent callers can still create extra runs before the hook is registered; handle that race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work — on a conflict it resolves with the run that owns the token, so the duplicate can return the active owner to the caller. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Idempotency](/docs/foundations/idempotency#run-idempotency).
* Pass a deterministic `hook.token` to atomically prevent duplicate runs from starting. See [Prevent duplicate starts](#prevent-duplicate-starts) and [Idempotency](/docs/foundations/idempotency#run-idempotency).
* All arguments must be [serializable](/docs/foundations/serialization).
* 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.
* `attributes` seeds plaintext run metadata as part of creation and requires a World implementing spec version 4 or later. Keys that start with `$` are reserved for framework and library code; framework-level callers can pass `allowReservedAttributes: true` to seed reserved keys, with the same semantics as the [`setAttributes`](/docs/api-reference/workflow/set-attributes) option of the same name.
Expand All @@ -67,6 +67,52 @@ If `start()` throws `'start' received an invalid workflow function. Ensure the W

## Examples

### Prevent duplicate starts

Use a hook token as an idempotency key. The World reserves the token while it admits the run, before the workflow body executes:

```typescript lineNumbers
import { start } from "workflow/api";
import { HookConflictError, WorkflowStartError } from "workflow/errors";
import { processOrder } from "./workflows/process-order";

export async function POST(request: Request) {
const { orderId } = await request.json();

try {
const run = await start(processOrder, [orderId], {
hook: { // [!code highlight]
token: `order:${orderId}`, // [!code highlight]
experimental_minRetention: "30 days", // [!code highlight]
}, // [!code highlight]
});
return Response.json({ runId: run.runId, duplicate: false });
} catch (error) {
if (HookConflictError.is(error)) {
return Response.json({
runId: error.conflictingRunId,
duplicate: true,
});
}

if (WorkflowStartError.is(error)) {
console.error(`Could not confirm start candidate ${error.runId}`);
}
throw error;
}
}
```

Exactly one run can own the token. A duplicate throws [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error) with the owner's run ID, without creating or executing another run.

`experimental_minRetention` accepts the same duration strings, millisecond numbers, and absolute `Date` values as [`sleep()`](/docs/api-reference/workflow/sleep). A duration starts when `start()` is called. The token remains unavailable until both the run has ended and that time has passed.

You do not need to call `createHook()` inside the workflow when the token is only an idempotency key. If the workflow also receives data through a Hook, create one with the same token. Its `experimental_minRetention` may extend the original time, but cannot shorten it.

If the SDK cannot confirm whether the candidate was queued or admitted, `start()` throws [`WorkflowStartError`](/docs/api-reference/workflow-errors/workflow-start-error). Its `runId` identifies that candidate, and its `stage` identifies the failed operation. Retrying the logical request with the same token cannot admit two owners.

Worlds that do not support atomic start Hooks reject this option before sending the workflow message.

### With Arguments

```typescript
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: HookConflictError
description: Thrown when creating a hook with a token that is already in use by another workflow run.
description: Thrown when creating or atomically starting with a Hook token that is already in use by another workflow run.
type: reference
summary: Catch HookConflictError when a hook token is already claimed by another active workflow run.
related:
Expand All @@ -9,7 +9,9 @@ related:
- /docs/errors/hook-conflict
---

`HookConflictError` is thrown when creating a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows — see the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies.
`HookConflictError` is thrown when creating a Hook or calling `start({ hook })` with a token that another workflow run owns. See the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies.

Atomic `start({ hook })` conflicts always include `conflictingRunId`. It remains optional on the general error type because old persisted Hook conflicts may not contain the owner's run ID.

```typescript lineNumbers
import { HookConflictError } from "workflow/errors"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow
<Card href="/docs/api-reference/workflow-errors/workflow-runtime-error" title="WorkflowRuntimeError">
Thrown when the workflow runtime encounters an execution error, such as serialization failures or timeouts.
</Card>
<Card href="/docs/api-reference/workflow-errors/workflow-start-error" title="WorkflowStartError">
Thrown when an atomic-start candidate's queue or admission outcome cannot be confirmed.
</Card>
<Card href="/docs/api-reference/workflow-errors/run-expired-error" title="RunExpiredError">
Thrown when a workflow run has expired and can no longer be operated on.
</Card>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"workflow-run-cancelled-error",
"workflow-run-not-completed-error",
"workflow-runtime-error",
"workflow-start-error",
"workflow-world-error",
"throttle-error",
"entity-conflict-error",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
title: WorkflowStartError
description: Thrown when start with a Hook cannot confirm whether a workflow candidate was queued or admitted.
type: reference
summary: Use WorkflowStartError to identify an atomic-start candidate whose outcome could not be confirmed.
related:
- /docs/api-reference/workflow-api/start
- /docs/foundations/idempotency
---

`WorkflowStartError` is thrown when [`start()`](/docs/api-reference/workflow-api/start#prevent-duplicate-starts) uses a Hook token but cannot confirm whether its candidate was queued or admitted.

```typescript lineNumbers
import { start } from "workflow/api";
import { WorkflowStartError } from "workflow/errors";
import { processOrder } from "./workflows/process-order";
declare const orderId: string; // @setup

try {
await start(processOrder, [orderId], {
hook: { token: `order:${orderId}` },
});
} catch (error) {
if (WorkflowStartError.is(error)) {
console.error(error.runId, error.stage);
}
}
```

The candidate might never become a run. Keep `runId` for inspection and retry the logical request with the same Hook token.

## API Signature

### Properties

<TSDoc
definition={`
import { WorkflowStartError } from "workflow/errors";
export default WorkflowStartError;`}
/>

`stage` is `"queue"` when the SDK could not confirm queueing and `"admission"` when the workflow message was queued but the SDK could not confirm admission.

### Static Methods

#### `WorkflowStartError.is(value)`

Type-safe check for `WorkflowStartError` instances across module boundaries and VM contexts.
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ if (WorkflowWorldError.is(error)) {
The following error types extend `WorkflowWorldError`:

- [`EntityConflictError`](/docs/api-reference/workflow-errors/entity-conflict-error) — operation conflicts with entity state
- [`WorkflowStartError`](/docs/api-reference/workflow-errors/workflow-start-error) — atomic-start queue or admission outcome could not be confirmed
- [`RunExpiredError`](/docs/api-reference/workflow-errors/run-expired-error) — run has expired
- [`TooEarlyError`](/docs/api-reference/workflow-errors/too-early-error) — request made before system is ready
- [`ThrottleError`](/docs/api-reference/workflow-errors/throttle-error) — request was rate-limited
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ const world = await getWorld(); // [!code highlight]
The World SDK is the low-level foundation that higher-level functions like [`getRun()`](/docs/api-reference/workflow-api/get-run) and [`start()`](/docs/api-reference/workflow-api/start) are built on. Use it when you need capabilities beyond what those functions provide.
</Callout>

## Capabilities

`world.capabilities` declares optional behavior implemented by the complete World, including its storage and queue runtime. The SDK returns the same object from deployment health checks so `start()` can verify both the calling and target deployments before using a feature.

A World may advertise `atomicStartHook: { active: true }` only when `events.create()` atomically admits a `run_created` or resilient `run_started` event containing `startHook`:

- Reserve the token and create the run as one operation.
- If another run owns the token, throw `HookConflictError` with its `conflictingRunId` and create no run or events for the candidate.
- Keep each candidate `runId`'s decision immutable while a queued copy can still arrive. Repeating admission for that candidate must return the same accepted run or the same conflict, even if the token becomes available between attempts.

This contract lets the direct `start()` request and its queued copy safely race without executing a candidate after the caller was told it lost.

## Data Hydration

Step input/output data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. To display this data in your UI, use the hydration utilities from `workflow/observability`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ await world.events.create(runId, { // [!code highlight]

**Returns:** `EventResult` — The created event and the affected entity (run/step/hook)

Worlds that advertise `capabilities.atomicStartHook.active` must apply the [atomic start Hook contract](/docs/api-reference/workflow-runtime/world#capabilities) when `run_created` or resilient `run_started` includes `startHook`.

### events.get()

Retrieve a single event by run ID and event ID.
Expand Down
42 changes: 37 additions & 5 deletions docs/content/docs/v5/foundations/idempotency.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,43 @@ Why this works:

Step idempotency protects side effects **inside** a workflow run. Run idempotency answers a different question: if the same API request is sent twice, should it create one workflow run or two?

Because [hooks](/docs/foundations/hooks) already ensure globally unique active tokens, Workflow can use the same mechanism to coordinate duplicate requests while a run is active.
Use a deterministic [Hook](/docs/foundations/hooks) token from your domain, such as an order ID, invoice ID, import ID, or request ID.

Use a hook token as the idempotency key for an active workflow run. Hook tokens are globally unique while they are active: if another run tries to create a hook with the same token, the runtime records a conflict, `hook.getConflict()` resolves with a `Run` handle for the run that owns the token, and the hook rejects with [`HookConflictError`](/docs/errors/hook-conflict) when the workflow awaits or iterates its payload.
### Atomic admission

The token should come from your domain, such as an order ID, invoice ID, import ID, or request ID. Create the hook near the beginning of the workflow and check `await hook.getConflict()` before doing duplicate-sensitive work that depends on owning the active token. Calling `createHook()` alone does not register the hook — awaiting `getConflict()` suspends the workflow to commit the registration.
Pass the token to `start()`. The World reserves it while admitting the run, so a duplicate is rejected before another run or any duplicate-sensitive work is created:

```typescript lineNumbers
import { start } from "workflow/api";
import { HookConflictError } from "workflow/errors";
import { processOrder } from "./workflows/process-order";

export async function POST(request: Request) {
const { orderId } = await request.json();

try {
const run = await start(processOrder, [orderId], {
hook: {
token: `order:${orderId}`, // [!code highlight]
experimental_minRetention: "30 days", // [!code highlight]
},
});
return Response.json({ runId: run.runId, duplicate: false });
} catch (error) {
if (!HookConflictError.is(error)) throw error;
return Response.json({
runId: error.conflictingRunId,
duplicate: true,
});
}
}
```

The workflow does not need to call `createHook()` unless it also receives data through that Hook. See [`start()`](/docs/api-reference/workflow-api/start#prevent-duplicate-starts) for retention and uncertain-start behavior.

### Workflow-side fallback

If a World does not support atomic start Hooks, create a Hook near the beginning of the workflow and check `await hook.getConflict()` before duplicate-sensitive work. Calling `createHook()` alone does not register the Hook; awaiting `getConflict()` suspends the workflow to commit its registration.

```typescript lineNumbers
import { createHook } from "workflow";
Expand Down Expand Up @@ -146,10 +178,10 @@ export async function POST(request: Request) {
```

<Callout type="warn">
This avoids creating a new run only after the first run has registered its hook. Because `start()` returns before the run body executes and calls `createHook()`, two concurrent requests can both observe "no hook yet" and each call `start()`. The race is resolved inside the workflow body, where the losing run observes `getConflict()` resolving with the active owner and returns without doing duplicate-sensitive work — and the route detects it by comparing the resumed hook's `runId` against the run it just started, without waiting for either run to finish. A native API for atomically starting a run and registering a hook is in the works. Until then, model recovery inside the workflow by checking `hook.getConflict()`.
This fallback can create duplicate runs during the gap between `start()` and Hook registration. The losing workflow returns before duplicate-sensitive work, but it was still created, queued, and started. Use atomic admission when the World supports it.
</Callout>

This coordinates active runs by default: the token becomes available when its workflow ends. Set `experimental_minRetention` to keep it unavailable to late duplicates. After the workflow ends, the Hook can still be found with `getHookByToken()` until retention ends, but it cannot be resumed. See [`createHook()` minimum retention](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) for examples and supported values.
In either pattern, the token becomes available when its workflow ends by default. Set `experimental_minRetention` to keep it unavailable to late duplicates. See [`createHook()` minimum retention](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) for examples and supported values.

### Conflict-handling strategies

Expand Down
Loading