Commit Graph

1633 Commits

Author SHA1 Message Date
Nathan Colosimo e6f1b6f548 feat(world-local): support Hook minimum retention (#2866)
* feat(core): add hook token retention contract

* refactor(core): constrain hook retention options

* fix(core): preserve boolean hook visibility options

* revert(core): preserve HookOptions interface

* docs(core): clarify retained conflict ownership

* docs(core): retain newest-wins conflict pattern

* docs(core): simplify hook retention guidance

* docs(core): explain retained token cleanup

* docs(core): simplify idempotency guidance

* docs(core): clarify retained token results

* refactor(core): rename hook token expiration option

* chore(core): name hook expiration changeset

* docs(core): simplify Hook expiration language

* docs(core): clarify Hook expiration deadline

* docs(core): remove Hook deadline caveat

* refactor(core): align Hook expiration field names

* docs(core): narrow Hook expiration documentation

* docs(core): clarify hook expiration availability

* Update packages/core/src/workflow/hook.ts

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>

* docs(core): clarify Hook token expiration behavior

* docs(core): explain active Hook expiration behavior

* feat(world): advertise hook ttl capability

* fix(core): validate hook ttl capability after main merge

* refactor(core): rename hook expiry to minimum retention

* docs: keep hook retention guidance on v5

* docs: define retained run availability

* fix(core): validate Hook retention at creation

* feat(core): define retained Hook lookup semantics

* refactor(core): simplify hook retention checks

* feat(world-local): support Hook token expiration

* fix(world-local): make hook recovery atomic

* refactor(world-local): align Hook minimum retention

* fix(world-local): preserve Hook creation order

* fix(world-local): expose retained Hooks consistently

* refactor(world-local): simplify retained hook storage

* fix(world-local): allow stale lock recovery

* refactor(world-local): simplify hook retention storage

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

* fix(world-local): serialize expired hook token handoff

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

* fix(world-local): preserve hook creation order

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

* refactor(world-local): clarify hook availability cleanup

* docs: note Local World Hook retention support

* fix(world-local): harden hook retention persistence

* fix(web-shared): render hook retention deadline

* fix(world-postgres): exclude unsupported hook retention

* feat(world-local): enforce Hook retention limit

* docs(world-local): clarify retention limit error

* docs(world): clarify Hook retention deadline

* docs(hooks): link retention configuration

---------

Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-08-03 17:42:31 -07:00
Peter Wielander cb77725960 [core] Derive correlation ids from per-kind sequences (opt-in) (#3301) 2026-08-03 16:49:15 -07:00
Nathan Rajlich f8f6e17aeb Add opt-in QuickJS WASM VM engine (WORKFLOW_VM=quickjs) (#3048)
* 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)
2026-08-03 16:38:58 -07:00
Pranay Prakash 4a192c85c8 [v5 only] docs: restore start-in-workflow documentation (#1803)
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
2026-08-03 16:37:39 -07:00
Nathan Colosimo 89ede82faa feat(core): widen retained boundaries to plain data and standard built-ins (#3047)
* gate retention on the hardened serializer's guest-code report instead of primitives-only args

Replaces the isPrimitiveStepArgument allowlist with the GuestCodeStats sink
that dehydrateStepArguments already exposes: a boundary retains unless
serializing its step inputs actually executed workflow code (getters, proxy
traps, custom serializers), plus a descriptor-walk probe for a replaced
Error.prepareStackTrace — the one execution path the sink cannot see,
because the serializer treats V8's engine stack getter as engine-provided.

Plain data and standard built-ins (Map, Set, Date, RegExp, Error, typed
arrays, URL, Headers) now stay on the fast path, including under prototype
patching and polyfills, since serialization reads them through captured
intrinsics.

* reword changeset and docs in plain language
2026-08-03 15:49:02 -07:00
Nathan Colosimo 5d591d2886 perf(core): retain workflow VM across inline steps (primitives-gated) (#3046)
* perf(core): retain workflow VM across inline steps

Combines the retained-session architecture from #2984 with the env kill
switch and loop-level single-VM test from #2966.

- executeWorkflow with discriminated request/result types and a
  WorkflowSession state machine (running/suspended/failed/replay/completed)
- EventsConsumer.append: only newly durable events feed the live VM
- WORKFLOW_RETAINED_VM=0 kill switch (default on)
- retained-vm-loop.test.ts: proves one VM per run and byte-identical
  output vs the from-scratch replay path

* refactor(core): simplify retained-session control flow

- executeWorkflow overloads: a fresh replay request can no longer return
  { type: 'replay' }, deleting the runtime invariant throw and
  runWorkflow's dead branch
- isSameSuspensionBoundary reduced to the steps-array comparison (all
  suspension counts are derived from steps in the constructor)
- runtime loop initializes workflowResult with a ternary

* fix(core): decline retention for VMs that ran host-timed async work

crypto.subtle.digest is the only sandbox API whose promise resolves on
host timing rather than from the event log, so a workflow racing it
against a step can advance while suspended and diverge from what replay
reconstructs. A sticky usedHostAsync bit on the VM context makes
canRetainWorkflowSession fall back to ordinary replay for such VMs;
a quiescent step-only VM remains a pure function of the consumed
event prefix and stays retainable.

* fix(core): track all host-timed async VM APIs for retention

Atomics.waitAsync (a wall-clock timer via SharedArrayBuffer) and the
async WebAssembly compilation entry points resolve on host timing just
like crypto.subtle.digest. Wrap every such intrinsic in createContext so
usedHostAsync covers the complete set; dynamic import() settles within a
microtask and cannot advance a suspended VM.

* feat(core): compute crypto.subtle.digest synchronously in the sandbox

node:crypto createHash produces byte-identical values to WebCrypto and
settles the digest promise on a deterministic microtask instead of host
threadpool timing. A digest can therefore never advance a suspended
workflow, so digest-using VMs stay retainable; only Atomics.waitAsync
and async WebAssembly compilation remain host-timed. createHash is
stable and undeprecated on Node 18-26 (DEP0179 only removed the direct
Hash constructor).

* fix(core): remove WeakRef and FinalizationRegistry from the sandbox

GC observation depends on host GC timing that neither replay nor a
retained VM can reconstruct from the event log. WeakMap/WeakSet stay
available (they do not expose GC state).

* fix(core): enforce the BufferSource contract in the sandbox digest

Reject non-BufferSource digest input with TypeError like WebCrypto does,
via the native ArrayBuffer.prototype.byteLength brand check (works
across vm realms). Previously a plain number was treated as a
Uint8Array length, turning a small input into a giant allocation.

* fix(core): demote retention when suspension serialization draws randomness

handleSuspension dehydrates step arguments with the live VM, and that
serialization can execute user code (getters, WORKFLOW_SERIALIZE hooks).
Randomness drawn there would desync the retained VM's future correlation
IDs from what a fresh replay regenerates. Count every draw from the
seeded stream at its single source in createContext and fall back to
ordinary replay if handleSuspension consumed any.

* refactor(core): make VM quiescence unconditional, cut tracking machinery

Delete Atomics.waitAsync and the async WebAssembly entry points from the
sandbox instead of tracking their use — with digest synchronous and GC
intrinsics removed, no sandbox API settles a promise on host timing, so
a suspended VM provably cannot advance. This deletes the trackHostAsync
wrapper, the usedHostAsync bit and session method, the runtime gate
clause, the session 'failed' state (unreachable), and the
background-progress test scenarios (impossible by construction).

* refactor(core): gate retention on passively cloneable step inputs

Replace the RNG draw-counter demotion with prevention: when a session is
a retention candidate, new step inputs take a passive descriptor walk
(never invoking getters; proxies, accessors, functions, custom classes,
and platform wrappers decline) and safe values are structuredClone'd
into the host realm before dehydration, so serialization never executes
workflow-owned code against a retained VM. Unsafe inputs serialize the
old way and the session falls back to ordinary replay.

* fix(core): harden the passive step-input walker

- require enumerable on array index descriptors: structuredClone drops
  non-enumerable indices that devalue persists
- read workflow globals and constructor prototypes via own-property
  descriptors only, so validation can never execute workflow-owned
  accessors on redefined globals

* fix(core): guard proxied constructors in the passive-input walker

constructorPrototype reads both realms' constructors via own-property
descriptors only and refuses proxies before any descriptor read, so a
proxied redefined global can never observe validation.

* fix(core): preserve retention gate after rebase

* fix(core): all-or-nothing clone batches; reject SAB views in digest

- A mixed step batch (one unsafe sibling input) now serializes every
  input through the ordinary VM path: a clone snapshotted before an
  unsafe sibling's serialization runs its getters could otherwise
  durably capture stale sibling state.
- crypto.subtle.digest rejects SharedArrayBuffer-backed views with
  TypeError, matching WebCrypto's BufferSource contract.

* fix(core): narrow the fast path to prototype-independent types

devalue serializes Map/Set through the realm's iterator protocol and
Date/RegExp/typed arrays through prototype getters, all of which
workflow code can mutate — so their serialization is not provably
passive and their bytes could differ between retained and cold modes.
The fast path now accepts only primitives, plain objects, and plain
arrays, which devalue traverses exclusively via own-property reads.
Slot-bearing exotics decline even with a swapped prototype.

The sandbox digest now reads view metadata (buffer/byteOffset/
byteLength) through captured intrinsic getters, so own properties
shadowing them cannot change which bytes are hashed or bypass the
SharedArrayBuffer rejection.

* fix(core): freeze serialization-consulted sandbox intrinsics

instanceof dispatch (Symbol.hasInstance via the constructor,
Function.prototype, and Object.prototype), the class reducer's
value.constructor walk, and devalue's Object/Array traversal all consult
intrinsics workflow code could redefine — legally and deterministically —
which would make the durable step input depend on WORKFLOW_RETAINED_VM
(spoofed values serialize as e.g. Maps on the cold path but as plain
clones on the retained path). Freeze Object/Array/Function (constructors
and prototypes), the VM collection constructors, and every
reducer-referenced global binding (absent ones pinned to undefined)
right before the workflow bundle evaluates, so the retained-input
equivalence holds by construction.

Host-realm constructor escapes (e.g. TextEncoder.constructor) remain
out of the determinism contract: code scheduling host timers was never
deterministic under ordinary replay either; documented on
canRetainWorkflowSession.

* fix(core): freeze every non-shared serialization constructor

Typed-array constructors (and their shared %TypedArray% parent), the
Date wrapper, and the session-local AbortController/AbortSignal/
Request/Response bindings were pinned but not frozen, so workflow code
could still add Symbol.hasInstance statics that diverge reducer dispatch
between the retained clone (host constructors) and ordinary VM
serialization. Freeze every binding value that is not the shared host
intrinsic; shared host objects are dispatched identically by both paths,
so mutations there cannot cause mode divergence.

* fix(core): build retained clones in a pristine realm

Replace structuredClone with an explicit deep copy into an SDK-private
realm: clones previously inherited host prototypes, which workflow code
can reach (e.g. via structuredClone's return values) and vandalize with
Symbol.toStringTag or constructor overrides, shifting devalue's
classification of the clone relative to the ordinary VM path. The
pristine realm is unreachable by any user code, and the explicit copy
serializes exactly what devalue traverses (own indices, own enumerable
string props). Arrays also now decline own constructor properties,
which the class reducer reads even when non-enumerable.

* fix(core): verify host dispatch pristineness before retained cloning

Host intrinsics are shared with the whole process and cannot be frozen,
but workflow code can reach them (structuredClone results, exposed host
classes) and install Symbol.hasInstance predicates that distinguish the
original from its clone — or WORKFLOW_SERIALIZE statics on host
Object/Array that the class reducer reads for host-prototype originals
(hydrated step results). prepareRetainedStepInput now verifies, via
own-descriptor reads only, that every host dispatch point is pristine
and declines retention before any clone exists — so a spoofed predicate
can never observe or capture a pristine-realm object.

* fix(core): reject symbol properties from retained step inputs

Reducers dispatch on symbol tags (e.g. the workflow abort-signal
markers) that are non-enumerable and dropped by the pristine-realm
copy, so a tagged object would serialize as an abort descriptor on the
cold path but as plain data on the retained path.

* fix(core): retained inputs accept only own enumerable data properties

Hidden own keys of any kind — non-enumerable properties, accessors,
symbols — can be observed by serialization dispatch (reducer probes
like .signal, thenable checks, the class reducer) while the pristine
clone drops them. With no hidden own keys, every probe on an accepted
object resolves deterministically through validated data or pristine
prototypes.

* fix(core): freeze binding prototype chains for hasInstance lookup

Symbol.hasInstance dispatch walks the constructor's prototype chain, so
the frozen Date wrapper still exposed the unfrozen original VM Date it
delegates statics to. Freeze each non-shared binding's full chain
(stopping at host Function/Object prototypes) and verify host
Object.prototype carries no added hasInstance on the detection side.

* refactor(core): single-path retained serialization via pinned members (v2)

Serialize step inputs for retained boundaries through the one ordinary
pipeline (original value, workflow global) instead of cloning into a
pristine realm and serializing under the host global. With a single
serialization event shared by every mode, durable bytes structurally
cannot depend on WORKFLOW_RETAINED_VM; the only property retention needs
is that serialization executes no workflow code, established by:

- the passive walker (descriptor-only, unchanged in spirit), now also
  accepting Map/Set/Date/typed arrays/ArrayBuffer — the common built-in
  step arguments — via prototype-identity checks
- vm/serialization-pins.ts: the 10 prototype members serialization
  executes for those built-ins (measured empirically), captured at
  context creation and identity-verified at each retained boundary; the
  'touches only pinned members' test instruments every member and locks
  the list against serde drift
- host-realm instances (hydrated step results) accepted without member
  verification: host members run host code, which cannot touch retained
  VM state

Deletes the pristine clone realm, the host-dispatch pristineness checks,
and the batch clone bookkeeping.

* refactor(core): freeze built-in prototypes instead of pinning members (v3)

Review found the pin approach's structural hole: the class reducer READS
value.constructor through Map.prototype — a data property when pristine
(so member instrumentation never listed it), but executable the moment
workflow code redefines it as a getter. Pinning what serialization
executes misses what it reads. Freeze the accepted built-ins' prototypes
wholesale (Map/Set/Date + iterator prototypes, %TypedArray% + subclass
prototypes, ArrayBuffer): reads and executes are both immutable, and a
patch attempt now throws loudly at the patch site instead of silently
degrading. Deletes vm/serialization-pins.ts; the walker requires
Object.isFrozen on the realm prototype (also covering realms where the
freeze never ran).

Also restores the host-dispatch pristineness check the v2 cut lost:
workflow code can reach shared host constructors (exposed classes,
structuredClone results) and plant workflow-realm Symbol.hasInstance
hooks or WORKFLOW_SERIALIZE statics that reducers would execute during
retained serialization. Host-realm built-in instances decline for the
same reason; host-realm plain data (hydrated results) stays retainable.

* fix(core): harden the passivity checker's own execution surface

- Capture Map/Set forEach and the %TypedArray% buffer getter as module-
  load primordials: the checker previously invoked live host methods that
  workflow code can reach (structuredClone(new Map()).constructor) and
  replace with delegating workflow-realm closures.
- Typed arrays must have one of the realm's real frozen subclass
  prototypes by identity — 'frozen and chains to %TypedArray%' admitted
  manufactured frozen hostile prototypes with delegating buffer getters.

* fix(core): checker uses module-load primordials; verify inherited serializer statics

- The walker resolved Object.getOwnPropertyDescriptor, Reflect.ownKeys,
  Array.isArray, Number/String helpers, and Object.getPrototypeOf/isFrozen
  from live host globals workflow code can reach and replace; all are now
  module-load captures, so the checker can never execute a planted
  delegate.
- The class reducer reads cls[WORKFLOW_SERIALIZE]/cls.classId as
  inherited Gets, so isHostDispatchPristine now also verifies host
  Function.prototype and Object.prototype carry no serializer statics.

Generic replacement of shared host statics (Object.keys, Array.from, …)
via realm escape remains the documented host-reachability boundary,
tracked by the realm-local intrinsics follow-up.

* fix(core): stale-suspension generation token; cover BigInt toString

- Suspension signals capture ctx.suspensionGeneration when scheduled and
  no-op if the session resumed past that boundary. The harmful interleaving
  was already unreachable (queue items are deleted on consume, completion
  writes state synchronously, nextTick precedes timers) — the token turns
  those ordering facts into an explicit invariant.
- The BigInt reducer calls .toString() on primitives from host code, which
  resolves on host BigInt.prototype: its identity joins the host dispatch
  check, and the VM BigInt.prototype is frozen besides.

* feat(core): deterministic sandbox hardening

- crypto.subtle.digest computes synchronously via node:crypto:
  byte-identical values, promise settles on a deterministic microtask,
  full BufferSource validation (internal-slot view reads, SAB rejection)
- Atomics.waitAsync (a wall-clock timer), async WebAssembly compilation,
  WeakRef, and FinalizationRegistry are removed from the sandbox — wall
  clock and GC observation are unreplayable; sync WebAssembly
  constructors remain
- freezeSerializationIntrinsics pins the universal dispatch surfaces:
  Object.prototype/Array.prototype/Function.prototype are frozen (every
  missed property read and hasInstance lookup terminates there) and
  serialization-referenced global bindings are non-writable. Value-type
  prototypes and constructor statics stay patchable so polyfills
  (Temporal's Date.prototype.toTemporalInstant, core-js Set.prototype
  .union / Object.groupBy) keep working — the retained-input gate
  verifies the members serialization executes per boundary instead.

Groundwork for retained-VM replay (#2990).

* feat(core): retain the workflow VM across inline steps (primitive args)

Keeps the suspended workflow VM, its events consumer, and the paused
async stack alive across inline step executions within one invocation.
Each loop iteration appends only the newly written events instead of
replaying the entire event log in a fresh VM, so step-to-step overhead
stays flat as runs grow.

- WorkflowSession wraps executeWorkflow: suspended sessions expose
  resume(events) which appends to the retained EventsConsumer and lets
  the parked run() continuation settle; any divergence (unexpected
  suspension shape, consumer error) demotes to full replay permanently
- Retention is gated per boundary: only suspensions whose queued step
  inputs are all primitives (null/undefined/boolean/number/string) are
  retainable, because serializing primitives executes no workflow code;
  a follow-up widens this to plain data and standard built-ins
- Suspensions with hooks, waits, or attributes always fall back
- A suspension generation token invalidates stale timer callbacks from
  an abandoned suspension so they cannot advance a resumed VM
- WORKFLOW_RETAINED_VM=0 kill switch; telemetry records
  workflow.execution.mode = replay | retained

Part 2 of the retained-VM stack (#2990); requires the determinism
hardening in part 1.

* chore: retrigger vercel deployments

* Drop serialization intrinsic freezing from the sandbox

The retained-VM passivity design moved from pinning/verifying the
sandbox surfaces serialization dispatches on to injecting hardened
operations into devalue itself (with taint-based de-opt), so freezing
Object/Array/Function prototypes and pinning global bindings is no
longer needed. Keep only the determinism hardening (sync digest,
removal of wall-clock/GC-observing APIs).

* Document and lock in why async crypto.subtle methods cannot break quiescence

The remaining async subtle methods reject immediately through the crypto
proxy (brand check — the receiver is not a real SubtleCrypto), so they can
never mint a host-timing promise. Narrow the quiescence comment to what the
code actually enforces and add a test so the unreachability is not silently
"fixed" later.

* simplify sandbox hardening: lean digest input conversion, async digest, explicit subtle throwers

* simplify retention: single decision site in suspension catch, steps-only allow-list gate, drop prepareForRetention param

* mark sandbox API removals as a major change

* simplify retention further: one staleness mechanism (generation bump on suspend), whole predicate in canRetainWorkflowSession, lazy hook/wait scan, prewarm on resume path

* simplify session API and tests: replace executeWorkflow overloads with replayWorkflow/resumeWorkflow, drop low-value events-consumer tests, compact session and retained-loop tests

* add parallel-batch retention test (sibling signal absorption) and document the unguarded-signaler invariant

* simplify workflow.ts types: 5 named types (WorkflowResult/WorkflowResumeResult), async resume(), rename runtime local to retainedSession

* add retention-interleaving e2e (retained/demoted/wait/hook boundaries), drop session telemetry test

* discard the retained session on every in-process 412 restart

Review finding (both panel reviewers): restartReplayInProcess — added on
main by #3145 while this branch was in flight — reset the cached log but
not the parked VM session. Any stale-snapshot continue then resumed a
session belonging to the discarded log: after a run_completed 412 the
completed session's resume() throws and the run is durably failed despite
having completed; after a suspension-create 412 the session is resumed
without ever passing the retention decision, bypassing both the
WORKFLOW_RETAINED_VM kill switch and the step-input gate. A restart now
always falls back to a fresh replay. Regression test injects a 412 on
run_completed and proves fresh-replay completion (red without the fix).

* review round 2: set suspensionGeneration in typed test harness contexts; correct the open-hook/wait scan comment (this suspension's writes are not merged into the cached log — non-step suspensions never reach the scan)

* simplify pass: reuse once() from @workflow/utils for the open-hook/wait memo; drop optional-chaining that contradicted the surrounding guards

---------

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-08-03 15:49:02 -07:00
github-actions[bot] bf4a591f12 Version Packages (beta) (#3256)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
workflow@5.0.0-beta.39
2026-08-03 13:36:21 -07:00
Mitul Shah d06b55e641 Rename new-trace-viewer to trace-viewer (#3298)
* Rename new-trace-viewer to trace-viewer.

Move the directory, rename NewTraceViewer to TraceViewer across web-shared and web, and update the build script and README.

Signed-off-by: mitul-s <mitulxshah@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix TraceViewer import ordering

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

---------

Signed-off-by: mitul-s <mitulxshah@gmail.com>
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:34:48 -04:00
Mitul Shah 951695ba2a Remove the legacy trace viewer in favor of NewTraceViewer. (#3296)
Drop RunTraceView and WorkflowTraceViewer, move shared Span/Trace types into lib/trace-types, and keep timing helpers under the new viewer.

Signed-off-by: mitul-s <mitulxshah@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:34:48 -04:00
Greg Schofield 5d986d021a Swap python team for individual members (#3303)
* Swap python team for individual members

Signed-off-by: gscho <greg.c.schofield@gmail.com>

* Update .github/CODEOWNERS

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Greg Schofield <greg.c.schofield@gmail.com>

* Update .github/CODEOWNERS

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Greg Schofield <greg.c.schofield@gmail.com>

---------

Signed-off-by: gscho <greg.c.schofield@gmail.com>
Signed-off-by: Greg Schofield <greg.c.schofield@gmail.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-08-03 20:33:30 +00:00
Karthik Kalyan 27d0ce7904 Route preview benchmarks through the e2e server (#3274)
* Expose Workflow web server override

Signed-off-by: Karthik Kalyanaraman <karthik.kalyanaraman@vercel.com>

* Use neutral workflow server test URL

Signed-off-by: Karthik Kalyanaraman <karthik.kalyanaraman@vercel.com>

* Route preview benchmarks through e2e server

Signed-off-by: Karthik Kalyanaraman <karthik.kalyanaraman@vercel.com>

* Use an empty changeset

Signed-off-by: Karthik Kalyanaraman <karthik.kalyanaraman@vercel.com>

---------

Signed-off-by: Karthik Kalyanaraman <karthik.kalyanaraman@vercel.com>
2026-08-03 12:54:27 -07:00
Nathan Rajlich aa78a7e63c world-local: converge redelivered hook_received re-ensure on its committed resumeId claim (#3297)
A redelivered re-ensure of an already-committed resume (same runId +
resumeId + digest) was rejected with HookNotFoundError when the hook had
since been disposed by the workflow (dispose -> sleep releases the
token while the run continues). The queue consumer treats HookNotFound
as 'nothing left to resume' and acks the delivery — silently dropping
whatever continuation the redelivered message carried and wedging the
run.

Check the (runId, resumeId) claim BEFORE the disposal/existence
rejections: a committed claim whose pinned event is journaled proves
this exact resume was accepted while the hook was alive, so return that
event as success. Claims with a mismatched hookId or payload digest
still fall through to full validation and are rejected as before, as
are genuinely new resumes of a disposed hook.
2026-08-03 12:43:20 -07:00
Mitul Shah dc4cf944ae Align web-shared typography with Geist tokens (#3294)
* fix(web-shared): align typography with system tokens

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

* test(web-shared): enforce typography tokens

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

* refactor(web-shared): simplify typography audit

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

* test(web-shared): cover typography guard failures

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

* test(web-shared): narrow typography guard

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

* test(web-shared): remove typography guard

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-03 11:58:17 -07:00
Nathan Colosimo 679dfa9c15 simplify hardened serialization: assert intrinsic captures, drop optional-capture states, close the bound-getter reporting hole (#3288)
- Every captured intrinsic exists on all supported engines (Node 18+), so
  the optional-capture layer (intrinsicGetter-returns-undefined, canReadUrl/
  canReadUrlSearchParams/canReadHeaders, per-use fallbacks) is replaced by
  captures that throw at import if absent.
- URLSearchParams.prototype.size (the one genuinely missing member on Node
  18) is not needed: emptiness falls out of the captured toString() result,
  which the reducer already computes. Node 18 now serializes URLSearchParams
  natively instead of falling back to devalue's default handling.
- The call/get tables and their re-export aliases flatten into direct typed
  exports; readProxyAware and the viewInfo getter-indirection unroll into
  two-branch functions.
- isEngineAccessor: drop the WeakMap memo and try/catch (descriptor getters
  are always callable); exclude bound functions, which stringify as native
  code but run their target — previously workflow code could launder a
  side-effectful getter past the report with fn.bind() (test added).
- 763 -> 625 lines, byte output unchanged (parity checked for DataView and
  typed-array subviews on top of the existing test suite).
2026-08-03 11:26:36 -07:00
Alex Langenfeld a799025af9 Surface a Request ID in the run sidebar attribute panel (#3293)
## Summary & Motivation

One warm compute instance serves many invocations, so the Compute Instance ID already in this panel can't distinguish steps that ran inline within a single flow-function invocation — sibling steps sharing a Request ID did. It's also the value Vercel Logs indexes by, and this panel's View Logs button is where a reader takes it next.

The key is `vercelId`, not `requestId`: that's the name world-vercel stores the SDK's request id under crossing the wire, and AnalyticsEvent's sibling `requestId` field is declared but never written. Nothing in this repository populates `vercelId` on the object the panel receives yet — only AnalyticsEvent carries it, and grouping steps by invocation needs a step-level aggregation in workflow-server first — so the row ships as forward-compatible plumbing in the slot next to Compute Instance ID.

The second commit is an independent fix: `sortByAttributeOrder` guarded `indexOf` with `|| 0`, but a miss returns -1, which is truthy, so any key absent from `attributeOrder` sorted ahead of every listed key. It can be dropped on its own.

## Test Plan

Unit tests added; the two ordering assertions fail against the unfixed comparator. The new row could not be verified by hand — no local code path populates it.
2026-08-03 13:05:09 -05:00
Shalabh Chaturvedi ba2cddc861 [benchmarks] Log the run id and Datadog trace for each sequential-steps run (#3248)
* [benchmarks] Link the run id and Datadog trace under the STSO histograms

The STSO distribution section added in #3213 shows the shape of the
sequential-steps run but not which run produced it, so investigating an
odd-looking bucket meant hunting for the run by deployment id and time
window.

Capture the identity alongside the samples (the mechanism prototyped on
the WIP variance branch, #3107): `/api/bench` returns the trace id of the
span @vercel/otel opened for the trigger request, the runner threads it
through the sequential iteration and records `sequentialRuns` in the
result file, and the renderer prints one line under the histograms with
the run id + Datadog trace link for this run and for the `main` run it is
diffed against.

Every part is optional — a deployment predating the route change yields a
bare run id, and a `main` baseline predating this yields only this run's
side — so the section degrades instead of breaking on mixed-vintage
artifacts.

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

Co-Authored-By: Shalabh Chaturvedi <7066873+shalabhc@users.noreply.github.com>

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

* Log the run/trace links instead of rendering them into the comment

The run id and Datadog trace are debugging aids, not part of the
benchmark's reported result, so they belong in the job's own output
rather than in the PR comment body.

Logging them where the runs are produced also makes them available in
two cases the comment could never cover: a local `pnpm bench`, and a
job that fails before the comment step runs.

This drops the comment-rendering side entirely -- `renderSequentialRunLinks`,
the `baselineSequentialRuns` baseline plumbing in `annotateWithBaseline`,
and the `sequentialRuns` field on the result artifact, which existed only
to carry the data to the renderer.

The `main`-baseline side of the link goes away with it: which run produced
the baseline histogram is only knowable at comment-render time, where the
two artifacts are matched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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

* Say what the trigger trace actually contains under linked mode

The route comment claimed the trigger request's span "propagates into
the workflow's own spans". That only holds under
WORKFLOW_TRACE_MODE=continuous. Nothing in the workbench or
benchmarks.yml sets the mode, so the benchmark deployment runs the
default `linked` (packages/core/src/telemetry.ts), where each
workflow/step invocation is its own trace root and the trigger's trace
carries `workflow.start` plus span links out to those roots.

The logged link is still the right entry point -- one hop through the
links, which Datadog renders -- but the comment should describe that,
so nobody opening a trigger-only trace while debugging a histogram
concludes the run produced no spans.

Raised by @TooTallNate in review of #3248.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-Authored-By: Shalabh Chaturvedi <7066873+shalabhc@users.noreply.github.com>

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

* Log a Datadog span search alongside the trigger trace link

Under the default linked trace mode the trigger's trace holds only
`workflow.start` plus span links, so opening it lands one hop away from
the spans an STSO investigation needs. Log an APM search on
`@workflow.run.id:<runId>` next to it, which goes straight to the run's
execution spans.

Both links are logged rather than one replacing the other: the search
depends on `workflow.run.id` being an indexed span tag in the org, and
the permalink works regardless.

Suggested by @TooTallNate in review of #3248.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-Authored-By: Shalabh Chaturvedi <7066873+shalabhc@users.noreply.github.com>

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

---------

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-08-03 08:46:53 -07:00
Karthik Kalyan 31f92df10d Lazy hook resumption: parallel event write + queue publish (#3230)
* feat(core): lazy hook resumption via parallel event write + queue publish (rebased onto #1834 + #3145)

Rebase of #3230 onto current main (267765375 + #1834 resilient resumeHook
+ #3145 event-count-gated replay restart). Reconstructed as a single commit
since `git rebase -i` is unavailable in this environment.

Reconciliation vs the pre-rebase branch:
- Replaces #1834's version-prediction (`supportsQueueHookInput`,
  `QUEUE_HOOK_INPUT_MIN_VERSION`) with #3230's capability protocol
  (persisted `hookResumeInputVersion` + static `hookResumeDedup`).
- One idempotency protocol: a single `resumeId` + SHA-256 payload digest
  per resume, sent to both the direct event write and the queue `hookInput`.
- Two execution tiers: backend+consumer attest dedup -> parallel
  `Promise.allSettled(event write, queue publish)`; otherwise plain
  sequential (no hookInput/resumeId, event-write errors propagate).
- Consumer re-ensures the `hook_received` event (keyed by resumeId/digest)
  after event loading, before replay; skips when already preloaded.
- Preserves #3145: event-count guard, `preconditionReinvocations`,
  in-process replay restart, `insertEventByEventId`.
- Removes #1834's resumeId-only test (never released); adds parallel +
  consumer-preload + world-local dedup/producer-consumer suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(core): read top-level event.resumeId in replay dedup; reconcile unreleased #1834 docs/changeset

- hook.ts: dedup hook_received replay on top-level event.resumeId (the
  backend now hoists it to a first-class column), with the legacy nested
  eventData.resumeId retained as a deprecated parse-only fallback.
- workflow.test.ts: cover dedup across both top-level and legacy nested forms.
- resume-hook.ts: emit producer recovery telemetry when a transient
  event-write failure is swallowed on the parallel path.
- resume-hook.consumer-preload.test.ts: add terminal-run (consume) and
  transient-conflict (rethrow/redeliver) re-ensure cases.
- Consolidate the two overlapping changesets into resilient-resume-hook.md
  and delete the redundant lazy-hook-resumption.md.
- Docs: return type back to Promise<Hook> (resume-hook.mdx), rewrite the
  resilience changelog to the final parallel/deduplicated design, and correct
  the WORKFLOW_DISABLE_LAZY_HOOK_RESUME resilience wording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs,core: rename "Resilient hook resume" → "Lazy hook resume" for consistency

- changelog/index.mdx: update the changelog entry title.
- hook.ts: update the dedup comment label to "Lazy-resume dedup".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: give #3230 its own changeset instead of repurposing #1834's

The lazy-hook-resume work had been folded into #1834's pre-existing
`resilient-resume-hook.md` changeset. Give this PR its own changeset and
delete the superseded #1834 one, whose `resilientResume: true` flag promise
no longer holds (resumeHook() returns plain Promise<Hook>).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: add #3230's own lazy-hook-resumption changeset

Follow-up to 63d877178, which deleted #1834's superseded changeset but did
not stage the replacement. Adds this PR's own changeset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: tighten lazy-hook-resumption changeset

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: leave #1834's resilient-resume changeset/changelog/docs untouched

Restore #1834's own artifacts that #3230 had rewritten:
- .changeset/resilient-resume-hook.md (restored verbatim)
- docs/.../changelog/resilient-resume.mdx (restored verbatim)
- docs/.../changelog/index.mdx (restored verbatim)

#3230 keeps only its own changeset plus the two docs its code/config genuinely
require: the resumeHook() Promise<Hook> return type (ResumedHook is removed
from the code) and the new WORKFLOW_DISABLE_LAZY_HOOK_RESUME env var.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Reconcile #1834 ResumedHook contract with #3230 parallel resume

Preserve the resilient-resume contract from #1834 on the parallelized
resumeHook() fast path instead of dropping it:

- Restore the `ResumedHook` type (Hook + optional `resilientResume`) and its
  exports (`@workflow/core/runtime`, `workflow/api`); resumeHook/resumeHookImpl
  return `Promise<ResumedHook>`.
- Set `resilientResume: true` on the swallow-recover branch (transient direct
  write failure + successful queue dispatch), absent on the happy/sequential
  paths.
- Restore the producer OTEL convention `workflow.hook.resilient_resume` and the
  consumer `workflow.hook.resilient_resume_materialized`, wired where the
  consumer re-ensures the event.
- Restore the consumer `occurredAt` derivation from the resume ULID so the
  materialized hook_received is dated to resume time, not queue-round-trip time.
- Fix the #3230 changeset's contradictory "Still returns Promise<Hook>" line and
  update the resilient-resume changelog + resume-hook API reference to the
  shipped parallel/dedup behavior.
- Port the #1834 failure-path coverage into resume-hook.parallel.test.ts
  (non-retryable event-write rethrow, both-fail prioritizes the queue error,
  resilientResume flag + payload delivery on the recovered path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address review: drop dead nested resumeId fallback, remove server PR link

- Drop the legacy nested `eventData.resumeId` fallback in the hook consumer.
  The nested form was only ever written by unreleased preview builds and is
  stripped by `EventSchema` parsing (the `hook_received` eventData schema does
  not declare it), so the fallback was dead code. Dedup now keys solely off the
  top-level `event.resumeId` column. Repoint the replay dedup test to the
  surviving top-level path (it previously exercised the nested form only by
  building unparsed Event objects in memory).
- Remove the internal workflow-server PR reference from world-vercel's
  capability note (the link 404s outside the org); the note keeps the same
  information without the dead link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 08:43:48 -07:00
Peter Wielander 4174a6ea73 [ci] Shrink the event-log race repro job 100x and add a local world-postgres runner (#3273) 2026-08-01 10:59:07 -07:00
Nathan Rajlich b732e91fac feat(core): side-effect-free serialization of workflow VM values (#3257)
* 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.
2026-08-01 10:11:06 +00:00
Pranay Prakash ee944d2476 feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side (#3244)
* feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side

`start()` makes two writes that have to land in the same tenant: the
`run_created` event, attributed to whatever environment the caller
authenticates as, and the queue message, pinned to a deployment. A
misconfigured caller can split them — writing the run to one environment
while addressing the message to a deployment in another. The consumer
finds no run under its own tenant, the backend's resilient start
(`run_started` creates the run when `run_created` was never seen) mints a
second copy of the same run id in the consumer's environment, and both
copies are real: the creator's sits pending forever while the other
executes.

The deployment id is not the discriminator — it matched end to end in the
incident that motivated this. The environment is. So carry it: add an
optional `World.getEnvironment()`, implement it in world-vercel from the
same resolution that produces the `x-vercel-environment` header, and stamp
it into the queue message's `runInput`.

The consuming deployment already knows its own environment, so it can
refuse the delivery itself with no server coordination — and refuse before
`run_started`, the write that would create the fork. The refusal acks the
message instead of throwing: the mismatch is baked into the message, so
every redelivery would reach the same verdict and throwing would hot-loop
until MAX_QUEUE_DELIVERIES.

Both sides must be known for the check to run, so worlds with a single
tenant (local, Postgres) and runs started by an older SDK behave exactly
as before. A companion diagnostic logs a deployment-id mismatch without
refusing, since deployment ids differ for benign reasons too.

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

* fix(world-vercel): resolve the runtime environment from VERCEL_TARGET_ENV

For a deployment in a Vercel custom environment, the OIDC token's
environment claim is the custom environment's slug (the platform mints
`customEnvironment?.slug ?? envTarget`) while VERCEL_ENV reports
'preview' — so keying the cross-environment guard on VERCEL_ENV could
false-refuse a legitimate delivery, e.g. a CLI client attributed to
'staging' starting a run on the staging deployment. VERCEL_TARGET_ENV
is populated from exactly the same slug-or-target pair as the claim, so
prefer it, keeping VERCEL_ENV as the fallback for contexts that don't
inject it. Also sorts runtime.ts imports per the Biome rule that landed
on main in #3241.

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

---------

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
2026-07-31 15:53:33 -07:00
Peter Wielander 1471f252fa [core] Gate event creation on the loaded event count and restart replays in-process (#3145) 2026-07-31 14:27:43 -07:00
Nathan Rajlich 438eaa6a59 Make resumeHook() resilient to transient hook_received event write failures (#1834)
* Make resumeHook() resilient to transient hook_received event write failures

When events.create('hook_received') fails with a retryable error (429/5xx),
resumeHook() now dispatches the queue message with a `hookInput` payload
carrying the dehydrated hook payload. The workflow runtime materializes the
missing hook_received event from that payload on its next delivery, mirroring
the existing resilient-start behavior of start() / run_created / run_started.

Returned Hook carries a new `resilientResume: true` flag when the fallback
path was taken. Both write paths share a client-minted `resumeId` as an
idempotency key so the runtime can dedup if the direct write actually
committed but the client saw a transient error.

Uses a sequential write-then-queue flow (not parallel) to avoid a dedup race
on the happy path: hook_received events have no entity-level conflict guard
(unlike run_created), so a duplicate written before the direct write commits
would double-deliver the payload to the workflow.

* Fix resilient resume: use local payload in materialized hook_received event

The server returns a 'lazy' response for hook_received event creation,
where eventData.payload may be a RefDescriptor (when the payload
exceeded the inline size and was offloaded to blob storage) rather
than the raw bytes. Pushing this directly to the in-memory events
array caused the workflow VM to fail with 'Invalid input' when trying
to deserialize the RefDescriptor as a Uint8Array.

Substitute the eventData we already have locally so the in-memory
event matches what getWorkflowRunEvents would return after
client-side ref hydration.

* Gate resilient resume on target runtime capability; carry hook token; export ResumedHook; docs

- Only take the resilient path when the target run's recorded
  @workflow/core version understands hookInput on the queue payload.
  Runs keep executing on the deployment they were created on (skew
  protection), and older runtimes parse the queue message with a schema
  that silently strips unknown fields - the resume payload would be
  lost while resumeHook() reported success. Fail fast (propagate the
  original event-write error) for such runs instead, preserving the
  caller's ability to retry.
- Carry the hook token on hookInput and write it into the materialized
  hook_received event so it gets the same replay-divergence guard as a
  directly written event (#2030 parity).
- Export ResumedHook from @workflow/core/runtime and workflow/api.
- Add changelog page and update resumeHook() API reference docs.

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

* Address review: correct capability cutoff, drop own-version escape hatch, replay-side resumeId dedup

Review fixes for the resilient-resume capability gate and dedup:

- Bump the supportsQueueHookInput cutoff to 5.0.0-beta.39: 5.0.0-beta.38 is
  published WITHOUT this feature (its queue-payload schema strips hookInput),
  so classifying it as capable would silently lose resume payloads. The
  cutoff is now a single exported constant (QUEUE_HOOK_INPUT_MIN_VERSION)
  with a TODO(release) requiring re-verification at merge time.
- Remove the own-version exact-match escape hatch entirely: version strings
  do not identify builds (a published beta.38 and a main-built tarball can
  share a version string while differing in content), so the check could
  declare a featureless published deployment capable. Pre-release builds now
  fall back to fail-fast until the version is bumped past the cutoff — the
  safe direction. Tests simulate a capable target explicitly.
- Make duplicate suppression authoritative at the replay boundary: replay
  now dedups hook_received events sharing a resumeId (same resume attempt),
  so even when concurrent redelivery of the same queue message
  double-materializes the event (no World enforces uniqueness on
  hook_received), the payload reaches workflow code exactly once. This is a
  pure function of the persisted log, keeping replay deterministic. The
  runtime's snapshot check remains as best-effort write suppression, with
  its comment corrected to say so; the EntityConflictError catch is kept as
  the forward-compatible signal for planned server-side (runId, resumeId)
  uniqueness, with its comment corrected to say it is defensive today.
- Stamp materialized hook_received events with occurredAt decoded from the
  resumeId ULID so resiliently-resumed hooks are timestamped at resume time
  rather than after the queue round-trip.
- Pin the cross-version compat contract in a test: the direct write is
  resumeId-only (no digest or negotiation fields), which later server-side
  idempotency work must keep accepting.
- Exercise the published boundary (5.0.0-beta.38) in fail-fast tests, and
  make the capability tests self-check against the exported cutoff constant
  instead of restating literals.
- Docs: changelog date June -> July 2026, dash consistency, and document the
  replay-side dedup guarantee.

* Encode release-gate and successor-rebase contracts into code comments

Comment-only changes capturing the review agreements so they survive the
parallel-resume successor rebase (no behavior change):

- capabilities.ts: the QUEUE_HOOK_INPUT_MIN_VERSION re-verification point
  is the actual combined SDK release (after the successor lands and its
  server-side dedup is deployed), not source-merge time — this PR merges
  source-only and no SDK is published from it alone. Every Version
  Packages merge in between moves the earliest possible carrier.
- workflow/hook.ts + runtime.ts: scope the replay-side resumeId dedup
  honestly as defense-in-depth over the persisted log, not a
  cross-invocation exactly-once guarantee — concurrent invocations
  replaying pre-duplicate snapshots each see only their own row; the
  storage-level (runId, resumeId) constraint in the successor work is the
  correctness boundary. The set stays useful post-constraint for logs
  written before it deployed.
- runtime.ts: document the EntityConflictError swallow's known gap while
  the branch is defensive (this invocation's local log lacks the payload;
  progress relies on the other writer's delivery or redelivery) and pin
  the rebase contract for when the constraint makes it live: a matching
  claim must append the canonical event locally and succeed; a real
  conflict must rethrow for redelivery.
- resume-hook-resilient.test.ts: reframe the wire-shape pin as a tripwire
  rather than a permanent contract — the successor deliberately widens it
  (ID/digest pair + attestation) before any SDK release, so the
  resumeId-only shape never ships as a published server contract.

---------

Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:45:54 +00:00
Alex Langenfeld 4017597a5f feat(core): report replay divergence recovery (#3208)
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
2026-07-31 14:37:46 -05:00
christopherkindl 3c7875ad73 [docs] upgrade @vercel/geistdocs to 1.19.0 (#3170)
* chore: upgrade @vercel/geistdocs to 1.17.1

Picks up the new footer (Footer no longer takes a config prop), the
heading font-weight change to 450, and the tightened navbar OSS-menu
marks. Also switches the site's own navbar logo from the vendored
geistcn LogoWorkflow fallback to the package's LogoWorkflowSdk, using
its new tuned default height instead of a hardcoded 15.

Fixes a resulting regression: navbarOssProducts entries lacked an `id`,
so resolveOssProducts' `product.id !== activeProduct` filter evaluated
to `undefined !== undefined` (false) for every entry and emptied the
OSS flyout. Added stable `id`/`label` values to each entry.

* refactor: use default navbarOssProducts list instead of a custom override

The manual navbarOssProducts array (with local logo imports/heights) is
no longer needed now that the package's DEFAULT_OSS_PRODUCTS list
already includes all these SDKs with proper id/label/section values.
navbarActiveProduct: 'workflow-sdk' now handles self-exclusion instead.

* fix: use bg-background-200 for docs surfaces to match the template

The docs, cookbook, and v5 route layouts plus the shared DocsLayout
container hardcoded bg-background-100 (pure white), so /docs/* pages
rendered on a lighter surface than the rest of the site. Switch them to
bg-background-200, matching the geistdocs template's page background.

* style: adopt package text-heading-* utilities for homepage headings

The marketing homepage headings hardcoded font-semibold (weight 600)
plus manual responsive sizes/tracking, so they rendered heavier than
the docs headings that now use Geist's 450 heading weight. Swap each
display heading to the package's text-heading-* utilities, which bundle
the 450 weight, line-height, and tracking, mapped across breakpoints to
the nearest design-system size. Inline label/emphasis spans keep their
own weight.

* style: adopt package text-heading-* utilities for worlds headings

Extends the homepage heading change to the /worlds section: the world
listing, detail, compare, and building-a-world pages plus their
components hardcoded font-semibold display headings. Swap each to the
package's text-heading-* utilities (450 weight + line-height +
tracking), mapped across breakpoints to the nearest design-system size.
Mono stat numbers, per-benchmark item labels, and the dialog title keep
their own weight.

* style: remove the bordered grid framing from the homepage

The homepage sections were wrapped in a grid divide-y border-y sm:border-x
container, drawing side borders and divider lines between every section.
Drop that framing so the sections flow with whitespace separation.

* style: remove vertical column dividers from homepage sections

Drop the divide-x column dividers still drawn inside the use-cases
(3-col), feature-grid (2-col), and templates sections, so no vertical
lines remain after the section-grid removal. Section padding keeps the
columns visually separated.

* style: make the homepage "Get started" CTA button rounded-full

* style: use text-heading-* for the feature-grid paragraph text

The two 2-col feature blurbs ("Deep integration with AI SDK.",
"Durable agents by default.") hardcoded their size/leading/tracking
plus font-medium/font-semibold weights. Those manual sizes already
equal text-heading-20/24, so swap to text-heading-20 lg:text-heading-24
— same sizes, but the Geist 450 heading weight (lead drops 600 -> 500
via the utility's [&>strong] rule). The lead stays gray-1000 for
emphasis; body stays gray-900.

* style: fade out the run-anywhere provider logos at the left/right edges

Add linear-gradient masks to the flanking cloud-provider logo groups in
the "Run anywhere, no lock-in" viz so they fade to transparent toward
the outer edges, leaving the centered code block untouched.

* style: widen the right-edge fade on the Vercel dashboard viz

The "Workflow SDK on Vercel" dashboard is offset off the right edge, so
the existing to_left black_10% mask fell off-screen and the visible
right edge hard-clipped. Widen it to black_40% so the dashboard fades
out gradually at the visible right edge.

* style: add spacing between the Vercel, use-cases, and templates sections

Wrap the UseCases and Templates sections with a top margin so there's
clear separation between "Workflow SDK on Vercel", "Build anything with
AI Agents", and "Get started" now that the section dividers are gone.

* style: widen the homepage layout from 1080px to 1200px

* style: align use-cases code block and templates cards with the Vercel section

Switch the "Build anything with" and "Get started" sections from
grid-cols-3 / [1fr_2fr] to [1fr_1.5fr], matching the "Workflow SDK on
Vercel" section above so their code block and cards share the same
right-hand column. The wider text column also lets "Build anything with"
sit on one line. Normalize both to outer padding + column gap so the
code block and cards line up exactly.

* style: extend use-cases/templates content to the right layout edge

Drop the right padding at md+ (md:pr-0) so the code block and template
cards reach the same right edge as the "Workflow SDK on Vercel"
dashboard above, which bleeds to the container edge. Mobile keeps its
padding.

* style: remove the divider between the two feature cards

Drop divide-y/lg:divide-y-0 from the feature grid so no border shows
between "Deep integration with AI SDK" and "Durable agents by default".

* refactor: position homepage sections on a shared 12-col grid

Replace the ad-hoc [1fr_1.5fr] + md:pr-0 + lg:pl-* positioning on the
Vercel, use-cases, and templates sections with a shared grid-cols-12
layout (text col-span-5, visual col-span-7), matching the vercel.com
marketing grid convention. The Vercel dashboard becomes a proper grid
cell instead of an absolutely-offset right-bleed, so all three
sections' visuals align by the grid columns with no magic values.

* style: align homepage width with the navbar content

Widen the homepage container from max-w-[1200px] to the site's
max-w-[1448px] (matching the navbar/footer) and reduce the section
gutters from sm:px-12 to sm:px-6, so section content lines up with the
navbar's content edges (right edge flush at the same column as the
navbar and footer). Also convert the "Reliability-as-code" section to
the shared grid-cols-12 layout (col-span-5 text / col-span-7 code
example), replacing its lg:grid-cols-[330px_1fr] magic values.

* refactor: handle homepage horizontal padding at the root container

Move the mobile/desktop gutter (px-4 sm:px-6) onto the homepage root
container and remove the horizontal padding from every section
component. Section content still aligns with the navbar/footer content
edges, but the gutter is now defined once instead of repeated per
section. Inner-element padding (tab buttons, visual internals) is
unchanged.

* style: left-align content sections on mobile + fix run-anywhere/o11y viz

- Left-align the centered content sections on mobile only (FeatureCardWide,
  TweetWall heading, Frameworks, Run-anywhere heading/buttons), restoring
  their centered layout at sm and up.
- Make the "Inspect every run" timeline span edge-to-edge by shifting its
  gantt from a 14-col grid (content in cols 2-13) to a flush 12-col grid.
- Constrain the run-anywhere viz cluster to the code block width so the
  provider cards (AWS/Docker/etc.) overlap behind the code block again.

* style: anchor run-anywhere provider cards to overlap the code block

Position the flanking provider-card groups relative to the centered
code block (right/left calc(50%+140px)) instead of the section edges,
so the cards sit behind and overlap the code block regardless of the
section width.

* style: make the reliability-as-code example fill its column to the right edge

Drop max-w-3xl mx-auto from the workflow/non-workflow code examples so
they fill the col-span-7 cell, aligning the code block's right edge with
the layout's right content edge (matching the tabs and other sections).

* style: split feature-card copy into a title + description

Break the AI SDK / durable-agents feature blurbs into a heading and a
separate muted description with a gap (matching the other sections)
instead of one inline paragraph, and drop the trailing periods from the
feature titles so they read as headings.

* update

* update

* style: give the tweet cards a bg-background-100 surface

* fix(swc-playground): pin monaco-editor to 0.55.1

The lockfile refresh resolved the unpinned `monaco-editor: "latest"` from
0.55.1 to 0.56.0, breaking the workflow-swc-playground Turbopack build.

0.56.0 rewrote its exports map to reroot subpaths under `esm/vs/`
("./*": "./esm/vs/*.js"). monaco-vim@0.4.4 deep-imports
`monaco-editor/esm/vs/editor/editor.api` and
`.../common/commands/shiftCommand`, which now map to
`esm/vs/esm/vs/...` — a path that does not exist. Under 0.55.1
("./*": "./*") both specifiers resolve to real files.

monaco-vim 0.4.4 is the latest published release, so pinning
monaco-editor is the only available fix.

* update

---------

Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com>
2026-07-31 10:21:15 -07:00
Pranay Prakash 11dc036854 ci: stop deploying changeset-release/main, run its e2e against production (#3243)
* ci: stop deploying changeset-release/main, run its e2e against production

The changesets action force-pushes `changeset-release/main`, and it can
point at exactly main's HEAD SHA. Vercel keeps one commit status per
project per SHA, so when both a production deployment (from main) and a
preview deployment (from changeset-release/main) are built for the same
commit, whichever finishes last owns the status. On 2026-07-30 the
preview finished last, so `vercel/wait-for-deployment-action` — which
reads the deployment ID out of that status — handed production e2e runs
a preview deployment ID and forked runs across environments.

Disable git deployments for that branch in every Vercel project rooted
in this repo, and give the changeset PR's Vercel e2e lanes a deployment
to test that actually exists: main's production deployment for the PR's
base SHA, resolved by SHA so a mid-flight production build is waited out
rather than silently replaced by an older one.

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

* ci: resolve changeset-release e2e deployments with the wait action, tokenless

Per review: with changeset-release/main no longer deployed, main SHAs
can never again be deployed to a second environment of these projects,
so the per-SHA commit status the action reads is unambiguous for
exactly this lane. Reuse vercel/wait-for-deployment-action with
environment: production and sha pinned to the PR base SHA instead of
the Vercel-API polling script, drop the script and its VERCEL_TOKEN
usage, and inherit the action's inactive/skipped-build handling.

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

---------

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
2026-07-31 10:09:36 -07:00
Andrew Barba 2677653759 fix(world-local): bound stalled queue deliveries (#3255)
Signed-off-by: Andrew Barba <barba@hey.com>
2026-07-31 08:27:35 -07:00
Peter Wielander a54f2b1486 Sort imports in runtime.ts and step-executor.ts (#3241) 2026-07-30 17:20:56 -07:00
Nathan Rajlich 32ac8e73fd Fix Biome lint violations and add Biome CI check (#3222)
* Fix Biome lint violations and add Biome CI check

Biome was not configured to respect .gitignore, so ~92% of the 13,355
reported diagnostics came from gitignored build artifacts. Enable VCS
integration (useIgnoreFile), apply safe auto-fixes across the repo, fix
the remaining mechanical errors by hand, downgrade judgment-call a11y /
dangerouslySetInnerHTML rules to warnings, and add a 'biome ci' job to
the Lint workflow so violations block PRs going forward.

* Use an empty changeset (no behavior change, no release needed)
2026-07-30 22:32:12 +00:00
Alex Langenfeld 4a9d26b1cb feat(world): persist the compute instance that ran each step attempt (#3186)
* feat(world): persist the compute instance that ran each step attempt

Add CreateEventParams.computeInstanceId (ambient per-event identity, mirroring requestId) and a readable Event.computeInstanceId. Core stamps it on every step_started write; world-vercel forwards it in the v4 frame meta next to vercelId. Lets observability distinguish steps sharing a compute instance from those on different instances or invocations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>

* test: cover computeInstanceId threading from params to v4 frame meta

world-vercel: computeInstanceId reaches the v4 frame meta, rides alongside vercelId rather than replacing it, and is omitted when unset. core: step_started carries it without displacing the stateUpdatedAt precondition guard (both share one params object).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>

* fix(web-shared): render computeInstanceId in the attribute panel

AttributeKey derives from keyof Event, so adding computeInstanceId to the event schema widened it and left the exhaustive attributeToDisplayFn map incomplete (TS2741). Renders it beside deploymentId as 'Compute Instance ID', copyable like the other opaque ids.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>

* fix(world-postgres): exclude computeInstanceId from the events column contract

The events table asserts satisfies DrizzlishOfType<...Omit<Event, 'occurredAt'>...>, so adding computeInstanceId to the event schema broke the build (TS1360). This world does not persist it, matching how occurredAt is already handled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>

* refactor: move computeInstanceId to the analytics read contract

The server routes computeInstanceId into ClickHouse and returns it on AnalyticsEvent/AnalyticsStep, never on the event record — so Event.computeInstanceId was dead on read and zod would strip the field off the analytics wire. Move it to AnalyticsEventSchema/AnalyticsStepSchema (beside vercelId/requestId, the same class of ambient provenance), which also drops the world-postgres column-contract exclusion entirely.

Also: hoist the duplicated step_started params into one local, extract the repeated mock-agent harness in events.test.ts, and use vi.spyOn plus an identity assertion against COMPUTE_INSTANCE_ID.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>

---------

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:20:18 -05:00
Shalabh Chaturvedi 8bda7cef79 [benchmarks] Split STSO by inline vs queue-hop steps, add distribution diffs vs main (#3213)
* Split STSO by inline vs queue-hop steps, add distribution diffs vs main

The sequential-steps benchmark's STSO metric mixed two unrelated
phenomena: gaps between steps running back-to-back in the same warm
process, and gaps across an invocation boundary (queue dispatch, client
reinit, event-log replay), which cost ~10x more. The old step-index
windows (1-20 / 101-120 / 1001-1020) sampled 19 gaps each and captured
neither cleanly: whether a boundary happened to land inside a window
moved that window's P99 by hundreds of percent, which is most of the
run-to-run variance the benchmark comment was reporting.

The workflow now tags each step with whether it was the first step body
executed in its process ('queue-hop') or a later one in the same warm
process ('inline') via a process-global, so the split is ground truth
rather than inferred from step index or trace timestamps. STSO is
reported as two rows over *every* gap in the run instead of three
sampled windows. No targets on the new rows — the old ones described the
index-bucketed grouping.

computeStats now keeps the full sorted sample array alongside the
percentiles, and the comment renders a histogram + cumulative-time diff
against `main` under the table, one per STSO kind. Percentiles alone
hide how many samples moved and by how much, which is exactly where the
variance lives. Inline rows use a fixed 50ms bin width (the adaptive
width is coarse enough to hide structure inside that cluster); queue-hop
rows keep the adaptive width. Negative gaps (clock skew between two step
bodies' clocks) get their own bucket rather than being counted with the
slow tail.

Raw samples are stripped from the comment's embedded data block — ~1000
per run would exceed GitHub's comment size limit within a couple of
history entries — so the histogram renders for the current run only,
while collapsed history keeps its tables. Until this lands on `main` no
baseline has raw samples, so the section renders this run's distribution
as a single series.

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

* Clarify what stripping raw samples from the data block does not affect

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

* Drop the bucket tables; fold counts and deltas into the histogram bars

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

* Collapse the STSO distribution section into a dropdown

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

* Fix footer assertion after the dropdown wording change

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

---------

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-07-30 13:38:25 -07:00
Alex Langenfeld 9cc11f5329 feat(core): emit faas.instance span attribute for compute instance identity (#2989)
* feat(core): emit faas.instance span attribute for compute instance identity

Synthesize a per-warm-instance id (cinst_<ulid>) once at module load and emit it as the OTEL faas.instance attribute on the flow and step route spans. Vercel exposes no native per-instance id under Fluid compute, so this lets traces distinguish which compute instance handled each request. Purely additive telemetry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>

* test(core): assert faas.instance is stable across invocations

Covers the id format and, on the two-invocation warm-handler case, that both invocations report the same id — the module-scope minting contract that makes the attribute identify the instance rather than the invocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>

---------

Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 15:19:04 -05:00
Andrew Barba f05f642e89 [world-local] Retry transient EPERM unlink failures on Windows (#3215)
deleteJSON was the one mutation path in the fs layer that neither used
withWindowsRetry nor swallowed unlink errors. On Windows, unlink fails
with a share-violation EPERM while a concurrent reader briefly holds
the file open — hook polling races deleteAllHooksForRun by design — so
a transient EPERM surfaced as a failed operation, e.g. a failed
run.cancel().

Wrap the unlink in withWindowsRetry, matching the rename/link/unlink
guards the write pipeline already has. ENOENT is not in the retryable
set, so the already-deleted tolerance still short-circuits.

Signed-off-by: Andrew Barba <barba@hey.com>
2026-07-30 12:23:20 -07:00
Peter Wielander c93f6f7bd0 [world-vercel] Raise H2 receive windows on the events agent (#3212) 2026-07-30 11:54:18 -07:00
github-actions[bot] b12f248b66 Version Packages (beta) (#3185)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
workflow@5.0.0-beta.38
2026-07-30 08:40:06 -07:00
Mitul Shah e181f64b72 Align Streams UI with trace viewer (#3197)
* Align streams UI with trace viewer

Co-authored-by: Cursor <cursoragent@cursor.com>

* cleanupp

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 08:00:45 -07:00
Nathan Rajlich b92c23ccb4 fix(core): don't observe idle while a committed delivery is parked behind its deferral (#3198) 2026-07-29 16:12:45 -07:00
Peter Wielander 34975f6b7d [world-vercel] Make HTTP/2 actually multiplex on the events path (#3190) 2026-07-29 14:38:17 -07:00
Pranay Prakash 25715d4521 [RFC] feat(nitro): embed observability dashboard in-process at /_workflow (#2548)
* feat(nitro): embed observability dashboard in-process at /_workflow

Serve the @workflow/web observability UI inside the Nitro process at a
configurable route (default /_workflow) instead of spawning a separate
web server and 302-redirecting to it. Enabled in dev, omitted from
production builds by default (so prod bundles carry no @workflow/web
import). Never mounted on Vercel deploys (use the hosted dashboard).

- @workflow/web: add a framework-neutral `@workflow/web/handler`
  (createWorkflowWebHandler) that serves SSR + static client assets +
  RPC as one Web Request->Response handler under a runtime basename
  (asset manifest URLs + publicPath are reprefixed so the dashboard is
  self-contained under its mount). Add `@workflow/web/registry` for
  embedded-dashboard discovery; make the RPC/stream client basename-aware.
- @workflow/nitro: mount the handler in-process (Nitro v2 h3 + v3 native
  paths), gated by a new `dashboard` option (default = dev).
- @workflow/cli: `workflow web` / `inspect --web` defer to a running
  embedded dashboard instead of starting a redundant server; pass
  `--standalone` to force the standalone UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(nitro): normalize dashboard path once, use isNitroV2() helper

Address review feedback on the embedded dashboard:

- Normalize the dashboard mount path in one place before it feeds both
  the Nitro route registration (`[path, path + '/**']`) and the handler
  `basename`. Force a single leading slash, strip trailing slashes, and
  reject the root mount, so a custom `path` can't make the route and the
  handler's internal `normalizeBasename` disagree.
- Replace the handler-level `!nitro.routing` v2 checks with the existing
  `isNitroV2()` helper for consistent v2/v3 detection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-07-29 13:58:59 -07:00
Douglas Harcourt Parsons e8934ade9c Remove self-attribution from README (#3182)
Signed-off-by: Douglas Harcourt Parsons <dglsparsons@users.noreply.github.com>
2026-07-29 10:28:11 -07:00
Peter Wielander a09d00135b Revert "Statically inject workflow world target" (#2752) (#3142) 2026-07-29 08:55:29 -07:00
github-actions[bot] 741a0d9eaf Version Packages (beta) (#3087)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
workflow@5.0.0-beta.37
2026-07-28 17:20:21 -07:00
Pranay Prakash 2941b1c360 fix(core): order step-result deliveries against wait/hook deliveries by event-log position (#3139)
* fix(core): order step-result deliveries against wait/hook deliveries by event-log position

Two production runs on `@workflow/core@5.0.0-beta.36` burned all three
divergence-recovery replays at the same event and terminated with
CORRUPTED_EVENT_LOG:

  wrun_41KYJENABV0GSF5YTE9EETV5DD  (step vs wait)
  wrun_41KYJEE01S0GPC9RWT5MEKVCX8  (step vs hook)

  Replay divergence: step event step_created for step_X belongs to "A",
  but the current step consumer is "B"

`useStep` proxies draw deterministic ULIDs in invocation order, so the
ULID -> stepName allocation is a function of the order in which promise
resolutions are delivered to workflow code. The delivery-barrier registry
pinned that order to event-log position for hook payloads and wait
completions, but step results were delivered straight off the serial
`promiseQueue` — and their latency varies between replays of the SAME
invocation, because the first replay pays full hydration while later
replays memo-hit primitive results in the shared `ReplayPayloadCache`.
A step completion adjacent in the log to a `wait_completed` was therefore
delivered wait-first on a cold replay and step-first on a warm one;
whichever order the invocation that wrote the follow-up `step_created`
events happened to see became law, and every replay computing the other
order diverged permanently.

Step results and step failures now register a 'step' delivery barrier at
their event-log index and resolve from a detached continuation after every
relevant earlier-in-log delivery, mirroring the hook payload path:
hydration stays inside the serial queue slot (which also releases
`pendingDeliveries`), while the barrier wait and the resolve run off the
queue so a queue slot never blocks on a resolution the queue itself drives.
Waits and hook payloads likewise defer behind earlier step results.

Two details are what actually make the ordering hold, and both were found
by testing rather than by reading the code:

The deferral set is captured while CONSUMING the event, not at the start of
the hydration slot. Captured at slot start it is not merely less
deterministic, it is usually empty: an earlier delivery whose own slot runs
first on the serial queue has typically already resolved and deregistered
its barrier before the later slot begins, so the later delivery does not
defer at all. Every event in one drain window is consumed before any slot
runs, so consumption time sees all of them.

A delivery that had to wait then yields a macrotask before resolving. An
earlier delivery being "delivered" only means its `resolve()` ran; the
branch it woke may need arbitrarily many further microtask hops before it
reaches its next `useStep` call (a `for await` over a hook resumes the
generator, settles the promise from `next()`, and only then runs the loop
body). Ordering the `resolve()` calls alone therefore buys a fixed hop or
two of margin and leaves a hop-count race that holds only for the shortest
consumers; yielding a macrotask lets the earlier branch drain completely,
whatever its shape.

One asymmetry is load-bearing: a step result skips any earlier delivery
that will not resolve on its own, i.e. one blocked directly or
transitively on a buffered hook payload no consumer has claimed. Such a
payload is delivered only when the workflow next reads the hook, and
reaching that read commonly requires the step result itself, so gating the
step on it stalls the run until the barrier's idle safety net fires — which
then releases every delivery queued behind that payload at once and loses
the very race the ordering exists to protect. Waits and hooks keep gating
on unclaimed payloads, where waiting for the claim IS the guarantee.

Tests come in two files. `step-delivery-ordering.test.ts` is byte-identical
to the file in the repro-only companion PR vercel/workflow#3137 apart from
two `it.fails` markers there (which let a repro-only branch have green CI);
`sed 's/it\.fails(/it(/g' | cmp` verifies it. Each of its five cases
replays one committed log twice through a shared `ReplayPayloadCache`, and
the two warm-replay cases fail on main with the production error text.

`step-delivery-hop-count.test.ts` exists because those five cases cannot
tell "delivered in log order" apart from "resolves a hop or two later than
before". It replays logs a live run legitimately produced — the live
invocation received the two events in separate deliveries, so the first
branch finished long before the second event existed — while the replay
receives both in one drain window, and pads the consumer with a varying
number of extra awaits so hop count is the only variable. It covers step
results against both wait completions and hook payloads, plus step
FAILURES against wait completions, since a rejection decides whether a
`catch` continuation runs and so which ULID the `useStep` there draws. All
18 cases fail on main; of the 12 that predate the macrotask, 9 still fail
with the resolve-ordering-only version of this fix; all 18 pass here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

* fix(core): close remaining delivery-barrier ordering gaps

Follow-up on the step-delivery barrier work, addressing three cases the
registry did not yet cover. Each has a regression test in the new
`delivery-barrier-coverage.test.ts` that reproduces the production
`ReplayDivergenceError` when its fix is reverted.

- Step results now defer behind earlier STEP results. The old exclusion
  assumed the serial `promiseQueue` fixes step-vs-step order, which stopped
  holding once a step began resolving from a detached continuation instead
  of its queue slot: two steps consumed in different drain windows can
  disagree on their deferral set, and the earlier one — parked on the
  macrotask yield — gets overtaken.

- `sleep.ts` and `hook.ts` (waiting-consumer path) now capture their
  deferral at event-consumption time, as `step.ts` already does. Reading
  the registry after their queue work misses an earlier step or hook that
  delivered and retired its barrier in the meantime, skipping both the gate
  and the macrotask yield. The buffered hook payload path deliberately
  keeps evaluating at claim time; a consumption-time snapshot there stalls
  the e2e `hookWithSleepWorkflow`.

- Abort deliveries participate in the registry. `_setAborted` fires the
  signal's listeners, which may invoke a step and draw a ULID, so an abort
  is as branch-deciding as any other delivery.

Also memoizes `resolvesOnItsOwn`. The walk is exponential in the number of
live hook/wait barriers, and the registry is not bounded — a fan-out of
`Promise.race([hook, sleep])` branches accumulates one barrier per branch
per kind (49 measured for 24 branches). At 40 barriers a single scan took
92s before, and is instant after.

---------

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Nathan Rajlich <n@n8.io>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-07-28 15:54:50 -07:00
Nathan Rajlich 0d6ec43877 Pass the webhook request through without buffering its body (#3166)
The Astro and SvelteKit webhook wrappers copied the incoming request via
`normalizeRequest()`, which buffers the whole body with `arrayBuffer()`,
before calling the handler that validates the webhook token. Requests
carrying an unknown token therefore did unnecessary work before being
rejected.

The copy turns out to be unnecessary: both frameworks already hand the
route a standard `Request`, so the webhook wrappers now pass it straight
to the handler. The body is left untouched until `resumeWebhook()` has
accepted the token.

The flow route keeps `normalizeRequest()` for now — it authenticates via
the queue trigger rather than a URL token, so the ordering does not
matter there, and whether the shim is needed at all is a separate
question.

Note that the Astro dev server buffers request bodies upstream of the
route handler, so the new behavior is only observable in built output;
the node adapter and Vercel builds both benefit.
2026-07-28 15:08:33 -07:00
Nathan Colosimo 5d17c609b1 Reuse one event-log deduplication helper (#3110)
* refactor(core): reuse event log merge helper

* refactor(core): simplify event merge helper
2026-07-28 12:42:17 -07:00
Nathan Colosimo fba26fd9bf Correct step registration documentation (#3129) 2026-07-28 18:57:53 +00:00
Pranay Prakash 7d7effd49f docs: correct the workflow ID claim in publishing libraries (#3153)
* docs: correct the workflow ID claim in publishing libraries

The consumer re-export file does not relocate a library's workflow and
step IDs into the consumer's source tree. An ID is derived from where the
file lives, so any export-reachable package file keeps a name@version ID
whether or not it is re-exported.

Rename the section to describe what the re-export actually does — put the
package's directive files on the compiler's discovery graph and give the
entry point a resolvable address — and add the upgrade guidance that
follows from the real behavior: a package version bump renames every
workflow and step it ships, so in-flight runs must drain first.

The wrong claim also appeared in the page summary and the CopyPrompt, so
it is corrected in all three places, in both the v4 and v5 copies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

* docs: describe deployment pinning instead of drain guidance

Runs are pinned to the deployment that recorded their step IDs, so a
library version bump does not strand in-flight runs: new runs execute
the new version, in-flight runs keep replaying on their original
deployment. Replaces the incorrect drain-before-upgrade advice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

* docs: qualify deployment pinning as world-dependent

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

---------

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:42:15 -07:00
Nathan Colosimo 7959acc8cf Remove deprecated setAttributes aliases (#3128) 2026-07-28 18:36:22 +00:00
Peter Wielander 49276f2d0b [utils] Fix vercel world not being selected when running build on external CI (#3144) 2026-07-28 11:26:30 -07:00
Karthik Kalyan d24c91cfde feat(core): resume hooks from stored resumeContext and seal to the run key (#3125)
Hooks can carry an optional `resumeContext` mirrored from the run at
creation time. When present, `resumeHook`/`resumeWebhook` resume directly
from it instead of fetching the full run, saving a round trip per resume.

When the context also carries the run's `encryptionPublicKey`, the resume
seals its payload (`encp`) directly to that key. Combined with the sealed
envelope work (#3093-#3096), a default webhook resume then needs neither a
run read nor a cross-deployment run-key lookup: the key is resolved only
when the hook actually stores metadata that must be hydrated symmetrically.

Everything falls back transparently to the full run fetch and symmetric
key when the context (or the public key within it) is absent, so new
clients interoperate with old servers and vice versa.

- world: optional `encryptionPublicKey` on `HookResumeContext`
- world-postgres: `resume_context` column migration
- core: combined fast-path + seal in resume-hook; fast-path control-flow
  suite split from the real-serialization crypto suite
- world-vercel: cover the `getEncryptionKeyForRun(runId, { deploymentId })`
  overload the fast path relies on
- web-shared: render `resumeContext` in the attribute panel

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:55:26 -07:00
Peter Wielander 62c01d94b0 [e2e] Report partial results when the event-log race repro is cut short (#3148) 2026-07-28 08:32:59 -07:00