Commit Graph

1724 Commits

Author SHA1 Message Date
github-actions[bot] d3d240c003 Version Packages (beta) (#3816)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
workflow@5.0.0-beta.46
2026-08-26 12:36:41 -07:00
Karthik Kalyan 985d36d8b6 fix: repair empty changeset (#3814) 2026-08-26 12:33:05 -07:00
github-actions[bot] 2c953640e7 Version Packages (beta) (#3775)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-26 12:04:15 -07:00
Nathan Rajlich f806f8c258 fix(builders): dedupe pnpm peer-variant copies instead of failing on duplicate step IDs (#3795)
* fix(builders): dedupe pnpm peer-variant copies instead of failing on duplicate step IDs

When pnpm resolves the same package version into multiple virtual-store
instances (one per peer-dependency resolution), each instance is a
byte-identical copy of the same module that generates the same canonical
step/workflow ID. The duplicate-ID check treated these as a fatal
collision, failing builds for apps whose dependency graphs pull in two
peer variants of the Workflow SDK (or any step-defining package).

The duplicate-ID check now fingerprints (SHA-256) the source of each
file that contributes manifest entries. When two different files emit
the same ID for the same symbol and their contents are identical, the
registration is deduplicated and the build continues. Files with
differing contents still fail with the existing collision error.

Content hashing is used rather than realpath()/inode identity because
pnpm materializes virtual instances via hard links (Linux) or
clones/copies (macOS APFS), neither of which realpath resolves and the
latter of which allocates fresh inodes.

The previously duplicated merge/assert logic in swc-esbuild-plugin.ts
and base-builder.ts is consolidated into a shared manifest-ids.ts.

* Update .changeset/dedupe-pnpm-peer-variant-step-ids.md

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Nathan Rajlich <n@n8.io>

---------

Signed-off-by: Nathan Rajlich <n@n8.io>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-08-26 11:30:17 -07:00
Peter Wielander d9e0777eb8 [core] Never write hook_received eagerly on the lazy resume path (#3794) 2026-08-26 09:14:40 -07:00
Peter Wielander 0750fe8958 fix(astro,sveltekit): skip generated writes that would not change the file (#3688) 2026-08-25 15:47:09 -07:00
Nathan Colosimo 82e2678939 [core] Retain workflow VMs across attributes (#3609)
* Retain workflow VMs across attributes

* Restore runtime logger spy automatically

* Explain retained attribute race ordering

* Update retained attribute telemetry expectation

* Clarify retained attribute replay coverage

* Clarify retained attribute execution comment
2026-08-25 15:30:16 -07:00
Nathan Colosimo 556f3f080a [core] Retain workflow VMs across hooks (#3604)
* Retain workflow VMs across hooks

* Refine retained VM decisions and diagnostics

* Fix hook suspension assertion

* Harden retained hook race coverage

* Simplify retention blocker log metadata

* Preserve workflow suspension compatibility

* Bound retained VM serialization diagnostics

* Clarify bounded serialization diagnostics
2026-08-25 15:09:52 -07:00
christopherkindl 91eb1ae924 chore(docs): use geistdocs 1.23.1 (#3777)
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
2026-08-25 13:56:02 -07:00
Sergio 66af295590 fix(skills): quote v4→v5 migration skill description so it parses as YAML (#3779)
Signed-off-by: Sergio Diez <sergiodiez@users.noreply.github.com>
2026-08-25 13:21:47 -07:00
Pranay Prakash a2e513303e [world-local] Skip chmod-based permission tests where the bits aren't enforced (#3771) 2026-08-25 13:18:19 -07:00
Peter Wielander c8bcde53d0 [ci] Track the /flow route bundle size against main (#3739) 2026-08-25 13:06:49 -07:00
Peter Wielander 584155897f [core] Fail dev HMR cleanup on a stranded step registration (#3682) 2026-08-25 12:07:18 -07:00
Nathan Colosimo d62b44473b [core] Prune schema modules from workflow bundles (#3550)
* [core] Prune schema modules from workflow bundles

* [world] Inline one-off validation options

* refactor(world): simplify event schema boundaries

* refactor(world): simplify event schema boundaries

* fix(world): keep noop metadata schema-free

* refactor(world): drop zod 4.4 compatibility

* test(builders): cover workflow API bundle boundary
2026-08-25 11:11:54 -07:00
Peter Wielander 27cab14adc [core] Send the disposed hook's token from the QuickJS engine (#3773) 2026-08-25 07:49:35 -07:00
Shalabh Chaturvedi f7fb012652 feat(runtime): report replay cost for every step batch, flagged by retained-VM mode (#3490)
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-08-24 19:49:57 -07:00
Nathan Colosimo c332a9cc59 ci: make Python conformance advisory (#3772)
Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
2026-08-25 00:00:34 +00:00
github-actions[bot] 3c0d60be90 Version Packages (beta) (#3717) workflow@5.0.0-beta.44 2026-08-21 22:17:38 -07:00
Peter Wielander bf9de1cd81 [core] Re-arm a wait continuation delivered before its wait elapses (#3743) 2026-08-21 19:36:11 -07:00
Shin 71bc027a6c fix(world-postgres): make step creation atomic (#3575)
Signed-off-by: Shin <128954611+shin4141@users.noreply.github.com>
2026-08-21 19:18:44 -07:00
Peter Wielander 7e48e7b4de Re-enable the sealed log by default (#3737)
* Revert "[world] Make the sealed log opt-in instead of default-on (#3735)"

Reverts b2cac623d3. New runs are stamped at spec 7 again, now that a
read which cannot see past an unfilled position waits for it instead of
reporting a log that ends there (workflow-server: derive the in-request
seal poll budget from the staleness bound).

Two things are kept from #3735 rather than reverted:

- the world-testing conformance floor at mintedSpecVersion(), which was
  wrong for any staged bump and not specific to this default
- a note on mintedSpecVersion recording what default-on rests on: the
  events density requirement, and that a sealed log meets it by repair
  rather than by construction, so the READ has to wait

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

* TEMPORARY: point world-vercel at workflow-server#839 preview

Validating the seal-poll-budget fix end to end with spec 7 on. Reverted
before merge; the override lint guard is expected to fail meanwhile.

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

* Revert "TEMPORARY: point world-vercel at workflow-server#839 preview"

This reverts commit 5e17cc9335.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 17:32:08 -07:00
Shalabh Chaturvedi dc68611fbf Default the events transport to WebSockets (#3702)
* Default the events transport to WebSockets

WORKFLOW_EVENTS_TRANSPORT=http is the opt-out. Only that exact value
disables it, so a typo'd or empty value fails toward the default rather
than quietly pinning a deployment to HTTP.

The prerequisite the gate named for defaulting on is met:
postEventFrameOverWs opens a client span per frame. What is still missing
is Vercel's outgoing-requests view, which reads instrumented fetch calls
rather than spans and so cannot show a transport that issues no request.

Co-Authored-By: opencode <opencode@vercel.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* docs: WORKFLOW_EVENTS_TRANSPORT defaults to ws

Three places still documented http as the default. Each now states the
opt-out is the exact value http, rather than leaving 'default: ws' to
imply that anything non-ws disables it — the asymmetry is deliberate in
the code and is the part a reader would otherwise get wrong.

Also drops 'Experimental' from the Vercel World page: a setting that is
on for everyone by default is not opt-in experimental, whatever else it
is.

Co-Authored-By: opencode <opencode@vercel.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Fix the gate's own unit tests for the flipped default

Five tests in ws-transport.test.ts still encoded the opt-in semantics.
Three were the isWsEventsTransportEnabled table itself; the other two
(openWsChannel 'does nothing when the gate is off', and the channel
release equivalent) relied on the suite's ambient unset environment
meaning 'off', which it no longer does. Both now set http explicitly.

Two tests in ws-transport-spans.test.ts asserted HTTP-side span
behaviour the same way. The write one would have kept passing by
falling through resolveWsTransport's null rather than because the gate
was off - passing for the wrong reason, which is what this file exists
to catch.

Also makes the opt-out case-insensitive and trimmed. The gate is
deliberately asymmetric - unrecognized values take the default - but
that asymmetry should not extend to swallowing HTTP or ' http '.
Whoever reaches for the escape hatch is plausibly mid-incident, and
silently ignoring their opt-out over a capital letter is the same class
of silent-wrong-transport bug this flip is meant to stop shipping.

554 tests pass in packages/world-vercel.

Co-Authored-By: opencode <opencode@vercel.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* ci: add a required forced-HTTP e2e lane (#3703)

Flipping the default makes e2e-vercel-prod a WebSocket lane: it sets no
WORKFLOW_EVENTS_TRANSPORT, and unset now means ws. Nothing in the file
would exercise the HTTP events transport against a real deployment any
more, so this is not additive coverage — it replaces coverage the flip
silently removed.

Unconditional and required rather than label-gated like the WS lane.
HTTP is now the fallback, and the fallback is silent: resolveWsTransport
returning null costs a write nothing and logs nothing, which is the
shape of the durabench bug this stack came out of.

Two apps rather than the WS lane's four, since every row is a real
vercel deploy charged to every PR. nextjs-turbopack is the only fixture
emitting OTEL spans, so it is the one that can show which transport
actually ran; express covers the non-Next server path.

Also corrects the WS lane's docblock, which claimed every other job
exercises HTTP only. That stopped being true one commit ago.

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Fail loudly when step_completed falls back to HTTP under a strict flag

The WS e2e lane asserts that the transport is harmless, not that it is
used: an event written over HTTP produces the same run outcome as one
written over the socket, so the lane stayed green through the entire
period the transport was silently demoted.

WORKFLOW_INTERNAL_EVENTS_TRANSPORT_STRICT turns that one case into a
failed run, and the WS lane now sets it.

Scoped to step_completed alone, because most fallback is legitimate:
run_created is written outside any invocation that opens a channel;
run_started routinely lands before the channel is registered (34% HTTP
on a healthy deployment); step_created and wait_created mostly fold into
events.createBatch, which is not wired to the socket; and a write after
the invocation released its claim falls back by design. step_completed
is issued after a step body has run, and was 100% ws across every
WS-enabled deployment measured on two SDK versions.

The flag reads as off unless the value is exactly 1 or true - the
opposite asymmetry from the transport gate, which treats an unrecognized
value as on. That gate risks a deployment sitting quietly on the wrong
transport; this one fails runs, and should not be acquired by a typo.

Co-Authored-By: opencode <opencode@vercel.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* ci: run the WS transport lane on every PR

It was opt-in behind ws-transport-test because four real vercel deploys
were too much to charge an unrelated PR for a transport that was off by
default. Flipping the default expires that reasoning from both ends: the
cost is no longer for someone else's feature, and this is now the only
lane that asserts the socket carried the events. e2e-vercel-prod
inherits the new default but checks nothing, so behind a label the
average PR would move every deployment onto WebSockets with nothing
verifying they were used.

Drops WS_REQUIRED from the gate along with it. That existed only to let
the lane be legitimately skipped on an unlabelled PR; with no label the
lane is required unconditionally, like e2e-vercel-prod and the HTTP
lane, and the skipped case is now a failure rather than a warning.

Gate script extracted and run against the cases that matter: ws skipped
fails on a standard PR, ws skipped fails under workflow-server-test, and
all-green passes.

Co-Authored-By: opencode <opencode@vercel.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* ci: widen the HTTP transport lane to six server shapes

Before the flip, HTTP was the default and all 28 e2e-vercel-prod
lane-runs covered it. After the flip they cover WebSockets instead, and
this lane is the entirety of the HTTP coverage - two apps was too thin
for a transport that is still supported.

Six, not the full 14, because every row is a real vercel deploy charged
to every PR. Chosen by server shape rather than count: example
(baseline), nextjs-turbopack (Next, and the only fixture emitting OTEL
spans), vite (Vite SSR), express (Node req/res), nitro (h3, also covers
nuxt) and hono (fetch-API Request/Response, a different mount shape from
express). The rest duplicate a shape already covered; python is left out
because it has no conformance gate and needs routes this suite does not
serve.

The first four match the WS lane's matrix on purpose, so the same
fixture runs on both transports and a failure on one can be read against
the other.

Project ids and slugs are copied from e2e-vercel-prod and verified equal
to it; both lanes already use the same team and token.

Co-Authored-By: opencode <opencode@vercel.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

---------

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-08-21 17:17:04 -07:00
Pranay Prakash f771585486 fix(world-vercel,world-local): hold process-wide state on globalThis (#3728)
* fix(world-vercel,world-local): hold process-wide state on globalThis

Both packages are bundled into the host application's server build, and a
bundler keys module identity on (resource, layer) — Next.js alone builds
`instrument`, app-route, `ssr` and `edge` layers, so one process holds one
copy of each of these modules per layer. Every module-scope `const`/`let` in
them was therefore per-copy state wearing the costume of a process singleton.

vercel/workflow#3493 made `@workflow/world-vercel` bundled rather than
external and the events WebSocket transport regressed to HTTP for exactly
this reason: the queue consumer registered its channel in the `instrument`
copy's `Map` and the write path looked it up in the route copy's empty one. A
deterministic miss, for the life of the process. `@workflow/world-local` had
the same exposure all along — including `runFileLocks`, where a duplicated
mutex simply stops mutually excluding.

Add `globalSingleton()` to `@workflow/utils` (the primitive `@workflow/core`
already hand-rolls for its World cache) and route every mutable module-scope
binding in both worlds through it.

Regression cover, in three layers:

- `global-singleton.test.ts` pins the primitive's semantics.
- `ws-transport-module-copies.test.ts` imports the module twice in one
  process and asserts a transport registered by one copy is found by the
  other — it fails on a plain module-scope `Map`, which is the shipped bug.
- `scripts/lint/module-scope-state.mjs` fails the class: an AST rule banning
  mutable module-scope state in these packages, with `// per-copy-ok: <why>`
  as the deliberate escape. Wired into both packages' `vitest run src`, with
  fixture self-tests so it cannot rot into a no-op.

* test(world-postgres): pin the module-scope-state rule for the postgres world

It is deduped today only because `getRuntimeRequire()` loads it — a property
of how it is loaded, not how it is written, and exactly what changed for
world-vercel in #3493. The package is already clean; this keeps it that way.

* docs(worlds): codify "a world must not hold mutable module state"

A world package is loaded one of two ways, and only one of them gives it a
single module instance: a runtime `require()` (deduped by Node) or the host's
bundler (one copy per layer). Which one you get is a property of how the world
is loaded, not of how it is written, and it changed under `world-vercel` in
#3493 — so the rule has to be "never rely on module scope", not "rely on it
until someone flips a config".

Written down in the four places someone can meet it:

- `docs/content/worlds/{v4,v5}/building-a-world.mdx` — a "Process-wide state"
  section for custom-world authors, with the loading modes spelled out and a
  nudge to prefer World-instance state over a global.
- `packages/world/README.md` — the same constraint on the contract package.
- `CLAUDE.md` — so the next contributor working in these packages sees it.
- `packages/core/src/runtime/world.ts` — at the two static imports, which is
  where the difference between a bundled world and a required one originates.

The rule's own error message now teaches it too, rather than naming a helper.

Consolidates the guard while here: `@workflow/utils` owns the rule and its
fixture self-tests, and sweeps every *published* `packages/world-*` discovered
at runtime, so a world package added later is covered without anyone
remembering. Each world keeps a one-assertion mirror for locality.

* style: drop prose em dashes from this branch's new text

#3704 landed a repo-wide writing pass hours after this branch was written and
took `world-vercel/src` from 406 em dashes to 130 (`ws-transport.ts` alone went
35 to 1). This branch's docs section, README, comments and lint messages were
written before that and would have put 36 of them straight back into the files
that were just cleaned.

Rewritten sentence by sentence rather than by substitution: an em dash becomes a
colon, a comma, a full stop or a parenthetical depending on what it was doing.

Also fixes a real defect the sweep surfaced: `world-postgres`'s guard test was
generated through a shell heredoc and had literal backslash-backticks in its
doc comment.

* Update .changeset/world-module-scope-state.md

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

* fix(core): build the entrypoint's queue handler from getWorld()

Adopted from #3666 by @MintedKenny, which implements #3665 and could not run
CI as a fork PR. One line of behavior: `workflowEntrypoint`'s lazy handler
init calls `getWorld()` rather than `getWorldHandlers()`.

`getWorldHandlers()` owns a second, build-time-safe cache, so calling it from
the runtime route built a *second* World in the same process. That costs a
stateful World duplicate resources on every instance — world-postgres eagerly
constructs a `pg.Pool` (default `max: 10`) and a nested world-local World in
`createWorld()`, so self-hosted users have been paying for two of each — and,
for a bundled world package, the two Worlds are built by two different module
copies, which is the mechanism behind the WS transport regression the rest of
this branch contains.

The public `getWorldHandlers()` and its separate build-time cache are
unchanged; only the runtime route stops using it.

Kept from the original: the regression test asserting the factory runs exactly
once, and the api-reference wording (re-applied over #3704's list punctuation).
Not taken: renaming the `workflow.route.get_world_handlers` span. It is a
distinct span from the per-request `workflow.route.get_world` at the top of the
flow route, and reusing that name would collide with it in traces and in
`runtime-trace-mode.test.ts`; a comment records why the name outlived the call.

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

* fix: address AI review on the module-scope work

Two blocking findings, both real:

- **Cross-version state sharing** (`ws-transport.ts`). A process can hold two
  *published versions* of `@workflow/world-vercel` (a transitive dependency
  pinning an older `@workflow/core`, which depends on this package by exact
  version). Both wrote to the same unversioned `Symbol.for` key, so one
  version's write path could be handed a `WsEventsTransport` built by the
  other's class and frame against a protocol it may not share — with no version
  negotiation on the socket to catch it. `shapeVersion` cannot express this: the
  container is stable, the hazard is its contents. The registry and the events
  dispatcher recycler are now keyed by package version. The plain connection
  pools stay unversioned; sharing those across copies is the point.

- **The documented pattern failed the rule this PR adds.** The custom-world docs
  teach `store[StateKey] ??= …`, which the rule flagged as a field write. It now
  recognizes state rooted at `globalThis`, following one alias hop, which is
  also what `core/private.ts:23` and `next/src/index.ts:58` are already doing
  correctly (core drops 26 findings to 22, next 7 to 6). The docs also now say
  outright that `globalSingleton()` is the same thing, since AGENTS.md
  prescribes it and the page did not mention it.

Rule precision, from the review's probes:

- `.mts`/`.cts` are scanned. `@workflow/world-testing` is authored in `.mts`, so
  its entry in the sweep was passing vacuously — with the walk fixed it reports
  a real finding, now annotated (it is a standalone `serve()` entry).
- Mutations in top-level statements no longer count. A table filled at module
  evaluation is identical in every copy; divergence needs a later write.
- `static` class fields are collected, attributed to the class name.
- An *exported* binding initialized to an empty collection is a finding on its
  own, which approximates the cross-file case the walk cannot resolve.

Six fixtures pin the new behavior. The rule's header now states what it does not
see, and AGENTS.md states where the sweep stops and why core is not gated yet.

Also tags `resetGlobalSingletonForTest` `@internal`.

* fix(lint): attribute a static-field write to the field, not the class

The static-field support added in the previous commit keyed `declared` on the
class name, so a class carrying more than one mutable static reported one
finding instead of one per field, and labelled the survivor with whichever
mutation was seen first. On a two-static fixture it reported
`static Registry.latch  (`.set()`)`: the name of one field, the reason
belonging to the other, pointing the reader at the wrong line.

Key static fields `Class.field` and resolve a write to the same shape, via a
new `memberPath()` that takes the first two segments of a member chain and
tries that key before the bare root identifier. Two follow-ons fall out of
having the path:

- `this.field` inside a `static` member resolves to the class, which is the
  ordinary way to write the mutation. `staticClassOf()` returns nothing for an
  instance member, where `this` is an instance and the state is per-instance
  rather than per-copy, and nothing inside a nested `function`, which rebinds
  `this`.
- `state.count++` is now a finding, like the `state.count += 1` that
  `assignment()` already reported.

Fixtures pin all four, including the instance-field case that must stay clean.
The four world packages still report zero, and the extracted `recordMutation()`
keeps the file at its previous two Biome complexity warnings.

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

* fix: make module duplication inert across every bundled package

`@workflow/core` is bundled into the host server build the same way the worlds
are, and always has been — the original repro measured three live copies in
every arm, including the pre-#3493 external one. One instance is not reachable:
layers cannot share a module, and core cannot be external because it *is*
workflow code (`runtime/start.ts:253` and nine methods in `runtime/run.ts` are
`'use step'`), so it must go through the SWC loader. The Next integration
already encodes that rule by removing workflow-bearing packages from
`serverExternalPackages`.

So the duplication stays and the hazard is removed instead, everywhere the
duplication can happen.

`@workflow/core` (22 findings to 0): warn-once latches in `constants.ts`,
`start.ts` and `telemetry.ts`; the source-map tracer cache; the VM script cache;
the QuickJS compiled-assets and baseline caches; the dev-server port cache (its
own comment already said "per process"); the text codecs; the zstd browser
decoder; and the `useStep` closure brand, where a function marked by one copy
was invisible to another.

The one with teeth was `step-single-flight.ts`: a per-copy map is not
single-flight. Two invocations reaching it through different layers would each
believe they were alone in the process and both run the step body, silently
degrading in-process dedup to the cross-process residual its own doc scopes out
to the ownership lease.

Also `@workflow/world` (a warn-once set, hand-rolled onto `globalThis` to keep
that package dependency-free), `@workflow/ai` (the lazy OTel API), and
`@workflow/nest` (bootstrap config in a module-level `let` and two static class
fields — configure one copy, read another, and the controller is unconfigured
for the life of the process).

Five sites are deliberately per-copy and now say why: state keyed on objects
that never cross copies (the barrier safety-net `WeakSet`, the QuickJS pending
byte `WeakMap`), the synchronously-scoped guest-code sink, and the OTel
diagnostic that reports what *this* copy sees.

The sweep now covers all of it. Packages with a single module graph stay out
(build-time code, the CLI, the o11y UI, the test runner) and AGENTS.md records
which and why.

Found while doing this: two static fields on one class collapsed into a single
entry in the rule, so `WorkflowModule.options` was invisible behind
`WorkflowModule.outDir`. Statics are now keyed `Class.field`.

* fix(world): suppress noAssignInExpressions on the globalThis idiom

The hand-rolled form trips Biome, as it does in `packages/core/src/private.ts`,
which carries the same suppression. Restructuring it into a helper function
instead would hide the state behind a call the module-scope rule cannot follow,
so the binding would stop being recognized as off-module and the package would
report a finding for correct code.

* fix: sweep every bundled package, and mark utils side-effect free

@shalabhc asked on review whether `@workflow/utils` needs this too. It does,
and so do three others: `utils`, `errors`, `serde` and `workflow` all end up in
the host application's server build and none were in the sweep. All four report
zero today, which is exactly the state `world-testing` appeared to be in before
the `.mts` walk was fixed and it turned out to have a real finding. Being clean
and being *checked* are different properties, and only the second one survives
the next contributor.

`sideEffects: false` on `@workflow/utils`: verified that every module in the
package only declares (no import-time work), so a bundler can now drop the
unused parts of the barrel instead of keeping all ~64 KB of it because three
packages import one 476-byte function.

---------

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Kenneth <kenneth@standardforensics.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
2026-08-21 16:55:24 -07:00
Peter Wielander b2cac623d3 [world] Make the sealed log opt-in instead of default-on (#3735) 2026-08-21 16:32:25 -07:00
Pranay Prakash 447013b73a Run the test suites CI was silently skipping (#3733)
* Run the test suites CI was silently skipping

`turbo test` runs a package's tests only if that package declares a `test`
script, so a suite can sit in the repo for months without ever running. Four
were in that state: @workflow/world (13 files, 160 tests), @workflow/cli (5 /
51), @workflow/nitro (1 / 30), and two files under packages/core/e2e that no
workflow named.

Wire each one up, and add scripts/check-test-suites-wired.mjs plus a lint job
so the next unwired suite fails CI instead of going unnoticed.

Fixes #3731

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

* Drop the changesets and rename the guard job

The PR only wires up existing suites and adds a CI check, so there is nothing
to release. Rename the job to match its `no-test-overrides` sibling.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:59:29 -07:00
Peter Wielander 9760f640bb [e2e] Change race repro hook poke to soft-degrade instead of hard-stop at budget (#3561) 2026-08-21 15:31:52 -07:00
Karthik Kalyan 5a59bb82e8 Add disabled state to decrypt controls (#3715) 2026-08-21 14:35:55 -07:00
Nathan Rajlich e1e64e3de3 docs: apply Vercel technical writing standards (#3704)
* docs: apply Vercel technical writing standards

Audit the complete documentation corpus, package READMEs, skills, and
source TSDoc/comments against the vercel-technical-writing skill and
style-rules.md. Normalize sentence-case headings without changing
published anchors, remove prose em dashes and filler wording, improve
active voice and self-contained phrasing, standardize product/brand
capitalization, American English, list punctuation, units, and code
fence languages, and preserve exact runtime strings/table placeholders.

All executable code is unchanged. Modified skills have their metadata
versions bumped.

* docs: extend writing audit to repository Markdown

Apply the same technical-writing rules to design documents, compiler
specifications, workbench guides, package changelogs, and the remaining
tracked Markdown outside the deployed docs corpus. Preserve historical
meaning, commands, output literals, table placeholders, and heading
anchors.

* docs: exclude generated package changelogs from audit
2026-08-21 14:24:31 -07:00
Shalabh Chaturvedi 252a292f18 [ci] Keep workflow-server override formatting stable (#3713)
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-08-21 13:18:49 -07:00
Pranay Prakash 7b79ba37cc Add support for 'noop' event type - spec version 7 (#3634)
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
2026-08-21 12:53:59 -07:00
Peter Wielander b3dbc6d264 [docs] v5 changes docs: what's new, world upgrade guide, migration skills (#3100) 2026-08-21 12:45:05 -07:00
Karthik Kalyan d012bf0fe3 Preserve run-key HTTP errors (#3599) 2026-08-21 11:59:04 -07:00
Rich Haines d5d19fc8d5 docs: update Geistdocs to 1.20.4 (#3654) 2026-08-21 11:43:41 -07:00
Peter Wielander 9b1b8c7111 [core] Pin correlation-id draw order to event-log order (#3700) 2026-08-21 11:31:42 -07:00
Pranay Prakash 9454d51db0 feat(core): resolve run.returnValue via a World long poll instead of a 1s poll (#3570)
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
2026-08-20 13:19:25 -07:00
Peter Wielander c431cc18fd [e2e] Add blocked-branch scenario to the event-log race repro (#3696) 2026-08-20 12:41:57 -07:00
Mitul Shah a06afeefe6 Prefix marker context cards with Hook received / Attribute set (#3692)
* fix(web-shared): use UTC tooltips for trace viewer markers

Replace the relative-time context card on hook and attribute ticks with the
shared tooltip, labeled `Hook received [UTC] …` and `Attribute set [UTC] …`.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Mitul Shah <mitulxshah@gmail.com>

* fix(web-shared): format marker helpers and drop invalid aria-label

Biome requires the kind filter ternary on one line, and aria-label is not
valid on the tooltip trigger span.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

* fix(web-shared): prefix marker context cards with event kind

Restore the relative-time context card on hook and attribute ticks, and
prefix the relative time with "Hook received" or "Attribute set".

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

* refactor(web-shared): slim marker context-card prefix

Drop the label helper, type guard, and unused DefaultTimeText prefix path.
Keep kind on the marker, map copy at the tick, and prefix only the card heading.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

* refactor(web-shared): drop prefix comments and generic event-mark helper

Leave sortedEventMarks as a plain string filter. The prefix prop does not
need a JSDoc restatement.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

---------

Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-20 10:06:22 -07:00
Simon H 37ed0493e1 fix: Better loading of SvelteKit routes directory (#3509)
Instead of accessing private files from SvelteKit, we use @sveltejs/load-config to load the Svelte config (that package also knows about checking Vite config). The deadlock is avoided by having a module-level Set to see if we're currently recursing or not.

This is necessary for SvelteKit 3 since there the config lives exclusively in the vite config, and the previous logic did not handle that.

This also uncovered that we're needlessly rebuilding the generated files in sub builds/workers (SvelteKit, at least below 3, starts off secondary builds; and some things are done in workers), which a new file cache now checks.

Picked from stable branch PR #3474

Signed-off-by: Simon Holthausen <simon.holthausen@vercel.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-08-20 09:22:16 +00:00
Nathan Rajlich 5b5a926f88 fix(core): make step-argument serialization failures catchable in workflow code (#3675)
* fix(core): make step-argument serialization failures catchable in workflow code

A step whose arguments fail to serialize is now finalized by the
suspension handler as step_created + step_failed (mirroring a step-body
failure) instead of rejecting the whole suspension. The next replay —
forced in-process, since no step message is dispatched for the failed
step — rejects the step's promise with the SerializationError, so a
try/catch around the step call observes it. Uncaught, the error
propagates out of the workflow body and fails the run as a fatal
USER_ERROR immediately, instead of redelivering the orchestrator
message until max deliveries (49/48) as reported in production on v4.

* Serialize the step_failed error with the VM global; one-sentence changeset

Addresses review feedback: dehydrateStepError in
finalizeUnserializableStep now receives suspension.globalThis like every
other dehydration in this file. Error detection is realm-independent, so
the host-created SerializationError serializes identically, but VM-realm
values guest code threw into the cause chain are now detected by the
realm-sensitive reducers.

* Address review: QuickJS engine support, deferred-batch join, drain gate, placeholder marker, telemetry, docs

- QuickJS: dumpPendingOps now catches a step input's serialization
  failure per-op, reframes it as a SerializationError with the same
  framed message as dehydrateStepArguments, and surfaces it on the
  pending op instead of failing the whole collection. The entrypoint's
  dispatchPendingOps finalizes such steps as step_created (placeholder
  input) + step_failed, excludes them from inline claims and queue
  publishes, marks them handled, and raises the requeue signal so the
  failure is observed even when the feed lags — mirroring the node:vm
  engine, so both engines agree: catchable in workflow code, USER_ERROR
  with the framed message when uncaught. Both step-argument e2e tests
  now pass on WORKFLOW_VM=quickjs.
- runtime.ts: the failed-step replay path now joins
  suspensionResult.deferredBatchWork before continuing, so a trailing
  chunk commit or step-message publish rejection propagates instead of
  being swallowed after ack; committed inline claims are documented as
  deliberately handed to owned recovery.
- Terminal drain: finalization is gated on a stepDispatch target. The
  drain caller has no replay to observe a finalization, so a completed
  run no longer gains failed-step rows for an unawaited unserializable
  step — the rethrown error is swallowed by the drain's catch,
  preserving its pre-existing behavior.
- The placeholder input now carries a marker string ('[input
  unavailable: step argument serialization failed]', shared via
  runtime/unserializable-step.ts) so inspect/o11y don't render the
  failed step as a genuine zero-argument call.
- New workflow.steps.failed_serialization span attribute on the
  suspension span, so occurrence is measurable without log search.
- Docs: v5 serialization-failed error page documents where each
  boundary's failure surfaces (catchable step failure vs run failure)
  and the no-retry USER_ERROR semantics; foundations/errors-and-retries
  gains a Serialization Failures section with the try/catch shape.

* Guard the finalization crash window; self-contained docs samples

- A crash or transient failure between finalization's two durable
  writes leaves a lone placeholder step_created, and redelivery then
  dispatches the step through normal crash recovery — previously
  running user code with the placeholder arguments. The placeholder
  now carries a structural flag on the input triple's top level (which
  user code never controls, so no false positives), and the step
  executor checks it after hydration: instead of running the body, it
  throws the intended fatal SerializationError, completing the
  interrupted finalization as step_failed. Applies to both engines
  (they share the placeholder and the executor).
- Regression tests: executor fails a placeholder-input step without
  running the body (and doesn't trip on a genuine argument equal to
  the display marker); handleSuspension rejects for redelivery when
  step_failed can't be written after step_created landed, leaving the
  recoverable placeholder behind; mixed bad-step + large fan-out
  returns the failure set alongside still-pending deferredBatchWork
  whose rejection surfaces — the contract the runtime's failed-step
  join (added previously) relies on.
- Docs: the two new code samples are now self-contained so the docs
  code-sample typecheck passes.
2026-08-19 17:32:51 -07:00
Alex Langenfeld 8c29b85322 [e2e] Re-enable concurrent execution of the e2e suite (#3658) 2026-08-19 17:30:55 -07:00
Karthik Kalyan 4bd533de17 fix(web-shared): shorten workflow-prefixed step names (#3684)
* fix(web-shared): shorten workflow-prefixed step names

* fix(web-shared): shorten event list step names
2026-08-19 17:07:50 -07:00
josuediazflores 8a2648e35f fix(builders): skip generated writes that would not change the file (#3454)
Signed-off-by: josuediazflores <152042011+josuediazflores@users.noreply.github.com>
Signed-off-by: Peter Wielander <peter.wielander@vercel.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
2026-08-19 16:57:24 -07:00
Peter Wielander f6513f1f75 [ci] Remove the disabled Front release-PR dispatch workflow (#3680) 2026-08-19 15:36:20 -07:00
github-actions[bot] 16352a21c1 Version Packages (beta) (#3655) workflow@5.0.0-beta.43 2026-08-19 14:55:52 -07:00
Peter Wielander 0b2797bbac [next] Bundle the Vercel world into the Next.js server output (#3493) 2026-08-19 14:49:12 -07:00
Karthik Kalyan a074259166 Add run status polling changeset (#3674) 2026-08-19 14:06:26 -07:00
Peter Wielander 0ff689023d [ci] Polish changeset entry wording (#3673) 2026-08-19 13:59:58 -07:00
Karthik Kalyan 78a531a088 Reduce payload resolution during run polling (#3672) 2026-08-19 13:52:22 -07:00
Pranay Prakash 37e1d9e5a9 Batch: pre-claim inline steps in the same batch (#3568)
* Pre-claim inline steps inside the suspension batch (born-running pairs)

Restacked onto main after #3025's squash-merge; folds in the review-round
changes to the flush loop (per-write requestId attribution on createBatch,
and the seeded/advancing slot-bump expectation, now shared with the
pre-claim ceiling).

Fold each lazy-inline step's deferred writes into the batched fan-out as an
adjacent [step_created, step_started] pair: the created row carries the input,
the started row is a bare ownership-stamped claim the server folds into one
born-running create. The whole scheduling turn commits as ONE durable write,
inline bodies start straight off that commit (in parallel with the VQS
publishes for backgrounded steps), and executeStep gains a pre-claimed mode
that runs or skips the body off the batch's per-event verdict - a pair 409 is
the same skipped outcome as losing the lazy claim. The lone-inline case keeps
the optimistic lazy path (a pair-only batch buys nothing over the single
claim). Also threads per-event computeInstanceId through the World batch
request, and folds the batch's committed slot ceiling into the inline slot
snapshot so terminal writes stop being answered with reports echoing the
batch's own events.

* Parallel chunk commits, per-chunk continuation, batch span attributes

Production trace of a 67-event fan-out showed the three batch chunks
POSTing back-to-back (~230ms each) with no bodies or queue messages until
all three settled (~670ms). Three changes:

- Chunks now POST concurrently. Slot assignment is the server's, so
  parallel chunks race for slot ranges exactly like the pre-fold path's
  parallel single writes did; entity conditions, not commit order, carry
  correctness. The foreign-interleaving diagnostic is computed once over
  the whole fold (committed span vs seed) instead of per chunk.

- Per-chunk continuation: each chunk's step-execution queue messages
  publish the moment ITS creates are durable (in-flush, via stepDispatch,
  same message shape and idempotency key as the caller's dispatch pass -
  the affected steps are pre-reported in queuedStepCorrelationIds so the
  caller skips them). Only the chunk carrying the inline pairs gates
  handleSuspension's return (opt-in via allowDeferredBatchWork); trailing
  chunk commits + all publishes ride result.deferredBatchWork, which the
  runtime joins next to the dispatch join before it can ack - the
  every-create-durable-before-ack contract is unchanged, the bodies just
  start off the pair chunk instead of the slowest chunk.

- OTel: batch identity attributes (workflow.batch.size, per-type
  workflow.batch.shape) now live on the world.events.createBatch span
  (instrumentObject) instead of the http POST span, which keeps only
  wire-level facts (transport, bytes) and no longer sets
  workflow.event.type - that attribute names a single event write and
  tagging a batch with its first event's type misclassifies traffic.

* Address review: settle deferred fold on failure, drop pair-batch retry

Three fixes from review of the deferred/parallel-chunk fold.

1. A pair-chunk rejection escaped `handleSuspension` while the trailing
   chunks' commits and publishes were still in flight. `deferredBatchWork`
   never reaches the caller once the handler throws, so nothing joined that
   work — exactly the state `settlePhase` exists to prevent: a sibling create
   landing after the rejection commits an event from the abandoned replay's
   seeded sequence and races the caller's restart reload. The failure path now
   settles `trailing` before rethrowing.

2. Every pair-carrying chunk gates the return, not just the first. Pairs sort
   to the front and two rows per inline step fit inside one chunk, so this is
   one commit today, but `findIndex` silently degraded if either cap moved: a
   pair in an unawaited chunk yields no `inlineClaims` entry, the caller falls
   back to a lazy `step_started`, and that races this same fold's in-flight
   pair for the same step. constants.test.ts now pins the cap relationship.

3. A batch carrying a `step_started` is no longer retried in-process. The
   born-running pair does converge to a 409, but the pre-claim caller reads a
   pair 409 as "a concurrent writer owns this step" and skips the body — and
   on a retry that is indistinguishable from "my own first attempt committed
   the pair". Skipping there stranded a running step stamped with this
   invocation's own message id until the ownership lease expired (860s), where
   the single-POST path deliberately fails the delivery and recovers through
   owned-recovery in seconds. Same reasoning `EVENT_RETRY_ELIGIBILITY` already
   applies to `step_started`.

Also asserts `lazyStepInput` / `preclaimedStart` mutual exclusivity in
executeStep instead of only documenting it, and adds the changeset.

Tests: +1 suspension-handler (pair-chunk failure settles the trailing chunk
before escaping — fails without fix 1), +1 constants (cap relationship), +1
world-vercel (a born-running pair batch is single-attempt), and the existing
batch-retry test retargeted at an entity-conditioned batch. Full
@workflow/core unit suite 2178 green, @workflow/world-vercel 514 green,
typecheck green across core / world / world-vercel.

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

* Guard inline bodies against unhandledRejection; review follow-ups

The dispatch/deferred-batch joins now sit between the step promises'
creation and the `Promise.all` that reads them, so a body rejecting in that
window had no handler attached at the microtask checkpoint — an
unhandledRejection, fatal under Node's default --unhandled-rejections=throw.
A 412 fenced claim races exactly that window, and `deferredBatchWork` widens
it by a trailing-chunk round trip. Attach a no-op catch at creation, the same
way `dispatchesSettled` already does two lines up; the awaits below still
decide the outcome.

Review follow-ups:

- `workflow.batch.shape` is sorted by event type. Map iteration is first-seen
  order, so a pre-claimed fold and a pure eager fold rendered the same
  composition as different strings, which is not groupable as a dimension.

- A lost pre-claim reports StepSkipReason `running`, not `completed`. The
  pair's 409 says the step already exists and its claim winner is executing;
  the other skip site is a genuine terminal-state conflict, and tagging both
  `completed` left the attribute unable to separate the two.

- `batchCommittedSlotCeiling`'s docstring now says the echo is only fully
  suppressed for a single-chunk fold: on a multi-chunk fan-out an inline
  terminal write issued before the trailing chunks land still names a
  position below them and still draws a report.

- The defensive throw on a missing dehydrated input records where it lands —
  the pair is already durable, so it fails with the step claimed and its body
  unrun, recovered on redelivery via owned-recovery rather than failing
  cleanly.

No regression test for the unhandledRejection: the existing
inlineClaimRejectionScenario runs both steps inline, so `dispatches` is empty
and the join resolves in a microtask — the window never opens and a test
there passes with or without the fix. Reproducing it needs a scenario with a
backgrounded step and a slow queue publish alongside the fenced claim.

Full @workflow/core unit suite 2178 green, @workflow/world-vercel 514 green,
typecheck and biome clean.

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

* Pin per-event computeInstanceId on the batch wire

Batch encoding is a separate path from the single-event POST, so the frame
meta had no coverage: the only assertion was at the World-call boundary.
Adds a wire-level test that a pre-claimed pair's step_started half carries
computeInstanceId in its frame meta and the step_created half does not.
Verified it fails when the threading in createWorkflowRunEventBatch is
removed.

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

* Guard the pre-claim path as inert on Worlds without createBatch

world-local and world-postgres do not implement createBatch, so the fold
never engages there — but the runtime passes ownerMessageId and
allowDeferredBatchWork unconditionally. The existing "keeps the single path
when the World lacks createBatch" test passed neither, so it never covered
the pre-claim path at all.

Assert the inertness with the params the runtime actually sends: no claims,
no deferred work, no slot ceiling, the lazy-inline step still carrying its
input, and no step_started reaching the world.

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

---------

Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 13:32:44 -07:00
Peter Wielander 771cdb22a8 fix(world-postgres): refuse a hook resume that races the disposal (#3645) 2026-08-19 11:38:04 -07:00