Skip to content

Attributes MVP (experimental and write-only)#2088

Open
VaguelySerious wants to merge 20 commits into
mainfrom
peter/attributes-mvp-plan
Open

Attributes MVP (experimental and write-only)#2088
VaguelySerious wants to merge 20 commits into
mainfrom
peter/attributes-mvp-plan

Conversation

@VaguelySerious
Copy link
Copy Markdown
Member

@VaguelySerious VaguelySerious commented May 22, 2026

Summary

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 { setAttributes } from 'workflow';

export async function myWorkflow(orderId: string) {
  'use workflow';
  await setAttributes({ phase: 'processing', orderId });
  // ...
  await setAttributes({ phase: 'done' });
  await 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.

setAttributes is callable from a workflow body only. 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 can be added later without breaking the workflow-body contract.

See docs/content/docs/v5/changelog/attributes-mvp.mdx for the full design, trade-offs, and implementation notes — including decisions made during build-out (endpoint namespacing, concurrency semantics, usage-fact schema choice, optional-world fallback behavior, etc.).

Paired with vercel/workflow-server#442 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

Layer Change
@workflow/world Shared validation + applyAttributeChanges helper. Optional experimentalSetAttributes on Storage.runs. Optional attributes field on WorkflowRunBaseSchema.
@workflow/core New 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.
@workflow/world-local Filesystem impl with a per-run async mutex so concurrent writes within a process don't lose updates. Threads attributes through the run lifecycle event reconstructions.
@workflow/world-postgres New attributes jsonb column (migration 0013_add_attributes.sql). SQL-side atomic merge using jsonb_set / - operators in a single UPDATE.
@workflow/world-vercel Pure HTTP wrapper posting { changes: [...] } to /v2/runs/:runId/attributes.
Docs Changelog entry at docs/content/docs/v5/changelog/attributes-mvp.mdx with full design + implementation notes.

Architecture (workflow-body dispatch)

  1. User calls 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.
  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.

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

  • Calling setAttributes from a step body or plain host code (throws FatalError; can be added later)
  • 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)

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>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 22, 2026

📊 Benchmark Results

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

