Skip to content
Open
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
4 changes: 4 additions & 0 deletions .changeset/docs-cleanup-oct-8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Docs: note that the Local World deletes data written in the old flat layout on upgrade, add WebSocket stream writes to What's New, and keep the Sandbox cookbook's command hook from losing a command sent between races.
17 changes: 12 additions & 5 deletions docs/content/docs/v5/cookbook/integrations/sandbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ export async function sandboxSessionWorkflow() {
// Create the hook once, outside the loop: reusing the same token from inside // [!code highlight]
// the loop would throw HookConflictError. // [!code highlight]
const hook = commandHook.create({ token: workflowRunId });
// Keep one pending command across iterations, so a command that arrives // [!code highlight]
// while a timer branch runs still reaches the next wait. // [!code highlight]
let nextCommand = hook.then((p) => p.command); // [!code highlight]

const startedAt = Date.now();

Expand Down Expand Up @@ -207,8 +210,9 @@ export async function sandboxSessionWorkflow() {
if (hibernated && snapshot) {
// While hibernated, the VM is already stopped. Wait for the next
// command: no idle timer, no compute cost.
const payload = await hook;
if (payload.command === "/destroy") { destroyed = true; break; }
const command = await nextCommand;
nextCommand = hook.then((p) => p.command);
if (command === "/destroy") { destroyed = true; break; }

await emit({ type: "status", state: "resuming", at: Date.now() });
sandbox = await Sandbox.create({ // [!code highlight]
Expand All @@ -225,7 +229,7 @@ export async function sandboxSessionWorkflow() {
});

counter += 1;
await runCommandAndStream(sandbox, `cmd-${counter}`, payload.command);
await runCommandAndStream(sandbox, `cmd-${counter}`, command);
lastActivityAt = Date.now();
await emit({ type: "activity", at: lastActivityAt });
continue;
Expand All @@ -238,7 +242,7 @@ export async function sandboxSessionWorkflow() {
const sleepMs = Math.max(0, wakeAt - Date.now());

const outcome = await Promise.race([ // [!code highlight]
hook.then((p) => ({ type: "command" as const, command: p.command })),
nextCommand.then((command) => ({ type: "command" as const, command })),
sleep(`${sleepMs}ms`).then(() => ({ type: "timer" as const })),
]);

Expand Down Expand Up @@ -275,6 +279,7 @@ export async function sandboxSessionWorkflow() {
continue;
}

nextCommand = hook.then((p) => p.command);
if (outcome.command === "/destroy") { destroyed = true; break; }

counter += 1;
Expand Down Expand Up @@ -519,7 +524,9 @@ The sample treats a missing or stale `runId` as a request for a new session. For

### Keep the hook outside the loop

Each iteration's `hook.then(...)` attaches a listener to the same hook instance. Creating a new hook per iteration with the same token throws `HookConflictError`. One hook, one token (`workflowRunId`), reused every iteration.
Creating a new hook per iteration with the same token throws `HookConflictError`. One hook, one token (`workflowRunId`), reused every iteration.

Race the same pending `nextCommand` promise until it resolves, and only then call `hook.then()` again. A `hook.then()` left behind by a race the timer won still takes the next command, so a fresh `hook.then()` per race would miss a command sent while the workflow snapshots or refreshes the sandbox. See [Waiting with a timeout](/docs/foundations/hooks#waiting-with-a-timeout).

## Key APIs

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/v5/whats-new.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ All three first-party Worlds now implement it: Vercel accepts up to 30 days, and
- **A run cannot be forked across environments.** `start()` stamps the environment it was called from onto the queue message, and a deployment refuses a delivery whose run was created in a different environment. Previously a preview client and a production deployment could each hold half of one run ID.
- **An experimental QuickJS VM engine.** Set [`WORKFLOW_VM=quickjs`](/docs/configuration/runtime-tuning#workflow_vm) to run workflow functions in a QuickJS VM compiled to WebAssembly instead of `node:vm`, for platforms that do not provide `node:vm`. Replay semantics are identical, but the available globals are not: check the differences before switching an existing deployment. By default, an encrypted QuickJS run that passes 1,000 events resumes from a VM-memory snapshot instead of replaying its full event log (see [`WORKFLOW_SNAPSHOT_THRESHOLD`](/docs/configuration/runtime-tuning#workflow_snapshot_threshold)).
- **Experimental dynamic workflows.** Pass workflow source as a string to `start()` to run orchestration assembled after deployment over steps already deployed with your app. The code is stored with the run (encrypted on Vercel), so replays always execute the code the run started with. Off unless the deployment sets `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1`. See [Dynamic Workflows](/docs/advanced/dynamic-workflows).
- **A WebSocket transport for event writes** on the Vercel World, which ships a run's events over one socket instead of one HTTP request each. It is the default; set [`WORKFLOW_EVENTS_TRANSPORT=http`](/worlds/vercel#workflow_events_transport) to opt out. Tracing is unaffected: each write still emits an `http POST` client span, synthesized around the frame, with the transport on `workflow.events.transport`.
- **A WebSocket transport for event and stream writes** on the Vercel World, which ships a run's events over one socket instead of one HTTP request each. It is the default; set [`WORKFLOW_EVENTS_TRANSPORT=http`](/worlds/vercel#workflow_events_transport) to opt out. Tracing is unaffected: each write still emits an `http POST` client span, synthesized around the frame, with the transport on `workflow.events.transport`. Stream writes use a WebSocket by default too; set [`WORKFLOW_STREAMS_TRANSPORT=http`](/worlds/vercel#workflow_streams_transport) to keep them on HTTP.
- **`await run.returnValue` waits instead of polling.** Reading a run's result long-polls the World until the run reaches a terminal status, rather than asking again on a fixed interval. A result is observed as soon as it exists, and an idle wait costs one open request instead of a request per tick. Worlds that do not implement the long poll keep the interval.
- **An event arriving mid-replay no longer fails the run.** A hook resume or step completion landing while a replay is in flight used to be able to fail it with [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log). Writes now come back with the events the replay had not seen, and the event is held for whichever part of the workflow awaits it. A run only fails when the log is genuinely missing a position.

Expand Down
2 changes: 1 addition & 1 deletion docs/content/worlds/v5/local.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ WORKFLOW_TARGET_WORLD="./my-world.ts"

## Data directory layout

Each run's event and step files are stored in their own directory (`events/<runId>/`, `steps/<runId>/`). A directory that mixes both layouts, or holds `.json` files in `events/` or `steps/` that the World did not write, is never deleted: starting fails with a `DataDirLayoutError` that lists the files.
Each run's event and step files are stored in their own directory (`events/<runId>/`, `steps/<runId>/`).

Comment thread
VaguelySerious marked this conversation as resolved.
Resolved
## Limitations

Expand Down