Skip to content

Attributes MVP (experimental and write-only) - #2088

Merged
VaguelySerious merged 26 commits into
peter/fix-large-inline-sourcemap-remapfrom
peter/attributes-mvp-plan
May 28, 2026
Merged

Attributes MVP (experimental and write-only)#2088
VaguelySerious merged 26 commits into
peter/fix-large-inline-sourcemap-remapfrom
peter/attributes-mvp-plan

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented May 22, 2026

Copy link
Copy Markdown
Member

Implements the Workflow Attributes MVP — a minimal, write-only attributes API designed to land before the full event-sourced attributes feature in #1933 (which requires a SPEC_VERSION_CURRENT bump and coordinated rollout across worlds, builders, and runtime).

User surface:

import{experimental_setAttributes}from'workflow';exportasyncfunctionmyWorkflow(orderId: string){'use workflow';awaitexperimental_setAttributes({phase: 'processing', orderId });// ...awaitexperimental_setAttributes({phase: 'done'});awaitexperimental_setAttributes({orderId: undefined});// remove a key}

Attributes are stored plaintext on the WorkflowRun entity and visible via world.runs.get() / world.runs.list() (and any observability surface built on top). The wire format mirrors the future attr_set event's eventData.changes, so the SDK signature and wire body shape are stable across MVP → 5.0.0.

experimental_setAttributes is callable from a workflow body only in this MVP. The call is dispatched through an internal __builtin_set_attributes step bridge so the mutation gets a step_created → step_completed event pair without inventing a new event type. The host-side export (resolved from step bodies or plain host code) throws FatalError directing the caller back to a workflow body — step-body support is a follow-up, not a hard architectural constraint.

The public surface uses the experimental_ prefix so every call site signals "this WILL change" — when V1 lands the rename to setAttributes doubles as a forcing function for consumers to re-review the new contract.

See docs/content/docs/v5/changelog/attributes-mvp.mdx for the full design, trade-offs, and implementation notes — including the call patterns (awaited, fire-and-forget, Promise.all), the platform-level limitation on fire-and-forget calls placed immediately before return, and the decisions made during build-out (endpoint namespacing, concurrency semantics, usage-fact schema choice, optional-world fallback behavior, etc.).

Paired with vercel/workflow-server#442 (merged) which adds the server-side POST /api/v2/runs/:runId/attributes endpoint, ElectroDB column, and the new WORKFLOW_ATTRIBUTE usage-fact carrying the post-merge map.

What's in this PR

LayerChange
@workflow/worldShared validation + applyAttributeChanges helper. Optional experimentalSetAttributes on Storage.runs. attributes field on WorkflowRunBaseSchema defaults to {} after Zod parse, so consumers always receive a record regardless of world.
@workflow/coreNew experimental_setAttributes(record). VM-side helper validates inline and dispatches via the standard WORKFLOW_USE_STEP mechanism — no new global symbols, no bridge plumbing. Host-side export is a FatalError-throwing stub for non-workflow-body callers.
workflow package__builtin_set_attributes step body in internal/builtins.ts. Reads world + run id directly from globalThis symbols populated by the runtime; zero imports from @workflow/core so it stays a true leaf in the deferred-entries graph. Emits a one-time console.warn if the active world adapter doesn't implement experimentalSetAttributes.
@workflow/world-localFilesystem impl with a per-run async file lock (withRunFileLock) extended to lifecycle handlers (run_started / run_completed / run_failed / run_cancelled) so an attribute write that lands between the pre-validation read and the lifecycle write is preserved. New runs seed attributes: {} so reads are uniform.
@workflow/world-postgresNew attributes jsonb column (migration 0013_add_attributes.sql). SQL-side atomic merge using jsonb_set / - operators in a single UPDATE. Per-run cap is enforced atomically inside the UPDATE's WHERE clause (COUNT(jsonb_object_keys(merged)) <= 64), so concurrent disjoint-key writers can't push the row past the cap.
@workflow/world-vercelPure HTTP wrapper posting { changes: [...] } to /v2/runs/:runId/attributes.
DocsChangelog entry at docs/content/docs/v5/changelog/attributes-mvp.mdx with full design + implementation notes + the three call patterns.

Architecture (workflow-body dispatch)

  1. User calls experimental_setAttributes(attrs) from 'use workflow' body.
  2. Workflow VM resolves the import via the workflow package-exports condition to packages/core/src/workflow/set-attributes.ts, which validates the input inline and produces canonical AttributeChange[].
  3. Dispatch happens through the standard globalThis[WORKFLOW_USE_STEP]('__builtin_set_attributes')(changes) — same mechanism every other step call uses. The SWC plugin already special-cases __builtin* step names (packages/swc-plugin-workflow/transform/src/lib.rs:1906) to register them with the bare function name so the bare-name lookup at dispatch time resolves.
  4. Step worker runs __builtin_set_attributes (in packages/workflow/src/internal/builtins.ts). The step body reads the world from globalThis[Symbol.for('@workflow/world//cache')] and the active run id from globalThis[Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')], then calls world.runs.experimentalSetAttributes(runId, changes). No imports from @workflow/core — this is what keeps the Next.js deferred-entries discoverer from walking into world adapters and triggering webpack's regex-extractor stack overflow.
  5. Step completes; workflow resumes.

Validation rules

Shared helper (@workflow/world/attributes.ts) used by both the SDK (in the VM, before dispatch) and the world (after the change reaches it):

  • Key: 1–256 chars, non-empty, must not start with $ (reserved for future system use)
  • Value: ≤ 256 bytes UTF-8 (or null for unset)
  • Maximum 64 attributes per run, computed against the real post-merge total when the world has the snapshot (existingKeys parameter on validateAttributeChanges). An update to an existing key counts as +0 net adds, so a run at the cap can still update its own keys.

Violations throw FatalError from @workflow/errors.

Call patterns supported

Awaited (default). Workflow blocks on the write.

awaitexperimental_setAttributes({phase: 'init'});

Fire-and-forget (void). Drop the await. The pending step queues on the workflow's next suspension. Canonical pattern for observability metadata where the workflow doesn't depend on the write.

voidexperimental_setAttributes({phase: 'init'});constresult=awaitprocessOrder();// suspension queues the void above

Parallel (Promise.all). Disjoint-key writes all land; same-key writes are LWW-by-arrival.

awaitPromise.all([experimental_setAttributes({phase: 'init'}),experimental_setAttributes({ orderId }),]);

Known limitation: void calls placed immediately before return are not reliably executed. Drain commits the step_created event, but by the time the queue worker dequeues the message the run has transitioned to completed and the worker rejects step_started with RunExpiredError. The fire-and-forget e2e test for this exact case is marked test.todo with an explanatory TODO; mid-workflow fire-and-forget (the common case for tracking attributes) works correctly via the regular suspension path. Resolving this needs either platform-side support for step_started on drain-queued steps or a non-step dispatch path for attributes (planned for V1 alongside the attr_set event type).

What's NOT in this PR (not in MVP)

  • Calling experimental_setAttributes from a step body or plain host code (throws FatalError; can be added later — it's a scope cut, not an architectural constraint)
  • Reading attributes inside a workflow or step (getAttribute / getAttributes)
  • start(workflow, input, { attributes }) (initial attributes at run creation)
  • Filtering / enumerating runs by attribute (runs.list({ attributes }), listAttributeKeys, listAttributeValues)
  • Writer attribution (workflow vs step + attempt) — needs the attr_set event type
  • Non-string value types
  • Reserved-key ($-prefixed) namespace (just blocked at validation today)
  • Reliable fire-and-forget for void immediately before return (see above)

Test coverage

Unit tests in @workflow/world (validation) and @workflow/core (VM-side dispatch + host-side stub). Integration tests in world-local (upsert / merge / unset / set-and-unset / cap-boundary updates / idempotency / concurrent writes via the per-run mutex / validation rejection) and world-postgres (upsert / merge / unset, exercising the SQL-side jsonb_set / - chain and the atomic cap enforcement in the WHERE clause).

E2E in workbench/nextjs-turbopack exercises the full SWC plugin + workflow VM + step worker + world-vercel wire path against the production workflow-server /v2/runs/:runId/attributes endpoint:

  • Awaited workflow-body calls dispatch through the __builtin_set_attributes step bridge and merge correctly (also asserts the step_created/step_completed pair lands on the event log)
  • Promise.all of disjoint-key writes — every key persists
  • Workflow throws after an awaited experimental_setAttributes — the attribute persists on the now-failed run (verifies the per-run file lock on run_failed re-reads inside the critical section so attribute snapshots survive the lifecycle write)
  • Fire-and-forget last-call-before-return is test.todo pending the platform-level fix described above

@github-actions

github-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (-28.9% 🟢)1.005s (~)0.973s101.00x
🐘 PostgresNitro0.056s (-41.2% 🟢)1.010s (-3.1%)0.954s101.78x
💻 LocalNext.js (Turbopack)0.060s1.006s0.946s101.90x
🐘 PostgresExpress0.066s (+13.3% 🔺)1.013s (~)0.947s102.09x
🐘 PostgresNext.js (Turbopack)0.070s1.012s0.942s102.21x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.343s (+36.4% 🔺)2.252s (-3.5%)1.909s101.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.078s (-4.2%)2.005s (~)0.927s101.00x
🐘 PostgresNitro1.101s (-3.4%)2.009s (~)0.908s101.02x
🐘 PostgresExpress1.111s (-3.1%)2.009s (~)0.899s101.03x
💻 LocalNext.js (Turbopack)1.132s2.006s0.875s101.05x
🐘 PostgresNext.js (Turbopack)1.142s2.009s0.868s101.06x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.607s (-21.0% 🟢)3.420s (-10.7% 🟢)1.813s101.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express10.419s (-4.6%)11.020s (~)0.601s31.00x
🐘 PostgresNitro10.543s (-3.0%)11.019s (~)0.476s31.01x
🐘 PostgresExpress10.547s (-3.8%)11.019s (~)0.472s31.01x
💻 LocalNext.js (Turbopack)10.814s11.022s0.208s31.04x
🐘 PostgresNext.js (Turbopack)10.863s11.019s0.156s31.04x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)14.384s (-16.9% 🟢)15.955s (-17.8% 🟢)1.571s21.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.534s (-9.6% 🟢)14.026s (-6.7% 🟢)0.492s51.00x
🐘 PostgresExpress13.769s (-5.6% 🟢)14.019s (-6.7% 🟢)0.250s51.02x
🐘 PostgresNitro13.843s (-5.2% 🟢)14.017s (-6.7% 🟢)0.174s51.02x
💻 LocalNext.js (Turbopack)14.396s15.031s0.634s41.06x
🐘 PostgresNext.js (Turbopack)14.515s15.020s0.505s41.07x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)21.655s (-58.8% 🟢)22.907s (-58.1% 🟢)1.252s31.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express11.978s (-27.8% 🟢)12.522s (-26.5% 🟢)0.544s81.00x
🐘 PostgresExpress12.428s (-11.3% 🟢)13.018s (-10.8% 🟢)0.590s71.04x
🐘 PostgresNitro12.563s (-10.1% 🟢)13.018s (-9.0% 🟢)0.456s71.05x
💻 LocalNext.js (Turbopack)13.608s14.028s0.420s71.14x
🐘 PostgresNext.js (Turbopack)13.899s14.305s0.407s71.16x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)29.486s (-92.5% 🟢)31.212s (-92.1% 🟢)1.726s31.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.147s (-23.0% 🟢)2.006s (~)0.859s151.00x
🐘 PostgresNitro1.158s (-9.2% 🟢)2.007s (~)0.849s151.01x
🐘 PostgresExpress1.193s (-5.3% 🟢)2.007s (~)0.814s151.04x
🐘 PostgresNext.js (Turbopack)1.244s2.007s0.763s151.08x
💻 LocalNext.js (Turbopack)1.352s2.006s0.654s151.18x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.970s (-12.6% 🟢)4.400s (-10.8% 🟢)1.430s71.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.211s (-48.5% 🟢)2.006s (-33.3% 🟢)0.794s151.00x
🐘 PostgresExpress1.249s (-47.1% 🟢)2.006s (-33.3% 🟢)0.758s151.03x
🐘 PostgresNext.js (Turbopack)1.425s2.008s0.583s151.18x
💻 LocalExpress1.573s (-46.7% 🟢)2.005s (-41.9% 🟢)0.432s151.30x
💻 LocalNext.js (Turbopack)1.772s2.007s0.235s151.46x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.252s (-26.0% 🟢)6.836s (-23.2% 🟢)1.584s51.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.305s (-62.5% 🟢)2.007s (-49.9% 🟢)0.702s151.00x
🐘 PostgresExpress1.357s (-61.1% 🟢)2.007s (-49.9% 🟢)0.650s151.04x
🐘 PostgresNext.js (Turbopack)1.749s2.395s0.646s131.34x
💻 LocalExpress3.733s (-55.2% 🟢)4.296s (-52.4% 🟢)0.563s72.86x
💻 LocalNext.js (Turbopack)5.373s6.018s0.645s54.12x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)7.186s (-19.4% 🟢)9.034s (-17.6% 🟢)1.847s41.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.165s (-7.3% 🟢)2.008s (~)0.843s151.00x
🐘 PostgresExpress1.194s (-5.0% 🟢)2.007s (~)0.814s151.02x
🐘 PostgresNext.js (Turbopack)1.252s2.009s0.757s151.07x
💻 LocalNext.js (Turbopack)1.385s2.006s0.621s151.19x
💻 LocalExpress1.427s (-24.6% 🟢)2.006s (-15.1% 🟢)0.579s151.22x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.677s (-8.7% 🟢)4.316s (-7.0% 🟢)1.639s71.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.217s (-48.0% 🟢)2.008s (-33.3% 🟢)0.791s151.00x
🐘 PostgresExpress1.237s (-47.2% 🟢)2.009s (-33.3% 🟢)0.772s151.02x
🐘 PostgresNext.js (Turbopack)1.396s2.007s0.611s151.15x
💻 LocalExpress1.799s (-42.6% 🟢)2.220s (-41.0% 🟢)0.421s141.48x
💻 LocalNext.js (Turbopack)2.028s2.826s0.798s111.67x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.559s (+13.3% 🔺)4.872s (+7.7% 🔺)1.313s71.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.335s (-61.6% 🟢)2.007s (-49.9% 🟢)0.672s151.00x
🐘 PostgresExpress1.352s (-61.4% 🟢)2.008s (-49.9% 🟢)0.656s151.01x
🐘 PostgresNext.js (Turbopack)1.699s2.319s0.620s131.27x
💻 LocalExpress4.469s (-49.2% 🟢)5.021s (-45.9% 🟢)0.552s63.35x
💻 LocalNext.js (Turbopack)5.212s5.682s0.470s63.90x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)7.332s (+8.5% 🔺)9.158s (+7.2% 🔺)1.826s41.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.521s (-47.0% 🟢)1.003s (-6.7% 🟢)0.482s601.00x
🐘 PostgresExpress0.567s (-32.4% 🟢)1.006s (-1.6%)0.439s601.09x
🐘 PostgresNitro0.583s (-29.0% 🟢)1.040s (+3.4%)0.458s581.12x
🐘 PostgresNext.js (Turbopack)0.825s1.024s0.199s591.58x
💻 LocalNext.js (Turbopack)0.866s1.039s0.173s581.66x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.525s (-61.9% 🟢)7.164s (-55.5% 🟢)1.638s91.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.292s (-57.1% 🟢)2.027s (-43.5% 🟢)0.735s451.00x
🐘 PostgresExpress1.336s (-32.4% 🟢)2.007s (-11.1% 🟢)0.671s451.03x
🐘 PostgresNitro1.340s (-30.5% 🟢)2.007s (-4.4%)0.667s451.04x
🐘 PostgresNext.js (Turbopack)1.943s2.147s0.204s431.50x
💻 LocalNext.js (Turbopack)2.086s3.008s0.922s301.61x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)13.589s (-72.7% 🟢)15.059s (-70.9% 🟢)1.470s61.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.611s (-34.6% 🟢)3.033s (-30.6% 🟢)0.421s401.00x
🐘 PostgresNitro2.708s (-34.0% 🟢)3.058s (-33.6% 🟢)0.351s401.04x
💻 LocalExpress2.794s (-69.7% 🟢)3.165s (-68.4% 🟢)0.371s381.07x
🐘 PostgresNext.js (Turbopack)3.844s4.011s0.166s301.47x
💻 LocalNext.js (Turbopack)4.317s5.010s0.693s241.65x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)29.532s (-72.4% 🟢)31.541s (-71.0% 🟢)2.008s41.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.219s (-22.8% 🟢)1.006s (~)0.787s601.00x
🐘 PostgresExpress0.226s (-19.9% 🟢)1.006s (~)0.780s601.04x
🐘 PostgresNext.js (Turbopack)0.264s1.007s0.743s601.21x
💻 LocalExpress0.415s (-25.9% 🟢)1.003s (~)0.588s601.90x
💻 LocalNext.js (Turbopack)0.582s1.039s0.457s582.66x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.626s (+29.9% 🔺)4.133s (+8.9% 🔺)1.507s151.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.320s (-35.5% 🟢)1.006s (~)0.685s901.00x
🐘 PostgresExpress0.332s (-34.9% 🟢)1.006s (~)0.674s901.04x
🐘 PostgresNext.js (Turbopack)0.498s1.006s0.509s901.55x
💻 LocalExpress1.851s (-26.3% 🟢)2.315s (-23.1% 🟢)0.464s395.78x
💻 LocalNext.js (Turbopack)2.663s3.225s0.563s288.31x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.444s (+54.0% 🔺)6.975s (+34.3% 🔺)1.531s131.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.608s (-23.1% 🟢)1.006s (~)0.398s1201.00x
🐘 PostgresExpress0.647s (-20.9% 🟢)1.006s (-1.1%)0.359s1201.06x
🐘 PostgresNext.js (Turbopack)1.023s1.870s0.848s651.68x
💻 LocalExpress8.118s (-27.5% 🟢)8.664s (-27.4% 🟢)0.546s1413.35x
💻 LocalNext.js (Turbopack)10.218s11.118s0.900s1116.81x
💻 LocalNitro⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)161.905s (+1467.7% 🔺)163.826s (+1233.4% 🔺)1.921s21.00x
▲ VercelExpress⚠️missing----
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.137s (+471.0% 🔺)2.005s (+99.6% 🔺)0.008s (-31.4% 🟢)2.015s (+97.9% 🔺)0.878s101.00x
🐘 PostgresExpress1.160s (+465.6% 🔺)1.995s (+99.8% 🔺)0.001s (-18.8% 🟢)2.010s (+98.8% 🔺)0.850s101.02x
🐘 PostgresNitro1.160s (+465.9% 🔺)2.000s (+100.1% 🔺)0.001s (-13.3% 🟢)2.010s (+98.8% 🔺)0.850s101.02x
💻 LocalNext.js (Turbopack)1.203s2.003s0.011s2.018s0.815s101.06x
🐘 PostgresNext.js (Turbopack)1.227s2.003s0.001s2.012s0.786s101.08x
💻 LocalNitro⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.556s (-62.7% 🟢)3.326s (-61.6% 🟢)2.194s (+247.2% 🔺)6.163s (-37.0% 🟢)3.606s101.00x
▲ VercelExpress⚠️missing-----
▲ VercelNitro⚠️missing-----