workflow with no steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 0.042s (-5.6% 🟢) 1.006s (~) 0.964s 10 1.00x
💻 Local Nitro 0.042s (-3.0%) 1.006s (~) 0.964s 10 1.00x
🐘 Postgres Express 0.057s (-1.6%) 1.011s (~) 0.954s 10 1.37x
💻 Local Next.js (Turbopack) 0.058s 1.005s 0.947s 10 1.39x
🐘 Postgres Nitro 0.060s (-37.4% 🟢) 1.013s (-2.8%) 0.954s 10 1.43x
🐘 Postgres Next.js (Turbopack) 0.065s 1.011s 0.946s 10 1.55x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 0.272s (+8.2% 🔺) 2.117s (-9.3% 🟢) 1.845s 10 1.00x
▲ Vercel Nitro 0.277s (-32.4% 🟢) 2.481s (-1.1%) 2.204s 10 1.02x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.091s (-3.0%) 2.006s (~) 0.915s 10 1.00x
💻 Local Nitro 1.104s (-2.4%) 2.006s (~) 0.902s 10 1.01x
🐘 Postgres Express 1.105s (-3.6%) 2.010s (~) 0.906s 10 1.01x
🐘 Postgres Nitro 1.107s (-2.9%) 2.010s (~) 0.903s 10 1.01x
🐘 Postgres Next.js (Turbopack) 1.133s 2.009s 0.876s 10 1.04x
💻 Local Next.js (Turbopack) 1.137s 2.006s 0.869s 10 1.04x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 1.698s (-16.6% 🟢) 3.302s (-13.8% 🟢) 1.604s 10 1.00x
▲ Vercel Nitro 1.725s (-55.7% 🟢) 3.587s (-39.3% 🟢) 1.862s 10 1.02x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 10.494s (-3.9%) 11.022s (~) 0.528s 3 1.00x
💻 Local Nitro 10.548s (-3.6%) 11.023s (~) 0.475s 3 1.01x
🐘 Postgres Express 10.551s (-3.8%) 11.017s (~) 0.466s 3 1.01x
🐘 Postgres Nitro 10.577s (-2.7%) 11.020s (~) 0.443s 3 1.01x
💻 Local Next.js (Turbopack) 10.737s 11.020s 0.283s 3 1.02x
🐘 Postgres Next.js (Turbopack) 10.866s 11.016s 0.150s 3 1.04x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 13.797s (-41.9% 🟢) 15.721s (-37.4% 🟢) 1.924s 2 1.00x
▲ Vercel Next.js (Turbopack) 13.864s (-19.9% 🟢) 15.183s (-21.7% 🟢) 1.318s 2 1.00x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 13.710s (-6.0% 🟢) 14.018s (-6.7% 🟢) 0.307s 5 1.00x
💻 Local Nitro 13.742s (-8.8% 🟢) 14.028s (-12.5% 🟢) 0.286s 5 1.00x
💻 Local Express 13.853s (-7.5% 🟢) 14.227s (-5.3% 🟢) 0.374s 5 1.01x
🐘 Postgres Nitro 13.937s (-4.5%) 14.021s (-6.7% 🟢) 0.084s 5 1.02x
💻 Local Next.js (Turbopack) 14.339s 15.029s 0.690s 4 1.05x
🐘 Postgres Next.js (Turbopack) 14.360s 15.017s 0.657s 4 1.05x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 21.728s (-66.3% 🟢) 23.515s (-64.7% 🟢) 1.786s 3 1.00x
▲ Vercel Next.js (Turbopack) 22.468s (-57.3% 🟢) 24.093s (-55.9% 🟢) 1.624s 3 1.03x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 12.341s (-25.7% 🟢) 13.026s (-23.5% 🟢) 0.685s 7 1.00x
🐘 Postgres Express 12.429s (-11.3% 🟢) 13.018s (-10.8% 🟢) 0.589s 7 1.01x
💻 Local Nitro 12.453s (-25.8% 🟢) 13.025s (-23.5% 🟢) 0.572s 7 1.01x
🐘 Postgres Nitro 12.476s (-10.7% 🟢) 13.021s (-9.0% 🟢) 0.545s 7 1.01x
💻 Local Next.js (Turbopack) 13.490s 14.026s 0.536s 7 1.09x
🐘 Postgres Next.js (Turbopack) 13.693s 14.163s 0.470s 7 1.11x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 31.302s (-92.6% 🟢) 34.109s (-92.0% 🟢) 2.806s 3 1.00x
▲ Vercel Next.js (Turbopack) 31.624s (-92.0% 🟢) 33.272s (-91.6% 🟢) 1.648s 3 1.01x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.182s (-7.2% 🟢) 2.007s (~) 0.825s 15 1.00x
🐘 Postgres Express 1.200s (-4.8%) 2.007s (~) 0.807s 15 1.01x
🐘 Postgres Next.js (Turbopack) 1.222s 2.007s 0.785s 15 1.03x
💻 Local Express 1.257s (-15.5% 🟢) 2.006s (~) 0.749s 15 1.06x
💻 Local Nitro 1.264s (-22.5% 🟢) 2.006s (-3.3%) 0.742s 15 1.07x
💻 Local Next.js (Turbopack) 1.342s 2.005s 0.663s 15 1.14x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 2.591s (-23.8% 🟢) 4.137s (-16.1% 🟢) 1.546s 8 1.00x
▲ Vercel Nitro 2.600s (-7.7% 🟢) 3.901s (-9.7% 🟢) 1.301s 8 1.00x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.237s (-47.6% 🟢) 2.007s (-33.3% 🟢) 0.771s 15 1.00x
🐘 Postgres Nitro 1.251s (-46.8% 🟢) 2.007s (-33.3% 🟢) 0.756s 15 1.01x
🐘 Postgres Next.js (Turbopack) 1.394s 2.006s 0.612s 15 1.13x
💻 Local Next.js (Turbopack) 1.712s 2.074s 0.362s 15 1.38x
💻 Local Express 1.718s (-41.8% 🟢) 2.005s (-41.9% 🟢) 0.287s 15 1.39x
💻 Local Nitro 1.819s (-42.1% 🟢) 2.073s (-46.6% 🟢) 0.254s 15 1.47x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.778s (-6.8% 🟢) 5.241s (-11.5% 🟢) 1.463s 6 1.00x
▲ Vercel Next.js (Turbopack) 4.598s (-35.2% 🟢) 6.052s (-32.0% 🟢) 1.454s 6 1.22x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.344s (-61.5% 🟢) 2.007s (-50.0% 🟢) 0.663s 15 1.00x
🐘 Postgres Nitro 1.409s (-59.5% 🟢) 2.007s (-49.9% 🟢) 0.598s 15 1.05x
🐘 Postgres Next.js (Turbopack) 1.627s 2.074s 0.447s 15 1.21x
💻 Local Next.js (Turbopack) 4.132s 5.010s 0.879s 6 3.08x
💻 Local Express 4.775s (-42.7% 🟢) 5.345s (-40.8% 🟢) 0.570s 6 3.55x
💻 Local Nitro 5.563s (-33.4% 🟢) 6.012s (-33.4% 🟢) 0.449s 5 4.14x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 5.631s (-36.8% 🟢) 7.671s (-30.0% 🟢) 2.040s 4 1.00x
▲ Vercel Nitro 6.951s (+97.2% 🔺) 9.256s (+67.3% 🔺) 2.305s 4 1.23x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.179s (-6.2% 🟢) 2.009s (~) 0.831s 15 1.00x
🐘 Postgres Nitro 1.179s (-6.2% 🟢) 2.008s (~) 0.829s 15 1.00x
🐘 Postgres Next.js (Turbopack) 1.256s 2.007s 0.751s 15 1.07x
💻 Local Next.js (Turbopack) 1.388s 2.006s 0.618s 15 1.18x
💻 Local Express 1.510s (-20.2% 🟢) 2.006s (-15.1% 🟢) 0.496s 15 1.28x
💻 Local Nitro 1.547s (-17.1% 🟢) 2.006s (-14.3% 🟢) 0.459s 15 1.31x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 3.223s (+9.9% 🔺) 4.697s (+1.2%) 1.474s 7 1.00x
▲ Vercel Nitro 3.228s (+31.3% 🔺) 4.592s (+10.1% 🔺) 1.364s 7 1.00x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.217s (-48.0% 🟢) 2.008s (-33.3% 🟢) 0.791s 15 1.00x
🐘 Postgres Nitro 1.254s (-46.4% 🟢) 2.010s (-33.2% 🟢) 0.756s 15 1.03x
🐘 Postgres Next.js (Turbopack) 1.374s 2.007s 0.633s 15 1.13x
💻 Local Next.js (Turbopack) 2.037s 2.591s 0.554s 12 1.67x
💻 Local Express 2.041s (-34.8% 🟢) 2.470s (-34.4% 🟢) 0.429s 13 1.68x
💻 Local Nitro 2.106s (-31.3% 🟢) 2.674s (-31.2% 🟢) 0.569s 12 1.73x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.468s (+7.3% 🔺) 5.070s (~) 1.602s 6 1.00x
▲ Vercel Next.js (Turbopack) 3.889s (+23.8% 🔺) 5.396s (+19.3% 🔺) 1.506s 6 1.12x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.333s (-61.9% 🟢) 2.008s (-49.9% 🟢) 0.675s 15 1.00x
🐘 Postgres Nitro 1.386s (-60.2% 🟢) 2.008s (-49.9% 🟢) 0.622s 15 1.04x
🐘 Postgres Next.js (Turbopack) 1.608s 2.006s 0.398s 15 1.21x
💻 Local Express 4.979s (-43.4% 🟢) 5.514s (-40.5% 🟢) 0.536s 6 3.73x
💻 Local Next.js (Turbopack) 5.160s 5.513s 0.353s 6 3.87x
💻 Local Nitro 6.215s (-32.0% 🟢) 6.413s (-36.0% 🟢) 0.198s 5 4.66x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 5.389s (-20.2% 🟢) 7.042s (-17.6% 🟢) 1.652s 5 1.00x
▲ Vercel Nitro 5.601s (+10.0% 🔺) 7.512s (+10.2% 🔺) 1.911s 4 1.04x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.553s (-34.0% 🟢) 1.023s (~) 0.469s 59 1.00x
💻 Local Express 0.585s (-40.6% 🟢) 1.004s (-6.6% 🟢) 0.420s 60 1.06x
🐘 Postgres Nitro 0.587s (-28.5% 🟢) 1.024s (+1.8%) 0.437s 59 1.06x
💻 Local Nitro 0.592s (-39.6% 🟢) 1.005s (-8.1% 🟢) 0.413s 60 1.07x
🐘 Postgres Next.js (Turbopack) 0.787s 1.040s 0.253s 58 1.42x
💻 Local Next.js (Turbopack) 0.842s 1.004s 0.163s 60 1.52x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 5.583s (-61.5% 🟢) 6.744s (-58.1% 🟢) 1.161s 9 1.00x
▲ Vercel Nitro 5.638s (-74.4% 🟢) 7.170s (-70.2% 🟢) 1.532s 9 1.01x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.297s (-34.4% 🟢) 2.007s (-11.1% 🟢) 0.710s 45 1.00x
🐘 Postgres Nitro 1.405s (-27.1% 🟢) 2.054s (-2.2%) 0.649s 44 1.08x
💻 Local Express 1.517s (-49.7% 🟢) 2.028s (-43.4% 🟢) 0.511s 45 1.17x
💻 Local Nitro 1.544s (-49.1% 🟢) 2.029s (-46.0% 🟢) 0.484s 45 1.19x
🐘 Postgres Next.js (Turbopack) 1.869s 2.030s 0.161s 45 1.44x
💻 Local Next.js (Turbopack) 2.073s 2.943s 0.870s 31 1.60x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 12.756s (-67.7% 🟢) 14.741s (-64.3% 🟢) 1.986s 7 1.00x
▲ Vercel Next.js (Turbopack) 14.172s (-71.5% 🟢) 15.962s (-69.1% 🟢) 1.790s 6 1.11x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 2.554s (-36.0% 🟢) 3.033s (-30.6% 🟢) 0.479s 40 1.00x
🐘 Postgres Nitro 2.652s (-35.4% 🟢) 3.059s (-33.6% 🟢) 0.407s 40 1.04x
💻 Local Express 3.225s (-65.0% 🟢) 3.944s (-60.6% 🟢) 0.719s 31 1.26x
💻 Local Nitro 3.225s (-65.3% 🟢) 4.009s (-60.0% 🟢) 0.784s 30 1.26x
🐘 Postgres Next.js (Turbopack) 3.756s 4.148s 0.392s 29 1.47x
💻 Local Next.js (Turbopack) 4.239s 5.010s 0.771s 24 1.66x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 27.419s (-71.7% 🟢) 30.216s (-69.3% 🟢) 2.798s 5 1.00x
▲ Vercel Next.js (Turbopack) 29.855s (-72.1% 🟢) 32.192s (-70.4% 🟢) 2.337s 4 1.09x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.204s (-27.9% 🟢) 1.006s (~) 0.802s 60 1.00x
🐘 Postgres Nitro 0.215s (-24.0% 🟢) 1.006s (~) 0.791s 60 1.06x
🐘 Postgres Next.js (Turbopack) 0.262s 1.006s 0.744s 60 1.28x
💻 Local Express 0.415s (-25.9% 🟢) 1.004s (~) 0.589s 60 2.04x
💻 Local Nitro 0.432s (-28.5% 🟢) 1.004s (-1.7%) 0.572s 60 2.12x
💻 Local Next.js (Turbopack) 0.528s 1.004s 0.476s 60 2.59x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.508s (+51.0% 🔺) 4.127s (+23.1% 🔺) 1.618s 15 1.00x
▲ Vercel Next.js (Turbopack) 2.731s (+35.0% 🔺) 4.064s (+7.1% 🔺) 1.333s 15 1.09x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.317s (-37.8% 🟢) 1.006s (~) 0.689s 90 1.00x
🐘 Postgres Nitro 0.336s (-32.4% 🟢) 1.007s (~) 0.671s 90 1.06x
🐘 Postgres Next.js (Turbopack) 0.451s 1.005s 0.554s 90 1.42x
💻 Local Express 2.100s (-16.4% 🟢) 2.655s (-11.8% 🟢) 0.555s 34 6.62x
💻 Local Nitro 2.177s (-14.2% 🟢) 2.714s (-9.8% 🟢) 0.537s 34 6.87x
💻 Local Next.js (Turbopack) 2.246s 2.945s 0.699s 31 7.08x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 5.073s (+57.3% 🔺) 6.664s (+38.2% 🔺) 1.591s 14 1.00x
▲ Vercel Next.js (Turbopack) 6.198s (+75.3% 🔺) 7.694s (+48.2% 🔺) 1.496s 12 1.22x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.643s (-21.5% 🟢) 1.006s (-1.2%) 0.363s 120 1.00x
🐘 Postgres Nitro 0.659s (-16.6% 🟢) 1.006s (~) 0.347s 120 1.03x
🐘 Postgres Next.js (Turbopack) 0.903s 1.171s 0.268s 103 1.40x
💻 Local Express 9.652s (-13.7% 🟢) 10.357s (-13.3% 🟢) 0.705s 12 15.01x
💻 Local Nitro 9.740s (-13.0% 🟢) 10.358s (-11.2% 🟢) 0.618s 12 15.15x
💻 Local Next.js (Turbopack) 9.914s 10.691s 0.777s 12 15.42x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 15.006s (+94.3% 🔺) 17.001s (+80.9% 🔺) 1.995s 8 1.00x
▲ Vercel Next.js (Turbopack) 16.999s (+64.6% 🔺) 18.567s (+51.1% 🔺) 1.568s 7 1.13x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.162s (+483.6% 🔺) 2.005s (+99.6% 🔺) 0.011s (-13.2% 🟢) 2.018s (+98.2% 🔺) 0.856s 10 1.00x
🐘 Postgres Nitro 1.169s (+470.0% 🔺) 2.001s (+100.2% 🔺) 0.001s (-40.0% 🟢) 2.011s (+98.9% 🔺) 0.842s 10 1.01x
💻 Local Nitro 1.179s (+451.6% 🔺) 2.005s (+99.6% 🔺) 0.013s (~) 2.020s (+98.3% 🔺) 0.841s 10 1.01x
🐘 Postgres Express 1.195s (+482.6% 🔺) 2.001s (+100.4% 🔺) 0.001s (-37.5% 🟢) 2.011s (+98.8% 🔺) 0.816s 10 1.03x
💻 Local Next.js (Turbopack) 1.198s 2.003s 0.010s 2.018s 0.819s 10 1.03x
🐘 Postgres Next.js (Turbopack) 1.216s 2.001s 0.001s 2.010s 0.794s 10 1.05x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.430s (-36.6% 🟢) 3.602s (-31.7% 🟢) 1.720s (+131.7% 🔺) 5.809s (-10.4% 🟢) 3.379s 10 1.00x
▲ Vercel Next.js (Turbopack) 2.658s (-61.2% 🟢) 3.430s (-60.4% 🟢) 1.845s (+192.1% 🔺) 5.853s (-40.2% 🟢) 3.196s 10 1.09x
▲ Vercel Express ⚠️ missing - - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.569s (+149.1% 🔺) 2.007s (+99.4% 🔺) 0.004s (+5.3% 🔺) 2.026s (+98.0% 🔺) 0.457s 30 1.00x
🐘 Postgres Nitro 1.575s (+152.4% 🔺) 2.004s (+99.0% 🔺) 0.004s (-7.3% 🟢) 2.027s (+98.2% 🔺) 0.452s 30 1.00x
💻 Local Nitro 1.599s (+90.7% 🔺) 2.010s (+98.7% 🔺) 0.010s (+7.8% 🔺) 2.022s (+81.2% 🔺) 0.423s 30 1.02x
🐘 Postgres Next.js (Turbopack) 1.701s 2.010s 0.004s 2.025s 0.324s 30 1.08x
💻 Local Next.js (Turbopack) 1.740s 2.043s 0.009s 2.055s 0.314s 30 1.11x
💻 Local Express 1.765s (+133.2% 🔺) 2.012s (+95.5% 🔺) 0.009s (-5.2% 🟢) 2.202s (+111.8% 🔺) 0.437s 28 1.12x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 6.107s (-79.3% 🟢) 7.426s (-75.9% 🟢) 0.330s (+194.6% 🔺) 8.272s (-74.0% 🟢) 2.165s 8 1.00x
▲ Vercel Next.js (Turbopack) 6.441s (-61.9% 🟢) 7.853s (-56.9% 🟢) 0.182s (-13.9% 🟢) 8.430s (-55.5% 🟢) 1.989s 8 1.05x
▲ Vercel Express ⚠️ missing - - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.692s (-27.9% 🟢) 1.050s (-17.8% 🟢) 0.000s (-19.3% 🟢) 1.060s (-18.9% 🟢) 0.367s 57 1.00x
🐘 Postgres Nitro 0.693s (-28.5% 🟢) 1.016s (-18.6% 🟢) 0.000s (-17.2% 🟢) 1.035s (-17.7% 🟢) 0.342s 58 1.00x
🐘 Postgres Next.js (Turbopack) 0.776s 1.053s 0.000s 1.063s 0.287s 57 1.12x
💻 Local Nitro 1.436s (+17.5% 🔺) 2.016s (~) 0.000s (+266.7% 🔺) 2.018s (~) 0.581s 30 2.07x
💻 Local Express 1.485s (+21.2% 🔺) 2.016s (~) 0.000s (-10.0% 🟢) 2.018s (~) 0.533s 30 2.14x
💻 Local Next.js (Turbopack) 1.558s 2.014s 0.000s 2.017s 0.459s 30 2.25x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 4.226s (+38.5% 🔺) 5.388s (+22.7% 🔺) 0.000s (+18.2% 🔺) 5.959s (+23.9% 🔺) 1.733s 11 1.00x
▲ Vercel Next.js (Turbopack) 5.082s (-50.1% 🟢) 6.325s (-45.1% 🟢) 0.000s (NaN%) 6.756s (-43.9% 🟢) 1.674s 9 1.20x
▲ Vercel Express ⚠️ missing - - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

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

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.366s (-23.7% 🟢) 2.067s (-3.5%) 0.000s (+93.1% 🔺) 2.083s (-4.2%) 0.716s 29 1.00x
🐘 Postgres Express 1.424s (-19.6% 🟢) 2.101s (-3.5%) 0.000s (+Infinity% 🔺) 2.139s (-2.7%) 0.714s 29 1.04x
🐘 Postgres Next.js (Turbopack) 1.647s 2.224s 0.000s 2.266s 0.620s 27 1.20x
💻 Local Next.js (Turbopack) 3.233s 3.728s 0.000s 3.739s 0.506s 17 2.37x
💻 Local Nitro 3.292s (-2.8%) 3.969s (-1.6%) 0.001s (+64.1% 🔺) 3.971s (-1.6%) 0.680s 16 2.41x
💻 Local Express 3.699s (+6.7% 🔺) 3.895s (-3.4%) 0.000s (-50.0% 🟢) 4.232s (+4.8%) 0.533s 15 2.71x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 6.195s (+10.3% 🔺) 7.589s (+8.7% 🔺) 0.014s (+11200.0% 🔺) 8.002s (+6.1% 🔺) 1.807s 8 1.00x
▲ Vercel Nitro 6.242s (+52.5% 🔺) 7.756s (+44.3% 🔺) 0.023s (+8425.0% 🔺) 8.288s (+43.0% 🔺) 2.046s 8 1.01x
▲ Vercel Express ⚠️ missing - - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Express 13/21
🐘 Postgres Express 18/21
▲ Vercel Nitro 13/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 16/21
Next.js (Turbopack) 🐘 Postgres 16/21
Nitro 🐘 Postgres 16/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: success
  • Postgres: success
  • Vercel: failure

