* 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>
* Add opt-in QuickJS WASM VM engine (WORKFLOW_VM=quickjs) with full event replay
* QuickJS engine: AbortController, setAttributes, terminal drain, turbo-safe requeue, stable PRNG seed
* QuickJS engine: hook.getConflict support, cross-run writable forwarding symbols
* QuickJS engine: stream framing round-trip, bound step proxies, webhook fidelity
* Apply biome fixes to QuickJS engine files
* Address review feedback: anchor source-map strip to end-of-input, use getWorkflowQueueName for conflict requeue, Buffer-free asset decoding, function replacers for payload injection, maxEventsLimit guard
* CI: include generated QuickJS source assets in shared e2e build artifacts
* Fix same-token hook ordering and conflicted-hook disposal in the QuickJS engine
* CI: run both VM engines across all frameworks and worlds; label jobs with the engine
* Fix stack overflow stripping inline source maps from webpack dev bundles; harden step-listing e2e assertions against eventually-consistent reads
* e2e: poll step listings until analytics rows include attempt (optional column can lag terminal status)
* e2e: use --withData to force storage-backed step listings for attempt assertions (analytics listing can omit attempt entirely)
* Sort imports in QuickJS serialization files (biome organizeImports)
* QuickJS engine: resolve the run's full payload-key capability so sealed (encp) hook payloads open
Main's sealed-box work (#3096) makes cross-deployment resumeHook() seal
hook payloads to the target run's published X25519 public key. The shared
start() path publishes that key regardless of engine, so QuickJS runs
receive sealed payloads too — but the QuickJS entrypoint resolved only the
bare symmetric key via importKey(), which cannot open encp envelopes. The
first sealed hook payload wedged the run right after hook_received, timing
out every hook/webhook e2e on Vercel prod (node:vm legs were fine — the
node engine resolves the full capability via memoizeEncryptionKey).
Resolve deriveRunPayloadKeys() in the entrypoint instead and widen the
runtime's key types from CryptoKey to DecryptionKey. Writes stay symmetric
(encrypt() with RunPayloadKeys takes the encr path). Regression test seals
a payload exactly as resumeHook does and round-trips it through the VM.
* Address review: crypto/process parity, loud Intl guards, lazy engine import, VM-leak guard, telemetry namespace, eval-string escaping
- Deterministic crypto.getRandomValues/randomUUID in the VM bootstrap,
drawing from the seeded Math.random (identical sequences to the node
engine's vm/index.ts implementations); all crypto.subtle methods throw
with step-function guidance. process.env exposed as a frozen copy,
matching node.
- Intl: throwing constructors (no ICU in QuickJS), and toLocale*-family
methods (incl. localeCompare) throw when given an explicit locale so
cross-engine divergence is loud instead of silently writing different
values into the event log. No-argument forms keep working.
- runtime.ts lazy-imports the QuickJS entrypoint at dispatch, keeping the
~1.3MB embedded WASM assets out of node-engine deployments.
- runQuickJSWorkflow wraps the per-run phase so an exceptional exit
disposes the VM instead of leaking it in a reused compute instance;
corrected the misleading fail-loud comment (run_failed, not retry);
warn when the event drain loop exhausts its iteration bound.
- Telemetry attributes renamed quickjs.* → workflow.vm.* to stay in the
file's workflow.* namespace.
- Eval-string correlation-id interpolation uses JSON.stringify instead of
quote-only escaping.
- common-vm.test.ts pins the reducer/reviver superset invariant against
common.ts so the duplicated sets can't silently drift.
- Docs enumerate the remaining global-surface differences (subtle.digest,
Intl, WebAssembly, Atomics); quickjs-entrypoint documents the known
precondition-guard gap.
* QuickJS engine: implement resilient resumeHook (hookInput materialization + resumeId dedup)
#1834 made resumeHook() fall back to enqueueing the run with a hookInput
payload when the direct hook_received write fails transiently, with the
runtime materializing the missing event on delivery. Only the node:vm
path implemented it — the QuickJS dispatch returned before the node
block, so the resilient payload was silently dropped and the new e2e
timed out on every quickjs leg.
- runtime.ts threads hookInput into runWorkflowWithQuickJS; the
entrypoint materializes the missing hook_received after loading the
event log (resumeId-keyed dedup, occurredAt from the resumeId ULID,
local eventData substitution for lazy/ref responses, EntityConflict /
HookNotFound handling) — mirroring the node block.
- processEvents drops duplicate hook_received rows sharing a resumeId
(first-in-log wins), matching the node engine's EventsConsumer dedup;
the seen-set lives in the VM heap so it is deterministic per replay.
Verified against the dev server with WORKFLOW_VM=quickjs: the resilient
resume e2e passes and the materialization is observable in the logs; all
27 hook e2e tests green.
* QuickJS engine: inline step execution via live-VM continuation loop + WASM module caching
* Address review: exclusive inline step claims, self-write requeue, in-loop event ceiling
- Inline steps now claim via a lazy step_started carrying the input
(step_created deferred, atomic create-claim in the world), with
ownerMessageId stamped and authoritativeAttempt=1 — a concurrent
invocation racing on the same fresh step loses with
EntityConflictError and skips instead of both bare-starting the step
and double-running the body. This also removes the stepsCreatedByUs
set, whose 'created by us' invariant didn't survive the swallowed
create-race conflict; redelivery backstops now key on hasCreatedEvent.
- dispatchPendingOps' createdAttributeEvent/createdGetConflictHook
signals are consumed again: when the loop exits suspended without ever
reading back a self-written attr_set / getConflict hook_created
(eventually-consistent listing lag), the entrypoint requeues
immediately instead of parking the run awaiting_external with its
unblocking event already written.
- The server-supplied event ceiling is re-checked at the top of every
continuation-loop turn (seenEventIds.size), so a single invocation
fanning out inline can no longer grow the log arbitrarily past the
operator's limit. The quickjs dispatch in runtime.ts converts
MaxEventsExceededError into run_failed / MAX_EVENTS_EXCEEDED — the
guard's throw previously nacked forever, parking runaway runs in
'running'.
- Documented the deliberate decision that the platform function timeout
is the only bound on inline chaining (budget parked per batch),
matching the node engine.
* QuickJS engine: host-side, side-effect-free serialization via handles
(Re-applied onto the review-fixed base; original commits da2723016 +
9814ed9ac squashed.)
Replace the in-VM serde bundle with a host-side codec
(runtime/quickjs-serde.ts) built on quickjs-wasi 3.3's introspection
primitives and devalue 5.9's pluggable stringify/parse operations —
mirroring the node:vm engine's architecture.
Review fixes incorporated:
- reducer/reviver key sets are pinned against codec-devalue-vm's
workflow mode by exhaustiveness tests (exact order for reducers —
first match wins), so the handle-space codec can't silently drift
from the shared value-space sets.
- the devalue entry in minimumReleaseAgeExclude is removed: the exact
version is pinned via the workspace catalog + lockfile, so the
cooldown waiver was unnecessary (verified with both frozen and
regular installs).
- eval-string interpolation inherits the JSON.stringify(cid) hardening
from the base branch.
* Address review: NUL-safe string extraction, deterministic retryAfter, pass-scoped handle disposal, byte-cache lifecycle
- NUL (U+0000) safety across the WASM boundary: handle.toString() routes
through JS_ToCString and silently truncates at the first NUL, and the
C-string key APIs mangle NUL-bearing property keys (drop or collide).
guestString() detects truncation by comparing against the handle's
true guest length and recovers via in-VM JSON.stringify escaping;
shapeOf verifies its fast host-string key list against a guest
Object.keys count (+ duplicate check) and re-extracts through key
handles on mismatch; get/hasOwn route NUL-bearing keys through
length-aware guest string handles. All string funnels (primitives,
symbol descriptions, error fields via chained/own reads, Headers
entries, RegExp source/flags, URL href) go through guestString.
Regression-tested down to the truncate-vs-collide enumeration shapes;
fixes nullByteWorkflow on the quickjs e2e legs.
- RetryableError's absent/invalid retryAfter fallback now reads the
GUEST clock (the deterministic replay clock at the WASI layer) via a
captured Date.now instead of the host wall clock — the in-VM reducer
was replay-stable by construction and the host port silently lost
that.
- Pass-scoped handle disposal: serialize/deserialize sweep every
intermediate handle their pass creates (call/invoke results,
descriptor reads, dups, parse-op constructions), closing the
~one-leaked-handle-per-value-node growth across long-lived inline
sessions. Implemented with module-owned tracking rather than
vm.withScope: the library scope also captures the handles the
host-callback trampoline wraps around C-owned argv pointers, and
disposing those (Map/Set/Headers forEach visitors run mid-pass)
double-frees guest values — observed as WASM memory corruption.
identities is cleared per pass so freed-pointer reuse cannot alias
entries across passes.
- Byte-cache lifecycle: terminal drain now shares the per-VM cache with
the suspension path (re-serializing an op at drain could re-invoke
getters and produce different bytes for what the log treats as one
value), and entries for settled ops — which neither collection filter
can match again — are evicted, bounding the cache by the live pending
set.
* Adopt quickjs-wasi 3.3.1: withScope handle sweeping, real memoryLimit accounting, loud unregistered-callback failures
3.3.1 ships the three fixes this branch surfaced upstream:
- Borrowed host-callback handles (vercel-labs/quickjs-wasi#31): the
trampoline's this/argv handles are scope-exempt, making vm.withScope
safe around host callbacks. The serde's module-owned pass-disposal
apparatus (passDisposal/track/runWithPassDisposal and ~18 track()
wraps) is replaced by withScope in serialize/deserialize — simpler,
and strictly more complete: every handle constructed during the pass
is swept, not just the ones our creation funnels saw. Bench parity
confirmed (within ~10% on the 50k-node extreme case, unchanged
elsewhere; still 2.6-100x over the in-VM codec).
- Real memoryLimit accounting (vercel-labs/quickjs-wasi#33): the
engine's 256 MB VM ceiling now actually bounds retained guest
allocations (usable-size was 0 on wasm32-wasi before, so the limit
never accumulated).
- Unregistered host callbacks throw (vercel-labs/quickjs-wasi#34):
guest calls into missing callbacks fail loud instead of silently
returning undefined — protection this engine wants for
snapshot-restore re-registration bugs.
Also merges origin/main (undici 7.29.0).
* Address review: lossless lone-surrogate string extraction, portable base64
P1 — the guestString length check was insufficient: JS_ToCString has
TWO corruptions (NUL truncation, lone-surrogate -> U+FFFD replacement)
and they can cancel — the replacement expansion offsets the truncation
so the extracted length matches the true guest length. A bare lone
surrogate can also replace 1:1 with no length change at all. Worse,
the JSON.stringify slow path was itself lossy for lone surrogates:
QuickJS passes them through raw, and the C-string extraction of ITS
output corrupts them.
- guestString accepts the fast value only when length matches AND it
contains no U+FFFD (legitimate U+FFFD strings take the loss-free slow
path); the slow path now escapes INSIDE the VM to printable ASCII via
a new captured escapeString intrinsic (WTF-16-safe per-code-unit
\uXXXX escaping), then JSON-parses host-side.
- shapeOf's fast-key acceptance adds a U+FFFD scan alongside the
count/duplicate checks (lone-surrogate keys corrupt with count and
uniqueness intact).
- get()/hasOwn() route keys through guest string handles when they
carry a NUL or an UNPAIRED surrogate (paired surrogates - emoji keys
- encode fine through the C-string APIs; vm.newString is verified
WTF-16-preserving for the handle path).
- Tests: the reviewer's exact length-canceling case, bare lone
surrogates, legit-U+FFFD passthrough, byte parity with the reference
codec, and lone-surrogate/mixed keys.
P2 — the codec's base64 helpers no longer carry an unconditional Node
Buffer dependency: feature-detected Uint8Array.fromBase64/toBase64
when available, Buffer when present, btoa/atob loop otherwise —
keeping WASM-only/non-Node hosts (Cloudflare Workers) viable.
* Adopt quickjs-wasi 3.4.0: delete the NUL/surrogate string machinery
3.4.0 ships lossless string transport (vercel-labs/quickjs-wasi#35 —
found by this PR's review cycle), so the SDK-side detection and escape
machinery is deleted wholesale:
- guestString (length + U+FFFD detection, in-VM escape fallback) — plain
toString() is lossless now
- the escapeString / hasOwnCall / jsonStringify / objectKeys captured
intrinsics
- keyNeedsHandleLookup and the handle-keyed get/hasOwn routing — the
library routes inexpressible keys itself
- shapeOf's guest-key verification pass (count/duplicate/U+FFFD scans) —
enumeration is lossless
Net ~130 lines and four captured intrinsics removed; the serde now uses
the plain quickjs-wasi surface everywhere.
Test honesty fix that 3.4.0 forced: the earlier lone-surrogate
round-trip tests passed only via mutual corruption — the pre-3.4.0
lossy host→guest transport corrupted the guest comparison literals
identically to the wire. With an honest transport they exposed that the
WIRE itself (devalue emits lone surrogates raw; the wire is UTF-8)
degrades lone surrogates to U+FFFD — in the node engine's reference
codec exactly as here, verified. Bug-compatible parity is the
load-bearing property (event logs replay across engines), so those
tests now assert byte parity with the reference codec plus
guest-observed equality with the reference codec's own round trip; NULs
are devalue-escaped and asserted to survive exactly. Wire-level
surrogate preservation is a product-wide devalue/UTF-8 question,
tracked separately from this engine.
* Add opt-in QuickJS WASM VM engine (WORKFLOW_VM=quickjs) with full event replay
* QuickJS engine: AbortController, setAttributes, terminal drain, turbo-safe requeue, stable PRNG seed
* QuickJS engine: hook.getConflict support, cross-run writable forwarding symbols
* QuickJS engine: stream framing round-trip, bound step proxies, webhook fidelity
* Apply biome fixes to QuickJS engine files
* Address review feedback: anchor source-map strip to end-of-input, use getWorkflowQueueName for conflict requeue, Buffer-free asset decoding, function replacers for payload injection, maxEventsLimit guard
* CI: include generated QuickJS source assets in shared e2e build artifacts
* Fix same-token hook ordering and conflicted-hook disposal in the QuickJS engine
* CI: run both VM engines across all frameworks and worlds; label jobs with the engine
* Fix stack overflow stripping inline source maps from webpack dev bundles; harden step-listing e2e assertions against eventually-consistent reads
* e2e: poll step listings until analytics rows include attempt (optional column can lag terminal status)
* e2e: use --withData to force storage-backed step listings for attempt assertions (analytics listing can omit attempt entirely)
* Sort imports in QuickJS serialization files (biome organizeImports)
* QuickJS engine: resolve the run's full payload-key capability so sealed (encp) hook payloads open
Main's sealed-box work (#3096) makes cross-deployment resumeHook() seal
hook payloads to the target run's published X25519 public key. The shared
start() path publishes that key regardless of engine, so QuickJS runs
receive sealed payloads too — but the QuickJS entrypoint resolved only the
bare symmetric key via importKey(), which cannot open encp envelopes. The
first sealed hook payload wedged the run right after hook_received, timing
out every hook/webhook e2e on Vercel prod (node:vm legs were fine — the
node engine resolves the full capability via memoizeEncryptionKey).
Resolve deriveRunPayloadKeys() in the entrypoint instead and widen the
runtime's key types from CryptoKey to DecryptionKey. Writes stay symmetric
(encrypt() with RunPayloadKeys takes the encr path). Regression test seals
a payload exactly as resumeHook does and round-trips it through the VM.
* Address review: crypto/process parity, loud Intl guards, lazy engine import, VM-leak guard, telemetry namespace, eval-string escaping
- Deterministic crypto.getRandomValues/randomUUID in the VM bootstrap,
drawing from the seeded Math.random (identical sequences to the node
engine's vm/index.ts implementations); all crypto.subtle methods throw
with step-function guidance. process.env exposed as a frozen copy,
matching node.
- Intl: throwing constructors (no ICU in QuickJS), and toLocale*-family
methods (incl. localeCompare) throw when given an explicit locale so
cross-engine divergence is loud instead of silently writing different
values into the event log. No-argument forms keep working.
- runtime.ts lazy-imports the QuickJS entrypoint at dispatch, keeping the
~1.3MB embedded WASM assets out of node-engine deployments.
- runQuickJSWorkflow wraps the per-run phase so an exceptional exit
disposes the VM instead of leaking it in a reused compute instance;
corrected the misleading fail-loud comment (run_failed, not retry);
warn when the event drain loop exhausts its iteration bound.
- Telemetry attributes renamed quickjs.* → workflow.vm.* to stay in the
file's workflow.* namespace.
- Eval-string correlation-id interpolation uses JSON.stringify instead of
quote-only escaping.
- common-vm.test.ts pins the reducer/reviver superset invariant against
common.ts so the duplicated sets can't silently drift.
- Docs enumerate the remaining global-surface differences (subtle.digest,
Intl, WebAssembly, Atomics); quickjs-entrypoint documents the known
precondition-guard gap.
* QuickJS engine: implement resilient resumeHook (hookInput materialization + resumeId dedup)
#1834 made resumeHook() fall back to enqueueing the run with a hookInput
payload when the direct hook_received write fails transiently, with the
runtime materializing the missing event on delivery. Only the node:vm
path implemented it — the QuickJS dispatch returned before the node
block, so the resilient payload was silently dropped and the new e2e
timed out on every quickjs leg.
- runtime.ts threads hookInput into runWorkflowWithQuickJS; the
entrypoint materializes the missing hook_received after loading the
event log (resumeId-keyed dedup, occurredAt from the resumeId ULID,
local eventData substitution for lazy/ref responses, EntityConflict /
HookNotFound handling) — mirroring the node block.
- processEvents drops duplicate hook_received rows sharing a resumeId
(first-in-log wins), matching the node engine's EventsConsumer dedup;
the seen-set lives in the VM heap so it is deterministic per replay.
Verified against the dev server with WORKFLOW_VM=quickjs: the resilient
resume e2e passes and the materialization is observable in the logs; all
27 hook e2e tests green.
* Rerun CI
* QuickJS engine: split VM-local class/step-function reducers off the hardened host codec
The hardened host-side serialization (#3257) made the shared
reducers/class.ts and reducers/step-function.ts depend on
serialization/hardened.ts, which imports node:util and captures host
intrinsics — unbundleable and meaningless inside the QuickJS guest,
where the codec already runs in the guest realm. Point the VM codec at
pre-hardening copies with identical wire format; the host/guest
boundary hardening for this engine arrives with the host-side serde
that retires the VM bundle.
* QuickJS engine: enqueue explicit wait continuations instead of same-message redelivery
Scheduling sleep wakeups by returning { timeoutSeconds } redelivers the
CURRENT queue message. When that message is a hook-resume delivery
(carrying hookInput), its redelivery re-runs the lazy-resume re-ensure
in the handler prologue; if the workflow disposed the hook during the
first delivery (dispose -> sleep), the re-ensure gets HookNotFound, the
prologue acks the message as 'nothing left to resume', and the wait
timer it carried is silently lost — the run wedges (caught by the
hookDisposeTestWorkflow e2e).
Enqueue fresh continuation messages instead, matching the node engine's
suspension handler: getWaitContinuationDispatch for pending waits
(gaining delay clamping/hop chaining and pending-wait dedup keys) and a
plain immediate message for elapsed-wait / attr_set / getConflict
requeues. A fresh message carries only runId, so its delivery always
reaches replay.
Also: read hook_received resumeId from the canonical top-level event
field (eventData.resumeId is the deprecated legacy fallback), and stop
passing hookInput into the entrypoint — the shared prologue in
runtime.ts materializes the event for both engines. Adds a VM replay
test for the hook -> dispose -> sleep shape.
* Sort imports in quickjs-entrypoint (biome organizeImports)
* Address review: dispatch inside run-level try/catch, queue namespace + run-origin trace carrier threading, configurable interrupt budget
- Move the QuickJS engine dispatch inside the replay loop's try so
escaping engine failures (MaxEventsExceededError, WASM OOM,
bundle-eval errors) reach the catch that classifies and records
run_failed, instead of nacking the message and burning all 48 queue
redeliveries into MAX_DELIVERIES_EXCEEDED. Transient world errors
still rethrow for redelivery. Updated the two comments that describe
the propagation.
- Thread the queue namespace from runtime.ts through
runWorkflowWithQuickJS into every message publish (step dispatch,
hook_conflict requeue, immediate requeue, wait continuation) —
without it, publishes on a namespaced deployment land on
__wkf_workflow_* while consumers listen on __<ns>_wkf_workflow_*.
- Thread the run-origin nextTraceCarrier accessor through instead of
capturing the current invocation context, so linked-mode invocations
form a star around workflow.start rather than chaining; the
hook_conflict requeue now carries a traceCarrier and requestedAt.
- Replace the hardcoded 30s VM interrupt budget with the configurable
replay budget (getReplayTimeoutMs, default 240s), matching the node
engine.
* Sort imports in quickjs-runtime (biome organizeImports)
* feat(core): side-effect-free serialization of workflow VM values
Serialization runs on the host but inspects values constructed inside the
node:vm sandbox, so ordinary dynamic operations dispatch into the sandbox
realm and execute workflow code: `value.toISOString()`, `Array.from(map)`,
`Object.prototype.toString` (via Symbol.toStringTag), `.source`/`.flags`,
`.href`, view `.buffer`/`.byteOffset`/`.byteLength`, and error
`.message`/`.stack`/`.cause` reads.
That is a determinism hazard. A payload is serialized exactly once and is
never re-serialized on replay, so any workflow-visible side effect it
triggers exists only on the live path — a patched `Date.prototype.toISOString`
that consumes a seeded `Math.random()` draw, for example, shifts every
subsequent draw and diverges from replay.
This makes serialization side-effect free where the data allows it, and
observable where it does not:
- Classification uses engine brand checks (node:util types, internal-slot
probes) instead of `instanceof global.X` and Object.prototype.toString, so
it is immune to Symbol.hasInstance, reassigned sandbox globals, and
Symbol.toStringTag spoofs. An unbranded value claiming a brand-decided tag
is now classified as a plain object instead of being routed into an
extractor that requires the real internal slot (unhardened devalue crashes
on that input).
- Extraction goes through intrinsics captured at module load — host boot,
before any workflow bundle runs — invoked with explicit receivers.
Internal slots are realm-agnostic, so host intrinsics read VM-realm
objects without touching the sandbox's patchable prototypes.
- Property access reads through descriptors, so plain data never invokes
anything.
Where workflow code must run because the data lives behind it — getters,
proxies, custom [WORKFLOW_SERIALIZE] methods, toString() on
toStringTag-branded objects like Temporal polyfills — the execution is
preserved for compatibility and recorded in a new `CodecOptions.guestCodeStats`
sink, surfaced as workflow.serialization.guest_code_{executions,details} span
attributes. Consumers that retain a VM across steps can treat a non-empty
report as "serialization may have perturbed VM state".
Engine-provided accessors are deliberately not reported: V8 defines `stack`
as an own accessor on every Error instance, so reporting it would flag every
serialized error. Nativeness is decided with the captured host
Function.prototype.toString; the bound-function caveat is documented in
hardened.ts.
Requires devalue 5.9.0 for the pluggable `operations` option.
* chore: shorten changeset
* fix(core): close review gaps in hardened serialization
Five correctness fixes, all with repros:
- Callable proxies were treated as engine accessors. V8 returns
`function () { [native code] }` from Function.prototype.toString for a
proxy around a function rather than throwing, so a proxy-wrapped getter
was cached as engine-provided and invoked unreported. Gate on
types.isProxy first.
- Host builtins implemented in JavaScript were reported as workflow code.
Node's DOMException.prototype.message/name are ordinary functions, so
the nativeness test failed and every serialized DOMException reported
two getter executions. They belong to the *host* realm, though, and
workflow code cannot author a host-realm function — so provenance is
now decided by nativeness OR host-realm `Function.prototype`, which are
disjoint and together cover both cases (V8 installs `stack` per realm,
so a VM error's getter is native but VM-realm).
- The extraReducers at the two VM call sites were still unhardened, and
they run on every value the earlier reducers do not claim — which is
exactly where the report has to be complete. `instanceof
global.ReadableStream/WritableStream/Request/Response` consulted
Symbol.hasInstance on the sandbox class (14 invocations for an ordinary
payload once the classes are patched), and AbortController's guard did
a bare `value.signal` read, so a non-enumerable `signal` getter ran
with an empty report. All five now walk the prototype chain and read
through descriptors.
- `__closureVarsFn` was invoked unreported on a purity argument that
nothing checked: the property is reachable from workflow code, which
can replace the compiler-generated function. step.ts now registers the
generated function as trusted when it builds the proxy, so provenance
is verified rather than assumed, and an unrecognized function is
reported.
- The URL/URLSearchParams test patched prototypes of *host* classes
injected into the sandbox, mutating them for the rest of the worker
process. Restored in a finally.
Also, per review:
- `dehydrateStepArguments` / `dehydrateWorkflowReturnValue` take an
optional GuestCodeStats out-param, so a retained-VM gate can consume
the report instead of it being spent on span attributes. The
report-completeness tests use it to exercise the real dehydrate path.
- Every intrinsic capture is now optional. The table is built at module
scope, so a missing member was an import-time crash of @workflow/core
rather than a degraded path; only SharedArrayBuffer was guarded, while
URLSearchParams.prototype.size (Node 19.8+) and the WHATWG classes were
assumed. Absent captures now make the corresponding reducer decline to
match.
- Documented that recording is not prevention (a recorded getter calling
Math.random() still advances the run's seeded PRNG), and that a
`{ kind: 'proxy' }` report implies a silent shape change (a proxied Map
serializes as a plain object).
- Parity coverage extended to DataView, boxed primitives, null-prototype
objects, setter-only properties, DOMException, AggregateError, an
accessor-valued Symbol.toStringTag, both RetryableError retryAfter
paths, and a WORKFLOW_SERIALIZE class instance.
* fix(core): keep identifying proxied host classes
Every Next.js e2e job failed on the two webhook tests: the hook POST
returned 404 because `resumeWebhook` could not serialize its step return
value ("Cannot stringify arbitrary non-POJOs"), so no hook was ever
registered.
The value was a `NextRequest`, which Next.js hands over as a **Proxy**.
`isInstanceOfPrototype` rejected proxies outright, so the Request reducer
answered "not a Request" and devalue fell through to the POJO check. The
reasoning behind rejecting them — that proxied built-ins were never
serializable, because internal-slot reads throw on a proxy receiver — is
true for `Map`/`Date`/`URL`, whose reducers read internal slots, but not
for `Request`/`Response`/streams, whose reducers read ordinary
properties. Next's proxy forwards those with the target as receiver, so
they serialized fine before this PR.
Identification now walks through proxies, matching `instanceof`, and
records the traps rather than suppressing the answer. The three reducers
that do read internal slots (URL, URLSearchParams, Headers) fall back to
the dynamic read when the value is a proxy, so their behavior is exactly
what it was before — including throwing for a bare proxy over a built-in,
which threw before too.
Verified against the real thing: the full nextjs-turbopack e2e suite
(135 tests) passes locally, having reproduced the failure first and
confirmed a reverted `serialization.ts` fixed it.
The regression test uses a receiver-correcting proxy, which is what makes
NextRequest work in practice; a comment records that a bare
`new Proxy(request, {})` throws on undici's private slots with or without
this change.
* fix(core): state what the closure-fn mark proves, and correct stale docs
- `isInstanceOfPrototype`'s JSDoc still described the behavior removed in
8bc462fb5 (proxies rejected without firing traps), which is the opposite
of what it now does.
- The `__closureVarsFn` provenance check proves the function was passed to
`useStep`, not that this package generated it: `useStep` is published on
the sandbox global, so workflow code can call it with a function of its
own and have it marked. Renamed `registerTrustedFunction` /
`isTrustedFunction` to `markUseStepClosureFn` / `isUseStepClosureFn` so
the name states the boundary, and documented the laundering caveat
alongside the existing ones. Marking still earns its keep — reporting
every step that captures a variable would bury the signal — and closing
the gap properly needs a compiler-emitted marker, which is a compiler
change.
- Added the missing coverage for both sides of that check: an unmarked
`__closureVarsFn` is invoked and reported, a marked one is invoked and
not.
- `guestCodeStats` was documented as something a retained-VM gate consumes,
but no runtime caller passes a sink; the executions reach telemetry from
every dehydrate path regardless. Reworded both docs to say that, so the
out-param is not mistaken for wiring that already exists.
* build: declare typescript (catalog:) in every package that runs tsc
Twenty packages invoke tsc in their build/typecheck scripts without
declaring a typescript dependency, resolving whatever tsc pnpm happens
to leave reachable. That broke locally after the TypeScript 6 upgrade
(#2700): base.json now uses the TS6-only 'types': ['*'] wildcard, and
worktrees carrying pre-upgrade node_modules/.bin/tsc shims (orphaned
typescript@5.9.3 bins that pnpm never refreshes for an undeclared
dependency) fail with TS2688 'Cannot find type definition file for *'.
Declaring 'typescript': 'catalog:' (the convention nest already
follows) makes pnpm own each package's tsc bin, so version upgrades
refresh the shims and this staleness class cannot recur. Packages
without tsc in their scripts are left unchanged.
Full pnpm build: 27/27 tasks green.
* Address review: drop duplicate zod devDep; regenerate lockfile minimally
- packages/world listed zod in both dependencies and devDependencies
(pre-existing on main, surfaced by the devDependencies sort) — keep
the runtime dependency only.
- Regenerate pnpm-lock.yaml from a pristine main baseline with
--lockfile-only (a clean-main run produces zero diff, so main has no
drift). Remaining non-typescript changes are mechanical consequences
of the change itself: typescript is an (optional) peer of several
tooling dependencies, so declaring it in 20 importers creates new
peer-resolution snapshot variants and prunes the now-orphaned old
ones; plus one radix-ui 1.6.1->1.6.2 refresh in docs caused by its
floating 'latest' specifier.
- Validated: pnpm install --frozen-lockfile succeeds; full build 27/27.
* Statically inject workflow world target
* Fix static world injection in host bundles
* Fix static world injection gaps
* Fix Vite Nitro server startup
* Fix Nitro pg-native aliasing
* Fix static world target CI gaps
* Fix static world dev rebuild gaps
* Avoid broad runtime alias in Nitro
* Refresh Next dev route for step HMR
* Externalize Nest target world
* Use canary HMR rediscovery timeout
* Bundle local world in Nest builds
* Dedupe world target helpers and fix SvelteKit chunk patch guard
* otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces
- Add WORKFLOW_TRACE_MODE ('linked' default, 'continuous' legacy) to the
workflow and step queue handlers. In linked mode, WORKFLOW_V2/STEP spans
start a new trace root with span links to the incoming delivery context
and the run-origin context, and re-enqueued messages forward the
ORIGINAL run-origin trace carrier unchanged.
- world-vercel now explicitly injects W3C traceparent/tracestate/baggage
headers on outgoing workflow-server HTTP requests from inside the
client span (no-op without an OTEL SDK registered).
- New workflow.trace.mode span attribute; unit tests for both modes and
for header injection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* changeset: call out behavioral telemetry changes of the linked default
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: add v5 observability tracing page
Documents OTEL spans/attributes, linked trace mode and WORKFLOW_TRACE_MODE,
span links, context propagation, and the v4 behavior-change callout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* otel: human-friendly span names for workflow and step spans
WORKFLOW_V2/STEP prefixes with full machine names (workflow//./src/...//fn)
become workflow.execute / step.execute / workflow.start with the short
function name. New workflowDisplayName/stepDisplayName helpers in
@workflow/utils handle both raw and queue-sanitized name forms; full names
remain in the workflow.name/step.name attributes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* changeset: merge span-name and linked-trace notes into one changeset
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: update trace-shape prose to renamed span names
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: replace ascii trace diagram with mermaid
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* address review: empty carriers, shared trace helpers, mode warning, name edge cases, consumer span kind
- Treat an empty ({}) trace carrier as absent everywhere the trace-mode
logic branches, so linked mode falls back to a fresh origin instead of
forwarding a useless {} forever; workflow.trace.propagated now reports
whether a usable carrier arrived.
- Extract the duplicated linked-mode logic into shared telemetry helpers
getNextTraceCarrier() and buildInvocationSpanLinks(), used by both the
workflow and step queue handlers; resume-hook now uses
linkToTraceCarrier (gaining the isSpanContextValid guard).
- Warn once per distinct unrecognized WORKFLOW_TRACE_MODE value instead
of silently selecting linked.
- shortNameFromSanitized: map default/__default to the module short name
(mirroring parseName) and document the `$`-sanitization limitation.
- Queue-delivered workflow.execute spans now use the CONSUMER span kind,
matching queue-delivered step.execute spans; docs span table and
changeset updated accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>