🔍 Observability: Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.466s (+93.7% 🔺)2.009s (+95.3% 🔺)0.008s (-14.0% 🟢)2.019s (+94.2% 🔺)0.553s301.00x
🐘 PostgresNitro1.583s (+153.7% 🔺)2.006s (+99.2% 🔺)0.003s (-16.2% 🟢)2.023s (+97.9% 🔺)0.440s301.08x
🐘 PostgresExpress1.593s (+152.8% 🔺)2.038s (+102.5% 🔺)0.004s (~)2.058s (+101.1% 🔺)0.465s301.09x
💻 LocalNext.js (Turbopack)1.733s2.012s0.010s2.026s0.293s301.18x
🐘 PostgresNext.js (Turbopack)1.815s2.011s0.004s2.028s0.212s301.24x
💻 LocalNitro⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.977s (-64.7% 🟢)6.993s (-61.7% 🟢)0.248s (+17.3% 🔺)7.690s (-59.4% 🟢)1.713s81.00x
▲ VercelExpress⚠️missing-----
▲ VercelNitro⚠️missing-----

🔍 Observability: Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.690s (-28.2% 🟢)1.028s (-19.5% 🟢)0.000s (-20.7% 🟢)1.050s (-19.7% 🟢)0.360s581.00x
🐘 PostgresNitro0.711s (-26.6% 🟢)1.052s (-15.7% 🟢)0.000s (+68.4% 🔺)1.059s (-15.8% 🟢)0.348s571.03x
🐘 PostgresNext.js (Turbopack)0.850s1.091s0.000s1.098s0.249s551.23x
💻 LocalExpress1.256s (+2.6%)1.980s (-2.0%)0.000s (-32.3% 🟢)1.982s (-2.0%)0.726s311.82x
💻 LocalNext.js (Turbopack)1.566s2.014s0.000s2.018s0.451s302.27x
💻 LocalNitro⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.861s (-62.1% 🟢)4.838s (-58.0% 🟢)0.000s (+Infinity% 🔺)5.222s (-56.7% 🟢)1.361s121.00x
▲ VercelExpress⚠️missing-----
▲ VercelNitro⚠️missing-----