Check the workflow run for details.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 22, 2026

🦋 Changeset detected

Latest commit: 4aa2bb4

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

This PR includes changesets to release 20 packages
Name Type
@workflow/core Patch
@workflow/world Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/world-vercel Patch
workflow Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-testing Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

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
Copy link
Copy Markdown
Contributor

vercel Bot commented May 22, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment May 27, 2026 12:39pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment May 27, 2026 12:39pm
example-workflow Ready Ready Preview, Comment May 27, 2026 12:39pm
workbench-astro-workflow Ready Ready Preview, Comment May 27, 2026 12:39pm
workbench-express-workflow Ready Ready Preview, Comment May 27, 2026 12:39pm
workbench-fastify-workflow Ready Ready Preview, Comment May 27, 2026 12:39pm
workbench-hono-workflow Ready Ready Preview, Comment May 27, 2026 12:39pm
workbench-nitro-workflow Ready Ready Preview, Comment May 27, 2026 12:39pm
workbench-nuxt-workflow Ready Ready Preview, Comment May 27, 2026 12:39pm
workbench-sveltekit-workflow Ready Ready Preview, Comment May 27, 2026 12:39pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment May 27, 2026 12:39pm
workbench-vite-workflow Ready Ready Preview, Comment May 27, 2026 12:39pm
workflow-docs Ready Ready Preview, Comment, Open in v0 May 27, 2026 12:39pm
workflow-swc-playground Ready Ready Preview, Comment May 27, 2026 12:39pm
workflow-tarballs Ready Ready Preview, Comment May 27, 2026 12:39pm
workflow-web Ready Ready Preview, Comment May 27, 2026 12:39pm

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 22, 2026

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1233 0 219 1452
❌ 💻 Local Development 1608 21 219 1848
✅ 📦 Local Production 1629 0 219 1848
✅ 🐘 Local Postgres 1629 0 219 1848
✅ 🪟 Windows 132 0 0 132
✅ 📋 Other 748 0 176 924
Total 6979 21 1052 8052

