Commit Graph

154 Commits

Author SHA1 Message Date
Pranay Prakash dc0be50618 [codex] Forward port stale wait replay fix (#2038)
* Forward port stale wait replay fix

* Guard V5 replay writes against stale events

* Revert "Guard V5 replay writes against stale events"

This reverts commit 22e74d3558.

* Update wait replay comments
2026-05-20 14:41:25 -07:00
Pranay Prakash b124365e14 [codex] Guard event consumers during replay (#2030)
* Guard event consumers during replay

* Address event guard review feedback

* Update event guard test fixtures
2026-05-20 11:40:03 -07:00
Pranay Prakash 9d2a9261fd Expose conflicting run id on hook conflicts (#2012)
* Expose conflicting run id on hook conflicts

* Mark hook conflict run id as future required

* Address hook conflict docs review

* Address hook conflict review comments

* Fix hook conflict docs typecheck
2026-05-18 17:31:20 -07:00
Pranay Prakash aee56993c7 feat: serializable AbortController/AbortSignal (#1301)
* feat: add docs and test stubs for serializable AbortController/AbortSignal

Adds documentation and test infrastructure for making AbortController and
AbortSignal serializable across workflow and step boundaries. The feature
uses a dual hook+stream backing: hooks for deterministic replay in the
workflow context, streams for real-time propagation to running steps.

Docs:
- Cancellation guide (foundations) covering AbortSignal and run cancellation
- How Cancellation Works (how-it-works) explaining hook+stream internals
- AbortSignal.timeout() error page for the workflow VM restriction
- Updated serialization docs with AbortController/AbortSignal section

Tests (all .todo stubs for TDD):
- VM behavior: AbortController API, static methods, hook integration
- Step-side: stream reader setup, abort propagation, ops queue
- Serialization round-trips: all boundaries, encryption, nested structures
- Consistency: race conditions, partial failure, eventual convergence
- E2E workflows: timeout, parallel, step-initiated, hook-triggered, replay

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

* fix: use correct frontmatter type for error page

Change type from "error" to "troubleshooting" to match the valid
frontmatter schema used by all other error pages.

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

* docs: address review feedback on cancellation docs

- abort() in workflow does not synchronously update signal.aborted;
  instead it queues hook resumption and the replay handles state update
- stream name and hook token are generated at serialization time (not
  deterministically in the workflow) and stored in the event log
- use throwIfAborted() instead of manual signal.aborted checks

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

* docs: document runtime change for processing abort queue items on completion

The current runtime only processes invocation queue items on suspension.
When abort() is called after the last suspension point and the workflow
completes, the queue items are dropped with a warning. Document that the
runtime needs to flush abort-related items on completion/failure too.

Add test stubs for this behavior.

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

* docs: generalize queue processing on completion to all item types

Processing pending invocations queue items on workflow completion/failure
should apply to all queue item types (steps, hooks, waits, abort signals),
not just abort-related ones. Update docs and tests accordingly.

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

* docs: abort errors in steps are automatically wrapped in FatalError

When a step throws due to an abort (AbortError from fetch, throwIfAborted,
etc.), the error is wrapped in FatalError so the step skips retries. An
abort is intentional cancellation, not a transient failure.

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

* docs: remove contrived "aborting from within a step" example

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

* docs: add meaningful step-initiated abort example (quota monitor)

Replace the contrived example with a watchdog pattern where a monitoring
step polls an external condition and aborts parallel work when triggered.

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

* docs: remove unnecessary "as const" from hook cancellation example

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

* feat: implement serializable AbortController/AbortSignal

Core serialization layer:
- Add AbortController/AbortSignal to SerializableSpecial interface
- Add reducers for all 4 contexts (external, workflow, step, common)
- Add revivers for all 4 contexts with stream-backed propagation
- Add reviveAbortController helper for step/external contexts
- Guard instanceof checks for VMs without AbortController global

Workflow VM:
- New workflow/abort-controller.ts with createCreateAbortController factory
- WorkflowAbortSignal class with hook-backed state
- AbortSignal static methods (abort, any, timeout blocked)
- Hook integration via invocations queue and events consumer

Supporting changes:
- Add ABORT_STREAM_NAME, ABORT_HOOK_TOKEN symbols
- Add getAbortStreamId() for system stream namespace
- Add isSystem, abortRequested, abortReason to HookInvocationQueueItem
- Add isSystem to world Hook entity and events
- Wrap AbortError in FatalError in step handler (skip retries)
- Add AbortController/AbortSignal to Serializable type
- Add observability revivers for abort types
- Add isSystem to postgres schema and web-shared attribute panel

All 454 existing tests pass with no regressions.

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

* feat: wire up AbortController in workflow VM and process queue on completion

- Wire up AbortController/AbortSignal in workflow VM (workflow.ts)
- Add abort processing to suspension handler (hook resume + stream write)
- Process pending queue items on workflow completion (throw
  WorkflowSuspension instead of warning for actionable items)
- Fix instanceof guards for non-function AbortSignal in VM
- Update test to expect WorkflowSuspension for unawaited steps

All 454 existing tests pass.

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

* feat: implement tests and Request.signal serialization

Tests (516 passing, 18 todo for integration tests):
- 18 VM behavior tests (abort-controller.test.ts)
- 18 step-side behavior tests (abort-controller-step.test.ts)
- 4 consistency tests + 14 integration todos (abort-consistency.test.ts)
- 14 serialization round-trip tests (serialization.test.ts)
- 7 hook integration + 4 integration todos (step.test.ts)

Request.signal serialization:
- Add signal field to SerializableSpecial Request type
- Include signal in Request reducer when present
- Pass signal through in external and step Request revivers

Fix workflow reviver for AbortController/AbortSignal:
- Use plain objects instead of prototype-based stubs

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

* test: implement all remaining .todo test stubs

Convert all 27 remaining .todo stubs to real implementations:
- 14 consistency tests (race conditions, partial failures, queue processing)
- 4 hook integration tests (suspension handler, hydration, eventual consistency)
- 9 e2e tests (timeout, parallel, step-abort, hook-cancel, replay, external signal)

All 558 tests pass, 0 todos remaining.

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

* fix: address PR review comments + add changelog

PR review fixes:
- Move cancellation after streaming in foundations nav
- Fix AbortSignal reducer to detect WorkflowAbortSignal via symbol
- Guard AbortController reducer from matching AbortSignal objects
- Add e2e tests: throwIfAborted, reason types, uncaught fetch AbortError

Changelog:
- Add hidden changelog section (not in sidebar, accessible via URL)
- Add draft changelog entry for serializable AbortController/AbortSignal

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

* feat: show changelog in nav for preview deployments only

- Add `preview` flag to nav items in geistdocs.tsx
- Filter preview items in Navbar (server component) based on VERCEL_ENV
- Show "Preview" badge on preview nav items in DesktopMenu
- Changelog link visible in preview deployments and local dev only

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

* feat: move preview badge from home page to navbar

Move the PreviewBadge (with package tarball install modal) from the
fixed bottom-right position on the home page to the navbar, so it
appears on every page during preview deployments.

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

* feat: consolidate preview tools into single Internal page

Replace separate Changelog nav item and PreviewBadge with a single
"Internal" page that only appears in preview deployments:
- Rename docs/changelog/ to docs/internal/
- Internal page includes preview package install commands and draft
  changelogs in one place
- Nav shows "Internal" with Preview badge in preview/dev only
- Remove PreviewBadge from navbar (now on the Internal page)
- Add callout that page is preview-only

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

* feat: use real deployment URLs on internal page + exclude from indexing

- Add PreviewInstall component with copy-to-clipboard buttons using
  the actual VERCEL_URL (not placeholders)
- Register PreviewInstallServer as MDX component for docs pages
- Exclude /internal/ pages from sitemap.xml, sitemap.md, and llms.mdx
- Add robots.txt Disallow for /internal/ paths

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

* fix: add missing type declarations for docs code sample typechecking

Add declare statements and @setup/@skip-typecheck annotations for
undeclared functions in code samples (stepA, stepB, fetchData,
cancellableStep, splitIntoChunks, processChunk).

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

* fix: add missing type declarations for all docs code samples

Fix docs typecheck CI by adding declare statements and
@skip-typecheck annotations for all undeclared function references
across cancellation docs, error page, how-it-works page, and
internal changelog.

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

* fix: only suspend on completion for abort items, not all pending items

The previous logic threw WorkflowSuspension for any pending queue item
on completion (steps, waits, hooks). This broke fire-and-forget patterns
like `void sleep('1d').then(...)` which intentionally leave a wait in
the queue without awaiting it.

Now only abort-related items (hooks with abortRequested) trigger
suspension on completion. Other pending items get the original warning
behavior — they may be intentional fire-and-forget operations.

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

* fix: all pending queue items are fire-and-forget on completion

Remove special-case suspension for abort items on workflow completion.
ALL pending queue items (steps, hooks, waits, abort signals) are now
fire-and-forget when the workflow completes — they get warned about
but don't block completion. This matches the existing behavior for
fire-and-forget patterns like `void sleep('1d').then(...)`.

Abort signals propagate through the normal suspension flow during
the workflow (not at completion time).

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

* fix: resolve docs typecheck errors in code samples

Move declare statements before imports to avoid TypeScript overload
signature conflicts with auto-inferred imports. Add @skip-typecheck
for conceptual snippets.

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

* fix: abort() in workflow updates signal.aborted synchronously

abort() must update signal.aborted immediately so that:
1. Subsequent reads in the workflow see the correct state
2. Serialization captures aborted=true when passing signal to steps
3. Event listeners fire synchronously

The hook resumption still happens via the suspension handler for
durable event log recording. Both local state and durable state
are now updated.

Fixes e2e failures where steps received aborted=false for signals
that were aborted before being passed to the step.

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

* docs: update how-it-works to reflect synchronous signal.aborted update

abort() now updates signal.aborted synchronously in the workflow.
Update lifecycle diagram and remove outdated paragraph about signal
not being updated synchronously.

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

* fix: ensure abort listeners fire at deterministic point across replays

On replay, hook_received is processed during event consumer subscription
(at AbortController construction time), which is BEFORE the abort() call
in the workflow code. If listeners fired during event processing, they'd
fire at a different point than on first-run — breaking determinism.

Solution: split abort into two phases:
1. _markAbortedFromReplay(): Sets signal.aborted=true (for reads/serialization)
   but does NOT fire listeners. Called by event consumer during replay.
2. abort(): Detects the replay flag and fires listeners at the call site.
   On first-run, fires listeners immediately as before.

This ensures listeners fire at the abort() call site on BOTH first-run
and replay, maintaining consistent ordering of side effects.

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

* test: add replay ordering tests for interleaved hook scenarios

Add 3 tests validating that abort listeners fire at the abort() call
site on both first-run and replay, even when other hook events are
interleaved in the event log.

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

* fix: signal.aborted stays false until abort() is called for deterministic replay

_markAbortedFromReplay no longer sets signal.aborted = true. Both
aborted state and listener firing are fully deferred to abort().
This prevents if-checks on signal.aborted from taking different
branches on first-run vs replay.

Add deterministic branching test (unit + e2e):
  const controller = new AbortController();
  if (controller.signal.aborted) {
    return 'was aborted';  // never taken
  } else {
    controller.abort();
    return 'just aborted';  // always taken, both runs
  }

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

* test: add abort+hook ordering matrix e2e tests (4 combinations)

Test all combinations of listener registration order and event trigger
order to validate deterministic ordering across first-run and replay:

1. addEventListener first, abort() first
2. addEventListener first, resumeHook first
3. hook.then first, abort() first
4. hook.then first, resumeHook first

Each test verifies that abort-listener fires synchronously at the
abort() call site (immediately before 'after-abort' in the log),
regardless of when the hook is resumed or when listeners are registered.

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

* fix: simplify abort — event consumer calls _setAborted directly

Remove the deferred _markAbortedFromReplay approach. The event consumer
now calls _setAborted directly when hook_received is processed, which
sets signal.aborted = true AND fires listeners at that point.

This is correct because:
- Cross-execution aborts (step/external): signal.aborted SHOULD be true
  on replay since the abort is a fact from a previous run. Listeners must
  fire so the workflow can react to the abort.
- Same-execution aborts: abort() fires _setAborted synchronously. On
  replay, the event consumer fires it first, and abort() is a no-op.
- The promiseQueue ensures listeners fire at the deterministic point
  matching the hook_received event's position in the event log.

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

* test: skip abort+hook ordering e2e tests pending full integration

The 4 ordering matrix tests require the abort controller's internal
system hook to be fully wired through the suspension handler. The hook
creation timing interacts with the user hook lookup in getHookByToken.
Skip until the full integration is complete.

All 13 other abort e2e tests pass on CI.

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

* handle dangling streams

* fix postgres world

* fix abort serialization bug

* refactors

* add drizzle migration file

* fix tests

* fix tests

* replace setTimeout probe and any casts with typed abort internals

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

* cover post-serialization abort and nested-in-Request reader cleanup

Two leak paths the prior fix left uncovered:

- External signal aborted after serialization: verifies the listener
  attached by reduceAbortWithListener actually fires and writes the
  abort packet once the caller aborts later.
- Signal nested inside a Request: exposed a real leak. The Request
  constructor copies the signal to an internal AbortSignal, so the
  ABORT_READER_CANCEL symbol set by reviveAbortSignal never reached
  request.signal, and cancelAbortReaders' walker had no Request case
  so Object.values(request) returned []. Fixed both sides:
  - Request reviver copies abort-internal symbols via copyAbortInternals
  - Walker descends into Request.signal explicitly

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

* add v4/v5 docs switcher and pre-release gating

- Mark new abort-controller/cancellation pages with preRelease: true
  (cancellation, how-it-works/cancellation, abort-signal-timeout-in-workflow,
  serializable-abort-controller). preRelease is a new optional frontmatter
  field declared in source.config.ts.
- lib/geistdocs/versions.ts: declarative version list (v4 Latest, v5 Pre-release)
  plus getVersionFromPathname and buildVersionUrl helpers used by the switcher.
- lib/geistdocs/version-source.ts: filter preRelease pages out of the v4
  sidebar tree; rewrite sidebar URLs to /v5/docs/* on v5 so links stay in
  the pre-release view.
- components/geistdocs/version-switcher.tsx: dropdown at the top of the
  sidebar, styled after the ai-sdk.dev pattern (label + subtitle).
- components/geistdocs/pre-release-banner.tsx: banner rendered above the
  docs layout on all /v5/docs/* routes, linking back to /docs/* (Latest).
- app/[lang]/v5/docs: parallel route (layout + page) that reuses the
  existing docs rendering but keeps preRelease pages visible.
- app/[lang]/docs/[[...slug]]: 404 direct access to preRelease pages on v4
  so unreleased content is never reachable without the /v5 prefix.
- next.config.ts: /v5/docs -> /v5/docs/getting-started mirror of the
  existing /docs -> /docs/getting-started redirect.

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

* fix version switcher URL when default locale is hidden

buildVersionUrl assumed segment 0 was the locale, but next.js i18n
middleware hides the default locale from the URL so usePathname()
returns '/docs/...' rather than '/en/docs/...'. The old logic treated
'docs' as the locale and produced '/docs/v5/getting-started' (404)
instead of '/v5/docs/getting-started'.

Detect the locale by checking whether segment 0 is a known structural
token ('docs' or 'v5') rather than by position, so the function works
for both '/docs/...' and '/<locale>/docs/...' inputs.

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

* match ai-sdk pre-release banner styling

Filled sparkles glyph, blue tint on the message text, and a plain
underlined "Go to ..." link in the foreground color instead of a
bordered pill. Matches the ai-sdk.dev v7 banner reference.

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

* match ai-sdk switcher icons and banner link color

- Switcher: colored rounded icon tile next to each version (orange tint
  for pre-release, blue for latest), matching the ai-sdk.dev dropdown.
  Uses a workflow glyph inside a tinted ring.
- Banner link: blue text with a softer underline by default, deeper
  blue on hover. Replaces the foreground-colored link that didn't
  match ai-sdk's styling.

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

* use exact ai-sdk icons and darker banner link

- Switcher tile: use the T-mark SVG and the bg-orange-100/border-orange-300
  (pre-release) / bg-blue-100/border-blue-300 (latest) palette extracted
  from the ai-sdk.dev live markup, with matching dark-mode variants.
- Pre-release banner sparkle: replaced the placeholder with the exact
  three-path geist sparkle used by ai-sdk.
- Banner "Go to Latest" link: foreground color with a muted underline
  by default (same weight as ai-sdk's near-black link), underline
  intensifies on hover. The previous blue-600 was too light.

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

* fix(docs): correct dark-mode colors for pre-release banner and version switcher

The geistcn design-system palette inverts brightness semantics in dark
mode (low indices = dim, high indices = bright) and remaps `blue-*` but
not `orange-*`, so the previous token choices rendered as dim gray-blue
text and a mid-bright blue icon inconsistent with the dropdown list.

- Banner: use `dark:text-blue-900` for icon + label and switch the "Go
  to" link from `text-foreground` to the same blue (with a blue
  underline) so it reads as a single colored banner.
- VersionSwitcher: move the text color onto the SVG itself so the
  `DropdownMenuItem` SVG-color override no longer hijacks the T color,
  and invert the dark blue palette (dark bg, light border, bright T) so
  the selected/trigger icon matches the list icon.
- Active-row check icon: use green instead of `fd-primary` (which
  resolves to near-white in dark mode).

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

* fix: add signal field to Request serializable type

The merge from main moved the Request type into serialization/types.ts
without carrying over the signal?: AbortSignal field, causing the
abort-related reducers/revivers in serialization.ts to fail typecheck.

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

* refactor: address review feedback on abort serialization

- Dedupe abort listener attach in serialization reducers via marker symbol
  (prevents N-listener leak when one controller is serialized to N steps,
  which would double-close the backing stream on abort).
- Replace token.replace('abrt_', '') string-surgery in suspension-handler
  by storing streamName directly on HookInvocationQueueItem at the point
  where it's already known (workflow/abort-controller.ts construction).
- Document the deliberate sync-vs-microtask listener divergence in the
  workflow VM (replay determinism > spec parity inside the VM).
- Add changeset noting the AbortError -> FatalError behavior change.

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

* docs: correct cancellation docs against implementation

- Remove the contradictory paragraph claiming signal.aborted is not set
  synchronously when abort() is called in the workflow. The implementation
  sets it sync via _setAborted; replay re-applies via the events consumer.
- Reword the "Stream Succeeds, Hook Fails" recovery — there's no in-process
  retry loop on the step-side resumeHook call; convergence comes from the
  next replay re-reading the stream.
- Tighten Request.signal handling: plain non-aborted native signals are
  intentionally dropped to avoid minting stream infra for auto-generated
  Request signals; only already-aborted or workflow-tagged signals are
  forwarded.
- Replace the wrong "Pending queue items processed on completion" bullet
  with an accurate fire-and-forget note matching the warn-only behavior.

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

* fix: DOMException serialization (replace broken isNativeError guard)

DOMException is `instanceof Error` in Node but does NOT pass
`types.isNativeError()` — the existing reducer's first guard was
`isNativeError(value)`, so DOMException never matched. Devalue then
fell through to its arbitrary-POJO failure path.

This surfaced as a real bug for AbortController/AbortSignal: when
abort() is called with no argument, native AbortController synthesizes
a default DOMException as signal.reason. Returning that signal's reason
from a step (e.g. `{aborted, reason: signal.reason}`) crashed step
return-value serialization.

Replace the guard with a constructor-name check (cross-VM safe; same
pattern used elsewhere for matching Error subclasses across realms).

Also fixes 7 pre-existing DOMException tests in serialization.test.ts
that were previously failing on main.

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

* fix: drain pending queue items on workflow completion

End-of-run now goes through the same suspension handler that processes a
real suspension. Previously, items left in the invocations queue when the
workflow function returned (or threw) were dropped with an "uncommitted
operation" warning — `controller.abort()` called as the last statement of
a workflow never actually propagated.

Concretely fixes:
- Abort hooks now write hook_received + stream packet so in-flight steps
  on other compute instances see signal.aborted=true and bail out.
- Unawaited hooks are created (so external callers can resume them).
- Unawaited steps and sleeps are queued (will execute / fire later).

Strengthens abortTimeoutWorkflow's test to inspect the event log for the
hook_received event — the original assertion only verified the workflow
VM's local signal.aborted, which was set synchronously by the abort()
call regardless of whether propagation actually happened. The strengthened
test fails on main and passes after this commit.

Drops the warnPendingQueueItems warning entirely. Drain failures are
swallowed so the workflow's own outcome (return value or thrown error)
remains the source of truth for the run's terminal state.

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

* test: cover the deserialized AbortSignal listener path with an in-flight fetch

The existing abort tests exercised either the polled `signal.aborted` read
path (longStep busy-wait) or the already-aborted-before-fetch path. Nothing
exercised the live listener path: signal starts non-aborted, step kicks off
a fetch against a slow endpoint, abort fires while fetch is awaiting the
response, and fetch's internal `signal.addEventListener('abort', …)` listener
cancels the in-flight HTTP request.

The pre-existing `fetchWithSignal` helper step was orphaned — defined but
not referenced by any workflow. Wires it into a new `abortFetchInFlightWorkflow`
that races a 30s fetch against a 2s sleep, aborts when the sleep wins, and
returns the step's catch-path result. The test asserts both `winner=timeout`
and `fetchResult.aborted=true`, which together prove fetch saw the cancellation
mid-flight (the natural-completion path would set ok=true,aborted=false).

Adds a local /api/delay endpoint to the nextjs-turbopack workbench so the test
doesn't depend on an external service. Honors the request's own AbortSignal
so cancelled connections close immediately.

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

* test: extend abortFromStepWorkflow to verify in-flight sibling cancellation

The original test only asserted that the workflow VM's signal saw aborted=true
after a step called controller.abort(). It didn't actually verify that another
in-flight step received the cancellation through the backing stream — those
two paths are different (workflow VM signal updates via the hook event;
sibling-step propagation runs through the live stream packet).

Restructure the workflow to run longStep (a 30s polling loop on signal.aborted)
in parallel with abortFromStep (now sleeps 1s, then aborts). The new assertion
expects longStep.result === 'aborted' — proving it exited via the abort branch
within ~1.5s, NOT ran to its 30s natural completion. Returning 'completed'
would mean realtime cross-step cancellation is broken.

abortFromStep gained an optional delayMs parameter so it can be sequenced
against a sibling without an out-of-band sleep.

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

* fix: dehydrate abort stream packets via the same machinery as hook events

The abort stream packet was being encoded with bare `JSON.stringify({reason})`
on the writer and decoded with `JSON.parse(text).reason` on the reader. That
codec drops `undefined` (so a reason-less abort wrote literally `{}` and the
observability UI showed an empty stream), and doesn't handle DOMException or
any other type the rest of the codebase serializes via devalue+reducers.

Switch all three sites — suspension-handler workflow-side write, patched
abort step-side write, and `setupAbortStreamReader` — to use
`dehydrateStepArguments`/`hydrateStepArguments`. Now the `reason` round-trips
with full type fidelity (DOMException, custom errors, encrypted payloads),
matching what the hook event payload already does. The suspension handler
literally reuses the same dehydrated bytes for the event and the stream so
they're guaranteed identical.

Encryption key threading:
- Suspension handler: `encryptionKey` was already in scope.
- Patched abort: read from `contextStorage.getStore()?.encryptionKey` (set
  by the step handler before invoking the deserialize chain).
- Reader (`setupAbortStreamReader`): read from `contextStorage.getStore()?.encryptionKey`
  for the same reason; falls back to `undefined` when called outside step
  context (the hydrate path is key-tolerant).

On-disk verification:
- Before: chunk for `controller.abort()` (no reason) was `00 7b 7d` — 3 bytes,
  the literal JSON `{}`, no reason carried at all.
- After: chunk is `00 64 65 76 6c [{"aborted":1,"reason":2},true,"test"]` —
  43 bytes, devalue-flat-encoded with the reason intact.

Updated the existing stream-reader unit test to encode its mock payload
through the same dehydrate path so the reader can decode it.

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

* test: cover addEventListener, mid-flight throwIfAborted, and step-initiated determinism

The polled-`signal.aborted` path was the only abort consumption pattern
exercised end-to-end. Three new e2e tests fill the gaps:

- **abortListenerWorkflow** — `signal.addEventListener('abort', cb)` firing
  on the deserialized step-side signal. Distinct from abortFetchInFlightWorkflow
  which only proves it indirectly through fetch's internal listener; this one
  verifies user-attached listeners directly. Step resolves with via:'listener'
  if propagation worked, via:'timeout' on a 30s safety timeout if it didn't.

- **abortThrowIfAbortedMidFlightWorkflow** — throwIfAborted() in a polling
  loop, not just at step entry. The existing abortThrowIfAbortedWorkflow
  only covers the synchronous-throw case on a pre-aborted signal. This one
  starts the signal non-aborted, polls throwIfAborted every 500ms, and aborts
  from a sibling step after 1s. Verifies the DOMException propagates as
  FatalError (no retries) when fired mid-flight.

- **abortDeterministicBranchFromStepWorkflow** — counterpart to
  abortDeterministicBranchWorkflow, but with the abort source being a step
  (via the patched abort() path / hook event) instead of the workflow body.
  Both branch-reads MUST take the same path on every replay. Uncovered a
  real semantic: signal.aborted reflects step-initiated aborts only after
  the next promise-queue checkpoint (sleep, step await, etc.) since
  _setAborted is chained on promiseQueue. The test inserts the required
  sleep('1s') checkpoint and asserts both pre and post values.

Helper steps factored: stepWaitingOnAbortListener and stepPollingThrowIfAborted.

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

* test: drop signal.aborted shortcut in stepWaitingOnAbortListener

The shortcut would have masked a regression in the addEventListener-on-an-
already-aborted-signal contract. Per the AbortSignal spec, calling
addEventListener('abort', cb) on an aborted signal fires the callback (on a
microtask), so user code that subscribes via the listener path alone — the
common pattern — depends on it. Test the contract directly: rely solely on
the listener resolving the promise. If addEventListener-on-aborted ever
silently breaks, this test now reports via:'timeout' instead of paving over
it with a fast-path that reads signal.aborted directly.

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

* fix: add DOMException reviver to observabilityRevivers so the o11y UI hydrates abort reasons

The observability UI (and CLI) hydrates step IO via `observabilityRevivers`,
which had no `DOMException` entry. When a step returned a value containing
a DOMException (typically `{aborted, reason: <DOMException>}` — synthesized
by native AbortController when abort() is called with no reason), devalue's
`parse` would throw on the `["DOMException", ...]` tag, `hydrateStepIO`'s
try/catch would swallow it, and the raw devalue-flat string survived to
the UI. The user-visible result was step Output showing literal text like:

  devl[{"aborted":1,"reason":2},true,["DOMException",3]...]

instead of a JSON viewer with a proper DOMException card.

Add the reviver. Reconstruct as a real DOMException when the global is
available (modern browsers + Node 18+, where the o11y consumers run),
falling back to a name-tagged Error otherwise. Preserves message/name/
stack/cause for display.

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

* test: cover the external-signal-aborted-in-flight propagation path

The existing abortExternalSignalWorkflow only validates a static read of
an already-aborted signal — it tells us nothing about whether an abort
that fires AFTER serialization actually propagates from the caller process,
through the listener attached at workflow-start, into the backing stream,
and out into the deserialized signals on the in-flight step compute.

Add abortExternalSignalInFlightWorkflow that takes a non-aborted signal
and runs two parallel consumption patterns against it: longStep (polling
signal.aborted) and stepWaitingOnAbortListener (addEventListener path).

The test creates a fresh AbortController, calls start() with its non-aborted
signal, and aborts the source controller 1.5s later via setTimeout — well
after both steps are mid-flight on their compute instances.

Both consumers must see the cancellation:
- pollResult === 'aborted' (NOT 'completed' — that would mean longStep ran
  the full 30s without ever seeing signal.aborted=true)
- listenerResult.via === 'listener' (NOT 'timeout' — that would mean the
  addEventListener callback never fired)

This exercises the longest end-to-end abort path in the codebase:
  caller-process AbortController → serialization-time listener →
  backing stream → step compute → deserialized signal →
  (poll OR addEventListener)

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

* fix(test): use httpbin.org/delay for abortFetchInFlightWorkflow

The previous setup added a /api/delay route to workbench/nextjs-turbopack
to give the test a slow endpoint to fetch against. That made the workflow
fail in CI on every other workbench (nextjs-webpack, astro, sveltekit, …)
since the route only existed on one of them — fetch returned 404 and the
test failed within 1s instead of taking the expected ~3s.

Switch to httpbin.org/delay/30, the same external-service pattern used by
other e2e workflows in this file (jsonplaceholder, example.com). Removes
the per-workbench dependency. Drops the now-unused deploymentUrl argument
from the workflow signature and test call site.

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

* docs: fix serialization page — drop duplicate header, move AbortController section

Two issues on the serialization foundations page:

1. `## Pass-by-Value Semantics` appeared twice. The second occurrence had no
   body, which rendered as an orphaned heading just above the AbortController
   section in the docs preview.

2. `## AbortController & AbortSignal` was at the bottom of the page, after
   `## Custom Class Serialization`. It belongs above the custom-class section
   so the standard serializable types are grouped together before the
   advanced topic.

Removes the empty duplicate; relocates the AbortController section to sit
between Request & Response and Custom Class Serialization. No content
changes inside the section.

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

* docs: note that run.cancel() is the same as the observability Cancel button

The Run Cancellation section showed the programmatic path but didn't tie
it back to the UI. Add a callout: calling run.cancel() is the same action
as clicking the Cancel button on a run in the observability UI — both
produce identical run_cancelled events.

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

* test: cover AbortSignal.any in both workflow VM and step contexts

Two distinct paths: the workflow VM ships its own AbortSignal.any impl in
workflow/abort-controller.ts (composes WorkflowAbortSignals via listeners,
no stream/hook backing on the composite), while steps use the native
Node implementation over deserialized signals. Neither was tested.

abortAnyInWorkflowWorkflow exercises the VM impl directly: creates two
controllers, composes their signals via AbortSignal.any, aborts one, and
asserts the composite reflects the abort synchronously without any stream
round-trip. Also asserts the other source signal is unaffected so a
mass-abort regression would surface here.

abortAnyInStepWorkflow exercises the longest end-to-end path that uses
AbortSignal.any: source controller is aborted by a sibling step, abort
flows through the workflow's VM, then the backing stream, into the step's
deserialized signal, into the AbortSignal.any composite, into the user's
listener. Returning via:'timeout' instead of via:'listener' would mean a
break anywhere on that chain.

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

* Update .changeset/fix-dom-exception-serialization.md

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

* Update .changeset/serializable-abort-controller.md

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

* Update .changeset/drain-pending-queue-on-completion.md

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

* docs(errors): match slug-as-title convention + simplify the timeout example

Two toolbar-comment fixes on the abort-signal-timeout-in-workflow error
page:

1. The page title was Title Case ("AbortSignal.timeout() in Workflow")
   while every other page in docs/content/docs/errors/ uses the kebab-case
   slug as the title (e.g. timeout-in-workflow, fetch-in-workflow,
   workflow-not-registered). Match the convention.

2. The recommended replacement for AbortSignal.timeout() was a
   Promise.race that wrapped the abort + null sentinel + custom Error
   throw. Boil it down to the much simpler:

       const controller = new AbortController();
       void sleep("10s").then(() => controller.abort());
       return await fetchData(controller.signal);

   If fetchData finishes within 10s you get the response; if not, the
   timer fires controller.abort(), fetch rejects with AbortError, and
   the step's failure propagates to the workflow as a FatalError (no
   retries). Same observable behavior, no Promise.race scaffolding.

Adds abortVoidSleepTimeoutWorkflow + matching e2e test that exercises
this exact pattern end-to-end so the doc example is verified runnable
(not just pseudocode). Asserts the fetch is cancelled mid-flight by
the timer, returning aborted=true,ok=false from the step.

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

---------

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Karthik Kalyanaraman <karthik.kalyanaraman@vercel.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-05-05 19:42:15 +09:00
Ziyak Jehangir c56c4f6c7e Fix compatibility with Zod 4.4.x (#1902)
* Fix compatibility with Zod 4.4.x

* DCO Remediation Commit for Ziyak <ziyak97@gmail.com>

I, Ziyak <ziyak97@gmail.com>, hereby add my Signed-off-by to this commit: 2bf58718e6

Signed-off-by: Ziyak <ziyak97@gmail.com>

---------

Signed-off-by: Ziyak <ziyak97@gmail.com>
Signed-off-by: Nathan Rajlich <n@n8.io>
Co-authored-by: Nathan Rajlich <n@n8.io>
2026-05-04 22:57:37 +00:00
Nathan Rajlich 5f22832675 Serialize run_failed/step_failed errors through serialization pipeline (#1851)
* Serialize run_failed/step_failed errors through serialization pipeline

Switch run_failed, step_failed, and step_retrying events to persist
the full thrown value via the workflow serialization pipeline (as
SerializedData / Uint8Array) instead of a lossy { message, stack, code }
StructuredError shape. Consumers hydrate via hydrateRunError /
hydrateStepError to reconstruct the original thrown value, preserving
Error subclass identity, cause chains, and custom properties.

- WorkflowRun.error and Step.error are now SerializedData
- WorkflowRun gains a top-level errorCode plaintext field
- WorkflowRunFailedError.cause is now the hydrated thrown value
- Adds world-postgres migration 0010_add_error_code.sql
- Legacy pre-pipeline errorJson records surface as undefined on read

* Update Next.js workbenches for new WorkflowRunFailedError.cause type

cause is now `unknown` (the hydrated thrown value) rather than
`Error & { code }`. Defensively extract Error-shaped fields when the
hydrated value is an Error, otherwise round-trip the raw value, and
expose the new `errorCode` classification field.

* Update docs for WorkflowRunFailedError.cause: unknown

The hydrated `cause` is now `unknown` (the original thrown value
through the serialization pipeline) and the error classification has
moved to the top-level `errorCode` property. Update the two affected
docs pages and the `TSDoc` interface to reflect the new shape, and
narrow `cause` with `instanceof Error` before accessing fields.

* Expand test coverage for the run/step error serialization pipeline

Unit tests:
- 19 new dehydrate/hydrate{Step,Run}Error round-trip tests covering
  FatalError, plain Error, built-in Error subclasses, non-Error thrown
  values (string, plain object), cause chains, encryption round-trip,
  the binary format prefix contract, and the unserializable / unknown-
  format error paths.
- 5 new tests for Run.returnValue when the run is failed: hydrated
  FatalError + cause as cause, plain Error preservation, non-Error
  thrown values surfaced verbatim, cross-class cause chains, and the
  hydration-failure fallback that still surfaces errorCode.

E2E tests (new, in 99_e2e.ts + e2e.test.ts):
- Step throw → workflow catch round-trips a FatalError with a TypeError
  cause chain, asserting class identity, fatal marker, and cause name +
  message all survive the step_failed event pipeline.
- Workflow throw → run_failed reaches  status with the new
  top-level errorCode metadata exposed (cause-shape coverage lives at
  the unit level, since the SWC plugin's class registration is not
  invoked in the plain-Node e2e runner).
- Workflow throw of a non-Error value round-trips that value verbatim
  as WorkflowRunFailedError.cause.

Adjustments to existing assertions:
- error.cause is now ; tests narrow with
  and use the new top-level  field instead of .
- step.error / run.error from CLI --withData are now hydrated payloads:
  unregistered class instances surface as Instance refs whose
  carries the original message + stack.

Observability hydration:
- hydrateStepIO / hydrateWorkflowIO in serialization-format.ts now
  hydrate the  field via hydrateData, so the CLI and web UI
  continue to surface readable run/step error messages and stacks.

* Tighten error serialization changeset description

* Trim error serialization changeset to a single sentence

* Resolve FatalError/RetryableError revivers via cross-realm registry

When a workflow runs in a Node `vm` context, its bundled
`@workflow/errors` is a different module instance than the host's
import (separate prototype chains, separate class identity). Calling
`new FatalError(...)` from the host-side reviver produces a
host-realm instance that fails `err instanceof FatalError` checks
in the workflow code — even when the serialized payload was correctly
tagged via the dedicated `FatalError` reducer.

Surfaced by the local-prod e2e "step throw round-trips FatalError"
test on Next.js Turbopack: each route gets its own bundled chunk, so
the flow handler's `@workflow/errors` and the workflow VM bundle's
`@workflow/errors` are two distinct copies of the same module.

Fix:

- Each bundled copy of `@workflow/errors` self-registers its
  `FatalError` and `RetryableError` classes on `globalThis` via
  `Symbol.for("@workflow/errors//FatalError")` /
  `Symbol.for("@workflow/errors//RetryableError")`. First load wins
  per realm; the descriptor is non-writable / non-configurable to make
  accidental clobbering loud.

- The revivers in `@workflow/core`'s common reducers module read the
  consumer's `globalThis` (passed in as `global`) to pick up the
  realm-local class, falling back to the host-imported class when no
  registration is present (e.g. in the CLI / test runner).

* Use `types.isNativeError` to remap workflow stacks across VM realms

The runtime's run-failure path computes a source-map-remapped stack
and then assigns it back onto the thrown value via `if (err
instanceof Error) err.stack = errorStack`. Workflows run inside a
Node `vm` context, so a workflow-thrown error is an instance of the
VM realm's `Error` — `instanceof` against the host realm's
`Error` returns `false`, the assignment is skipped, and the
serialized `run_failed` event carries the un-remapped (bundled-line-
number) stack instead of the source-mapped one.

Switch the gate to `types.isNativeError`, which uses V8's internal
type tag and works across realms — same approach already in place
for the serialization reducers.

Caught by the local-prod e2e "nested function calls preserve message
and stack trace" and "cross-file imports preserve message and stack
trace" tests, which assert that the persisted run-error stack
contains `99_e2e.ts` / `helpers.ts`.

* Sync CLI revivers with core + add toJSON shim for Error subclasses

Two issues with the CLI's hand-rolled reviver list:

1. It hadn't been updated for the new first-class Error subclass
   reducers (`TypeError`, `RangeError`, `FatalError`, `RetryableError`,
   etc.). devalue throws "Unknown type X" when it encounters a
   reduced value with no matching reviver, and `hydrateResourceIO`
   swallows that error and surfaces the raw `Uint8Array` payload —
   so `step.error` / `run.error` showed up as raw byte dumps in
   `workflow inspect` output.

2. Even with all the right revivers, `Error.prototype`'s `message`
   / `stack` / `cause` are non-enumerable, so `JSON.stringify`
   (used by `workflow inspect --json`) drops them — leaving the
   subclass-specific enumerable fields (e.g. `FatalError.fatal`)
   visible but the actual error data missing.

Fix:

- Build the CLI reviver set on top of `getCommonRevivers()` from
  `@workflow/core` so the CLI stays in sync with the runtime's
  reducer set automatically. New core reducers/revivers will Just
  Work without any CLI-side change.

- Wrap each Error reviver from the common set with a thin shim that
  attaches a non-enumerable `toJSON` method to the produced
  `Error` instance. `JSON.stringify` calls `toJSON` and gets a
  full object (`name` + `message` + `stack` + `cause` + any
  enumerable subclass fields like `fatal` / `retryAfter` /
  `errors`); `util.inspect` ignores `toJSON` and renders the
  canonical `Error: msg\\n at ...` format. Best of both worlds for
  CLI output without compromising the runtime hydration path.

Caught by the local-prod e2e "basic step error preserves" and
"cross-file step error preserves" tests, which read
`failedStep.error.message` / `.stack` from the CLI's JSON output.

* Clarify parseErrorJson JSDoc to match its always-null return

The previous JSDoc described preserving legacy values "for best-effort
hydration" which contradicted the implementation, where legacy errors
are intentionally surfaced as absent (the pre-pipeline shapes can't be
hydrated by the new error revivers). Rewrite the comment so the contract
matches behavior. Also rename the now-unused parameter to `_errorJson`
to reflect that the function ignores it.

Caught by a code review on #1851.

* Refine error-handler ergonomics on the step / run hot paths

Three review-driven adjustments that all touch the queue handlers and
their interaction with the error serialization pipeline:

1. Memoize the per-run encryption key fetch. The step handler used to
   eagerly fetch + import the key at the top of every step delivery so
   the value would be in scope for every potential dehydrateStepError
   path. That pessimized step-started early-return cases (the fetch
   happens unconditionally even when the step never reaches user code)
   and required duplicating the same boilerplate at four call sites in
   runtime.ts. Introduce `memoizeEncryptionKey(world, run)` in
   runtime/helpers.ts that returns a lazy, single-fetch accessor;
   step-handler / runtime call sites use `await getEncryptionKey()`
   instead. The first caller pays the fetch cost, subsequent callers
   await the cached promise, and steps that fail before any
   encryption-aware work happens skip the fetch entirely.

2. Preserve the prior attempt's serialized error as the cause on the
   defensive max-retries-exceeded `step_failed` re-invocation guard.
   The existing comment explicitly opted out of cause attachment, but
   the symmetric post-failure path below already does this and the
   reviewer is right that consumers shouldn't have to walk the
   step_retrying event history to recover the underlying error. Best-
   effort: if hydration of the prior `step.error` throws, fall back
   to a FatalError without cause rather than letting the event write
   itself fail.

3. Document the intentional `unflatten` throw in
   `hydrateStepError` / `hydrateRunError` for non-Uint8Array input.
   SDK version is pinned per workflow run via skew protection so the
   non-binary branch is dead in production; if a misshapen value
   reaches it, surfacing the throw via the surrounding o11y try/catch
   is more debuggable than masking it. Add a comment so future
   reviewers don't reach for a defensive fallback.

A standalone `falls back to plaintext` suggestion on the run_failed
key fetch was rejected: when encryption is configured we should fail
loudly rather than silently emit plaintext error data. The queue's
redelivery semantics will retry the key fetch; persistent KMS outages
get logged with the existing "persistent error preventing the run from
being terminated" message rather than a security regression.

* Hydrate `event.eventData.error` in event listings

`hydrateEventData` enumerated the per-event fields that need
hydration (`result`, `input`, `output`, `metadata`, `payload`)
but omitted the new `error` field on `step_failed`,
`step_retrying`, and `run_failed` events. Without this branch,
o11y tools that list events (e.g. `workflow inspect events`) surface
the raw `Uint8Array` payload instead of a hydrated
`{ name, message, stack, … }` object even though the entity-level
`Run.error` / `Step.error` paths already hydrate.

Mirrors the existing per-field branches; the `try/catch` leaves the
field un-hydrated on parse failure rather than failing the whole
event view. Adds a unit test.

* Use `.is()` static checks in `classifyRunError` for cross-realm safety

Workflows execute inside a separate `vm` realm: the
`WorkflowRuntimeError` class bundled into the workflow code and the
host-imported one are distinct constructors, so an
`err instanceof WorkflowRuntimeError` check on a VM-thrown error
returns `false` and we'd misclassify genuine runtime errors (corrupted
event log, missing timestamps, workflow/step not registered) as user
errors.

Switch to each subclass's `.is()` static (a name-based duck check that
works across realms). Since `WorkflowRuntimeError.is` only matches its
own concrete name, enumerate every concrete subclass we want to
recognize (`StepNotRegisteredError`, `WorkflowNotRegisteredError`)
in a `RUNTIME_ERROR_CHECKS` table; keep that table in sync with the
class hierarchy in `@workflow/errors`.

Existing `classify-error.test.ts` already covers `WorkflowRuntimeError`
and `WorkflowNotRegisteredError` cases — both still pass.

* Add e2e coverage for step throws of non-Error values

We had `errorWorkflowThrowNonErrorValue` (workflow body throws a plain
object — round-trips verbatim as `WorkflowRunFailedError.cause`) but
no symmetric coverage for the step-throw side. Step-throw goes through
a different code path: non-Error values aren't recognized as
`FatalError` (no `name === 'FatalError'`) nor `RetryableError`,
so they take the transient retry path. After max retries the runtime
wraps the original thrown value as `cause` on a fresh `FatalError`
which the workflow's catch block then sees.

Add a workflow that throws a recognizable plain object from a step
with `maxRetries = 0` (so we exhaust on first attempt and avoid a
long test wait) and a workflow that asserts the wrapped FatalError
shape: `isFatal`, `instanceof FatalError`, message includes the
original object's serialized form, `cause` is the original non-Error
object verbatim with structure preserved.

Documents the current retry-then-wrap behavior so any future change
to "non-Error throws skip retries" semantics has to update the test.

* Note legacy postgres error-data loss in the run/step error changeset

Pre-upgrade failed runs that wrote into world-postgres's deprecated
`error` text column can't be hydrated through the new pipeline (the
shape is incompatible with the new revivers). The new runtime
intentionally surfaces them as `error: undefined` on read; the
original payload is still readable directly from the `errorJson`
column for manual inspection. Add a one-sentence note to the
changeset's migration text so consumers upgrading don't get blindsided
by suddenly-empty error fields on historical runs.
2026-05-04 15:18:46 -07:00
Peter Wielander 8ea1532e48 [core] Combine flow+step bundle and process steps eagerly (#1338) 2026-05-04 09:53:02 +00:00
Peter Wielander 66d49c0db6 [world] Restructure stream interface, require run ID for all step and stream operations (#1293) 2026-04-09 13:25:16 -07:00
Peter Wielander a5c90cefba [core] [world] Fix community world E2E tests broken by specVersion bump (#1658) 2026-04-08 13:14:59 -07:00
Peter Wielander 5f138f2cee [core] [world] Gate CBOR queue transport on specVersion (#1627) 2026-04-07 09:36:51 -07:00
Peter Wielander 7e70d1823a [core] Add configurable stream flush interval per world (#1533) 2026-04-06 12:38:01 -07:00
Peter Wielander c8dce52606 [core] [world] Lazy run creation on start (#1537) 2026-04-06 12:25:23 -07:00
Peter Wielander b30b0dcab6 [world] Asymmetric ULID timestamp validation thresholds (#1605) 2026-04-03 12:54:09 -07:00
Peter Wielander a98f8de53f [core] Combine initial run fetch, event fetch, and run_started event creation (#1569) 2026-04-01 14:23:29 -07:00
Peter Wielander 329cdb3e1b [world] Re-enqueue active runs on world restart (#1534) 2026-03-30 13:48:09 -07:00
Peter Wielander 01bbe66d5a [world] Add stream pagination and metadata endpoints (#1470) 2026-03-23 17:39:39 -07:00
Peter Wielander 78f1b0e19f [core] Support negative startIndex for streaming (#1460) 2026-03-20 13:29:42 -07:00
Pranay Prakash f7bbe71e39 Add TSDoc for stepName field and document world.steps API (#1451)
* feat: add stepName field for human-readable step names

Slack-Thread: https://vercel.slack.com/archives/C09125LC4AX/p1773936163539109?thread_ts=1773936163.539109&cid=C09125LC4AX
Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>

* docs: add step examples with resolveData and hydration, document parseStepName/parseWorkflowName utilities

- Split step examples into two: one with resolveData: 'none' for efficient listing without data, and one showing how to hydrate input/output with observabilityRevivers
- Add TSDoc to stepName and workflowName fields explaining the machine-readable format and how to use parse utilities
- Document parseStepName() and parseWorkflowName() from @workflow/utils/parse-name for extracting shortName and moduleSpecifier for UI display
- Add Callout explaining the naming format

---------

Co-authored-by: v0 <v0[bot]@users.noreply.github.com>
Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
2026-03-19 10:56:01 -07:00
Pranay Prakash 84599b7ec5 feat: classify run failure error codes and improve error logging (#1340)
- Add RUN_ERROR_CODES (USER_ERROR, RUNTIME_ERROR) to @workflow/errors
- Populate errorCode in run_failed events via classifyRunError()
- Update web UI StatusBadge to show amber dot for infrastructure errors
- Improve world-local queue error logging (concise, no body dump)
- Improve schema validation error messages (concise, verbose behind DEBUG)
- Add e2e tests for error code flow and infrastructure error retry

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:42:50 -07:00
Karthik Kalyan 2f0772d3df Track Vercel request IDs on workflow events for logs correlation (#1396)
* [logs] track vercel request ids for workflow events

* [logs] add tests

* Update packages/world/src/events.ts

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>

---------

Signed-off-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-03-17 10:08:37 -07:00
Karthik Kalyan 94c14c746b [world] When resolveData='none', only strip eventData related to refs, keep other metadata (#1364)
* add stepName with events

* add changeset

* add workflowname to run created

* add postgres migration

* update world-local

* update world-local

* preserve the fields in the original shape

* fix tests

* strip only ref/payload fields

* stub the helper into world

* add test coverage

* fix web package

* fix web package to not pass withData: true
2026-03-16 19:47:33 +00:00
Nathan Rajlich 825417acba feat: support deploymentId: 'latest' in start() to resolve most recent deployment (#1317)
* feat: support `deploymentId: 'latest'` in `start()` to resolve most recent deployment

Add resolveLatestDeploymentId() to the World interface and implement it
in world-vercel, which calls the Vercel API to resolve the latest
deployment for the current environment. When start() receives
deploymentId: 'latest', it invokes this method and replaces the sentinel
with the resolved deployment ID before passing it to event creation,
queue dispatch, and encryption key derivation.

* fix: address PR review feedback

- Use encodeURIComponent() for deployment ID in API URL path
- Avoid mutating caller-provided opts; pass resolved deploymentId via
  spread into getEncryptionKeyForRun context instead
- Add test asserting getEncryptionKeyForRun receives resolved ID
- Add test for OIDC token success path in resolve-latest-deployment
2026-03-11 18:20:40 +00:00
Peter Wielander d8daa2a9a9 [world] Add method to get individual events (#1287) 2026-03-06 14:35:12 -08:00
Peter Wielander 11dcb646d3 [world-local] [world-postgres] [cli] Validate local run ID and quiet dotenv logs (#1273) 2026-03-05 21:35:23 +00:00
Pranay Prakash adfe8b6b11 Add isWebhook flag to prevent hooks from being resumed via public webhook endpoint (#1270)
* Add isWebhook flag to prevent hooks from being resumed via public webhook endpoint

Hooks created with createHook() are now non-resumable via the public webhook
endpoint by default (isWebhook=false). Only hooks created with createWebhook()
set isWebhook=true, allowing them to be resumed via the public URL.

Also adds HookNotFoundError thrown by all world backends when a webhook
token doesn't match any hook, and an e2e test for the new behavior.

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

* Fix world-local: default isWebhook to false and fix test assertions

- Default isWebhook to false at write time in events-storage
- Default isWebhook to false at read time in hooks-storage (for old data)
- Update test assertions to match HookNotFoundError message

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

* Fix: default isWebhook to true for backwards compat, add postgres migration

- Revert read-side default to `isWebhook ?? true` in world-local for
  backwards compatibility with existing hooks that predate the field
- Add postgres migration 0009 to add `is_webhook` column with default true
- Update drizzle schema to match

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 11:04:53 -08:00
Casey Gowrie b06e491a47 New queue version (#1188)
* New queue package

* Update null messageId

* add comment

* changeset

* Update queue

* token

* queue proxy URL

* fix proxy URL

* update

* name

* comment
2026-02-26 16:49:45 -08:00
Nathan Rajlich b65bb072b5 Add AES-256-GCM encryption primitives and HKDF key derivation (#956)
* Add browser-compatible AES-GCM to core and HKDF key derivation to world-vercel

* update changeset

* Move HKDF key derivation server-side: API returns per-run derived key

* Refactor encrypt/decrypt to accept CryptoKey, export importKey for callers to import once per run

* Pass WorkflowRun entity to getEncryptionKeyForRun where available (avoids redundant lookup)

* .

* Refactor handleSuspension to accept WorkflowRun, pass run entity to getEncryptionKeyForRun

* Overload getEncryptionKeyForRun: accept context for start(), fetch WorkflowRun in resume-hook

* Split changeset into per-package descriptions for world, world-vercel, and core

* Remove unnecessary Uint8Array.from() wrapper around Buffer.from()

* Use zod to parse Vercel API response

* Address peter's comment
2026-02-20 22:47:54 +00:00
Nathan Rajlich 6e72b295e7 Add World.getEncryptionKeyForRun and thread encryption key through serialization (#979)
## Summary

- Adds `World.getEncryptionKeyForRun(run)` returning `Uint8Array | undefined` as the interface for retrieving per-run encryption keys
- Updates all 8 dehydrate/hydrate serialization functions to accept `key: Uint8Array | undefined`
- Updates runtime callers, CLI, and tests to thread the key parameter through
2026-02-18 00:53:49 -08:00
Nathan Rajlich 54879835f3 Fix pages router default args: use empty array instead of [42] (#1081) 2026-02-17 01:47:48 -08:00
Pranay Prakash 5e06a7c833 Materialize waits as entities to prevent duplicate wait_completed events (#1057)
* Handle 409 conflict when completing waits that were already completed

When multiple concurrent workflow invocations race to complete the same
wait, the server returns 409 (conflict) for duplicates. This change
handles the 409 gracefully in both runtime.ts (sleep elapsed check) and
runs.ts (wakeUpRun), preventing crashes and treating already-completed
waits as successful.

Also updates event-sourcing docs to reflect that waits are now
materialized as entities in storage with atomic completion guarantees.

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

* Point e2e tests at workflow-server preview for wait materialization branch

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

* Materialize waits as entities in local and postgres worlds to prevent duplicate wait_completed events

Adds Wait type/schema to the shared world package and implements wait entity
materialization in both local (filesystem) and postgres world implementations,
matching the DynamoDB behavior. wait_created creates a wait entity with status
'waiting', and wait_completed transitions it to 'completed' with guards that
reject duplicates (409).

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

* Fix waitId to use composite key for consistency with postgres world

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

* Clean up wait entities on terminal run states and register waits migration

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

* Clear WORKFLOW_SERVER_URL_OVERRIDE for merge

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

* Update changeset for all affected packages and restore server URL override

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

* Clear WORKFLOW_SERVER_URL_OVERRIDE now that server PR is merged

The server-side changes (vercel/workflow-server#265) have been merged to main
and deployed to production, so we no longer need to point at the preview URL.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:46:49 -08:00
Peter Wielander 56f22219b3 [core] Handle 429 and 500 errors from worlds in runtime (#966) 2026-02-09 14:43:12 -08:00
Nathan Rajlich d9e98590fa Generate runId client-side in start() and simplify streaming types (#954)
## Summary

- Generates `runId` client-side in `start()` using ULID (`wrun_${ulid()}`), instead of deferring to the server
- Passes client-generated `runId` to `events.create()` and verifies the server accepted it
- Simplifies `runId` types from `string | Promise<string>` to `string` throughout the serialization layer
- Removes the `withResolvers<string>()` / deferred runId pattern that was needed when `runId` was server-generated
- Updates `Streamer` interface and `WorkflowServerWritableStream` to use plain `string` runId
- Updates `Storage.events.create()` first overload to accept `string | null` instead of just `null`

This is a prerequisite for E2E encryption, where we need the `runId` before serialization so it can be used as part of the encryption context.

## Test plan

All existing tests pass. Updated `start.test.ts` to expect client-generated runId, removed `writable-stream.test.ts` promise runId test (no longer applicable).
2026-02-08 00:27:40 -08:00
Pranay Prakash 0ce46b91d9 Add x-workflow-run-id header to queue messages (#922)
* Add x-workflow-run-id header to queue messages

Pass the workflow run ID as a custom header when enqueueing messages, enabling better observability and correlation with VQS server logs.

- Add headers field to QueueOptions interface in @workflow/world
- Pass headers through to VQS client in @workflow/world-vercel
- Include x-workflow-run-id header in all queueMessage calls

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

* Upgrade @vercel/queue to 0.0.0-alpha.36

This version includes the headers support needed for passing x-workflow-run-id to VQS.

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

* Pass custom headers through world-local queue implementation

Custom headers are now spread first in the fetch headers so that system
headers (x-vqs-*, content-type) take precedence and cannot be overridden.

Also adds TODO comment in world-postgres noting headers are not propagated.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 20:34:12 -08:00
Nathan Rajlich c54ba21c19 Add optional writeToStreamMulti function to the World interface (#867) 2026-02-03 17:53:36 -08:00
Peter Wielander a2b688d062 [core] Add runtime and world backwards compatibility for specVersion v1 (#894) 2026-01-30 13:23:08 -08:00
Peter Wielander b16cec4fdf [world] Fix zod compat for specVersion v1 payloads (#890) 2026-01-29 15:00:57 -08:00
Nathan Rajlich 1060f9d04a Change user input/output to be binary data at the World interface (#853) 2026-01-28 10:36:28 -08:00
Pranay Prakash 4966b728a8 implement event-sourced architecture (#621)
* perf: implement event-sourced architecture

* Apply suggestions from code review

* Improve invalid event log handling in step/hook/wait

* Handle serialized workflow run errors correctly

* log error in failing test

* Handle queue idempotency in vercel world

* hotfix for error propogation

* Fix: Incorrect HTTP status code 409 should be 410 for terminal run state rejections in postgres storage

* Fix: The code attempts to pass an unsupported `fatal` property when creating a `step_failed` event. The TypeScript schema for `step_failed` events only allows `error` and `stack` properties, so the `fatal` property causes a compilation error.

This commit fixes the issue reported at packages/core/src/runtime/step-handler.ts:133-139

## TypeScript error: Invalid property 'fatal' in step_failed event

**What fails:** TypeScript compilation fails in `packages/core` due to an invalid property in the `step_failed` event creation.

**How to reproduce:**
```bash
cd /vercel/sandbox/primary
pnpm run -F @workflow/core build
```

**Result:**
```
src/runtime/step-handler.ts(133,32): error TS2769: No overload matches this call.
  Overload 1 of 2, '(runId: null, data: { eventType: "run_created"; ... }, gave the following error.
    Argument of type 'string' is not assignable to parameter of type 'null'.
  Overload 2 of 2, '(runId: string, data: CreateEventRequest, params?: CreateEventParams | undefined): Promise<EventResult>', gave the following error.
    Object literal may only specify known properties, and 'fatal' does not exist in type '{ error: any; stack?: string | undefined; }'.
```

**Issue:** The code attempted to pass a `fatal: true` property in the `eventData` object when creating a `step_failed` event. However, the event schema defined in `packages/world/src/events.ts` for `StepFailedEventSchema` only allows `error` and `stack` properties - the `fatal` property is not part of the schema.

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

* Fix: Code silently skips updating workflowRun if result.run is undefined, causing workflows to be incorrectly skipped instead of throwing an error

* Add hook_conflict event type for duplicate token detection

Implements hook_conflict events across all world implementations to handle
cases where a workflow attempts to use a hook token already claimed by another
workflow. Instead of throwing errors, the system now records hook_conflict
events in the event log, enabling deterministic replay.

- Add HookConflictEvent schema to @workflow/world
- Implement hook_conflict in world-local, world-postgres, and suspension-handler
- Update hook consumer to reject promises with WorkflowRuntimeError on conflict
- Add HOOK_CONFLICT error slug with documentation
- Add e2e and unit tests for conflict scenarios

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add changeset for hook_conflict events

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add unit tests for hook_conflict handling

- Add tests for hook_conflict event in workflow.test.ts
- Fix world-postgres test to not expect removed message field

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Improve hook-conflict.mdx error guide

- Remove redundant third point in 'Why This Happens' section
- Add example showing how to handle WorkflowRuntimeError for hook conflicts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix docs validation: add hook-conflict to errors index

- Fix broken link in hook-conflict.mdx (/docs/foundations/webhooks -> /docs/api-reference/workflow/create-webhook)
- Add hook-conflict to errors index page so it's discoverable by the docs link validator

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix world-local tests for hook_conflict event behavior

Update tests to expect hook_conflict events instead of thrown errors when
duplicate hook tokens are used. This aligns with the new event-sourced
approach where conflicts are recorded as events rather than thrown.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add specVersion property to World interface for backwards compatibility

- Add specVersion property to World interface to track world package version
- Add specVersion to WorkflowRun schema and run_created event data
- World implementations (vercel, local, postgres) set specVersion from npm version
- Server can use specVersion to route operations based on world version
- Add specVersion display to observability UI attribute panel
- Add spec_version column to postgres runs schema

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add migration for spec_version column in postgres schema

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add drizzle migration journal and snapshot for spec_version column

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Regenerate postgres migration using drizzle-kit

- Properly generates migration with drizzle-kit CLI
- Removes deprecated 'paused' status from enum
- Adds spec_version column

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add backwards compatibility for event-sourced runs

- Add RunNotSupportedError for runs requiring newer world versions
- Add semver-based version utilities (isLegacyVersion) to @workflow/world
- World implementations check specVersion and route to legacy handlers
- Legacy runs (< 4.1.0): run_cancelled skips event storage, wait_completed stores event only
- New runs always get current world version (4.1.0-beta.0)
- Make EventResult.event optional for legacy compatibility

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

* Refactor spec version from semver strings to integers

Replace semver-based version compatibility with explicit integer spec versions:
- SPEC_VERSION_LEGACY (1): pre-event-sourcing runs
- SPEC_VERSION_CURRENT (2): event-sourced architecture

Use branded SpecVersion type to enforce importing from @workflow/world.
Remove semver dependency from world, world-local, and world-postgres.

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

* Store error as CBOR in postgres world for consistency with input/output

- Add error_cbor bytea columns to workflow_runs and workflow_steps tables
- Deprecate text error column, rename to errorJson with fallback parsing
- Remove JSON.stringify from error writes (run_failed, step_failed, step_retrying)
- Add parseErrorJson helper for backwards compatibility with legacy data

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

* Use WorkflowRuntimeError and improve run entity handling in core runtime

- Replace generic Error with WorkflowRuntimeError for runtime assertions
- Add explicit check for run entity in run_created response
- Use run.runId instead of event.runId for consistency
- Use actual run status instead of hardcoded 'pending' in attributes

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

* Add specVersion to Step, Hook, and Event entities

- Add specVersion field to Step, Hook, and Event interfaces in @workflow/world
- Add spec_version column to steps, hooks, events tables in postgres schema
- Set specVersion to SPEC_VERSION_CURRENT when creating entities in all worlds
- Update migration to include spec_version columns for all entity tables

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

* Refactor world-local storage into modular files

Split storage.ts (1041 lines) into smaller, focused modules:
- storage/filters.ts: Data filtering helpers
- storage/helpers.ts: ULID and date utilities
- storage/hooks-storage.ts: Hook CRUD operations
- storage/legacy.ts: Legacy event handling
- storage/runs-storage.ts: Run get/list operations
- storage/steps-storage.ts: Step get/list operations
- storage/events-storage.ts: Event create/list operations
- storage/index.ts: Main composition

Also extracted test helpers to test-helpers.ts for reusability.

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

* Remove genversion and World.specVersion property

The World.specVersion string property was never actually read - only
the numeric SPEC_VERSION_CURRENT is used for backwards compatibility.

- Remove genversion dependency and generated version.ts from @workflow/world
- Remove specVersion property from World interface and all implementations
- Minor fix: correct error message to reference 'workflow' package
- Minor fix: correct error source priority in world-vercel events.ts
- Minor fix: update comment in runtime.ts

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

* Remove genversion from world-local, world-postgres, and world-vercel

These packages no longer need genversion since we removed the
World.specVersion property in the previous commit.

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

* Remove version.ts from .gitignore files

No longer needed after removing genversion.

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

* Add legacy/backwards compatibility tests

Tests for world-local and world-postgres covering:
- Legacy runs (specVersion < 2 or null/undefined)
  - run_cancelled handling (updates run, no event stored)
  - wait_completed handling (stores event only)
  - Rejection of unsupported events
  - Hook cleanup on cancellation
- Future runs (specVersion > current)
  - Rejection with RunNotSupportedError
- Current version runs (normal processing)
- Legacy error parsing (errorJson field parsing)

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

* Add hook_received support for legacy runs

When resumeHook() is called on a legacy run (specVersion < 2), the
hook_received event was previously rejected. This adds support for
storing hook_received events on legacy runs without entity mutation,
matching the behavior of wait_completed.

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

* Fix missing genversion in world-vercel

Signed-off-by: Peter Wielander <mittgfu@gmail.com>

* Remove deprecated workflow_completed, workflow_failed, and workflow_started events

Replace with run_completed, run_failed, and run_started equivalents.

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

* Add specVersion to EventWithRefsSchema in world-vercel

The manually-created EventWithRefsSchema was missing the specVersion field,
which caused specVersion to be stripped when using lazy (refs) mode for events.

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

* Wire specVersion from client through world backends

- Core runtime now sends specVersion in run_created eventData
- world-local accepts specVersion from eventData (defaults to current)
- world-postgres accepts specVersion from eventData (defaults to current)

This matches workflow-server behavior where v2 endpoints accept
specVersion from the client, while v1 endpoints default to legacy.

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

* Move specVersion to event object level, propagate to entities

- Add specVersion to BaseEventSchema (event level, not eventData)
- Remove specVersion from RunCreatedEventSchema.eventData
- Core runtime sends specVersion on event object
- world-local reads specVersion from event, propagates to run/step/hook entities
- world-postgres reads specVersion from event, propagates to run/step/hook entities

This ensures specVersion flows from client through event to all created entities.

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

* Update specVersion to be optional in types for backwards compatibility

- specVersion is optional in all entity schemas (runs, steps, hooks, events)
  for backwards compatibility with legacy data in storage
- Runtime always sends specVersion on event requests
- world-local and world-postgres provide fallback to SPEC_VERSION_CURRENT
- Test helpers include specVersion in all event creation calls
- EventWithRefsSchema in world-vercel defaults specVersion to 1 for legacy

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

* Fix world-vercel queue tests missing VERCEL_DEPLOYMENT_ID setup

Two tests were calling queue.queue() without setting up
VERCEL_DEPLOYMENT_ID, causing them to fail with "No deploymentId
provided" error before reaching the code they were testing.

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

* Add specVersion to all event creation calls in core package

The server's CreateEventSchemaV2 requires specVersion on all events,
but only run_created was sending it. This caused 400 Bad Request errors
for all other event types (run_started, run_completed, run_failed,
run_cancelled, step_created, step_started, step_completed, step_failed,
step_retrying, hook_created, hook_received, wait_created, wait_completed).

Now all event creation calls include specVersion: SPEC_VERSION_CURRENT.

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

---------

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-01-22 17:24:52 -08:00
Nathan Rajlich 0aa835fe30 Add typedoc comments to Hook interface (#772) 2026-01-12 17:32:51 -08:00
Nathan Rajlich 61fdb41e1b Add queue-based health check (#743)
* feat: add queue-based health check to bypass Deployment Protection

- Add HealthCheckPayloadSchema and HEALTH_CHECK_STREAM_PREFIX to @workflow/world
- Add healthCheck() method to Queue interface
- Update workflow and step handlers to detect and respond to health check messages
- Implement healthCheck() in world-local, world-vercel, and world-postgres

The queue-based health check sends a message through the queue pipeline,
which bypasses Vercel's Deployment Protection. The handler writes a response
to a stream that the caller reads to confirm health.

This complements the existing HTTP-based ?__health approach which still works
for local development and when bypass headers are available.

* refactor: move healthCheck to core package as utility function

Instead of adding healthCheck to the World interface (which duplicated
the same implementation across all worlds), this is now a utility function
in @workflow/core that takes the World as a parameter.

Usage:
  import { healthCheck } from '@workflow/core';
  const result = await healthCheck(world, 'workflow');

This is cleaner because:
- Single implementation instead of 3 identical ones
- World implementations remain simple
- No changes needed to the World interface

* .

* refactor: move health check types from world to core

Health check types (HealthCheckPayloadSchema, HealthCheckResult, etc.)
are now defined in @workflow/core since that's where they're used.

The HealthCheckPayloadSchema is still part of QueuePayloadSchema in
world (so the queue accepts health check messages), but it's not
exported from the public API.

* .

* Refactor health check implementation based on code review feedback (#746)

* Initial plan

* Address PR review comments: export types, fix race condition, improve error handling

Co-authored-by: TooTallNate <71256+TooTallNate@users.noreply.github.com>

* Add queue-based health check test and document security considerations

Co-authored-by: TooTallNate <71256+TooTallNate@users.noreply.github.com>

* Replace 'any' type with proper type guards for health check response

Co-authored-by: TooTallNate <71256+TooTallNate@users.noreply.github.com>

* Extract health check queue names as constants and improve type guards

Co-authored-by: TooTallNate <71256+TooTallNate@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TooTallNate <71256+TooTallNate@users.noreply.github.com>

* .

* Fix e2e test

* .

* .

* .

* fix(ai): preserve providerMetadata as providerOptions in multi-turn tool calls (#733)

When tool calls are added to the conversation history, map providerMetadata
to providerOptions following the AI SDK convention. This fixes Gemini thinking
models that require thoughtSignature to be preserved across multi-turn tool calls,
preventing the error 'function call is missing a thought_signature'.

Fixes #727

* Local ui cli flag (#744)

* [web] Increase contrast on attribute items in sidebar (#736)

Signed-off-by: Peter Wielander <mittgfu@gmail.com>

* [world] Remove pause and resume events, actions and states (#751)

* Version Packages (beta) (#735)

* .

* .

* Update turbo inputs to include shared config (#752)

* Update turbo inputs to include shared config

* Apply suggestions from code review

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

---------

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

* feat(web): add self-hosted mode for world configuration (#747)

* feat(web): add self-hosted mode for world configuration

When WORKFLOW_TARGET_WORLD env var is set, the web UI operates in
self-hosted mode where the world configuration is locked to server-side
environment variables and cannot be changed via query params or UI.

- Add getHardcodedConfig server action to detect self-hosted mode
- Modify getWorldFromEnv to use server env vars in hardcoded mode
- Create WorldConfigContext to provide config state app-wide
- Update settings sidebar to show locked state with disabled inputs
- Update connection status to show PostgreSQL backend info
- Mask sensitive values (postgres URL) in hardcoded mode UI

* fix: address PR review feedback

- Remove unused ConfigMode type export
- Fix postgres substring to undefined (tooltip has details)
- Extract buildEnvMapFromProcessEnv helper to reduce duplication
- Remove unused EnvMap import from layout-client
- Import HardcodedConfig from web-shared/server instead of re-defining

* Fix: PostgreSQL URL parameter missing from configParsers, causing loss of postgres URL configuration on page reload in dynamic mode

* fix(cli): clear WORKFLOW_TARGET_WORLD when spawning web server

The CLI sets WORKFLOW_TARGET_WORLD as an env var, which the spawned
Next.js server inherits. This caused the web UI to enter self-hosted
mode even when launched via CLI.

Now we explicitly clear WORKFLOW_TARGET_WORLD from the server's
environment so it starts in dynamic mode where config comes from
query params as intended.

* refactor(web): use server-side env vars for world config

BREAKING CHANGE: The web UI no longer supports configuring the world
backend via URL query parameters. Configuration is now read exclusively
from server-side environment variables.

Changes:
- Remove query param parsing from @workflow/web config.ts
- Add ServerConfig interface with non-sensitive display info
- Update all components to use useServerConfig() hook
- Settings sidebar is now read-only
- CLI passes env vars to spawned web server instead of query params
- Server actions use process.env directly (envMap param reserved for future use)

This simplifies the architecture and improves security by never sending
sensitive data (connection strings, auth tokens) to the client.

* fix(web): fix settings sidebar overflow and shorten data dir path

- Add truncate/overflow handling to settings sidebar config values
- Add shortenPath() helper to abbreviate long file paths:
  - Replaces home directory with ~
  - Shows .../last-two-segments if still too long
- Add title attributes for full path on hover

* Update changeest

---------

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

* Version Packages (beta) (#755)

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

* Update packages/world/src/queue.ts

Co-authored-by: Pranay Prakash <pranay.gp@gmail.com>

* [web] Tidy wake-up and re-enqueue buttons (#737)


---------

Signed-off-by: Peter Wielander <mittgfu@gmail.com>

* [cli] Use dotenv to resolve .env and .env.local files on startup (#765)

* Use temporary workflow-server deployment URL

* feat: add queue-based health check to bypass Deployment Protection

- Add HealthCheckPayloadSchema and HEALTH_CHECK_STREAM_PREFIX to @workflow/world
- Add healthCheck() method to Queue interface
- Update workflow and step handlers to detect and respond to health check messages
- Implement healthCheck() in world-local, world-vercel, and world-postgres

The queue-based health check sends a message through the queue pipeline,
which bypasses Vercel's Deployment Protection. The handler writes a response
to a stream that the caller reads to confirm health.

This complements the existing HTTP-based ?__health approach which still works
for local development and when bypass headers are available.

* refactor: move healthCheck to core package as utility function

Instead of adding healthCheck to the World interface (which duplicated
the same implementation across all worlds), this is now a utility function
in @workflow/core that takes the World as a parameter.

Usage:
  import { healthCheck } from '@workflow/core';
  const result = await healthCheck(world, 'workflow');

This is cleaner because:
- Single implementation instead of 3 identical ones
- World implementations remain simple
- No changes needed to the World interface

* .

* refactor: move health check types from world to core

Health check types (HealthCheckPayloadSchema, HealthCheckResult, etc.)
are now defined in @workflow/core since that's where they're used.

The HealthCheckPayloadSchema is still part of QueuePayloadSchema in
world (so the queue accepts health check messages), but it's not
exported from the public API.

* .

* Refactor health check implementation based on code review feedback (#746)

* Initial plan

* Address PR review comments: export types, fix race condition, improve error handling

Co-authored-by: TooTallNate <71256+TooTallNate@users.noreply.github.com>

* Add queue-based health check test and document security considerations

Co-authored-by: TooTallNate <71256+TooTallNate@users.noreply.github.com>

* Replace 'any' type with proper type guards for health check response

Co-authored-by: TooTallNate <71256+TooTallNate@users.noreply.github.com>

* Extract health check queue names as constants and improve type guards

Co-authored-by: TooTallNate <71256+TooTallNate@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TooTallNate <71256+TooTallNate@users.noreply.github.com>

* .

* Fix e2e test

* .

* .

* .

* .

* .

* Update packages/world/src/queue.ts

Co-authored-by: Pranay Prakash <pranay.gp@gmail.com>

* Use temporary workflow-server deployment URL

* .

* .

---------

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TooTallNate <71256+TooTallNate@users.noreply.github.com>
Co-authored-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Vercel Release Bot <88769842+vercel-release-bot@users.noreply.github.com>
Co-authored-by: JJ Kasper <jj@jjsweb.site>
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: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-12 14:57:10 -08:00
Peter Wielander dd3db13d54 [world] Remove pause and resume events, actions and states (#751) 2026-01-08 19:29:17 +01:00
Peter Wielander 616bc67be4 [web-shared] Fix null access in eventData (#673) 2025-12-22 16:18:17 +01:00
Peter Wielander c82b46720c [world] Add Streamer.listByRunId method to list all streams for a run (#574) 2025-12-08 16:05:44 -08:00
Peter Wielander 57a2c328ca [world] Add expiredAt attribute to Run (#515) 2025-12-04 12:05:58 -08:00
Gal Schlezinger bdde1bd806 core: add span links + message ids to queue spans (#472)
* core: add span links + message ids to queue spans

Signed-off-by: Gal Schlezinger <gal@spitfire.co.il>

* add queue overhead to metrics

Signed-off-by: Gal Schlezinger <gal@spitfire.co.il>

* fix types

Signed-off-by: Gal Schlezinger <gal@spitfire.co.il>

* rename messageId to message.id

Signed-off-by: Gal Schlezinger <gal@spitfire.co.il>

* add changeset

Signed-off-by: Gal Schlezinger <gal@spitfire.co.il>

---------

Signed-off-by: Gal Schlezinger <gal@spitfire.co.il>
2025-12-02 14:11:05 +02:00
Peter Wielander 10c5b91079 [web] Upgrade Next.js to 16 (#465) 2025-12-01 09:23:25 -08:00
Nathan Rajlich fb9fd0f893 Add support for closure scope vars in step functions (#358)
### What changed?

- Enhanced the SWC plugin to detect and collect closure variables from nested step functions
- Modified the workflow runtime to pass closure variables to step functions during execution
- Updated the serialization/deserialization logic to handle the new closure variable format
- Added a mechanism to access closure variables within step functions
- Added tests to verify closure variable functionality in nested step functions
- Closure variables are stored on the step function execution's `AsyncLocalStorage`​ context

### Example

```typescript
export async function myWorkflow(baseValue: number) {
  'use workflow';
  const multiplier = 3;
  const prefix = 'Result: ';

  const calculate = async () => {
    'use step';
    const result = baseValue * multiplier;
    return `${prefix}${result}`;
  };

  return await calculate();
}
```

### Why make this change?

Previously, nested step functions couldn't access variables from their parent workflow scope, limiting their usefulness and requiring workarounds like passing all needed values as parameters. This change enables a more natural programming model where step functions can access variables from their enclosing scope, making workflow code more intuitive and reducing the need for explicit parameter passing.
2025-11-25 00:21:05 -08:00
Peter Wielander 4b70739b50 Core: require specifying runId when writing to stream (#232) 2025-11-17 08:56:42 -08:00
Pranay Prakash 00b0bb9346 Proper error stack propogating (#280)
* Proper stacktrace propogation in world

Proper stacktrace propogation in world

* Merge Reconciliation

* Standardize the error type in the world spec

* Deduplicate vercel world utils

* fix undefined type issue

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-11-11 15:34:04 -08:00
Pranay Prakash 70be894ad1 Make sleep() a core runtime function (#40)
* Handle the sleep not completing at the end of the event log

* Handle multiple timers being created in the same suspension

* Better `buildWorkflowSuspensionMessage()`

* New sleep unit tests

---------

Co-authored-by: Nathan Rajlich <n@n8.io>
2025-10-30 15:31:25 -07:00