🔍 Observability: Next.js (Turbopack)

fan-out fan-in 10 streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.353s (-23.7% 🟢)2.138s (-1.8%)0.000s (NaN%)2.155s (-2.0%)0.802s281.00x
🐘 PostgresNitro1.403s (-21.7% 🟢)2.104s (-1.7%)0.000s (-3.4%)2.112s (-2.9%)0.709s291.04x
🐘 PostgresNext.js (Turbopack)1.777s2.262s0.000s2.269s0.492s271.31x
💻 LocalExpress2.776s (-19.9% 🟢)3.288s (-18.5% 🟢)0.000s (-47.4% 🟢)3.291s (-18.5% 🟢)0.515s192.05x
💻 LocalNext.js (Turbopack)3.152s3.554s0.001s3.567s0.415s172.33x
💻 LocalNitro⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)108.031s (+1823.4% 🔺)109.257s (+1464.9% 🔺)0.000s (-100.0% 🟢)109.672s (+1354.5% 🔺)1.641s31.00x
▲ VercelExpress⚠️missing-----
▲ VercelNitro⚠️missing-----

🔍 Observability: Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress20/21
🐘 PostgresNitro13/21
▲ VercelNext.js (Turbopack)21/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres11/21
Next.js (Turbopack)🐘 Postgres14/21
Nitro🐘 Postgres21/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run