❌ Failed Tests

💻 Local Development (21 failed)

nextjs-webpack-canary (7 failed):

  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling retry behavior FatalError fails immediately without retries
  • error handling catchability workflow throw round-trips FatalError + cause through run_failed event
  • error handling not registered WorkflowNotRegisteredError fails the run when workflow does not exist
  • error handling not registered StepNotRegisteredError fails the run when not caught in workflow
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KSMYMGNXTDVAT5Y3BE6R6WZ3

nextjs-webpack-stable-lazy-discovery-disabled (7 failed):

  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling retry behavior FatalError fails immediately without retries
  • error handling catchability workflow throw round-trips FatalError + cause through run_failed event
  • error handling not registered WorkflowNotRegisteredError fails the run when workflow does not exist
  • error handling not registered StepNotRegisteredError fails the run when not caught in workflow
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KSMYMGNXTDVAT5Y3BE6R6WZ3

nextjs-webpack-stable-lazy-discovery-enabled (7 failed):

  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling retry behavior FatalError fails immediately without retries
  • error handling catchability workflow throw round-trips FatalError + cause through run_failed event
  • error handling not registered WorkflowNotRegisteredError fails the run when workflow does not exist
  • error handling not registered StepNotRegisteredError fails the run when not caught in workflow
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KSMYMGNXTDVAT5Y3BE6R6WZ3

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 106 0 26
✅ example 106 0 26
✅ express 106 0 26
✅ fastify 106 0 26
✅ hono 106 0 26
✅ nextjs-turbopack 130 0 2
✅ nextjs-webpack 130 0 2
✅ nitro 106 0 26
✅ nuxt 106 0 26
✅ sveltekit 125 0 7
✅ vite 106 0 26
❌ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 107 0 25
✅ express-stable 107 0 25
✅ fastify-stable 107 0 25
✅ hono-stable 107 0 25
✅ nextjs-turbopack-canary 113 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 132 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 132 0 0
❌ nextjs-webpack-canary 106 7 19
❌ nextjs-webpack-stable-lazy-discovery-disabled 125 7 0
❌ nextjs-webpack-stable-lazy-discovery-enabled 125 7 0
✅ nitro-stable 107 0 25
✅ nuxt-stable 107 0 25
✅ sveltekit-stable 126 0 6
✅ vite-stable 107 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 107 0 25
✅ express-stable 107 0 25
✅ fastify-stable 107 0 25
✅ hono-stable 107 0 25
✅ nextjs-turbopack-canary 113 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 132 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 132 0 0
✅ nextjs-webpack-canary 113 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 132 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 132 0 0
✅ nitro-stable 107 0 25
✅ nuxt-stable 107 0 25
✅ sveltekit-stable 126 0 6
✅ vite-stable 107 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 107 0 25
✅ express-stable 107 0 25
✅ fastify-stable 107 0 25
✅ hono-stable 107 0 25
✅ nextjs-turbopack-canary 113 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 132 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 132 0 0
✅ nextjs-webpack-canary 113 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 132 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 132 0 0
✅ nitro-stable 107 0 25
✅ nuxt-stable 107 0 25
✅ sveltekit-stable 126 0 6
✅ vite-stable 107 0 25
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 132 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 107 0 25
✅ e2e-local-dev-tanstack-start- 107 0 25
✅ e2e-local-postgres-nest-stable 107 0 25
✅ e2e-local-postgres-tanstack-start- 107 0 25
✅ e2e-local-prod-nest-stable 107 0 25
✅ e2e-local-prod-tanstack-start- 107 0 25
✅ e2e-vercel-prod-tanstack-start 106 0 26

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: failure
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

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 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>
Comment thread packages/core/src/set-attributes-shared.ts Outdated
vercel Bot and others added 10 commits May 23, 2026 07:09
… 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>
Comment on lines +62 to +63
const WORKFLOW_SERVER_URL_OVERRIDE =
'https://workflow-server-git-peter-attributes-mvp.vercel.sh';
Copy link
Copy Markdown
Contributor

