Commit Graph

211 Commits

Author SHA1 Message Date
Nathan Colosimo 4bb86d3054 feat(world-vercel): support Hook minimum retention (#3286)
* feat(world-vercel): support Hook minimum retention

* fix(core): fail deterministic Hook validation
2026-08-07 13:00:52 -07:00
Nathan Colosimo 99f4aeb03d feat(world-postgres): support Hook minimum retention (#3276)
* feat(world-postgres): retain hook tokens after runs end

* refactor(world-postgres): reuse terminal run statuses

* docs: note Postgres Hook retention support

* fix(world-postgres): expose hook retention deadline

* Fix: Exhaustive `Record<AttributeKey, ...>` in `attribute-panel.tsx` is missing the `tokenRetentionUntil` key that was added to `HookSchema`, causing TS2741 and breaking every Vercel build.

This commit fixes the issue reported at packages/web-shared/src/components/sidebar/attribute-panel.tsx:426

## Bug

Commit `ad58321` added `tokenRetentionUntil: z.coerce.date().optional()` to `HookSchema` in `packages/world/src/hooks.ts:106`. This adds `tokenRetentionUntil` to the inferred `Hook` type.

In `packages/web-shared/src/components/sidebar/attribute-panel.tsx`, `AttributeKey` is a union that includes `keyof Hook`, so `tokenRetentionUntil` becomes a required member of the **exhaustive** `Record<AttributeKey, (value: unknown, context?: DisplayContext) => ...>` object literal `attributeToDisplayFn` (starting at line ~426).

Because the literal had no `tokenRetentionUntil` entry, `tsc` fails:

```
src/components/sidebar/attribute-panel.tsx(426,7): error TS2741:
Property 'tokenRetentionUntil' is missing in type '{ ... }' but required in type
'Record<AttributeKey, (value: unknown, context?: DisplayContext | undefined) => ReactNode>'.
```

This breaks `@workflow/web-shared#build` and therefore every Vercel deployment (17 failing deployments observed, all with this identical error).

## Fix

Added a `tokenRetentionUntil` entry to `attributeToDisplayFn`, placed alongside the other Hook date fields (`lastReceivedAt`, `disposedAt`):

```ts
tokenRetentionUntil: timestampWithTooltipOrNull,
```

`tokenRetentionUntil` is a `Date` field, and `timestampWithTooltipOrNull` (defined at line 402) is the display helper used by all the other surfaced date fields (`createdAt`, `startedAt`, `completedAt`, `retryAfter`, `resumeAt`, `occurredAt`). Given the intent of `ad58321` was to expose the hook retention deadline, surfacing it as a tooltip-annotated timestamp is the consistent choice.

Only `attributeToDisplayFn` is a fully exhaustive `Record<AttributeKey, ...>`; the other maps are `Partial<...>` / `Set`, so no other edits are required.

## Verification

`node_modules` are not installed in this sandbox, so `tsc` could not be executed directly. Verified structurally instead: the newly added `tokenRetentionUntil` entry (line 449) references `timestampWithTooltipOrNull`, which is defined in-file at line 402 and already used by the sibling date entries, so the fix satisfies the missing-key requirement without introducing new type errors.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>

* docs(world-postgres): clarify expired hook rows

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

* fix(world): remove duplicate Hook retention field

* fix(web-shared): remove duplicate retention renderer

* test(world): remove redundant retention coercion case

---------

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
2026-08-03 17:42:31 -07:00
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
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
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
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 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
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
Peter Wielander a09d00135b Revert "Statically inject workflow world target" (#2752) (#3142) 2026-07-29 08:55:29 -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
Peter Wielander 04e5ec9873 [e2e] Rebuild the event-log corruption repro around step-count divergence (#3147) 2026-07-27 18:12:00 -07:00
Nathan Colosimo 62d570ed4b Remove retired v1 step route plumbing (#3061) 2026-07-24 23:50:55 +00:00
Peter Wielander 599250771d [benchmarks/ci] SO payload variants + restructured E2E Test Results comment (#3080) 2026-07-23 19:52:41 -07:00
Peter Wielander 604aecb021 [benchmarks] Add SO (stream overhead) scenario and polish test result comment (#3077) 2026-07-23 17:05:11 -07:00
Karthik Kalyan 313a074ad1 test(e2e): force storage-backed inspect listings (read-your-writes) via WORKFLOW_DISABLE_ANALYTICS_READS (#3062)
* test(e2e): poll the events readback in stepFunctionPassingWorkflow

The events listing prefers the analytics store, which ingests
asynchronously. Reading it immediately after run completion can miss
the freshest events (the page is non-empty, so the storage fallback
does not trigger), failing the step_completed assertion. Poll for up
to 20s so ingestion has time to land; the assertion itself is
unchanged.

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

* test(e2e): force storage-backed inspect listings via WORKFLOW_DISABLE_ANALYTICS_READS

The analytics store ingests asynchronously; e2e assertions read events
and steps immediately after run completion and can catch a page missing
the freshest rows (observed as stepFunctionPassingWorkflow's
step_completed readback returning empty, and the same race on steps
listings in other suites). Instead of polling each readback, disable
the analytics namespace for the e2e's CLI invocations so every inspect
listing is served read-your-writes from primary storage. Replaces the
earlier bounded poll with the deterministic mechanism.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:43:40 -07:00
Pranay Prakash 9a2770ab34 test: regression coverage for hook.resume() from isolated route bundles (o2flow beta.26 incident) (#3001)
* test: regression coverage for hook.resume() from isolated route bundles (o2flow beta.26 incident)

Reproduces the o2flow v5 upgrade failure (workflow@5.0.0-beta.26, fixed by
#2752 in beta.28): a plain API route importing defineHook() from the root
`workflow` entry and calling .resume() failed with Turbopack's
"Cannot find module as expression is too dynamic" stub, because the world
registration was tree-shaken out of the route bundle and getWorldLazy()'s
dynamic-import fallback got stubbed.

The bug only manifests when a route bundle loads in isolation (a Vercel
lambda): local `next dev`/`next start` evaluates next.config.ts, whose
workflow/next import chain registers the world process-wide and masks it —
which is why no existing server-driven suite caught it.

- route-bundle-isolation.test.ts: production Turbopack build of the
  nextjs-turbopack workbench, then loads ONLY the compiled route bundle in a
  bare Node subprocess (cold-lambda simulation) and invokes its POST handler.
  Fails with the exact incident error on regressed code; passes on main.
  Wired into the build-error-messages CI job.
- e2e: plainModuleDoneHook round-trip through a plain API route on the two
  Next workbenches (deployed matrix covers real lambda isolation).
- Workbench fixtures mirroring o2flow: a directive-less defineHook module
  shared by a workflow (create) and a plain route (resume). The webpack
  workbench gets a real route file because `next dev` (webpack) does not
  serve directory-symlinked app routes.

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

* test: authenticate plain hook resume request

* test: address review — marker-based harness output parsing, changeset summary

- route-bundle-isolation: prefix the harness result line with a unique
  marker and locate it explicitly instead of JSON.parse()ing the last
  stdout line, so stray logging from the route bundle or the world can't
  break parsing; failures now include the full subprocess stdout.
- changeset: add a human-readable summary to the (release-less) changeset.

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>
Co-authored-by: Karthik Kalyanaraman <karthik.kalyanaraman@vercel.com>
Co-authored-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
2026-07-21 13:24:17 +07:00
Nathan Colosimo 9078126c43 Retry transient connection timeouts (#3013)
* fix: retry transient connection timeouts

* test: extend webpack canary HMR timeout

* Update packages/world-vercel/src/http-client.ts

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

---------

Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-07-20 23:29:46 +00:00
Peter Wielander 0bc22c8e9b [ci] Benchmark comment: Best column + best/p75/p99 deltas (drop Avg/P10) (#3005) 2026-07-20 13:50:22 -07:00
Peter Wielander d53b055a2b [ci] Run benchmarks in-deployment to avoid proxy overhead (#2967) 2026-07-17 14:49:42 -07:00
Nathan Colosimo 927b61ab41 Fix dotted tsconfig alias workflow discovery (#2963)
* Fix dotted alias workflow discovery

* Increase streamer stress test cleanup timeout

* Increase canary HMR rediscovery timeout
2026-07-16 22:14:10 -07:00
Peter Wielander fd107b9c33 [core] Fix time parsing for region-tagged run IDs (#2943) 2026-07-15 13:05:46 -07:00
Nathan Rajlich 9da2d76260 [core][world][world-vercel] Add World.createRunId() and region-aware queue routing (#1981)
* [world-vercel] Add /run-id sub-export with tagged ULID encode/decode

Encodes a tag bit, 5-bit version, and 6-bit Vercel region ID into a
ULID-shaped string used for workflow run IDs. Tagged values remain
valid 26-char Crockford-Base32 ULIDs so they still sort and round-trip
through any system that accepts ULIDs.

* [world-vercel] Add string-value assertions to run-id tests

Add exact-string expectations for encoded outputs at known inputs,
covering the default region/version pair, numeric region IDs, version
overrides, boundary values (all-zero, all-max), the dirty-input
overwrite case, and the lexicographic-order checks. Also adds an
explicit byte-array expectation for the canonical ULID-spec example
string and an additional first-char-range coverage test for isTagged.

* [world-vercel] Remove internal-repo reference from regions doc comment

* [world-vercel] Address PR review feedback on run-id sub-export

- isTaggedString now fully validates the input as a 26-char Crockford
  Base32 ULID (delegating to ulidToBytes) instead of only inspecting
  the first character. This fixes false positives on inputs like
  '4UUUU...' that have a valid tag-bit position but invalid chars
  later in the string.
- isTagged() now accepts `unknown` to match its documented behavior
  of safely rejecting non-string inputs without requiring callers to
  cast.
- Introduce `RegionKey` for the full set of keys including 'unknown',
  and narrow `RegionCode` to `Exclude<RegionKey, 'unknown'>` so the
  return type of `lookupRegion` and the `DecodedRunId.region` field
  accurately reflect that 'unknown' is never produced. Updates
  `encode` to reject 'unknown' as a region code string at runtime
  (callers wanting the unknown sentinel should pass numeric 0).

* [world] [core] [world-vercel] Add World.createRunId() and region-aware queue routing

- @workflow/world: add optional createRunId(input?) to the World
  interface so worlds can mint run IDs with embedded metadata, and
  add an optional 'region' field to QueueOptions for per-message
  routing hints.

- @workflow/core: start() now delegates run ID generation to
  world.createRunId() when defined (falling back to a monotonic
  ULID otherwise), and accepts a new 'runIdInput' option that is
  forwarded verbatim to createRunId. When runIdInput.region is a
  string, it is also threaded onto the queue options so the initial
  workflow message is dispatched to the matching region.

- @workflow/world-vercel: implement createRunId() to mint
  region-tagged ULIDs, preferring an explicit runIdInput.region and
  falling back to the VERCEL_REGION env var. The queue now resolves
  its destination region from (in order): an explicit opts.region,
  the region embedded in the payload's tagged run ID, the
  VERCEL_REGION env var, and finally a hardcoded 'iad1' default.
  This replaces the previous unconditional 'iad1' region passed to
  the @vercel/queue client.

Monotonicity within a process is preserved by tracking the last
emitted run ID and bumping the bit immediately above the 11-bit
metadata window when a same-ms collision would otherwise occur,
then re-stamping the requested region/version on top so metadata
remains stable.

* [core] [world] [world-vercel] Pass full StartOptions to World.createRunId

Drop the dedicated 'runIdInput' field on StartOptions and forward the
entire options bag to world.createRunId() instead. This keeps the
public API surface smaller and lets each World pick the fields it
recognises (e.g. world-vercel reads 'region'). The top-level 'region'
option remains on StartOptionsBase and is also threaded onto the
queue's per-call region opt when set.

* Address review feedback: doc fixes and deterministic same-ms tests

- Document the final iad1 fallback in QueueOptions.region (world)
- Correct the World.createRunId doc: start() always passes an object
- Fix the clientOptions comment: the handler client omits region and
  relies on SDK auto-detection + the ce-vqsregion header for acks
- Fix a misleading QueueClient-construction comment in queue.test.ts
- Freeze time in the same-ms monotonicity test so it deterministically
  exercises the intended path, and add a test covering the
  bump-above-metadata fallback when the region changes mid-millisecond

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

* test: keep workflow-server override rewrite-compatible

Export WORKFLOW_SERVER_URL_OVERRIDE while keeping the one-line const shape
that workflow-server's cross-repo e2e test automation rewrites. Update
world-vercel tests to import that exported value for mock origins and URL
expectations instead of duplicating the temporary preview URL.

* fix(world): clear region tag bit before ULID timestamp validation

Region-tagged run IDs set the high bit of the ULID timestamp byte. The
shared world timestamp validator used raw decodeTime(), so current tagged
run IDs appeared thousands of years in the future and were rejected before
reaching workflow-server. Clear the tag bit before decoding, matching the
workflow-server behavior, and cover tagged IDs in tests.

* fix(world-vercel): validate tagged runId timestamps via run-id decode

Keep @workflow/world's ULID helpers generic; they should not know about
world-vercel's region-tagged run ID layout. Instead, world-vercel decodes
its tagged runId to the original ULID before using the shared timestamp
validator for run_created events. Add a world-vercel regression test that a
current sfo1-tagged runId passes validation.

* fix(world-vercel): default run ID region to iad1 instead of unknown

When neither an explicit region option nor VERCEL_REGION is available,
createRunId minted a tagged ULID with the unknown (0) region sentinel,
producing the tagged: true, region: null state. The server already
resolves unknown/untagged runs to DEFAULT_VERCEL_REGION (iad1), so mint
a concrete iad1 tag instead, keeping every run ID self-describing and
routable.

* test(e2e): use verbose reporter + per-test start heartbeat

The default vitest reporter buffers per-file output, so a stalling e2e
test produces no output until its timeout — making CI look like a silent
30-minute hang. Switch the e2e CI invocations to the verbose reporter
(prints each test result as it completes) and emit a '[e2e] ▶ start:'
heartbeat to stdout at the start of every test (bypassing vitest's console
buffering) so a stuck test is immediately identifiable in the live CI log.

* test(world-vercel): point WORKFLOW_SERVER_URL_OVERRIDE at combined 527+529 preview

Temporarily target the workflow-server combined-527-529-preview deployment,
which bundles platform-directed multi-region routing (vercel/workflow-server#527,
incl. the iad1 hook pin) and durable stream state (vercel/workflow-server#529),
so e2e can validate the full multi-region path end-to-end. Revert to empty on main.

* fix(core): region-tag the health-check correlationId

The health-check response is delivered over a Redis stream whose name (and
synthetic run ID) embed the correlationId. Under platform-directed routing
the responding endpoint and the polling reader can be served from different
physical regions; Redis is physical-region-local, so the correlationId must
carry the region for both sides to resolve the same backend.

Generate the correlationId via world.createRunId() (a region-tagged ULID)
when the world provides it, falling back to a plain ULID for worlds that
don't tag IDs (e.g. local, single-region). The synthetic wrun_hc_<id> run ID
then carries the region; workflow-server's region middleware decodes it.

* Address review feedback: validate region overrides, reset server override

- queue: validate opts.region and VERCEL_REGION against the known region
  table before routing, ignoring unrecognised codes so a bad override
  can't clobber the payload-derived region (Copilot)
- add isKnownRegionCode() runtime guard to run-id/regions
- reset WORKFLOW_SERVER_URL_OVERRIDE to '' (must be empty on main)
- fold the within-PR iad1-default changeset into the main world-vercel
  changeset and delete it (review)
- start.test: declare specVersion on createRunId mock worlds now that
  the merged world-compatibility check requires it
- cover the new region-validation fall-through paths in queue.test

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

* test(world-vercel): point WORKFLOW_SERVER_URL_OVERRIDE at wave-1 multi-region preview

BRANCH-ONLY — revert the override to '' before merge (lint enforces).

Points this PR's e2e/benchmark runs at the wave-1 multi-region
workflow-server preview (vercel/workflow-server#590: iad1+sfo1+fra1
serving, staging data backends) so region-tagged runs are validated
against real multi-region serving end-to-end.

Also makes the unit-test mock origins in events-v4.test.ts and
trace-propagation.test.ts override-aware (same pattern the rest of
the file and utils.test.ts already use), so the suite passes whether
or not the override is set — these two files were the only spots
hardcoding https://vercel-workflow.com.

* test(e2e): Vercel multi-region suite for start()'s region option

Adds a dedicated e2e suite validating @workflow/world-vercel region
routing end to end, run as its own CI job (e2e-vercel-multi-region)
against the nextjs-turbopack workbench only — deliberately separate
from e2e.test.ts, which runs as a matrix across all worlds/frameworks
where Vercel-specific multi-region behavior doesn't apply.

- workbench/nextjs-turbopack/vercel.json: deploy to iad1+sfo1+fra1 so
  region-routed flow messages have a function to land on in each region.
- workflows/99_e2e.ts: regionProbeWorkflow returns the VERCEL_REGION
  observed by both the workflow and a step, so tests can assert the run
  EXECUTED in the intended region (not just that it was tagged).
- packages/core/e2e/e2e-region.test.ts: per-region cases assert
  1) start(..., { region }) mints a region-tagged run ID (decoded via
     @workflow/world-vercel/run-id),
  2) the workflow + step both observed VERCEL_REGION === region,
  3) the server reports the run completed;
  plus a concurrent all-regions case guarding against cross-region
  misrouting under simultaneous multi-region traffic. Skips on local
  deployments.
- .github/workflows/tests.yml: new e2e-vercel-multi-region job
  mirroring e2e-vercel-prod's env/deployment-wait, running only the
  new suite.

* test(e2e): start region probes in-function; fix getWorld await

The first multi-region CI run surfaced two issues:

1. sfo1/fra1-tagged runs executed in iad1. The suite started runs from
   the external test process, which uses the api.vercel.com token proxy
   — and the proxy's queues path forwards every send to the region-less
   VQS host (the world's proxy-mode resolveBaseUrl ignores the region
   argument, and the proxy's x-vercel-vqs-api-url escape hatch only
   allowlists vqs-server-*.vercel.sh preview hosts). Production traffic
   publishes IN-FUNCTION (direct regional queue routing), so the suite
   now triggers start() through a new workbench route
   (/api/e2e-region-start) and rehydrates the run with getRun() —
   testing the path production actually takes. Proxy-mode regional
   queue routing is a known gap to address separately in api-workflow.

2. TypeError on world.runs.get: getWorld() is async and was called
   without await.

* test(e2e): cover explicit and implicit region starts in the multi-region suite

With regional VQS routing now working through the api.vercel.com proxy
(vercel/api#79056 + #2789 + this branch's per-send region resolution),
the suite covers both start configurations, asserting the same three
properties for each (region-tagged run ID, execution in the intended
region via VERCEL_REGION echoed in the return value, server-side
completion):

1. EXPLICIT: start(..., { region }) called directly in the vitest
   runner — publishes through the token proxy, per-send region carried
   by x-vercel-queue-region. Restores the direct-start shape the suite
   had originally, plus the concurrent all-regions case.

2. IMPLICIT: dedicated per-region workbench routes
   (/api/e2e-region-implicit/{iad1,sfo1,fra1}), each pinned to a single
   region via a per-function 'regions' entry in the workbench
   vercel.json, calling start() with NO region option — createRunId
   derives the tag from the minting function's VERCEL_REGION. The test
   also asserts the route reported executing in its pinned region, so
   the implicit-tagging assertion can't pass vacuously.

Replaces the interim /api/e2e-region-start route (explicit region via
request body), which existed to work around the pre-#79056 proxy gap.

* Revert WORKFLOW_SERVER_URL_OVERRIDE to '' — wave-1 multi-region serving is in production

workflow-server#590 (iad1+sfo1+fra1 serving) merged and deployed to
production and the e2e backend, so this branch's e2e/benchmark runs no
longer need to target the wave-1 preview. Restores the empty override
the No Test Overrides lint job enforces for merge.

The override-aware unit-test origins (events-v4/trace-propagation)
stay — they are correct under any override value.

* test(e2e): cross-region stream visibility (iad1 writer, sfo1 reader)

Regression coverage for a backend bug that made cross-region stream
reads report zero chunks on IN-PROGRESS streams (completed streams were
unaffected), which forced the multi-region serving rollback.

The new case exercises exactly that geometry:
- crossRegionStreamWorkflow (99_e2e.ts) writes N chunks to the default
  output stream, then holds the stream OPEN for 45s before closing —
  the in-progress window is the point, since completed streams are the
  easy case.
- The e2e starts it with region iad1, waits (same-region, via the
  api.vercel.com proxy) until all chunks are written, asserts the run
  is still 'running', then reads through a new sfo1-pinned workbench
  route (/api/e2e-stream-read/sfo1) that returns getTailIndex() plus
  its VERCEL_REGION. The reader's region served none of the stream's
  writes, so the reported chunk count must come from the backend's
  cross-region stream metadata. The test fails loudly if the route
  isn't actually executing in sfo1.

Also bumps the explicit-region test timeout to 120s: the first case in
the file absorbs every cold start at once (fresh workbench instances
in up to three regions plus a cold backend preview) and was observed
just over the 60s default.

BRANCH-ONLY (revert before merge, lint enforces):
WORKFLOW_SERVER_URL_OVERRIDE points at a multi-region backend preview
that includes the fix, so this validates cross-region stream
visibility end-to-end before multi-region serving is re-enabled.

* test(e2e): extend multi-region suite to all 19 provisioned regions

Points the suite at an all-regions backend preview and widens coverage
from the wave-1 trio to every provisioned region:

- Explicit path: a single concurrent all-regions case starts one
  tagged run per region (one shared cold-start window instead of 19
  sequential ones) and aggregates per-region failures so a single
  region's breakage reports alongside the full picture. The trio keeps
  its detailed per-region cases and the 9-way concurrent-isolation
  case.
- Implicit path: workbench gains a region-pinned
  /api/e2e-region-implicit/<region> route per provisioned region (19
  total, shared handler), the workbench itself now deploys to all of
  them, and the test.each covers the full set with per-case timeouts
  for regional cold starts.
- Multi-region CI job timeout 20m -> 35m for the sequential implicit
  cases.

BRANCH-ONLY (revert before merge, lint enforces):
WORKFLOW_SERVER_URL_OVERRIDE now targets the all-regions backend
preview instead of the previous (stale, since-merged) fix preview.

* test(e2e): tolerate geo-adjacent execution of queue callbacks

The first all-regions run surfaced a subtle execution-locality
behavior: queue delivery is guaranteed to the tagged region's
dataplane and the delivery callback egresses from that region, but the
consumer invocation's execution region is chosen by where that
callback enters Vercel's edge — and adjacent regions can geo-resolve
to each other's functions. Observed live: kix1-tagged runs (callback
egressing from Osaka) deterministically executing in hnd1/Tokyo on
both the explicit and implicit paths, with tagging, data placement,
and completion all still strictly kix1.

expectRunInRegion now asserts execution lands in the tagged region OR
one of its geographic neighbors (EXECUTION_ADJACENCY), while run-ID
tagging and server-side completion remain strictly the requested
region. Gross misrouting (e.g. kix1 -> iad1) still fails.

* Revert WORKFLOW_SERVER_URL_OVERRIDE to '' — all-regions serving is in production

The all-regions workflow-server rollout is deployed and serving
production traffic from every Vercel region, so this branch's e2e no
longer needs to target a branch preview. Restores the empty override
the No Test Overrides lint enforces for merge.

With this the PR is complete: region-tagged run IDs, region-aware
queue routing, and the multi-region e2e suite (explicit + implicit +
all-regions + cross-region streams) all validate against the
production-default backends.

* docs: fix three stale comments flagged in review

- start.ts: StartOptionsBase.region fallback is iad1, not the unknown
  sentinel (createRunId always mints a concrete routable region)
- queue.ts: example used a nonexistent start({ runIdInput }) API; the
  real option is start({ region })
- events.ts: decode() clears only the tag bit (top bit of the 48-bit
  timestamp field) — it does not restore the original untagged ULID;
  reword to say what actually matters for timestamp validation

* test(e2e): cover hook resolve/resume for runs owned by non-iad1 regions

Hooks are resolved by opaque token, which carries no region hint, so
lookup and resume must work regardless of which region owns the run's
data. Exercises the full follow-up-message path on sfo1- and
fra1-tagged runs: create inside the workflow, resolve by token from
the test process, resume twice sequentially, and assert payload order
and completion.

Regression coverage for the failure mode where the first message to a
hook-driven app on a non-iad1 run worked but every follow-up failed
with 'Hook not found'.

---------

Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:03:56 +00:00
Peter Wielander a4d8de03e6 [world-vercel] [builders] Add WORKFLOW_SEQUENTIAL_REPLAYS option to limit flow route concurrency to one (#2193) 2026-07-13 20:57:38 +00:00
Peter Wielander 0b956f65cb Rename experimental_setAttributes to setAttributes (#2882) 2026-07-11 10:17:37 -07:00
Nathan Colosimo df7e71de1c fix(builders): harden Windows HMR file writes (#2853) 2026-07-09 15:57:41 -07:00
Peter Wielander 39673b7a38 [ci] Benchmark preflight + fail-fast for broken deployments (#2846) 2026-07-08 17:53:30 -07:00
Peter Wielander da4e0995b0 [ci] Overhaul performance benchmarks: focused metrics + sticky PR comment (#2820) 2026-07-08 15:06:28 -07:00
Nathan Colosimo aae47b9fdd Fix SvelteKit config loading (#2802) 2026-07-08 01:04:31 +00:00
Nathan Colosimo 49a50e83d9 Document configuration environment variables (v5) (#2468) 2026-07-07 17:56:41 -07:00
Peter Wielander 7637196cf0 Fix hook token reuse after dispose() (same-run and cross-run) (#2779) 2026-07-07 14:13:15 -07:00
Nathan Colosimo 239031ad9e fix(next): respect basePath for workflow routes (#2732)
* fix(next): respect basePath for workflow routes

* docs(core): note workflow URL resolution gap

* fix(next): expose workflow health route methods

* test(utils): remove workflow route helper tests

* test(builders): remove route handler string test

* fix(next): defer basePath validation to Next.js

* refactor(utils): remove workflow url helper wrappers

* Test Next basePath builder wiring
2026-07-06 16:43:35 -07:00
JJ Kasper 0f557d5ae4 Statically inject workflow world target (#2752)
* Statically inject workflow world target

* Fix static world injection in host bundles

* Fix static world injection gaps

* Fix Vite Nitro server startup

* Fix Nitro pg-native aliasing

* Fix static world target CI gaps

* Fix static world dev rebuild gaps

* Avoid broad runtime alias in Nitro

* Refresh Next dev route for step HMR

* Externalize Nest target world

* Use canary HMR rediscovery timeout

* Bundle local world in Nest builds

* Dedupe world target helpers and fix SvelteKit chunk patch guard
2026-07-06 14:19:45 -07:00
Nathan Colosimo dd36e26962 Fix Postgres step lifecycle event ordering (#2714)
* Fix Postgres step start event ordering

* Document Postgres step start transaction

* Increase canary HMR e2e timeouts

* Address Postgres lifecycle review comments
2026-07-02 18:35:56 +00:00
Nathan Colosimo 3077b8a803 fix(nitro): use workspaceDir for monorepos (#2713)
* fix(nitro): use workspaceDir for monorepos

* test: stabilize Next canary HMR e2e
2026-06-30 13:42:53 -07:00
Nathan Colosimo 5a231598e4 test: reduce e2e timing flakes (#2665)
* test: reduce e2e timing flakes

* test: tighten e2e timing bounds
2026-06-29 15:50:42 -07:00
JJ Kasper f6772d95c8 Optimize Next dev HMR rebuilds (#2678)
* Optimize Next dev HMR rebuilds

* Fix Next dev HMR CI coverage

* Gate dev HMR logs behind opt-in flag

* Match workflow dev build logs to Next style

* Fix Next dev HMR changed-file classification

* Fix Windows port detection

* Relax HMR log wait in dev e2e

* Avoid canary workflow execution cache flakes

* Allow slower Turbopack HMR propagation in e2e

* Scope canary HMR fuzz execution assertions
2026-06-29 20:58:38 +00:00
JJ Kasper 24f370773d Fix Workflow loader source map warnings (#2693) 2026-06-29 20:16:46 +00:00
Nathan Colosimo 6f4dd0e716 fix(nitro): reload steps during Vite HMR (#2572)
* test(nitro): reproduce stale steps after HMR

* fix(nitro): reload steps during Vite HMR
2026-06-25 20:37:51 +00:00
JJ Kasper 5291f1549f Optimize and fix the default eager build mode (#2546) 2026-06-22 14:47:39 -05:00
JJ Kasper 57cccaf373 Remove lazy discovery from workflow/next (#2545) 2026-06-22 13:14:35 -05:00
Pranay Prakash 37312edd0a Default source maps to dev-on / prod-off (#2529)
* Default source maps to dev-on / prod-off

Inline source maps are embedded in the step bundle and the intermediate
workflow VM bundle, which bloats production function bundles (a problem for
the Vercel 250MB limit) even though maps only help when reading a stack trace.

Make the default environment-aware in @workflow/builders: inline in
development (next dev / nitro dev / Vite-based dev servers, detected via
config.watch or NODE_ENV=development) and off in production. The `sourcemap`
config option and `WORKFLOW_SOURCEMAP` env var still override in either
environment. A production build with no override also drops the
source-map-support shim from the Vercel step function.

Keep runtime stack remapping graceful and fast when maps are absent
(@workflow/core): short-circuit when no frame references the workflow file
and memoize the parsed map (or its absence) per bundle, so production failures
don't rescan the bundle.

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

* test(e2e): make source-map expectations match dev-on/prod-off default

The e2e error-stack tests gate source-map assertions on hasWorkflowSourceMaps()
and hasStepSourceMaps(). Now that source maps default to off in production
builds, update those helpers:

- hasWorkflowSourceMaps(): false for all production builds (local prod,
  postgres, Vercel — keyed off DEV_TEST_CONFIG), and exclude nest in dev (the
  Nest integration builds with watch:false / no NODE_ENV=development, so its
  bundles have no maps).
- hasStepSourceMaps(): nest now resolves to a production build (maps off) in
  both dev and prod.

Add unit cases for the dev-vs-prod and nest behavior.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:28:26 -07:00
Pranay Prakash cb181392b9 feat(cli): print run deep links with --url, fix dashboard route (#2467)
Add a `--url` flag to `inspect`/`web` that prints a run's observability
dashboard deep link to stdout and exits — no browser, no local server —
so scripts and agents can share a link instead of opening a UI.

Fix the Vercel dashboard URL to the current
`…/workflows/runs/<id>?environment=<env>` route (drop the legacy
`/observability` segment) and respect `--env`. Apply the same route fix
to the e2e helpers, CI aggregation scripts, and the nextjs-turbopack
workbench. Document deep-linking in the workflow skill and observability
docs.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:37:03 -07:00
Pranay Prakash 4b7a7203bf fix(core): make deploymentId 'latest' a no-op in non-Vercel worlds (#2397)
* fix(core): make deploymentId 'latest' a no-op in non-Vercel worlds

Previously, start({ deploymentId: 'latest' }) threw a WorkflowRuntimeError
in any World that doesn't implement resolveLatestDeploymentId() (local dev,
Postgres). That meant a workflow which opts into 'latest' on Vercel would
fail outright in local development.

Resolving 'latest' only means something in worlds with atomic, immutable
deployments. In other worlds there is nothing to resolve between, so instead
of throwing we now log a warning and fall back to the current deployment,
making 'latest' an effective no-op there.

- start.ts: warn + fall back to currentDeploymentId instead of throwing
- start.test.ts: replace the "should throw" test with a warn + fallback test
- e2e.test.ts: assert 'latest' completes (no-op) on non-Vercel worlds
- docs: note the no-op behavior in v4 + v5 start.mdx

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

* fix(core): warn once for deploymentId 'latest' no-op; harden test cleanup

Address PR review:
- Gate the 'latest'-has-no-effect warning behind a once-per-process guard
  (mirrors the warnOnce pattern in constants.ts) so a workflow that hardcodes
  'latest' for Vercel doesn't flood local/Postgres dev logs on every run.
  Exposes _resetLatestNoOpWarnForTests() (@internal) for unit tests.
- start.test.ts: reset the guard in beforeEach and restore spies in afterEach
  via vi.restoreAllMocks() so a throwing assertion can't leak the
  runtimeLogger.warn spy into later tests; drop the manual mockRestore().
- Add a test asserting the warning fires exactly once across repeated
  'latest' starts while every run still falls back to the current deployment.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:09:49 -07:00
JJ Kasper d4dd6f9c01 Fix lazy Next workflow HMR (#2438) 2026-06-16 10:37:51 -05:00
Peter Wielander b3cc513220 [ci] Increase dev.test.ts cleanup hook timeout (#2416) 2026-06-15 11:13:53 +02:00
Pranay Prakash 4763a760bf test: e2e coverage for run-idempotency conflict-handling strategies (#2387)
* test: e2e coverage for run-idempotency conflict-handling strategies

Covers the patterns documented in foundations/idempotency:
- claim-only hook mutex: token claimed and held with no payload data,
  duplicate identifies the owner, token released after completion
- adopt the owner's result via conflict.returnValue
- signal the owner: duplicate forwards its payload via resumeHook
- supersede: duplicate cancels the owner and reclaims the token
- route-side resume-or-start retry pattern reaching the started run

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

* test: fix adopt-owner-result race — gate owner completion on observed conflict

On slow runtimes the duplicate's first invocation could land after the
owner completed and released the token, making the duplicate a fresh
owner that waits forever for a payload (90s timeout across CI matrices).
Poll the duplicate's event log for hook_conflict before resuming the
owner, and widen the test timeout for the added gate budget.

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

* review: assert superseded owner's returnValue rejection; empty changeset

- Await run1.returnValue and assert WorkflowRunCancelledError so the
  cancellation is verified end-to-end and no rejection leaks from the
  supersede test.
- Test-only PR: use an empty changeset.

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

* ci: retrigger preview deployments (turbopack deployment for 2e9d000 wedged in esbuild hang)

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

* ci: bust poisoned turbo cache entry for nextjs-turbopack build

The 2e9d000 deployment's next build crashed in an esbuild hang but its
task (70724907c9dd3a29) was recorded into the turbo remote cache anyway,
so every subsequent build with the same input hash replays the broken
artifact (missing routes-manifest). Change a build input to force a
fresh execution.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:08:43 -07:00
Pranay Prakash 628795aa87 Add allowReservedAttributes option to start() (#2385)
* Add allowReservedAttributes option to start()

experimental_setAttributes already exposes allowReservedAttributes for
framework-level callers that own a $-prefixed sub-namespace, and the
run_created / run_started event schemas plus the local and Postgres
worlds already accept and validate the flag. start() was the one gap:
it always validated initial attributes with the reserved prefix
disallowed and had no way to opt out, so framework code could not seed
reserved attributes at run creation.

Thread the option through start():
- StartOptions.allowReservedAttributes, passed to client-side
  validation and forwarded on the run_created eventData
- carried in the queue runInput (new RunInputSchema field) and
  forwarded to run_started so the resilient/lazy run creation path
  validates identically

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

* Add e2e coverage for reserved initial attributes via allowReservedAttributes

Verified locally against the nextjs-turbopack dev server: the reserved
key passes client and server validation, lands on the run at creation,
and survives the workflow's own attr_set writes.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:23:26 -07:00
Pranay Prakash 01c8c0878a Replace hook.hasConflict with hook.getConflict() returning the conflicting Run (#2373)
* feat: replace hook.hasConflict with hook.getConflict (Promise<Run | null>)

hasConflict's boolean didn't expose WHICH run owns the token, so the
duplicate run couldn't act on the conflict. getConflict resolves with
null once registration commits, or with a Run handle for the conflicting
run — letting the workflow return/log the owner's runId, inspect its
status, await its result, or cancel it and continue, all in code.

The workflow-mode create-hook module exposes the bundle's compiled Run
class (durable step-proxy methods) on a well-known symbol so the host-
side hook consumer can construct the conflicting run inside the VM.
Contexts without the class (plain unit tests) fall back to a { runId }
object, which is also the documented v4 shape (no native Run
serialization in v4).

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

* fix: never resolve getConflict with a non-Run fallback shape

getConflict's contract is Promise<Run | null>. In the degenerate cases
where a real Run cannot be constructed — a hook_conflict event persisted
by an old world without conflictingRunId, or a context that never loaded
the workflow-mode create-hook module — reject with HookConflictError
instead of resolving with a { runId }-shaped impostor.

Test harnesses now register the Run class on the (VM) globalThis like
real bundles do.

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

* refactor: make getConflict a method — hook.getConflict()

A property getter that triggers registration/suspension reads as passive
state; a method makes the side effect explicit. Update implementation,
types, tests, e2e workflows, docs, and changeset.

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

* review: guard Run class registration, fix anchors, clarify changeset

- Only register WORKFLOW_RUN_CLASS when the workflow runtime is present
  (WORKFLOW_CREATE_HOOK installed on globalThis), so host imports of the
  workflow-mode module neither mutate the host global nor expose the
  non-step-proxy host Run.
- Drop #run-idempotency link fragments — that section lands in the
  stacked docs PR (#2011), which restores the anchored links.
- Note in docs that getConflict() rejects with HookConflictError for
  legacy hook_conflict events lacking the owner's run ID.
- Changeset now calls out the hasConflict -> getConflict() replacement.

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

* refactor: resolve the conflicting Run through the serialization class registry

Replace the bespoke WORKFLOW_RUN_CLASS global with the registry the
serialization pipeline already uses to revive Run instances:

- The SWC plugin already auto-registers the workflow bundle's compiled
  Run in globalThis[workflow-class-registry], but under a path-derived
  classId the host cannot know statically. The workflow-mode create-hook
  module now aliases it under a stable id (class//workflow//Run) via a
  new aliasSerializationClass() helper (a plain registry entry —
  registerSerializationClass cannot be reused since the plugin's IIFE
  already defined the non-configurable classId property).

- createConflictingRun() looks the class up with
  getSerializationClass(RUN_CLASS_ID, ctx.globalThis) and constructs
  through its WORKFLOW_DESERIALIZE hook, exactly as the Instance reviver
  would for a serialized Run crossing from a step into the workflow.

- Because the registry is keyed per-global, no environment guard is
  needed: a stray host-side import registers the host Run on the host
  registry, which is the correct class for that context. The
  WORKFLOW_CREATE_HOOK guard, the ??=, and the WORKFLOW_RUN_CLASS symbol
  are all deleted.

Verified: 1156 core unit tests; compiled workbench bundle contains the
stable alias alongside the plugin's path-derived registration with zero
WORKFLOW_RUN_CLASS references; all 5 hookGetConflict e2e tests pass
against a local nextjs-turbopack dev server, including conflict
resolution reading conflict.status through a durable step.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Nathan Rajlich <n@n8.io>
2026-06-12 07:52:36 +00:00