Commit Graph

1788 Commits

Author SHA1 Message Date
Mitul Shah a5694d34ba fix(web-shared): stop event payload stub flash in events view (#3951) 2026-09-10 15:06:34 -07:00
nityam 74058c141c fix: order the 429 check before the 4xx check in the workflow skill (#3915) 2026-09-10 14:38:17 -07:00
Nathan Rajlich acb6b1370a test(swc-plugin): verify class-name preservation at runtime (#4015) 2026-09-10 14:34:59 -07:00
Pranay Prakash 0b1216ebe2 (chore) Update Next.js to 16.3.4 in the workbench apps and @workflow/next (#4026) 2026-09-10 14:06:20 -07:00
Alex Langenfeld 3aa4c161af Add step claim attribution to client spans (#4066)
## Summary & Motivation

Adds three bounded trace attributes so a sampled trace says how a `step_started` claim was made: `workflow.step_start.strategy` on the step span (`awaited` / `optimistic` / `batch_preclaimed`, set before the write so a losing claim keeps it after its 409 reconciles to `skipped`), and `workflow.step_start.mode` plus `workflow.step_start.owner_stamped` on the world-vercel write spans across the http, batch, and ws paths. Purely additive telemetry — no change to execution behavior.

## Test Plan

Test added covering a stamped lazy claim's attributes on the per-write span; existing coverage runs in CI. Local run of 81 focused core and world-vercel tests passed; full core typecheck is blocked by unrelated workspace resolution issues.
2026-09-10 15:32:01 -05:00
Alex Langenfeld 7740388d7f feat(streams): trace first WebSocket writes (#4074)
## Summary & Motivation

Tags the first write of a session, and the first write on each reconnect, with phase timings on the existing `workflow.stream.write` span — token/config resolution, connect, wait-for-open, send, and ack round trip — so cold WebSocket write latency can be attributed to a client-side phase before changing first-chunk transport behavior. Later writes keep the two attributes they had, since the per-phase clocks only mean anything while a connection is being established.

## Test Plan

Unit tests added; existing world-vercel suite and typecheck pass.
2026-09-10 13:29:01 -05:00
Peter Wielander 45a3072948 [core] Fix the python e2e conformance suite after the retention merge (#4022) 2026-09-10 08:10:57 -07:00
Alex Langenfeld d4817ce548 feat(streams): add WebSocket writer lifecycle (#3833)
## Summary & Motivation

Implements the client half of `workflow-stream-ws/v1` behind the existing default-off `WORKFLOW_STREAMS_TRANSPORT=ws` gate, populating the `createWriteSession` seam only when opted in.

- Writes and closes are serialized over one socket per writer lifetime; groups above the v1 per-request chunk cap are split without resetting writer-local sequence.
- Any failure before the upgrade is accepted (declined upgrade, proxy, load error, a dispatch that beats the handshake) falls back to HTTP for the rest of the writer's life.
- Once a write is on the socket, a missing or uncorrelatable reply poisons the session rather than replaying over HTTP, since a duplicate append cannot be ruled out.
- Idle clean closes reconnect with the same writer id, capped at three attempts so a draining server can't hot-loop.
- The handshake gets a `workflow.stream.ws.connect` span and each frame a synthesized `http POST` span, so per-event tracing survives the non-`fetch` transport.

## Test Plan

New unit tests cover the lifecycle, fallback, and poisoning paths; 598 `@workflow/world-vercel` tests plus package build/typecheck and workspace lint/format pass. Root build/typecheck couldn't run locally (missing Rust toolchain for the unrelated `@workflow/swc-plugin`).
2026-09-10 09:21:51 -05:00
Pranay Prakash f5aeaa869c Move to changesets v3 and changesets/action v2 (#3974)
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
@workflow/tsconfig@5.0.0-beta.0
2026-09-09 14:44:37 -07:00
Pranay Prakash c477cfa3d3 Keep one failed publish from taking down the release, and verify what actually reached npm (#3967) 2026-09-09 12:51:48 -07:00
github-actions[bot] 32a74e3941 Version Packages (beta) (#4062) workflow@5.0.0-beta.50 2026-09-09 12:40:45 -07:00
Pranay Prakash efbdc213a0 [core] Make hook.metadata a lazy Promise getter (#3988)
* [core] Make `hook.metadata` a lazy Promise getter

Hydrating a hook's metadata is a decrypting READ: it needs the owning
run's payload keys, and resolving those costs a run fetch plus a
`run-key` API round trip (~350ms). `getHookByToken()` did that work
eagerly on every lookup that found a metadata-bearing hook, so callers
that only wanted `runId`/`token` — and hook resumption, which never
reads metadata at all — paid for it anyway.

`metadata` is now a getter returning a memoized Promise, the same shape
as `run.returnValue`. The lookup is one read again; hydration and the
key resolution behind it happen on first access, or never.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>

* docs: surface the lazy hook.metadata change in What's new, the migration skill, and the resumeHook reference

Adds the breaking-change row to the v5 What's new page and puts that page
in the sidebar as the first visible entry (the /v5/docs redirect to
getting-started is unchanged). Teaches the migrating-workflow-v4-to-v5
skill the `await hook.metadata` rewrite and bumps its version. Points the
resumeHook reference at HookWithLazyMetadata, and notes on the World
storage page that world.hooks.getByToken() returns raw serialized
metadata.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* [core] Export the lazy-metadata hook type as `Hook` from `workflow/api`

`getHookByToken()` and `resumeHook()` return `Hook`, not a separate
`HookWithLazyMetadata`: one public hook type whose `metadata` is a lazy
Promise, mirroring `Run` for runs. The World-level record from
`@workflow/world` is unchanged and is referenced as `WorldHook` inside the
runtime.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* [core] Define the lazy `metadata` getter in place; tighten changeset and docs wording

Review feedback: the hook record a World returns is a fresh object per
lookup and the eager path mutated it anyway, so define the getter on it
directly instead of copying it with Object.create(). The changeset is one
sentence, and the docs describe hydration as extra network round trips
rather than decryption, since not every World encrypts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 12:26:28 -07:00
Peter Wielander 22b5f48e37 [ci] Print opencode's server log in the backport job (#4056) 2026-09-09 12:18:43 -07:00
github-actions[bot] 855b4e92e6 Version Packages (beta) (#4011) workflow@5.0.0-beta.49 2026-09-09 12:12:11 -07:00
Peter Wielander 2354301f39 [world-vercel] Bound the queue client's requests (#4049) 2026-09-09 18:50:37 +00:00
Alex Langenfeld 4547e1a7a9 feat(streams): add writer session seam (#3832)
## Summary & Motivation

Gives one in-memory stream writer a stable identity and its own sequence space, so a transport can preserve chunk ordering across a mid-stream HTTP/WebSocket transition. `Streamer.streams.createWriteSession` is optional — Worlds that don't implement it keep using `write`/`writeMulti`/`close` unchanged.

Abort disposes the session rather than closing it, since a producer failure is transport cleanup, not stream completion.

## Test Plan

Tests added, plus the full `@workflow/world-vercel` suite and package builds/typecheck pass. Root build/typecheck is blocked locally by a missing Rust toolchain for the unrelated `@workflow/swc-plugin`.
2026-09-09 12:20:08 -05:00
Peter Wielander 51a181af91 docs: document WORKFLOW_NODE_HTTP in the v4 World docs (#4050) 2026-09-09 10:17:33 -07:00
Peter Wielander f83e8367f4 [world-vercel] Honor WORKFLOW_NODE_HTTP on the queue transport (#4044)
* fix(world-vercel): honor WORKFLOW_NODE_HTTP on the queue transport

getQueueDispatcher was the one dispatcher getter that ignored the flag. The
reasoning was that `undefined` cannot move the queue client onto node:http
(QueueClient takes a dispatcher and no fetch override), so returning it would
only drop this package's pool tuning and fall back to undici's global agent.

That misses what the flag is actually for. The deployments that need it are the
ones where the undici copy *this package bundles* is unusable, and `undefined`
does move the request off that copy: global fetch dispatches on the runtime's
own undici instead. On such a deployment every other request survives while the
queue client keeps dispatching through the broken copy, and an
acknowledgeMessage that never resolves means the message is redelivered for as
long as the platform keeps killing the invocation holding it.

Losing pool tuning is the correct trade under a flag whose premise is that the
bundled undici is not usable here. An explicit config.dispatcher still wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct the queue-send note for WORKFLOW_NODE_HTTP

The queue client is a partial exception to the flag, not a full one: it cannot
move to node:http, but it does honor the flag by dispatching through the
runtime's own copy of the HTTP client library instead of the copy the World
bundles. That distinction is the whole point when the bundled copy is what does
not work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 10:17:08 -07:00
Alex Langenfeld fdeb642270 feat(streams): add WebSocket capability gate (#3764)
## Summary & Motivation

`WORKFLOW_STREAMS_TRANSPORT=ws` advertises client support for `workflow-stream-ws/v1` on stream writes; anything else keeps HTTP. It's a capability signal only — the server decides each upgrade, so there's no version or tenant-policy heuristic on the client side.

## Test Plan

Unit tests for the gate's accepted values; typecheck, build, and lint pass locally.
2026-09-09 11:48:08 -05:00
Chad Hietala 9a5660fbd6 fix(world-local): retry JSON reads on Windows (#4051) 2026-09-09 15:01:25 +00:00
Peter Wielander 8a91d18d0d [core] Add the wake-loop scenario to the event log race repro (#4017) 2026-09-08 15:19:32 -07:00
Peter Wielander 9a9af618f7 [ci] Cap concurrent Vercel E2E action repo-wide (10 by default) (#4039) 2026-09-08 14:47:37 -07:00
Alex Langenfeld c340820411 Add Run#getWritable() for appending to another run's stream (#3972)
## Summary & Motivation

Lets a long-lived run own a shared stream while independent runs append through `run.writable` or `run.getWritable()` using only the owner's run ID. The handle seals to the owner's public key when it has one, so it grants append access without read capability, and carries the existing forwarding symbols so passing it through `start()` and into a step keeps the owner's identity.

## Test Plan

Tests added
2026-09-08 21:13:53 +00:00
Alex Langenfeld 4fdbadcdce feat(streams): add WebSocket v1 client protocol contract (#3763)
## Summary & Motivation

Defines the `workflow-stream-ws/v1` frame schemas and encoder in `@workflow/world-vercel`, versioned independently of the REST API and workflow spec so a framing or acknowledgement change needs a new endpoint rather than a spec bump. Nothing calls it yet.

## Test Plan

Protocol tests added, including a byte-for-byte check against workflow-server's canonical frame fixture.
2026-09-08 15:38:08 -05:00
Nathan Rajlich b30ed49187 [swc-playground] Update to Next.js v16.3.4 (#4018) 2026-09-08 20:20:21 +00:00
Peter Wielander 61fb1f93bd [core] Add a retention option to start() (#3787) 2026-09-08 12:57:31 -07:00
Nathan Rajlich ae5ee5ba2e fix(swc-plugin): register class expressions via an IIFE instead of by name (#3971)
* fix(swc-plugin): register class expressions via an IIFE and reject unnameable classes

Class expressions with "use step" methods or custom serialization were
registered by module-level statements referencing the class by name. When no
module-scope binding could be resolved the plugin fell back to a placeholder
`AnonymousClass` identifier, which is a guaranteed ReferenceError at module
evaluation (vercel/workflow#3929). Other shapes were silently wrong as well:
`var A = class {}, B = class {}` registered A's steps under B, `X = class {}`
assignments and classes nested inside functions emitted unresolvable
references.

Class expressions are now wrapped in a single IIFE that receives the class,
performs every registration recorded for it, and returns it, so the
registration no longer depends on a name being in scope. The class name is
still needed for step/class IDs and is derived from the assigned variable,
the class's own identifier, or the property key it is assigned to
(`exports.Foo = class {}`, `{ Foo: class {} }`). When none is available, or
the class is declared inside a function, the plugin emits a compile error
instead of broken code.

Class declarations keep their existing module-level output; the emitters
were factored so both paths share the same statement builders.

* fix(swc-plugin): generate names for anonymous class expressions instead of erroring

With registration happening inside the IIFE, an anonymous class expression
in a position that provides no name (`foo(class { ... })`, an array element,
a conditional branch) only needs a name for its step/class IDs. Generate a
deterministic `AnonymousClass<N>`, counting only anonymous classes that have
something to register, instead of rejecting them. Classes declared inside a
function remain an error.

Dead-code elimination now keeps module-level declarations whose initializer
contains a wrapped class expression: evaluating the initializer is what
registers the class, and the binding may be otherwise unreferenced.
2026-09-08 10:14:31 -07:00
Shalabh Chaturvedi c129332923 [docs] Fix prose typos across v4/v5 docs and the SWC plugin README (#3948)
Copy-edit only, no behavior or API changes:

- "it's a only a short step" -> "it's only a short step" (ai/index)
- "When you tool needs" -> "When your tool needs" (ai/defining-tools)
- "Workflow operation that suspend" -> "operations that suspend" (ai/sleep-and-delays)
- "extend out ... to use emit" -> "extend our ... to emit", and
  "other tools calls ... inject out own" -> "other tool calls ... inject our own"
  (ai/streaming-updates-from-tools)
- "non-yet-standard" -> "not-yet-standard" (how-it-works/understanding-directives)
- "apps ... and needs no special configuration" -> "and need no special
  configuration" on the five getting-started pages that disagreed with the
  other five (express, fastify, hono, nuxt, vite)
- drop the orphan "needed." line after "No separate command is required."
  and fix "the local installed version" -> "the locally installed version"
  (observability/index)
- "Time between emissions of a chunk" -> "emission of a chunk"
  (observability/tracing)
- "determine that is safe" -> "determine that it is safe" (whats-new)
- "three rules bind an implementation" -> "four rules": the list has four
  bullets, and skills/migrating-world-v4-to-v5 already says four
  (worlds/upgrading-to-v5)
- drop the stray duplicate "Workflow" before the Workflow SDK link in
  @workflow/swc-plugin's README, and the duplicated horizontal rule before
  "## Detect mode" in its spec

Each fix is applied to both the v4 and v5 copies wherever the same text
exists in both.

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-09-04 17:16:59 -07:00
Pranay Prakash 22a9668dcf Validate pending changesets in CI so a bad one fails the PR, not the Release job (#3964)
`changeset version` assembles a release plan from every pending changeset
before it bumps anything, and throws on a changeset it cannot place there:
one naming a package outside the workspace, or one mixing a package from
the `ignore` list with published ones. #3938 shipped the latter and every
push to main since has failed to publish (#3963). Nothing at PR time ran
that step.

scripts/check-changesets.mjs runs the same assembly on the same inputs,
resolving the libraries from @changesets/cli's own install so the check
uses exactly the versions the Release job does, and stops before the
network-bound changelog generation. lint.yml runs it on every PR.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:02:00 -07:00
github-actions[bot] 70a9aa2520 Version Packages (beta) (#3919)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-03 14:47:15 -07:00
Alex Langenfeld fe2fd8c457 Classify Workflow stream failures (#3850)
## Summary & Motivation

Stream infrastructure failures (HTTP/2 session wedges, transport timeouts, non-2xx stream responses) surfaced as plain `Error`, so terminal classification attributed them to customer code as `USER_ERROR`. They now carry a catchable `StreamError` with a `STREAM_ERROR` run error code, attributed to the SDK and retried when transport-level or 5xx.

The v4 events response body is wrapped so a post-header stream failure is classified and reported to the dispatcher recycler — a response header arriving is not yet a successful streamed request.

## Test Plan

Unit tests added across classification, serialization round-trip, the streamer, and the v4 transport; 331 `@workflow/core` and 123 `@workflow/world-vercel` focused tests pass.
2026-09-03 16:31:18 -05:00
Pranay Prakash 63143e5f84 Drop the ignored @workflow/world-sim package from the hook_conflict delta changeset (#3963)
Changesets refuses a changeset that mixes packages in the `ignore` list
with published ones, so `changeset version` has failed on every push to
main since #3938 and nothing has been published.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 14:27:33 -07:00
Karthik Kalyan f9073d0739 Add attribute inspection to the CLI (#3950)
* Add attribute inspection to the CLI and probe the cancel window once

`wf inspect attributes` lists the distinct attribute keys on a project's
runs with their run counts and first/last seen times, and
`wf inspect runs --attribute key=value` filters by them. Between them
they turn attributes from something you can only write into something
you can discover and query. Both are analytics-only — storage has no
cross-run attribute index — so the listing says so rather than falling
back, and the filter warns and is ignored the way --since/--until
already do.

The flag is parsed and bounded in lib/inspect so the error names
--attribute rather than the parameter it becomes, and so it is testable
next to the other inspect flag helpers. It splits on the first `=` only,
since a value may contain one, and keeps an empty value, which matches
runs whose attribute was set to the empty string.

`wf cancel` also probed the plan's listing window inside its per-status
fan-out, so a four-status cancel issued four identical probes. The
window is a property of the plan rather than of a status, so the probe
is hoisted above the fan-out: eight requests become five. The harness
only ever modelled the storage path, so that probe logic had no
coverage; the new test fails with two probes before the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Do not depend on an unreleased world export for the flag cap

The --attribute cap was imported from @workflow/world, where the
constant is added by a different branch, so on main it resolved to
undefined and `values.length > undefined` was always false: the flag
accepted any number of pairs and the test for it never threw.

Declare the cap in the CLI instead. The World and the backend enforce
the same bound independently, and this copy exists only so the error can
name the flag the user typed rather than the parameter it becomes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Degrade sleeps to the event log and bound the inspect flags

`wf inspect sleeps` was the only list path that could not degrade: it
branched on analytics being present and either returned or exited, so on
any backend providing analytics the storage branch below it was
unreachable and an analytics failure ended the command. It now warns and
falls through, like the run, step, and event listings. An argument the
World rejected is not retried — the same argument fails either path, so
falling back would trade a precise message for a slower failure.

handleApiError also only recognised errors carrying an HTTP status.
A client-side argument rejection has none, because no request was made,
so it fell past every branch and was rethrown as an unhandled error. It
is now reported as given: the message already names the method, the
parameter, and what it received.

--limit and --runId are checked before any backend setup so a mistyped
value names the flag and costs no round trip. The limit bound is
deliberately looser than the per-endpoint caps, which differ by resource
and stay with the World; this one catches a typo'd digit or a negative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Scope --attribute to inspect and document the inspect flags

--attribute was added to the shared cliFlags, which cancel, health,
start, and web all spread — so `workflow health --attribute k=v` parsed
and was silently ignored. It belongs with the other inspect-only
filters in the command's own flags, next to --runId and --since.

The configuration reference documented every shared flag but none of
the inspect-only ones, so --runId, --stepId, --hookId, --since/--until,
--withData and --decrypt had no entries at all. They now do, in an
Inspect filtering section, alongside --attribute. --status and
--workflowName were documented under bulk cancel only; both also filter
inspect listings, which is now noted where they are.

--limit's entry described a default with no bound and is now rejected
outside 1 to 1000, so it says so, and points out that individual
listings cap lower.

The attributes guide claimed filtering was available "through the
Analytics API", which is no longer the whole story: the CLI can now
discover keys and filter by them, so that section splits into a CLI half
and an API half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Stop dropping inspect flags silently

Three flags the caller typed were being discarded without saying so —
the same failure the World argument guards were added to remove,
reintroduced one layer up.

--attribute and --since/--until warned that the backend has no
analytics read path, but that condition is also false when --withData
asks for payloads, which only storage carries. Blaming the backend for
the caller's own flag sends them looking in the wrong place, so the
warning now names whichever applies.

inspect attributes dropped --sort entirely, explained only by a code
comment. It is forwarded now, and still left unset when absent so the
backend's alphabetical key order stands rather than the `desc` the
time-ordered listings impose.

A repeated --attribute key silently kept the last value, and a test
asserted that as if it were intended. Matching is per-key, so resolving
it means discarding a filter the caller typed: it is rejected instead.

The shared --limit entry also stated the 1-to-1000 bound that only
inspect enforces, which is wrong for cancel's own 1-to-500. The bound
moves to an inspect entry and the shared one points at both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Reject --attribute on listings that cannot use it

Only the runs listing filters by attributes, but the flag was parsed for
every inspect resource: `inspect steps --attribute tenant=acme` returned
a normal, unfiltered step list with no warning, as did events, hooks,
attributes, and `inspect run <id>`, which already names one run. That is
the silent drop the preceding commit set out to remove, missed one layer
up in the command itself.

Validated alongside the other flag bounds, before any backend setup, so
a flag on the wrong subcommand costs no round trip.

Covered at the command level as well as in the unit, since the defect
was not in the validator but in nothing calling it: the tests drive
`Inspect.run` with a mocked setup module and assert the backend is never
reached. Five of them fail without this change.

Reported in review; verified against a real project rather than found
by the suite, which is why the command-level coverage goes in with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Resolve the test's oclif root without a URL pathname

`new URL('../..', import.meta.url).pathname` yields `/D:/a/...` on
Windows — a leading slash before the drive letter — so `Config.load`
could not find package.json and every command-level test failed there
while passing on Linux. `fileURLToPath` handles both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address review on the attribute flag

Attribute keys naming an Object.prototype member were rejected as
duplicates before anything was stored, because the duplicate check used
`in`, which walks the prototype. `--attribute toString=v` failed on
first sight, and `__proto__=v` would have set the prototype rather than
stored a value had it got that far. The map is null-prototype now and
the check uses Object.hasOwn.

--url and --web return before the filter is parsed, and neither
forwards it, so `inspect runs --attribute k=v --url` opened an
unfiltered view and a malformed pair skipped validation entirely. Both
are rejected: the dashboard takes no attribute filter.

--sort carried an oclif default of desc, so the "forward only when
asked" check in the attribute listing was always true and overrode the
backend's alphabetical key order. Every time-ordered listing already
falls back to desc itself, so the parser-level default is gone and the
flag now means what it says.

The docs claimed --since and --until must be supplied together, but the
CLI resolves the pair before the World sees it: --since alone is valid
and --until defaults to now. Only --until alone is rejected.

The vercel[bot] comment about ANALYTICS_MAX_ATTRIBUTE_FILTERS not being
exported was already addressed in 1811e4f0e, before #3943 landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address review: hoist the attribute parse, cap the limit, merge main

The INVALID_ARGUMENT handling was dead because this branch was cut from
main before #3943 landed: nothing in the tree threw that code and
WorkflowWorldError had no `field`, so both new arms were unreachable and
the field assertion in output.test.ts described an API that did not
exist here. Merging main makes all of it live, and makes the two
comments claiming the World enforces these bounds true — world-vercel
asserts them now.

parseAttributeFilters ran inside toInspectOptions, after setupCliWorld,
so a malformed pair paid for auth and a project lookup before failing.
It is parsed in the same block as the other bounds now, and
toInspectOptions receives the result. The gap was untested because the
only malformed-pair case paired it with --url, which returns before the
parse either way; there are now command-level cases for a missing
separator, an empty key, too many pairs, and a duplicate key.

--limit allowed up to 1000, but the cross-run listings cap at 100 and so
does the storage step listing a run-scoped read falls back to, so
101-1000 produced an opaque backend 400 — and on steps it depended on
whether analytics had rows for that run. Capped at 100, the smallest any
reachable listing accepts. The docs claim that listings "report the
limit they accept" was false and is gone.

--attribute with --withData warned and returned every row, which is the
failure the scope guard exists to prevent and is knowable at validation
time. It is a hard error now.

listSleeps degraded on any failure, including a plan-window 402 whose
message tells the caller to upgrade. Access, plan, and invalid-argument
failures are reported; only availability failures degrade. The comment
claiming the sibling listings degrade was wrong — none of them do — and
now says why sleeps is the exception.

Also: --sort/--since/--until/--workflowName help text and the options
type no longer say "runs only"; examples and the unknown-resource text
list attributes; listAttributes and listRuns' filter forwarding have
coverage, including both warning strings; the unreachable 'web' case is
out of the scope test; the ineffective biome suppression is gone; and
the cancel arithmetic is two statuses, so one probe is saved, not three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): warn on flags inspect attributes cannot apply; bound cancel --limit

`inspect attributes` accepted --status, --runId, --stepId, --hookId and
--withData, dropped all five, and printed the full key table. --status is
the likely one: filtering runs by attribute and status together is
documented, so reaching for it on the key listing is natural and the answer
looks narrowed. Warn per flag, as the sibling listings do.

`cancel --limit` advertised 1-500. Both read paths cap at 100 — the
analytics runs listing rejects more locally, the storage listing it falls
back to caps server-side — so 101-500 always failed, and cancel's catch
handled only the plan gate and rethrew the rest with nothing printed.
Bound it to 100 and route the catch through the shared reporter.

Collapse the three actionable-error checks listSleeps had inlined into
`reportActionableApiError`, shared with `handleApiError` and cancel, so the
set cannot drift between the three callers. Fold inspect's bounds chain and
--attribute parse into `validateInspectFlags` (run() 45 -> 39).

Correct two comments: the MAX_LIMIT rationale (cross-run listings now
reject locally rather than returning an opaque 400), and listSleeps'
--interactive note, which described a partial-table reprint that cannot
happen — pages after the first are fetched inside the keypress listener,
whose rejection never reaches that catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): forward --hookId to the listings

`--hookId` was declared as a flag, declared on `InspectCLIOptions`, and read
by `listEvents` (`correlationIdFilter = opts.hookId || opts.stepId`), but
`toInspectOptions` never copied it across. So `inspect events --hookId`
parsed, cleared every check, sent no correlationId, and returned the run's
whole event list.

Pre-existing on main, but this PR both documents the flag and adds a
`listAttributes` warning that depends on it, so the branch was unreachable
from the CLI and its unit test only passed by calling `listAttributes`
directly.

That is the gap: a listing's own tests pass options in, so they cannot see a
drop in the projection. `inspect-flag-forwarding.test.ts` goes through
`Inspect.run` instead, and pins the whole mapping key by key. Three of its
four cases fail without the one-line fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 14:06:58 -07:00
Pranay Prakash 7cc5c88a8b [core] Settle a hook's awaiter in-process instead of re-invoking, on creation and on conflict (#3938)
* [core] Settle a hook's awaiter in-process instead of re-invoking, on creation and on conflict

* [core] Address review: deterministic hook signal tests, split changesets, document the boundary

- hook.test.ts: drive the idle poll with explicit macrotask turns instead of a
  fixed 20ms sleep (Copilot)
- Split the changeset so each package's entry says only what changed in it
- runtime-tuning docs: hook-only suspensions no longer always park; the hook
  write continuation is the one exception

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 13:35:26 -07:00
Karthik Kalyan 1280163551 Use the storage APIs for run detail views (#3944)
* Fix empty trace and events tabs on older runs

A run's events were read from a different source than the rest of the run
detail view, one with a shorter retention window and a small ingestion
delay. Past that window the trace and events tabs came up empty even
though the run's data was still retained, and the events tab's own id
search would find events the list above it was not showing. Inside the
window, a run still executing could show gaps.

All the run-scoped reads now come from the same source as the rest of the
view. The runs list and hooks list are unchanged: they span runs and are
fine where they are.

The events tab's id search now stops after fewer pages before reporting a
truncated result.

Removes the fetchSteps server action and its /api/rpc method. The trace
viewer has built its spans from events since the observability
data-fetching refactor, which left fetchSteps the only /api/rpc method
with no caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Describe the read paths without backend internals

The comments explaining why these reads moved named the plan field that
gates the analytics window, its per-plan day counts, storage TTLs, index
choices and page-scan mechanics. This repo is the client SDK, so those
belong on the service side, not here — the reason a caller needs is that
the analytics namespace is a metadata mirror with a shorter retention
window and asynchronous ingestion.

Also drops the claim that this listing feeds the graph tab, which is
disabled in run-detail-view.tsx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:18:38 -07:00
Karthik Kalyan fbfb9fe869 Validate world.analytics arguments up front (#3943)
* Validate world.analytics arguments up front

Every analytics method now checks its arguments before making a request
and throws a RangeError naming the limit it broke: the ids, the
pagination limit against the cap for that listing, and the attribute
filter's pair count, key length and value size. Because analytics is an
optional capability, callers wrap it in a catch, which turned an invalid
argument into what looked like an empty result rather than an error.

Two arguments that used to be dropped silently now fail too. A limit of 0
fell back to the default page size, and a startTime without a matching
endTime turned a listing you meant to bound into an unbounded one that
looked like a normal answer.

Exports ANALYTICS_RUN_SCOPED_PAGE_LIMIT, ANALYTICS_PAGE_LIMIT and
ANALYTICS_MAX_ATTRIBUTE_FILTERS so callers can check the bounds
themselves.

Deprecates analytics.events.listByCorrelationId() in favour of
analytics.events.list({ runId, correlationId }), which issues the same
request and also accepts an eventType filter. It keeps its own
implementation rather than delegating: list() treats correlationId as
optional and skips an empty one, so a delegation would turn an empty id
into an unfiltered listing of the run.

Documents every analytics method in the reference. events.getMany() was
missing entirely, seven methods shared one code block with no parameters
or return shapes, and none of the limits were written down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Parse attribute key timestamps as UTC

firstSeenAt and lastSeenAt were the only analytics timestamps still on a
plain date coercion. The values arrive without a timezone designator, so
that read them in the process's local zone and every other field in the
namespace read them as UTC — a seven-hour skew on those two fields alone
for anyone running outside UTC.

The added test fails without the fix under TZ=America/Los_Angeles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: drop the deprecated correlation-id listing from the reference

A reference page should describe the API you should reach for. The
deprecation notice lives on the method itself, so editors surface it
where it matters without the page advertising a method nobody should
start using. Also drops it from the page-limit table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Close two gaps in the analytics argument guards

Run ids were validated with workflowRunIdSchema while every other id used
a regex mirroring the backend. Those disagree: z.ulid() accepts a
lowercase body and a first character above 7, and the backend accepts
neither, so the most-used parameter had the leakiest guard and still
produced the 400 this is meant to prevent. Run ids now use the same
pattern as the rest.

A supplied-but-empty filter value was also still being dropped —
correlationId, the optional runId scope on hooks.get, and workflowName
all tested truthiness. Dropping one widens the result set rather than
narrowing it, so an empty correlationId listed the whole run and an
empty workflowName listed every workflow. That is the same failure the
limit and time-window guards were added to prevent, and the comment on
listByCorrelationId already described the hazard. They now compare
against undefined, so an empty id throws and an empty name is forwarded
for the backend to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Raise argument rejections as a typed, non-retryable error

The guards threw bare RangeErrors, which left a caller — or an agent
driving this API — parsing English to decide whether to fix the call or
retry it. They now raise WorkflowWorldError with
code: 'INVALID_ARGUMENT', the code the rest of this client already uses
for its transport and throttle failures, so the retry decision is a
field lookup. normalizeEventIds moves with them rather than staying the
one guard that throws a different type.

Also sharpens the four messages that made a caller do the work:
a half-open window now names the bound that is missing rather than
restating the rule, an inverted window prints both ends, and the
attribute-value and event-id batch errors report the size measured
rather than only the bound they broke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Name the method and the field on an argument rejection

Two things a caller could not get without reading prose. The same guard
runs behind several methods, so `runId must be a workflow run id` was
identical whether it came from events.list or steps.get — fine with a
stack, lossy once the error has crossed a log line. And the offending
argument was only available as the first token of the message, which is
the part most likely to be reworded.

Messages now open with the method, and WorkflowWorldError carries an
optional `field`:

  analytics.runs.list: pagination.limit must be an integer between 1
  and 100 (received 9999)
  → code: 'INVALID_ARGUMENT', field: 'pagination.limit'

`field` is additive on the error class and set only by these guards, so
nothing that reads the existing properties changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:09:53 -07:00
Fantix King aba7c8d5d0 docs: expand Python workflow guide (#3923) 2026-09-02 16:41:04 -07:00
Fantix King 9ffd37d551 [e2e] Cover Python cancellable steps (#3930) 2026-09-02 18:40:00 -04:00
Alex Langenfeld 31dabce0c8 [core] Prefetch stream read encryption keys (#3871)
## Summary & Motivation

Stream reads now start the run-metadata and encryption-key lookup concurrently with the stream GET, instead of waiting for the first encrypted frame to arrive before touching either, so a reader no longer stalls on that round trip. The key resolver is memoized per readable session and its promise is observed eagerly, so a lookup that fails while nothing consumes it can't surface as an unhandled rejection; the original error still reaches the consumer through the deserialize transform. `getReadable()` builds its underlying stream lazily so an unread stream does no work. The key-resolution phase gets its own `workflow.stream.read.resolve_key` span, keeping `workflow.stream.read`'s time-to-first-chunk measuring the raw stream alone.

## Test Plan

Unit tests added for the concurrent start, the single resolver across reconnects and sessions, and the cancellation/failure paths; `pnpm --filter @workflow/core test` and `typecheck` pass locally.


## Durabench evidence

A completed matched Workflow stream sweep on the final-refactor implementation showed the intended first-chunk improvement. The cell used the canonical Eve cadence, speed 1, in-step reader, five executions per variant, and concurrency 1.

| Metric | Key prefetch | Control |
| --- | ---: | ---: |
| CTT-first p75 | **107 ms** | 167 ms |
| CTT-first p99 | **137 ms** | 189 ms |
| All-chunk CTT p75 | 160 ms | 159 ms |
| All-chunk CTT p99 | 1509 ms | 2260 ms |
| E2E | 54.024 s | 54.046 s |

The feature and control cells both completed successfully. The first-frame improvement does not come with an all-chunk CTT or end-to-end regression in this sample.

Source sweep: `psweep-1788293149845-37d61d53-477c-476c-bd19-a0b7cfa7a2ca`.

- Feature run: `prod-1788378070863-f2435fc6-3aa2-4fd3-9608-71c67d273c9a` at `36a17405`
- Control run: `prod-1788378070866-ecc76dbd-8cfd-4b23-9fd6-386c0fae9007` at `3c087789`

The current head (`d63263ee3`) adds only the reviewed byte-branch completion-state consistency guard; it does not alter the object-stream key-prefetch path measured above.
2026-09-02 16:09:05 -05:00
Shalabh Chaturvedi 4a18b0133a fix(world): accept lazy terminal run data (#3914)
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-09-02 13:06:33 -07:00
Fantix King 144b6d7601 [e2e] Expand Python conformance coverage (#3495)
Also fixes the issue that specVersion 7 broke Python e2e test.
2026-09-02 15:27:13 -04:00
Nathan Colosimo 5c4eef0a97 chore: upgrade to pnpm 11.24.0 (#3901)
* chore: upgrade to pnpm 12

* fix: support pnpm 12 in CI

* fix: enable pnpm 12 on Vercel

* refactor: simplify pnpm 12 setup

* refactor: target pnpm 11.24.0

* refactor: let pnpm setup own CI installs

* refactor: limit workspace Node versions

* fix: complete pnpm 11 CI migration
2026-09-01 12:50:26 -07:00
Nathan Colosimo 3c08778905 [core] Retain workflow VMs across waits (#3892)
* perf(core): retain workflow VMs across waits

* test(core): cover retained wait wake races

Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>

---------

Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
2026-09-01 09:53:33 -07:00
Casey Gowrie 564ad3966c fix(core): stop reconnecting cancelled streams (#3882)
### Description

Cancelling a reconnecting framed stream could be mistaken for a clean, incomplete EOF. The pending pull then reopened the World stream after cancellation, leaving the new reader unowned; local World consumers retained emitter listeners and polling intervals, eventually producing `MaxListenersExceededWarning` after repeated reads of the same durable stream.

The reader now latches cancellation across pending reads, completion checks, and reconnect acquisition. Reconnect work stops after cancellation, and a World stream that finishes opening after cancellation is immediately cancelled instead of being installed as the active reader.

### How did you test your changes?

Added focused regressions for cancellation while completion metadata is pending and while a reconnect acquisition is pending. The complete `@workflow/core` suite passes: 107 test files passed, 1 skipped; 2,285 tests passed, 3 expected failures, and 1 skipped. `@workflow/core` also typechecks. Before applying the fix, a 20-turn local eve session deterministically retained one World reader per turn and warned on listener 11; with this change, the same run peaked at one reader and ended with zero.
2026-09-01 10:45:06 -05:00
Michael J. Sullivan 40bed1777a python docs: add some missing material (#3875)
This merges a bunch of stuff currently on the vercel.com docs.
I'm going to go remove those next to centralize things for now.
2026-08-31 17:59:40 -07:00
github-actions[bot] 2d753279d5 Version Packages (beta) (#3826)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
workflow@5.0.0-beta.47
2026-08-31 16:36:07 -07:00
Karthik Kalyan 2668e3325b Durable hook resume: write, then wake (#3841)
* test(core): reproduce lazy resume disposal race

* Fix durable hook resume race

* Fail closed on unknown hook wakes

* Improve unsupported hook wake diagnostics

* Address durable hook resume review feedback

* Harden producer-committed wake handling

* Serialize durable hook resume: write, then wake

resumeHook() now dispatches strictly serially: the hook_received event
is made durable first, and the workflow wake is published only after
the write is acknowledged. The wake is a plain runId message (the shape
the sequential path always published), so the producer-committed wake
barrier, its queue-message field, and the HOOK_RESUME_INPUT_VERSION
bump are all removed — no consumer or backend coordination is needed,
and either side rolls back independently to today's behavior.

The pre-write ops flush now partitions serialization ops: producer-push
uploads are awaited before the event commits (the payload must not
point at bytes still in flight), while consumer-settled reader ops — a
dehydrated WritableStream, e.g. a manual webhook's responseWritable —
are backgrounded. Awaiting those deadlocked the resume against its own
wake (webhookWorkflow failing across the whole e2e matrix).

Also: wake retries stop on definitive 4xx errors instead of burning the
retry budget; WORKFLOW_DISABLE_LAZY_HOOK_RESUME no longer gates
anything and is ignored; the internal resumeHookDurable alias is
removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address review: retry classification, wake dedup, 409 passthrough

- Wake retry classification now actually fires against @vercel/queue:
  its errors carry no status field, so classify by the World's
  deployment-unavailable hook, then numeric status, then the queue
  client's definitive-4xx error names.
- The wake publish carries idempotencyKey `hook-<resumeId>` on the
  claim path, so a retried publish whose response was lost dedups
  instead of costing a duplicate full replay.
- EntityConflictError (HTTP 409) from the durable write is no longer
  re-keyed to HookNotFoundError: every 409 the backend emits on this
  write today is transient (slot conflict past the server's retry
  budget, claim race) and committed nothing, so it surfaces retryable
  instead of presenting as a permanent 404.
- Stamp workflow.hook.resume_committed / wake_published span
  attributes after each leg resolves, making stranded resumes
  (committed event, no wake) queryable from traces.
- Document on the public resumeHook signature that passing the token
  (not a cached Hook) is what makes the write idempotent-on-retry.
- Changeset/changelog: note the ended-run behavior change (late
  webhook deliveries to finished runs now 404 instead of 202) and the
  409 passthrough.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 23:18:01 +00:00
Nathan Colosimo e9d5c56701 [core] Prepare replay payloads as event frames arrive (#3548)
* Prepare replay payloads from streamed events

Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>

* Fix streamed replay preparation invariants

* fix(core): gate replay startup work by VM engine

* refactor(core): simplify replay startup state

* fix(core): preserve replay startup ordering

* refactor(core): simplify setup failure handling

* fix(core): observe replay load after setup failure

* refactor(core): simplify replay encryption key promise

* fix(core): scan only appended replay events

* refactor(world-vercel): type replay stream outcomes

---------

Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
2026-08-31 15:42:46 -07:00
Nathan Colosimo ee6f917cdb [core] Overlap workflow compilation with replay loading (#3798)
* Overlap workflow compile with replay loading

Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>

* Fix replay compilation scheduling

---------

Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
2026-08-31 14:10:40 -07:00
Karthik Kalyan 4f6cc69eb1 Prevent payload flicker during decryption (#3906) 2026-08-31 12:31:01 -07:00