@vercel vercel Bot May 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WORKFLOW_SERVER_URL_OVERRIDE is set to a preview branch URL instead of an empty string, which would route all production @workflow/world-vercel clients to a preview-branch workflow-server if merged to main.

Fix on Vercel

*/
const WORKFLOW_SERVER_URL_OVERRIDE = '';
const WORKFLOW_SERVER_URL_OVERRIDE =
'https://workflow-server-git-peter-attributes-mvp.vercel.sh';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must revert before merge. WORKFLOW_SERVER_URL_OVERRIDE is set to a preview-branch URL (workflow-server-git-peter-attributes-mvp.vercel.sh) instead of ''. The comment block right above this assignment says this constant defaults to main and is only rewritten by external CI for branch testing — committing a non-empty value here routes every @workflow/world-vercel production client (Next.js, Hono, etc.) at the preview-branch workflow-server once this PR merges to main.

Comment thread packages/world/src/attributes.ts Outdated
// counted as +1 here, which makes the cap check slightly conservative.
// For the MVP cap of 64 this is acceptable; the server's authoritative
// check uses the real post-merge size.
if (change.value !== null) netAdds += 1;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cap check counts every value !== null change as a fresh add, even when the key already exists on the run. Once a run hits 64 attributes, updating a single existing key fails: existing=64, netAdds=1, 64 + 1 > 64 — even though the post-merge size is still 64.