Some benchmark jobs failed:

  • Local: failure
  • Postgres: success
  • Vercel: failure

Check the workflow run for details.

@changeset-bot

changeset-botBot commented May 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 45db5ae

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
NameType
@workflow/corePatch
@workflow/worldPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/world-vercelPatch
workflowPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented May 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production125502191474
✅ 💻 Local Development165702191876
✅ 📦 Local Production165702191876
✅ 🐘 Local Postgres165702191876
✅ 🪟 Windows13400134
✅ 📋 Other7620176938
Total7122010528174

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro108026
✅ example108026
✅ express108026
✅ fastify108026
✅ hono108026
✅ nextjs-turbopack13202
✅ nextjs-webpack13202
✅ nitro108026
✅ nuxt108026
✅ sveltekit12707
✅ vite108026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable109025
✅ express-stable109025
✅ fastify-stable109025
✅ hono-stable109025
✅ nextjs-turbopack-canary115019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13400
✅ nextjs-turbopack-stable-lazy-discovery-enabled13400
✅ nextjs-webpack-canary115019
✅ nextjs-webpack-stable-lazy-discovery-disabled13400
✅ nextjs-webpack-stable-lazy-discovery-enabled13400
✅ nitro-stable109025
✅ nuxt-stable109025
✅ sveltekit-stable12806
✅ vite-stable109025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable109025
✅ express-stable109025
✅ fastify-stable109025
✅ hono-stable109025
✅ nextjs-turbopack-canary115019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13400
✅ nextjs-turbopack-stable-lazy-discovery-enabled13400
✅ nextjs-webpack-canary115019
✅ nextjs-webpack-stable-lazy-discovery-disabled13400
✅ nextjs-webpack-stable-lazy-discovery-enabled13400
✅ nitro-stable109025
✅ nuxt-stable109025
✅ sveltekit-stable12806
✅ vite-stable109025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable109025
✅ express-stable109025
✅ fastify-stable109025
✅ hono-stable109025
✅ nextjs-turbopack-canary115019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13400
✅ nextjs-turbopack-stable-lazy-discovery-enabled13400
✅ nextjs-webpack-canary115019
✅ nextjs-webpack-stable-lazy-discovery-disabled13400
✅ nextjs-webpack-stable-lazy-discovery-enabled13400
✅ nitro-stable109025
✅ nuxt-stable109025
✅ sveltekit-stable12806
✅ vite-stable109025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack13400
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable109025
✅ e2e-local-dev-tanstack-start-109025
✅ e2e-local-postgres-nest-stable109025
✅ e2e-local-postgres-tanstack-start-109025
✅ e2e-local-prod-nest-stable109025
✅ e2e-local-prod-tanstack-start-109025
✅ e2e-vercel-prod-tanstack-start108026

