* Redrive on transient workflow-server transport failures instead of failing the run
A firewall in front of workflow-server shedding load with sustained 429/503
makes undici's shared RetryAgent exhaust its retries and throw
UND_ERR_REQ_RETRY. That raw error was rethrown unwrapped, so it was
classified as USER_ERROR and the replay terminal branch wrote run_failed —
permanently failing a run on a transient blip (or, in an outage, falling back
to the ~5min queue visibility-timeout redrive).
- world-vercel: map exhausted-retry / socket / connect / DNS / timeout
failures to a typed WorkflowWorldError (code TRANSPORT/TIMEOUT) by walking
the fetch() cause chain.
- core: add isRetryableWorldError (429 / 5xx / TRANSPORT / TIMEOUT) and
rethrow such errors from the replay terminal branch so the queue redrives
quickly (1s->60s backoff) instead of failing the run. Reuse it in start()
and step_started handling.
- world-vercel: surface the Vercel firewall x-vercel-mitigated
(challenge/deny) header alongside x-vercel-id in error diagnostics and logs.
* Address review: back off + cap on every retry path; fix mock; refine scope
Revises the transport-error handling per PR review (VaguelySerious,
karthikscale3).
Blocking fix — step_started no longer self-enqueues a throttled defer for
transient world errors. Returning `{ type: 'throttled', timeoutSeconds: 1 }`
acked the delivery and enqueued a fresh message, resetting the delivery count
so the path never backed off and never reached MAX_QUEUE_DELIVERIES — an
unbounded flat-1s loop if step_started kept failing. It now throws, so the
error flows through the replay loop's retryable-world-error rethrow and earns
both the delivery-count backoff and the max-delivery cap. Throwing is safe on
step_started (the body hasn't run; a write that landed dedupes to skipped).
Also in this revision:
- Backoff that lasts: raise the queue handler-error retry ceiling 60s -> 900s.
VQS clamps each redelivery to its 900s SQS limit and adds its own post-32
exponential, so ramping our base toward 900s stretches survival from ~3.7h to
most of the 24h message-visibility window. Corrected the stale
MAX_QUEUE_DELIVERIES comment to match the real VQS schedule.
- Stop amplifying firewall challenges: the undici RetryAgent no longer retries
429 in-process (a challenge is a 429 the client can't solve). 429s surface
immediately as ThrottleError carrying x-vercel-mitigated / x-vercel-id, so
the diagnostic header now reaches us for the challenge case too.
- Track world faults as WORLD_CONTRACT_ERROR (not USER_ERROR) in
classifyRunError so an outage isn't attributed to user code.
- Fix queue.test.ts mock that `biome check --write` had rewritten from a
newable `function` into an arrow (broke `new QueueClient`); pin with a
biome-ignore.
All Vercel-specific logic stays in @workflow/world-vercel; @workflow/core
operates only on the generic WorkflowWorldError abstraction.
* Update .changeset/transport-error-redrive.md
* Trim changeset to a single sentence per review
* Route firewall-challenge 429s to the retryable transport path, not ThrottleError
A 429 carrying `x-vercel-mitigated: challenge` is a firewall challenge our
server-to-server client cannot solve, so it recurs for the life of the
incident. Mapping it to `ThrottleError` meant the `step_started` write deferred
it as `{ type: 'throttled' }`, which self-enqueues a FRESH queue message and
resets the delivery count — so it never backed off past `retryAfter` and never
reached `MAX_QUEUE_DELIVERIES`, hot-looping against an already-overloaded
firewall (the exact amplification this PR set out to remove, and contrary to
the "step_started can't loop unbounded" invariant, which only held for 5xx).
Map a challenge to a retryable transport `WorkflowWorldError` (`code:
'TRANSPORT'`) in both the v3 `makeRequest` and v4 `throwForErrorResponse`
(the hot event-write path) error mappings, via a shared `isFirewallChallenge429`
helper. It then propagates through the V1/V2 step paths and the replay loop's
retryable-world-error rethrow, earning the delivery-count backoff AND the
delivery cap. A genuine application-level 429 (no `challenge` mitigation) stays
a `ThrottleError` and keeps its `Retry-After`-paced defer.
Also correct the survival-window comments: with the 900s ceiling,
MAX_QUEUE_DELIVERIES=48 spans ~9-10h (~35,000s), not "the better part of 24h";
reaching 24h would need a higher delivery cap, not a higher per-hop ceiling.
---------
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
Move cn to its own lib/cn module that teaches tailwind-merge about the
design-system text-heading/text-label/text-copy/text-button/material
utilities, and import it directly from lib/cn.
Signed-off-by: Mitul Shah <mitulxshah@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* Make world own zod dependency
* Relax zod catalog range
Signed-off-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* docs(core): document scheduleWhenIdle macrotask is load-bearing
Revert the synchronous consume-loop drain optimization: it caused a
replay divergence (ReplayDivergenceError on step_started →
CorruptedEventLogError) in the world-testing inline-batches parallel
workflow on the Windows CI runner. The per-event `process.nextTick` in
the consume loop is load-bearing — it guarantees at most one event is
consumed per macrotask, letting the cross-VM `resolve → workflow VM body
→ subscribe()` chain register the next operation's consumer before the
drain advances. A synchronous drain races ahead of that registration.
What remains is a documentation comment on `scheduleWhenIdle` capturing
why its initial `setTimeout(0)` must not be downgraded to a microtask
(empirically: queueMicrotask breaks hook/sleep Promise.race ordering →
CorruptedEventLogError). No behavior change.
* docs(core): use empty changeset for comment-only macrotask doc
The scheduleWhenIdle change is a pure code comment with no consumer-facing
effect, so it does not warrant a patch bump / changelog entry. Replace the
patch changeset with an empty one to satisfy the changeset-bot convention
without claiming a release. Per the PR template's `pnpm changeset --empty`
guidance for non-releasing changes.
* perf(core): drain consumable replay events synchronously
The EventsConsumer rescheduled `process.nextTick(this.consume)` after every
consumed event, so replaying N already-consumable events (structural
lifecycle events, step_created/step_started, completed deliveries) cost N
macrotask hops — O(N) per consume wave across a sequential replay.
Drain consecutively consumable events within a single synchronous pass
instead. This is safe because callbacks only ever consume events with a
consumer that is already registered; new consumers are registered by
workflow VM body code that runs asynchronously off ctx.promiseQueue after a
delivery resolve(). When the next event's consumer is not yet registered,
no callback consumes it and we fall through to the existing cross-VM-safe
deferred unconsumed-event check, exactly as before. A null end-of-events
sentinel never continues the drain, so it cannot spin past end-of-log.
scheduleWhenIdle is intentionally left unchanged: its initial setTimeout(0)
is load-bearing for cross-VM propagation (pendingDeliveries is already 0
between a delivery resolve() and the VM body registering its next
subscriber). Replacing it with queueMicrotask empirically breaks hook/sleep
Promise.race ordering (CorruptedEventLogError); a comment now records this.
Re-validated after a premature revert: the windows-unit flake that prompted
the revert reproduces on unmodified main at the same rate (local 8-way
harness: opt 4/80 vs main 7/80; main historical windows-unit ~13%), so it is
a pre-existing flake, not a regression from this change.
---------
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* perf(core): cache compiled workflow-bundle vm.Script across replays
The inline replay loop calls runWorkflow on every iteration, and each call
re-parsed the entire workflow bundle string via vm.runInContext. For a bundle
containing many workflow definitions (the production shape: one workflow called
per replay), this re-scans every definition on every replay.
Cache the compiled vm.Script per process, keyed by (workflowCode, filename),
and run it against the fresh context instead of recompiling. Compilation is a
pure function of (code, filename), so the result is byte-identical to the
previous re-parse-every-time behaviour — determinism is preserved. filename is
part of the key because it drives source attribution in stack traces (consumed
by remapErrorStack).
Measured per-replay savings scale with bundle size (and multiply by replay
count): ~34% for a 50-workflow app, ~59% for 155 workflows, ~80% for 400.
* perf(core): bound script cache with LRU; soften determinism claim; add tests
Addresses review on #2471:
- Bound `scriptCache` to a small LRU (cap 8 bundle versions). Production
serves one bundle per process so the bound is never reached; it exists for
dev/watch mode, where each edit produces a new bundle string that would
otherwise be pinned forever (~0.8MB/edit, monotonic). Touch-on-access keeps
the latest bundle hot; evicting a `code` entry drops its per-filename scripts
together, restoring pre-cache GC behaviour.
- Document precisely why keying includes `filename` (intentional: drives
stack-trace attribution via `remapErrorStack`; NOT a dedupe key), and that
the whole bundle is compiled once per distinct filename.
- Soften the "byte-identical including thrown errors" claim to
same-workflow-function + same-`filename`-attribution, noting the one caveat:
a lookup-expression error's line number shifts to line 1 of the separate
lookup Script. Updated in both the code comment and the PR description.
- Add tests: cache-is-bounded regression (eviction past the cap), LRU recency
(hot bundle survives churn), and a realistic multi-workflow collision test
(distinct code/filename never returns the wrong Script, results carry their
own bundle marker).
---------
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The DCE usage collector skipped the entire variable name pattern when
visiting a `VarDeclarator` (to avoid marking the binding name as "used").
But default-value initializers inside destructuring patterns live in that
pattern — e.g. the `TTL` in `const { ttl = TTL } = options;` — so those
references were invisible to the collector. A module-scope `const`
referenced only through such a default was treated as unused and stripped,
while the surviving code kept reading it, producing a runtime
`ReferenceError` when the default fired.
Traverse the default-value initializer expressions (and computed keys)
within destructuring patterns while still not marking the binding names
themselves, so the referenced declaration is preserved. Function-parameter
defaults were already covered (params are visited in full).
Fixes#2396.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
hono <4.12.25 is vulnerable to CVE-2026-54290 (GHSA-88fw-hqm2-52qc):
the CORS middleware reflects any request Origin with
Access-Control-Allow-Credentials: true when credentials are enabled and
origin is left at the default wildcard, exposing cookie-authenticated
endpoints to arbitrary origins.
- packages/world-testing: hono 4.12.21 -> 4.12.25 (the flagged manifest)
- workbench/hono: ^4.12.8 -> ^4.12.25, clearing the also-vulnerable
4.12.9 from the lockfile
Neither app uses hono's CORS middleware, so neither was exploitable, but
the bump clears the vulnerable code from the dependency tree. Only the
core Hono class is imported in world-testing; build and typecheck pass.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 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
* 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.
---------
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Astro <6.4.6 is vulnerable to CVE-2026-54299 (GHSA-2pvr-wf23-7pc7, host
header SSRF in prerendered error page fetch). The fix only exists in the
6.x line — there is no 5.x backport — so this bumps:
- workbench/astro: astro ^6.4.6, @astrojs/node 10.1.4, @astrojs/vercel ^10.0.8
- packages/astro: astro devDependency 6.4.6 (typecheck only, not shipped)
Removes both vulnerable astro@5.16.3 and astro@5.18.0 from the lockfile.
Verified the example app builds under both the node and vercel adapters.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(web-shared): RelativeTimeCard with shared ContextCard provider (#2328)
* feat(web-shared): RelativeTimeCard with shared ContextCard provider
Add a ContextCard provider/trigger and rebuild the timestamp tooltip as a
RelativeTimeCard, giving animated, collision-aware morphing hover cards.
Mount the shared provider in EventListView and AttributePanel.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Match vercel/front timestamp format for run/activity fields
Render absolute Created/Started/Completed (and sibling) timestamps using
date-fns in vercel/front's request/activity format (e.g.
"JUN 10 10:16:02.69 GMT-4") via the shared formatLocalMillisecondTime helper.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shared): register dark-theme/light-theme Tailwind variants
The context-card arrow tip stroke uses `dark-theme:[--context-card-tip-stroke:#252525]`,
but Tailwind v4 has no built-in `dark-theme` variant, so the utility was silently
dropped and the stroke fell back to its light `#DBDBDB` value — rendering as a white
caret in dark mode. Register the `dark-theme`/`light-theme` custom variants in
styles.css (mirroring vercel/front's geistcn tailwind.css, extended to match the
`.dark`/`[data-theme="dark"]` selectors this package and next-themes use).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shared): match context card shadow to vercel/front
The --ds-shadow-tooltip token was guessed when added standalone, producing
an oversized/heavy drop shadow. Reproduce front's exact resolved value for
both light and dark themes (including the background-border layer).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shared): bridge context card hover gap to stop flicker
The card is positioned `sideOffset` away from the trigger, leaving a
transparent un-hoverable gap that caused the hover card to flicker
(open → close → open) when moving the cursor onto it. Add a transparent
hover bridge inside the floating wrapper that extends the hover surface
by `sideOffset` to meet the trigger edge, keeping the visual spacing
while making the hover surface continuous.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "fix(web-shared): bridge context card hover gap to stop flicker"
This reverts commit 2b961b1610.
* docs: simplify changeset description
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs: remove vercel/front references from comments
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shared): only clear active context card when the active trigger unmounts
The unmount cleanup had an inverted guard: an unmounting inactive trigger
would clear the shared active card, hiding another trigger's card (and an
unmounting active trigger left a stale card). Guard on === id instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs: remove theme-variant comment
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Mitul Shah <mitulxshah@gmail.com>
* fix(web): correct Geist tokens and context card styling in observability UI (#2435)
* fix(web): register dark-theme/light-theme custom variants in globals.css
* fix(web): register missing Geist color tokens (background-100, gray-1000, gray-alpha-400)
* chore(web): drop explanatory comments in globals.css
* fix(web): map full Geist gray scale to theme-aware Tailwind tokens
* fix(web): correct --ds-* neutral palette to canonical Geist values
* fix(web): add missing --ds-shadow-tooltip token for context card border/shadow
* fix(web-shared): match context card arrow stroke to border via --ds-gray-alpha-400
* docs(changeset): shorten web changeset
* docs(changeset): consolidate into a single changeset
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* Apply suggestions from code review
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
* Apply suggestion from @VaguelySerious
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
---------
Signed-off-by: Mitul Shah <mitulxshah@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Mitul Shah <mitulxshah@gmail.com>
Backport of #2437 to the stable (v4) release line.
- Add publish-time release-major guard (.github/scripts/check-release-major.js)
run in release.yml before install, gated on RELEASE_VERSION: 4.
- Realign @workflow/nest from 0.0.10 to 4.0.11 so the v4 stable line has no
out-of-line package majors (nest skipped straight to 5.0.0-beta on main and
had no 4.x version). Required for the guard to pass on stable.
The .changeset/config.json onlyUpdatePeerDependentsWhenOutOfRange option from
#2437 was already present on stable.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@workflow/ai was accidentally published to `latest` as 5.0.0, 6.0.0,
and 7.0.0. Root cause: it peer-depends on `workflow`, and changesets'
`onlyUpdatePeerDependentsWhenOutOfRange` defaults to false, so every
`workflow` minor on `stable` force-bumped ai a full major despite no
breaking changes.
- Add `onlyUpdatePeerDependentsWhenOutOfRange: true` so ai only majors
when a new `workflow` version actually leaves its `^4` peer range.
- Reset packages/ai/package.json from 7.0.0 back to 4.1.2; a patch
changeset re-releases it as 4.1.3 on the 4.x line (carries the
inline-sourcemaps packaging change that was trapped in 6.0.0).
Verified via `changeset status`: with pending `workflow` minors,
@workflow/ai now bumps patch, not major.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>