The inline comment calls this "slightly conservative" and acceptable for the MVP, but in practice it prevents legitimate updates and surfaces as an "exceeds limit 64 (existing 64 + incoming 1)" error that a user has no way to interpret. Mitigation requires knowing which incoming keys already exist, which is fine for the world-side check (it has the snapshot) but means the client-side validation in workflow/set-attributes.ts (which passes no existingCount) is doing nothing useful here.

Suggest: have world-side validators subtract changes.filter(c => c.value !== null && existing[c.key] !== undefined).length from netAdds, and accept that client-side validation only catches per-batch limits.

| undefined;
if (typeof world?.runs?.experimentalSetAttributes !== 'function') {
// World adapter doesn't implement attributes yet — silently no-op.
// The VM-side validation already ran, so input was well-formed.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent no-op contradicts the documented behavior. The MVP changelog (docs/content/docs/v5/changelog/attributes-mvp.mdx) and the JSDoc on Storage.runs.experimentalSetAttributes (packages/world/src/interfaces.ts:177-180) both state: "the SDK detects absence and no-ops setAttributes with a warning so that third-party / community worlds continue to function without adopting the experimental API."

This code returns silently with no log/warning. A user calling setAttributes() against a community world that hasn't adopted the API will see a successful await and have no signal whatsoever that their attributes weren't persisted. That's the exact failure mode the documented warning is supposed to prevent.

At minimum, emit runtimeLogger.warn(...) (or a process-lifetime once-flag if you're worried about noise) before return. Right now the docs/code drift is also itself a maintainability concern — either implement the warning or strike the claim from the docs.


if (!updated) {
throw new WorkflowRunNotFoundError(runId);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-run cap is not enforced atomically — two concurrent writers can both pass validation and produce a row over the 64-attribute limit.

Walkthrough (cap=64):

  1. Run currently has 60 attributes.
  2. Writer A reads existing.attributes → 60 keys.
  3. Writer B reads existing.attributes → 60 keys.
  4. A validates with existingCount=60 adding 3 new → 63 ≤ 64 ✓.
  5. B validates with existingCount=60 adding 3 new (different keys) → 63 ≤ 64 ✓.
  6. A's UPDATE runs → row has 63 attributes.
  7. B's UPDATE runs against the now-mutated row → ends up with 66 attributes.

The inline comment acknowledges this as "last-write-wins by arrival" but conflates write order with the cap invariant — LWW only governs which value of a given key survives; it does not stop concurrent disjoint-key inserts from blowing past the size cap. The comment in attributes.ts ("the server's authoritative check uses the real post-merge size") is also misleading for the Postgres world, since this read-then-update is the only authoritative path here.

Options: (a) wrap in a serializable transaction, (b) compute & assert the post-merge length inside the same UPDATE via a SQL CASE WHEN jsonb_object_keys(...) > 64 THEN error (Postgres doesn't make this elegant but RAISE inside a function works), or (c) accept the loose cap and document that the limit is best-effort under concurrent writers.

completedAt: undefined,
startedAt: currentRun.startedAt ?? now,
updatedAt: now,
attributes: currentRun.attributes,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Run-lifecycle event writes share the run file with experimentalSetAttributes but coordinate via no shared mutex — attribute writes can be silently lost.

The new withRunAttributeLock in runs-storage.ts only serializes calls to experimentalSetAttributes against each other. The run-lifecycle handlers (run_started, run_completed, run_failed, run_cancelled) here all use the read-then-write pattern: they read currentRun near the top of createImpl (line ~219), then build a fresh run object preserving attributes: currentRun.attributes and writeJSON({ overwrite: true }) it back. There's no lock around that span.

Race example: a __builtin_set_attributes step runs while run_completed is being persisted for the same run.

  1. events-storage reads currentRun with attributes = { phase: 'init' }.
  2. experimentalSetAttributes lands and writes { phase: 'init', orderId: 'ord_123' }.
  3. events-storage continues, constructs the completed run with attributes: currentRun.attributes (= the stale { phase: 'init' }), and overwrites the file.
  4. The orderId write is silently lost.

I don't think the in-process mutex can fix this cleanly — events-storage and runs-storage are independent write paths against the same file. The fix is probably to route attribute writes through the same per-run lock the lifecycle writer takes, or (cleaner) to re-read the run inside the lock and merge before writing on lifecycle events. Worth a comment at minimum if you're choosing to accept the race for the MVP.

'It must be called from within a workflow body (`use workflow`).'
);
}
await useStep('__builtin_set_attributes')(changes);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please verify the bare-name step lookup for __builtin_set_attributes actually resolves under the production SWC + step-handler pipeline.

The workflow VM dispatches with the bare name '__builtin_set_attributes', which becomes the stepName on the step_created event. On the step worker side, packages/core/src/runtime/step-handler.ts:198 calls getStepFunction(stepName) to resolve it.

getStepFunction (packages/core/src/private.ts:103-122) tries three things in order:

  1. registeredSteps.get(stepName) — but the SWC plugin spec (spec.md:115) registers steps with full IDs like step//<modulePath>//<fnName>, so a bare-name lookup misses.
  2. getStepIdAliasCandidates(stepName) — returns [] when the input isn't already a step//x//y triple.
  3. getBuiltinResponseStepAlias(stepName) — this is the only fallback that handles bare names, and it explicitly hard-codes BUILTIN_RESPONSE_STEP_NAMES = { __builtin_response_array_buffer, __builtin_response_json, __builtin_response_text } at private.ts:30-34. __builtin_set_attributes is not in that set, so the fallback returns undefined.

That private.ts file is not touched by this PR. The way __builtin_response_* work today is precisely that they're in the allowlist; __builtin_set_attributes needs the same treatment (or a generalization of the alias to all step//*//<bareName> matches).

The new e2e (packages/core/e2e/e2e.test.ts) does seem to pass in CI, which is what makes me less than fully confident — but I can't see a code path that makes the bare-name lookup succeed. Worth running the new e2e explicitly with getStepFunction instrumentation, or just adding __builtin_set_attributes to BUILTIN_RESPONSE_STEP_NAMES (and probably renaming that set) to be safe.

* }
* ```
*/
export async function setAttributes(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming nit: prefer experimental_setAttributes for the public export.

The repo doesn't yet have an established experimental_ / unstable_ convention, but adopting one here would make the "this WILL change" signal much louder at the call site — await setAttributes({...}) reads as production-ready, await experimental_setAttributes({...}) does not. This is the Next.js / React pattern (unstable_*, experimental_*) and it's a single codemod to remove when V1 stabilizes the API — which doubles as a forcing function for every consumer to re-review the new contract on the rename.

Suggest applying the same convention to the world-interface method too: experimental_setAttributes? rather than experimentalSetAttributes? in packages/world/src/interfaces.ts. Consistency across the surface, and the underscore separator makes the experimental marker scan visually rather than blending into a camelCase identifier.

*/
export async function setAttributes(
_attrs: Record<string, string | undefined>
): Promise<void> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the workflow-body-only restriction load-bearing, or scope?

Step-body support looks like ~10 lines: the host stub would do exactly what __builtin_set_attributes already does — read the runId from Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE'), read the world from Symbol.for('@workflow/world//cache'), validate, dispatch directly. Both symbols are already populated when a step body runs (the bridge step proves it).

const ctx = globalThis[Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')]?.getStore?.();
const runId = ctx?.workflowMetadata?.workflowRunId;
if (!runId) throw new FatalError('must be called from workflow or step body');
const world = globalThis[Symbol.for('@workflow/world//cache')];
await world?.runs?.experimentalSetAttributes?.(runId, normalizedChanges);

Plain host code outside any run genuinely can't be supported with this signature (no runId to infer) — that part isn't arbitrary, you'd need a separate experimental_setRunAttributes(runId, {...}). But step body is just a scope decision.

Trade-offs of allowing step body:

What you'd gain:

  • Users writing a step that already has the data don't have to bubble values back to the workflow body to set an attribute.
  • The natural ergonomic model — setAttributes works "anywhere inside a run."

What you'd lose:

  • The "single dispatch path" simplicity the PR description cites.
  • Event-log visibility of the dispatch: workflow-body calls produce a step_created/step_completed pair via the __builtin_set_attributes bridge; step-body calls would write directly with no extra events. Replay determinism is unaffected (step bodies aren't re-executed during replay anyway).

Forward-compat with #1933 is fine either way — the planned attr_set event's writer: { type: 'step', stepId, attempt } discriminator already accounts for step writers.

Not asking you to add it in this PR — but if the restriction is just scope-cut rather than something the architecture is leaning on, please mention that in the changelog so it doesn't read as a hard architectural constraint that has to stay until V1.

VaguelySerious and others added 2 commits May 27, 2026 12:15
`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>
@VaguelySerious
Copy link
Copy Markdown
Member Author

(AI) Review feedback addressed — f026a5e0b3 + server PR 414864d

Pushed a single commit on this PR addressing the world-side comments + renaming the public export, with a paired commit on vercel/workflow-server#442 covering the server-side feedback.

Code changes

# Comment Resolution
11 world-local mutex coordination — lifecycle writes can clobber attribute writes Renamed withRunAttributeLockwithRunFileLock, exported it, and acquire it from the run_started / run_completed / run_failed / run_cancelled branches in events-storage.ts. Each lifecycle write re-reads the run JSON inside the lock before writing, so an experimental_setAttributes call that lands between the lifecycle handler's pre-validation read and write is preserved instead of overwritten.
12 world-postgres per-run cap not enforced atomically Cap check is now inside the same UPDATE statement as the merge: WHERE ... AND (SELECT COUNT(*) FROM jsonb_object_keys(merged_expr)) <= ATTRIBUTE_MAX_PER_RUN. Two concurrent writers adding disjoint keys at the cap boundary can no longer both succeed. A follow-up re-read on rejection disambiguates "run not found" from "cap rejected after concurrent write".
13 world/attributes.ts cap counts every upsert as +1, including for already-present keys validateAttributeChanges now takes existingKeys?: Iterable<string> instead of existingCount?: number. With keys, the 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. Added a regression test that exercises the at-the-cap update case.
14 Silent no-op when world doesn't implement experimentalSetAttributes contradicts docs __builtin_set_attributes now emits one process-wide console.warn on first dispatch against an unsupporting world, matching the changelog and the Storage.runs.experimentalSetAttributes JSDoc. The warning is deduped via a globalThis symbol.
16 Rename to experimental_setAttributes Done at every call site: public exports from @workflow/core and workflow, the workflow VM bundle's workflow/index.ts, unit tests, e2e test, workbench example, and changelog. Internal step name stays __builtin_set_attributes (used only by the dispatcher).

Verified, no change

# Comment Verification
17 Does bare-name '__builtin_set_attributes' step lookup resolve through SWC + step handler? Yes — the SWC plugin already special-cases names starting with __builtin at lib.rs:1906 to use the bare function name as the step ID, which is exactly how __builtin_response_json / __builtin_response_text / __builtin_response_array_buffer ship today. Same registry, same dispatch, identical precedent — no integration check needed beyond the existing e2e workflow test.

Deferred

# Comment Status
10 WORKFLOW_SERVER_URL_OVERRIDE reverted to '' Intentional — will revert before merging once the server PR is live. Lint guard correctly catches it.
15 Workflow-body-only vs. add step-body back Keeping scope small for the MVP. A first attempt at step-body support clobbered the PR; doing in a follow-up so this lands clean.

Server-side response: vercel/workflow-server#442 (comment).

…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>
*/
const WORKFLOW_SERVER_URL_OVERRIDE = '';
const WORKFLOW_SERVER_URL_OVERRIDE =
'https://workflow-server-git-peter-attributes-mvp.vercel.sh';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Re-flagging after the latest push — still here.) WORKFLOW_SERVER_URL_OVERRIDE is still set to the preview-branch URL. Vercel bot flagged it; I flagged it; the response commit (f026a5e0b) addressed every other piece of feedback but not this one. Just want to make sure it doesn't get missed before merge — const WORKFLOW_SERVER_URL_OVERRIDE = ''; is the production value.