📋 View full workflow run

@VaguelySeriousVaguelySerious changed the title [docs] Workflow Attributes MVP planfeat(attributes): V5 workflow attributes MVP (write-only)May 22, 2026
VaguelySeriousand others added 26 commits May 28, 2026 13:26
Outlines the bare-MVP, write-only attributes design that defers the
`attr_set` event type (and the associated SPEC_VERSION_CURRENT bump)
to the full 5.0.0 feature. Forward-compatible SDK surface and wire
format. `experimentalSetAttributes` is optional on the World
interface so third-party worlds keep working.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the V5 Workflow Attributes MVP per the changelog plan:
- @workflow/world: shared validation + apply helpers; optional
experimentalSetAttributes on Storage.runs; attributes field on
WorkflowRunBaseSchema. Optional so third-party worlds keep working.
- @workflow/core: setAttributes() helper. Detects workflow VM vs step
context, normalizes undefined→null, validates client-side, dispatches
via an internal "use step" function. Feature-detects the world method
and no-ops with a one-time warning if missing.
- @workflow/world-local: file-backed impl with a per-run async mutex
so concurrent writes do not lose updates within a process. Threads
attributes through the run lifecycle event reconstructions so they
survive subsequent run_started/_completed/_failed/_cancelled writes.
- @workflow/world-postgres: jsonb column with SQL-side atomic merge
(jsonb_set / `-`); 0013 migration.
- @workflow/world-vercel: HTTP wrapper posting the documented
{ changes: [...] } body to /v2/runs/:runId/attributes.
Tests: 18 validation unit + 10 SDK unit + 10 world-local integration +
3 world-postgres integration. End-to-end coverage in the workbench is
deferred until the paired workflow-server endpoint is deployed to a
preview.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The V5 attributes field on WorkflowRunBaseSchema widened the
AttributeKey union in web-shared's attribute-panel exhaustive Record,
causing the CI Build Packages job to fail. Add a JsonBlock renderer
gated on hasDisplayContent so missing/empty maps don't render at all.
CI build error: the umbrella workflow package re-exports
\`@workflow/core/_workflow\`, which (via my earlier setAttributes
export) transitively pulled \`step/context-storage\` and therefore
\`node:async_hooks\`. The workflow VM bundle's no-Node-module
constraint rejected it.
Split into three modules:
- set-attributes-shared.ts: validation + 'use step' dispatcher. No
contextStorage import, safe in both bundles.
- set-attributes.ts (step/host): looks up runId via contextStorage,
falls back to WORKFLOW_CONTEXT_SYMBOL. Re-exported from core/index.
- workflow/set-attributes.ts (VM): reads only WORKFLOW_CONTEXT_SYMBOL.
Re-exported from core/_workflow.
Mirrors the same dual-context layout as getWorkflowMetadata.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous design used a 'use step' indirection inside @workflow/core
so setAttributes could be called from both workflow and step bodies via
a single SDK surface. That broke nextjs-webpack Local Dev: the deferred-
entries discoverer in webpack dev mode walks transitive imports from
'use step' files, and putting a step file inside @workflow/core/dist
pulled host-side world adapters and @vercel/queue into the
step-discovery graph. Webpack's regex-based import extractor then blew
the call stack with "RangeError: Maximum call stack size exceeded at
RegExpStringIterator.next" on tarball-installed deployments.
runtime/start.ts and runtime/run.ts get away with the same directive
because they're never reachable from packages/core/src/workflow/index.ts
(the VM bundle entry); ours was.
A host-side bridge comparable to sleep would have fixed it but is
substantial wiring for a feature whose end state (event-sourced
attr_set) replaces the bridge mechanism entirely. Pragmatic MVP path:
restrict to step body and let users wrap in a step explicitly. Full
5.0.0 lifts the restriction via attr_set events through the workflow
controller; SDK signature is stable across the cutover.
- Workflow-VM-side setAttributes throws FatalError with wrap-in-step
instructions
- Step-side setAttributes works (validates, dispatches, world-detects)
- set-attributes-shared.ts is now pure validation; no 'use step', no
world imports
- Test updated to assert the FatalError on workflow-body calls
- Changelog MDX updated with the new scope + a "Why workflow-body
dispatch is deferred" implementation note
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… setAttributes is "supported via the host-side bridge in `set-attributes.ts`, which calls into an actual step via `registerStepFunction`" — no such bridge or registerStepFunction usage exists.
This commit fixes the issue reported at packages/core/src/set-attributes-shared.ts:25
**Bug:** The comment on lines 24-26 of `packages/core/src/set-attributes-shared.ts` describes an intermediate design approach that was abandoned before the final implementation. It states: "workflow-body use is supported only via the host-side bridge in `set-attributes.ts`, which calls into an actual step via `registerStepFunction`."
In the actual final implementation:
1. `packages/core/src/workflow/set-attributes.ts` (the workflow-VM-side export) unconditionally throws `FatalError` — there is no bridge support at all.
2. `packages/core/src/set-attributes.ts` (the host-side export) explicitly checks for workflow-body context and throws `FatalError` with a message telling users to wrap the call in a `'use step'` function.
3. A grep for `registerStepFunction` in combination with `setAttributes` returns zero results — no such wiring exists.
The comment is misleading to any developer reading the codebase: it implies workflow-body use works via a bridge mechanism, when in fact it throws a fatal error.
**Fix:** Updated lines 24-26 to accurately describe the actual behavior: "workflow-body use throws FatalError — users must wrap the call in their own `'use step'` function." This aligns with the implementation in both `set-attributes.ts` and `workflow/set-attributes.ts`, and with the JSDoc on `setAttributes` which explicitly documents the step-body-only restriction and the workaround pattern.
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <peter.wielander@vercel.com>
For local e2e validation against the workflow-server attributes-mvp
preview deployment. Do not merge — this constant must be empty on main.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n step
Workflow-body `setAttributes` calls now dispatch through an internal
`__builtin_set_attributes` step rather than throwing FatalError. The
workflow-VM helper validates input and then invokes a host-side
useStep dispatcher pre-bound under WORKFLOW_SET_ATTRIBUTES; the step
body forwards to the same world adapter call the step-body path uses.
Putting the 'use step' directive inside `packages/workflow/src/internal/builtins.ts`
(next to the existing `__builtin_response_*` builtins) instead of
`@workflow/core/dist/` avoids the deferred-entry discoverer hazard
that motivated the prior MVP-only step-body restriction.
- Add `WORKFLOW_SET_ATTRIBUTES` symbol + workflow.ts wiring
- New `step-set-attributes.ts` host helper (`applySetAttributesChanges`)
shared between step-body and workflow-body paths
- `__builtin_set_attributes` builtin step
- Refactor workflow-side `setAttributes` to dispatch via the bridge
- Update changelog MDX + add tests (16 unit, 2 e2e)
- e2e workflows `setAttributesFromStepWorkflow` and
`setAttributesFromWorkflowBodyWorkflow` cover both paths
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The comment described the prior FatalError-on-workflow-body workaround;
update it to reflect the current bridge-via-builtins design.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…verer
`__builtin_set_attributes` dynamically imported
`@workflow/core/_step-set-attributes` as a literal string. The Next.js
deferred-entries discoverer in `@workflow/next/builder-deferred.ts`
matches `import('...')` regex-style and walks the resolved file's
transitive imports — which reach the world adapter and `@vercel/queue`,
triggering `RangeError: Maximum call stack size exceeded` inside
`RegExpStringIterator.next` on tarball-installed nextjs-webpack
builds. The build never completes, so dev-mode e2e tests time out
across the board.
Assemble the specifier at runtime (same trick `get-world-lazy.ts`
uses for `./world.js`) so the discoverer doesn't see a literal target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <peter.wielander@vercel.com>
Signed-off-by: Peter Wielander <peter.wielander@vercel.com>
…idge
The runtime-built specifier broke step bundle resolution across every
framework — `Cannot find package '@workflow/core' imported from
.../node_modules/.nitro/workflow/steps.mjs`. Bundlers can't statically
resolve a concatenated string, so the dependency never lands in the
step bundle and Node's loader fails at runtime.
Reverts fbb0c59. The original webpack-dev discoverer-overflow it
tried to fix only affected 3 jobs; this regression took down 30+ jobs
across all frameworks. The discoverer issue needs a different fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ngle dispatch path
Drops step-body support for the MVP. The architecture becomes:
- workflow VM `setAttributes` validates inline and dispatches via the
standard `globalThis[WORKFLOW_USE_STEP]('__builtin_set_attributes')`
mechanism — same as every other step call from a workflow body
- host-side `setAttributes` is a stub that throws FatalError telling
callers to use a workflow body
- `__builtin_set_attributes` step body reads world + run id directly
from `globalThis` symbols populated by the runtime, with no imports
from `@workflow/core`
This deletes the bridge plumbing the previous design needed:
- `WORKFLOW_SET_ATTRIBUTES` global symbol + the workflow.ts pre-bind
- `packages/core/src/set-attributes-shared.ts` (normalize helper)
- `packages/core/src/step-set-attributes.ts` (host-side helper)
- the `@workflow/core/_step-set-attributes` package export and the
dynamic import that pulled it in from the step bundle
Side benefit: the Next.js deferred-entries discoverer can no longer
walk from `__builtin_set_attributes` into the world adapter / queue
chain that broke webpack-dev builds in the previous shape, because the
step body holds zero @workflow/core imports.
Workbench example and e2e tests reduced to a single
`setAttributesWorkflow` covering workflow-body dispatch. World-side
implementations are unchanged; step-body support can be added later
without touching the workflow-body contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`extractBundleSourceFiles` used `matchAll` with `/...[A-Za-z0-9+/=]+.../g`
to pull inline base64 source maps out of generated bundles. V8's
irregexp uses recursion for greedy character-class quantifiers, and on
bundles with multi-MB inline sourcemaps the engine exhausts the stack
mid-match with `RangeError: Maximum call stack size exceeded at
RegExpStringIterator.next`. That broke nextjs-webpack-dev e2e jobs on
this branch after main enabled inline sourcemaps across all workspace
packages (#1799).
Switch to a literal-prefix scan with a manual base64 alphabet loop —
linear time, no recursion. The character-code check inlines what the
regex was doing (`[A-Za-z0-9+/=]`), so behaviour is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… post-merge cap
- Rename the public SDK export from `setAttributes` → `experimental_setAttributes`
to signal the unstable surface at every call site (workbench, docs,
e2e + unit tests updated). Internal step name stays
`__builtin_set_attributes`.
- `validateAttributeChanges`: replace `existingCount?: number` with
`existingKeys?: Iterable<string>`. With keys, the cap check uses
real net adds/deletes (an update to an already-present key is zero
net); without keys it falls back to the conservative "every upsert
is +1" shape. Fixes the off-by-design rejection of single-key
updates at the cap boundary.
- `world-local`: rename `withRunAttributeLock` → `withRunFileLock`,
export it, and acquire it from the events-storage run-lifecycle
branches (`run_started`/`run_completed`/`run_failed`/`run_cancelled`).
Each lifecycle write re-reads the run JSON inside the lock so an
attribute write that landed between the pre-validation read and the
write is no longer silently overwritten.
- `world-postgres`: atomic per-run cap. The cap check now lives in the
same `UPDATE` statement as the merge (`WHERE (SELECT COUNT(*) FROM
jsonb_object_keys(merged_expr)) <= ATTRIBUTE_MAX_PER_RUN`), so two
concurrent writers adding disjoint keys at the cap boundary can no
longer both succeed and push the row past 64. A separate re-read on
rejection disambiguates "run not found" from "cap rejected".
- `__builtin_set_attributes`: one process-wide `console.warn` when the
active world adapter doesn't implement `experimentalSetAttributes`,
matching the changelog and `Storage.runs.experimentalSetAttributes`
JSDoc.
Verified #17 (bare-name step lookup) by precedent: the SWC plugin
already special-cases names starting with `__builtin` at
`naming.rs`-adjacent path in `lib.rs:1906` to use the bare function
name as the step ID, which is exactly how `__builtin_response_json`
etc. ship today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nderLifecycleLock
The previous shape took `(baselineRun, overrides)` and spread them
inside the helper, which collapses the run's discriminated union
(`status: 'pending' | 'running' | 'completed' | ...`) into an
unassignable intersection. tsc rejected the call sites in CI.
Switch the helper to take an already-constructed `WorkflowRun` and a
generic `<T extends WorkflowRun>` so the caller-side narrowing
survives. Each lifecycle branch builds the full run object inline (as
it did before the lock refactor) and the helper only swaps in the
freshest `attributes` snapshot from the on-disk read.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-after e2e tests, normalize empty attributes
- Revert WORKFLOW_SERVER_URL_OVERRIDE to '' now that workflow-server
PR #442 has shipped to production.
- Normalize attributes to `{}` after Zod parse (was optional, so
world-local returned undefined while world-postgres returned `{}`).
Run construction sites in world-local (events-storage + legacy)
seed `attributes: {}` on `run_created`.
- Add three new e2e workflows + tests in workbench/nextjs-turbopack
exercising the world-vercel wire path against the production
endpoint:
1. Fire-and-forget (`void experimental_setAttributes`)
2. Promise.all of disjoint-key writes
3. Workflow throws after an awaited setAttributes (attribute
persists on the failed run via the per-run file lock)
- Add cap-boundary and idempotency unit tests in world-local.
- Document the three call patterns (awaited / fire-and-forget /
Promise.all) explicitly in the changelog. Includes the honest
caveat: a `void` call placed immediately before `return` with no
intervening await on a runtime primitive will not land — drain on
completion commits `step_created` but doesn't queue the step body.
In practice workflows always have an await after the last
fire-and-forget call (a step, a sleep, a hook).
- Refresh the changelog's Test coverage section to reflect what
actually ships; drop the stale "e2e deferred" line; tighten the
Concurrent writes section so the awaited-call serialization claim
doesn't generalize to Promise.all / fire-and-forget.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…created events
`drainPendingQueueItems` calls `handleSuspension` to commit `step_created` /
`hook_created` / `wait_created` events for any operations the workflow body
spawned but didn't await. The drain commit message claimed "Unawaited steps
and sleeps are queued (will execute / fire later)" but in practice only the
event was committed — the step body was never enqueued for execution,
because `handleSuspension`'s comment explicitly says
> Unlike V1, we do NOT queue step messages from here — the caller decides
> which steps to execute inline vs. queue to background.
The normal runtime loop in `runtime.ts:1019` iterates the returned
`pendingSteps` and calls `queueMessage` for each one. The drain caller in
`workflow.ts` discarded the return value and never queued, so fire-and-forget
step calls with side effects (`void experimental_setAttributes(...)`,
`void someStep()` placed right before `return`) committed step_created but the
step body never ran.
Existing fire-and-forget consumers in the codebase didn't catch this:
- Abort hooks work via `hook_received` whose landing is itself the abort
signal (no queue worker needed).
- `void sleep('Xs')` has no observable side effect when no one awaits it.
setAttributes is the first fire-and-forget consumer with a real side effect
(the row write), so it exposed the gap. The fix mirrors `runtime.ts`'s
post-suspension enqueue: iterate `pendingSteps`, filter by
`createdStepCorrelationIds` (only enqueue steps THIS drain owns — concurrent
crash-recovery handlers dedupe via the correlationId idempotency key), and
fire `queueMessage` calls in parallel.
Also drops the workaround in the fire-and-forget e2e workflow (the trailing
`await sleep('100ms')` that papered over the gap) and the changelog caveat
that warned users about the "last void before return won't land" footgun.
Both are no longer relevant.
Verified by:
- `pnpm --filter '@workflow/core' test` — 1020/1020 pass
- The e2e fire-and-forget workflow now drops three `void` calls (the third
immediately before `return`) and asserts all three attributes land
- CI will exercise the full path against the deployed Vercel preview /
production workflow-server
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…o, revert drain queueing
Investigation showed the drain queueMessage addition (f46faa1) is dead code in
practice: the step worker calls `executeStep` → `world.events.create('step_started')`,
which the platform rejects with `RunExpiredError` (HTTP 410) once the run has
transitioned to terminal. By the time the queue worker picks up a message
queued by drain, `run_completed` has landed and the worker logs "Workflow run
X has already completed, skipping step Y" (step-executor.ts:165) and returns
`{ type: 'gone' }`. The step body never executes.
For attribute writes specifically, the side effect bypasses the event log
(direct row update), so running against a terminal run would be safe — but
the platform doesn't special-case `__builtin_set_attributes`. Either the
worker needs to keep accepting step_started for drain-queued steps, or
attributes need a non-step dispatch path (planned for V1 with the attr_set
event type).
Changes:
- Revert the drain queueMessage logic in workflow.ts. Keep the doc comment
honest about what drain does and doesn't do.
- Mark the fire-and-forget e2e test as `test.todo` with a detailed TODO
explaining the platform-level issue and what's needed to fix it.
- Workbench workflow keeps the no-final-sleep shape — it's the eventual
contract we want to support; the .todo flags that the test will start
passing once the platform-level fix lands.
- Update the changelog "Usage patterns" section to honestly document the
last-void-before-return limitation. Mid-workflow fire-and-forget (the
common case for tracking attributes) works correctly via the regular
suspension path.
CI before this commit had 1 PR-related failure (`fire-and-forget` on every
adapter). That's now marked .todo. Other 54 failures are pre-existing on
nextjs-webpack and unrelated to this PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Awaited / Fire-and-forget / Promise.all" examples in the MVP changelog
reference free-floating identifiers (`processOrder`, `result.id`, `orderId`)
that aren't in scope as written — they're illustrative snippets, not runnable
code. Add `{/*@skip-typecheck - snippet, not runnable code*/}` directives
matching the existing usage above (interface and SDK examples). Also tighten
the Promise.all snippet to use a literal `'ord_123'` rather than a bare `id`.
Caught by the Docs Code Samples CI job.
User code calling experimental_setAttributes({ '$foo': 'bar' }) continues
to throw FatalError — the $ namespace is reserved for framework / library
code (telemetry tags, agent metadata, future platform attributes) and
accidental collisions there break tooling.
Framework callers that own a $-prefixed sub-namespace can now opt in
per-call: experimental_setAttributes({'$kind':'agent'}, {allowReservedAttributes: true}).
The flag is per-call (no run-level mode), so each call site explicitly
declares intent. SDK TSDoc warns this is framework-only and misuse can
conflict with observability surfaces.
Plumbing:
- validateAttributeKey / validateAttributeChanges accept
allowReservedAttributes. Default false at every layer.
- SDK helper takes a 2nd ExperimentalSetAttributesOptions argument and
forwards the flag to the step body via useStep('__builtin_set_attributes').
- World interface method gains a 3rd options argument. world-local and
world-postgres pass through to validateAttributeChanges. world-vercel
forwards in the HTTP body.
- Host-side stub keeps the matching signature for type consistency
(still throws — outside workflow body).
Tests:
- @workflow/world unit tests: accept reserved keys with opt-in, reject
without, reject when explicitly false (both validateAttributeKey and
validateAttributeChanges).
- @workflow/core SDK tests: opt-in dispatches with the flag, default
rejects, explicit false still rejects.
- @workflow/world-local integration test: framework write succeeds with
the opt-in; a follow-up write without the opt-in still rejects
(per-call, not sticky).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@VaguelySerious@pranaygp@TooTallNate@dvoytenko@karthikscale3