If you need behavior the MVP does not provide (read, list, filter, initial attributes at `start`, writer attribution), wait for 5.0.0 rather than building around the MVP surface.

## Test plan
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation and test coverage gaps — particularly around fire-and-forget, which is arguably the canonical usage pattern for observability attributes.

Fire-and-forget pattern is undocumented and untested

Every example in the changelog and JSDoc shows `await experimental_setAttributes({...})`. But for the primary use case (informational/tracking metadata), users almost certainly want to not block the workflow on it:

```ts
export async function processOrder(orderId: string) {
use workflow;
void experimental_setAttributes({ phase: init, orderId }); // fire-and-forget
const validated = await validateOrder(orderId);
void experimental_setAttributes({ phase: validated });
// ...
}
```

The repo already treats this as a first-class pattern — see `packages/core/src/abort-consistency.test.ts:608` (`pending queue items on workflow completion are fire-and-forget`) and `hook-sleep-interaction.test.ts:570`. The drain-on-completion mechanism in `workflow.ts` commits pending step requests even when the workflow body returns without awaiting them, so the attribute write should land — but nothing in this PR verifies that for `experimental_setAttributes` specifically, and the changelog never mentions the pattern as supported.

Suggested test cases (e2e in the workbench):

  • `void experimental_setAttributes({...})` followed immediately by `return` → attributes persist on the run row after `run_completed`.
  • `void experimental_setAttributes({...})` followed by a throw → attributes persist (the drain runs on the failure path too).
  • Mixed: one `await`-ed call followed by a `void` call — both land.

Suggested doc addition (under "Trade-offs and known limitations" or its own section): a paragraph explicitly endorsing the fire-and-forget pattern for observability metadata, with the caveat that the write may land after the run transitions to a terminal status (so list/get callers reading immediately after completion may see attributes flicker in). That tradeoff is real for the world-vercel HTTP path — the step queue runs out-of-band from `run_completed` event processing.

`Promise.all` is warned about but not tested

The JSDoc on `experimental_setAttributes` (`packages/core/src/workflow/set-attributes.ts:25-28`) warns that `Promise.all([experimental_setAttributes(...), experimental_setAttributes(...)])` is "not guaranteed to be ordered consistently." No test verifies the behavior the warning implies — that the workflow VM serializes the calls (it does, since step dispatch is sequential under the VM scheduler) and only the world-side ordering is racy. Worth a workflow.test.ts case that runs the two-call `Promise.all` and asserts the final state matches one of the two legal interleavings, plus an e2e on world-local where the per-run mutex makes the interleaving deterministic.

Stale test plan in the changelog

Three of the listed test cases in §"Test plan" (lines 197-208) dont match the shipped implementation:

  1. Line 200: "Idempotency: repeated identical calls converge to the same final state" — no such test exists in `runs-storage.test.ts` or anywhere else. It is a worthwhile property to test; should either be added or removed from the plan.
  2. Line 202: "world-vercel contract test" — no contract test was added; `runs.ts` in `world-vercel` is exercised only transitively via the e2e.
  3. Lines 205-207: the e2e plan calls for a workflow that "Awaits a step that calls `experimental_setAttributes({ phase: processing, orderId: ord_123 })`" — but step-body is explicitly unsupported and throws `FatalError`. The actual e2e (`setAttributesWorkflow` in `workbench/example/workflows/99_e2e.ts:3184`) is a much simpler workflow-body-only test. Update the plan to match what was shipped.

Missing test cases worth adding

  • Workflow throws after `await experimental_setAttributes` — does the attribute persist? It should (per the drain-on-throw path), but its the kind of guarantee users will rely on for crash-tracking attributes.
  • Run cancellation mid-setAttributes — what state does the run end in?
  • Replay determinism — `experimental_setAttributes` is dispatched as a step, so on workflow restart the step result is replayed from the event log and the side effect (the row write) is NOT re-executed. Worth an explicit test verifying no double-write on replay.
  • Step body calls `experimental_setAttributes` — only a unit test covers the host-stub `FatalError`. An e2e verifying the error reaches user code from a real step would close the loop.
  • World adapter missing `experimentalSetAttributes` no-ops with a warning — the `__builtin_set_attributes` implementation has the warn-once logic now, but no test verifies (a) the warning fires once, (b) subsequent calls in the same process dont re-warn, (c) the user-facing await still resolves cleanly.
  • `runs.list` returns `attributes` — the schema includes it; verify list responses include the field. Otherwise UIs that filter on it via list (not just `get`) silently see nothing.
  • Multi-world consistency on the empty case — `world-postgres` defaults the column to `{}` (NOT NULL); `world-local` reads existing JSON files where the field is absent and returns `undefined`. Code that does `run.attributes.foo` works on Postgres and crashes on local. Either align (default `attributes: {}` in the local run reconstruction) or document that callers must use `run.attributes ?? {}`.

Doc nits

  • §"Concurrent writes" (line 164) says workflow body writes are serialized "one step at a time within a single workflow body." Thats only true for `await`-ed calls. `Promise.all([...])` produces interleaved step dispatch; fire-and-forget produces a step that runs asynchronously after the workflow body returns. Worth tightening the wording so users dont assume awaited-call semantics generalize.
  • Line 282 (implementation notes, end): "End-to-end coverage in the workbench app is intentionally deferred" — but the e2e was added (`packages/core/e2e/e2e.test.ts:3438`). Stale; remove or update to describe what the e2e covers.
  • The "warns once" claim now matches the code (good). Consider also documenting the exact warning text and when it fires, so users who do see it in logs have a hit on Google/grep for the SDK source.

Sign up for free to 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.

2 participants