From 76902cf4d0178d0a8a6dcdc3ef6c3c1deab5ffae Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 22 May 2026 17:44:24 +0200 Subject: [PATCH 01/26] docs: changelog entry for Workflow Attributes MVP plan 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) --- .changeset/attributes-mvp-plan.md | 4 + .../docs/v5/changelog/attributes-mvp.mdx | 227 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 .changeset/attributes-mvp-plan.md create mode 100644 docs/content/docs/v5/changelog/attributes-mvp.mdx diff --git a/.changeset/attributes-mvp-plan.md b/.changeset/attributes-mvp-plan.md new file mode 100644 index 0000000000..0e371d82d9 --- /dev/null +++ b/.changeset/attributes-mvp-plan.md @@ -0,0 +1,4 @@ +--- +--- + +Docs: changelog entry outlining the Workflow Attributes MVP design (write-only, no event-log representation) as a forward-compatible stopgap ahead of the full 5.0.0 attributes feature. diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx new file mode 100644 index 0000000000..4a9143e79b --- /dev/null +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -0,0 +1,227 @@ +--- +title: Workflow Attributes (MVP, experimental) +description: A minimal, write-only subset of the planned Workflow Attributes feature, forward-compatible with the full 5.0.0 release. Lets workflow and step code attach plaintext string key/value metadata to a run. +--- + +# Workflow Attributes (MVP) + +This is a minimal, **experimental** subset of the [planned Workflow Attributes feature for 5.0.0](https://github.com/vercel/workflow/pull/1933). See [discussion #132](https://github.com/vercel/workflow/discussions/132) for broader background on the use cases and the full design space. + +The MVP lets workflow and step code attach plaintext `string → string` metadata to a run, viewable in any observability surface that reads the `WorkflowRun` entity. It is deliberately narrow: write-only, no reads from inside a run, no list/filter endpoints, no event-log representation. The wire format and SDK surface are chosen so the full 5.0.0 implementation replaces this without source-level breaking changes for end users. + +## What MVP supports + +- `setAttributes(record)` callable from a workflow body +- `setAttributes(record)` callable from a step body +- Attributes are materialized onto the `WorkflowRun` entity, plaintext, and visible via `world.runs.get()` / `world.runs.list()` and any observability UI built on top of those +- World implementations emit a side-channel observability record per write (in `world-vercel`, this hooks into the same observability/analytics pipeline already used for other run lifecycle events) + +## What MVP does **not** support (deferred to 5.0.0) + +- Reading attributes from inside a workflow or step (`getAttribute` / `getAttributes`) +- `start(workflow, input, { attributes })` (initial attributes at run creation) +- Filtering runs by attribute value (`runs.list({ attributes: { ... } })`) +- Enumerating attribute keys or values (`listAttributeKeys`, `listAttributeValues`) +- Writer attribution / event-log history of attribute changes +- Any non-string value type +- Reserved-key (`$`-prefixed) namespace + +See [PR #1933](https://github.com/vercel/workflow/pull/1933) for the design of those features. + +## Why the MVP defers an `attr_set` event type + +The full design represents attribute changes as a new `attr_set` event type in the event log, replayed by the workflow runtime VM to reconstruct the attribute snapshot. That requires bumping `SPEC_VERSION_CURRENT`, because every world implementation (including community worlds) needs to handle the new event during replay, and the runtime's reconstruction logic gains a new case. + +A spec version bump is expensive: it gates every world adapter and ties the rollout to coordinated upgrades. We do not want to pay that cost twice — once for the MVP, again for the full feature. + +The MVP instead writes attributes via a direct entity-mutation path (outside the event log) which does not require a spec version bump. The downside is that MVP-era attributes have **no representation in the event log** and will not be visible to event-based reconstruction (e.g. a materialization rebuild). When the full feature ships, new writes use `attr_set` events; old runs created during the MVP window retain whatever attributes were materialized at the time, but their history is not recoverable. + +## Implementation plan + +### 1. `@workflow/world` — Storage interface addition + +Add an `experimentalSetAttributes` method to `Storage.runs`: + +{/*@skip-typecheck - snippet, not runnable code*/} + +```ts +runs: { + get: /* unchanged */; + list: /* unchanged */; + + /** + * Apply a set of attribute changes to a run. Merge semantics: + * keys with a string value are upserted, keys with `value: null` + * are removed. Other keys on the run are untouched. + * + * OPTIONAL. World implementations may omit this method; 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. + * + * EXPERIMENTAL: this method exists as a stopgap until the + * `attr_set` event type lands. See the 5.0.0 attributes design. + */ + experimentalSetAttributes?( + runId: string, + changes: Array<{ key: string; value: string | null }> + ): Promise<{ attributes: Record }>; +} +``` + +The method is **optional** to avoid forcing every World implementation (especially community-maintained adapters such as Redis, MongoDB, Turso, and similar) to ship support before the API stabilises. World implementations that do support it return the post-merge attribute snapshot so callers — notably the SDK helper and world adapters emitting observability records — have it without a follow-up read. + +Add `attributes?: Record` to `WorkflowRunBaseSchema` in `packages/world/src/runs.ts`. Optional for backward compatibility: runs created before this field landed have no `attributes` and read as `undefined`. + +### 2. Wire format (used by `world-vercel`) + +`world-vercel` calls into a remote endpoint to persist attributes: + +``` +POST /v3/runs/:runId/attributes + +{ + "changes": [ + { "key": "phase", "value": "done" }, + { "key": "stale", "value": null } + ] +} +``` + +Response: `{ "attributes": { "phase": "done", "tenant": "t1" } }`. + +The body shape **deliberately mirrors** the eventual `attr_set` event's `eventData.changes`. When the full feature ships, this endpoint goes away — the same body shape is posted to `POST /v3/runs/:id/events` with `eventType: 'attr_set'` (plus a `writer` discriminator). No SDK signature change, no client-side migration. + +### 3. `@workflow/core` — SDK surface + +A new export from `@workflow/core` (re-exported by `workflow`): + +{/*@skip-typecheck - snippet, not runnable code*/} + +```ts +function setAttributes( + attrs: Record +): Promise +``` + +`undefined` is normalized to `null` (unset). An empty object is a no-op (no RPC, no events). + +Validation (shared helper, applied both client-side and server-side): + +- Key: 1–256 chars, must not start with `$` (reserved) +- Value: ≤ 256 bytes UTF-8 +- Maximum 64 attributes per run (validated against the post-merge snapshot when the server applies) +- Violations throw `FatalError` from `@workflow/errors` + +Two execution paths, one SDK surface: + +- **From a workflow body**: `setAttributes` internally invokes an SDK-private `"use step"` function (for example `_setAttributesStep`) which calls `world.runs.experimentalSetAttributes`. Going through a step gives durable retry and replay determinism for free — the workflow VM records `step_created` / `step_completed` events, and on replay the call is not re-executed; the recorded completion is read from the event log. +- **From a step body**: `setAttributes` calls `world.runs.experimentalSetAttributes` directly. Step bodies cannot nest steps, so the internal `"use step"` indirection does not apply. The two paths converge on the same world method. + +Context detection uses existing patterns: presence of the workflow VM context indicates workflow body; presence of `contextStorage.getStore()` indicates step body. Calling `setAttributes` from outside either context throws. + +#### Optional world support + +Because `runs.experimentalSetAttributes` is **optional** on the World interface (see §1), the SDK helper checks for its presence before dispatching: + +{/*@skip-typecheck - snippet, not runnable code*/} + +```ts +if (typeof world.runs.experimentalSetAttributes !== 'function') { + console.warn( + '[workflow] setAttributes: the current world implementation ' + + 'does not implement experimentalSetAttributes; this call is a no-op. ' + + 'Attributes will be available once the world adapter adds support.' + ); + return; +} +await world.runs.experimentalSetAttributes(runId, changes); +``` + +The no-op-with-warning behaviour applies in **both** dispatch paths (workflow body and step body). User code does not need to feature-detect; calling `setAttributes` against an unsupporting world is safe but silently ineffective beyond the warning. + +The warning is emitted once per process (deduped on the warning text) so high-frequency callers do not flood logs. + +### 4. World implementations + +#### `world-local` + +Implement `experimentalSetAttributes(runId, changes)` by reading the run's JSON file, merging the changes into `run.attributes` (set on string value, delete on null), and writing back atomically using the same per-run write gate that protects entity writes today. Apply validation server-side before merging. + +#### `world-postgres` + +Add an `attributes JSONB` column to the runs table (default `'{}'::jsonb`, NOT NULL). Apply the merge in SQL using `jsonb_set` / `jsonb_strip_nulls` so the database does the merge atomically without a read-modify-write cycle, returning the post-merge map via `RETURNING attributes`. + +#### `world-vercel` + +Pure HTTP wrapper. Calls the wire endpoint described in §2 and returns the response's `attributes`. The backing service materializes the attribute map onto its run-row storage; where the underlying data store supports atomic per-key map updates, the merge is a single atomic operation rather than a read-modify-write cycle — the same shape the future `attr_set` event handler will use, so the storage layout is forward-compatible. + +After the persistence ack, the service emits a side-channel observability record carrying the post-merge attribute snapshot, decoupled from the request path so the runtime never waits on analytics emission. + +### 5. Observability surfaces + +Because attributes are stored plaintext on the `WorkflowRun` entity, any UI that already calls `world.runs.get()` / `world.runs.list()` can read them with no additional plumbing. The render details (where attributes appear in the run-detail view, formatting, etc.) are out of scope for this MVP and tracked separately from the SDK work. + +## Trade-offs and known limitations + +### Concurrent writes to the same run + +The MVP applies **last-write-wins by arrival order**. Two concurrent `setAttributes` calls writing to the same key produce a final state matching whichever request the world processes second. There is no conditional / `expectedValue` semantic and no `unique: true` mode. + +Workflow body writes are serialized by the workflow VM (one step at a time within a single workflow body), so the concurrent case in practice arises when: + +- Multiple steps within the same workflow run write the same key in parallel (`Promise.all`). +- A step writes a key while another concurrent step is also writing. + +Both are well-defined under LWW-by-arrival, but applications that need conditional semantics should wait for the 5.0.0 release. We will not retrofit conditional writes onto the MVP path. + +### No event-log history + +Attribute changes do not appear in `world.events.list(runId)`. There is no record of *when* a key changed or *which step attempt* set it. The current snapshot on the run entity is authoritative; the history is lost. + +When the full feature ships, new writes carry writer attribution (`writer: { type: 'workflow' }` or `writer: { type: 'step', stepId, attempt }`) in their `attr_set` events. MVP-era writes will not have this — history starts at the `attr_set` cutover. + +### MVP attributes do not survive materialization rebuild + +Any tooling that reconstructs the run entity from the event log (disaster recovery, debugging, audit) will see no attributes on MVP-era runs, because the writes are not in the event log. This is the chief reason `experimentalSetAttributes` is named "experimental" — it is a known break from the otherwise-strict event-sourced model. + +The 5.0.0 path closes this gap. + +### SDK surface stability + +`setAttributes(record)` is intended to be stable across MVP and 5.0.0. User code calling it under the MVP will continue to work after the full feature lands; only the runtime dispatch path changes (`"use step"` indirection → workflow-VM-native intercept, parallel to `sleep`). + +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 + +- Unit tests in `@workflow/core` for: + - Validation rules (length, `$` prefix, post-merge count cap) + - `setAttributes({})` is a no-op (no RPC, no events) + - `undefined` value normalizes to a `null`-valued change on the wire + - Calling from neither context throws + - World without `experimentalSetAttributes`: warning is emitted once, call resolves without dispatching, no events are written +- `world-local` integration tests: + - `setAttributes` from a workflow body materializes onto the run row + - `setAttributes` from a step body materializes onto the run row + - Merge semantics: setting + unsetting in a single call leaves the run with the expected snapshot + - Idempotency: repeated identical calls converge to the same final state +- `world-postgres` integration tests: same shape as `world-local` +- `world-vercel` contract test: verifies the request body shape matches the documented wire format +- E2E in `workbench/nextjs-turbopack`: + 1. Start a workflow + 2. Workflow body calls `setAttributes({ tenant: 't1', phase: 'init' })` + 3. Awaits a step that calls `setAttributes({ phase: 'processing', orderId: 'ord_123' })` + 4. Then `setAttributes({ phase: 'done', orderId: undefined })` + 5. Assert via `world.runs.get(runId)`: `attributes === { tenant: 't1', phase: 'done' }` + +## Migration to 5.0.0 + +When the full attributes feature ships: + +- `setAttributes` (SDK) — unchanged signature, new dispatch path +- `runs.experimentalSetAttributes` (world interface) — deprecated, then removed; replaced by `events.create(runId, { eventType: 'attr_set', eventData: { changes, writer } })` +- Wire endpoint — `POST /v3/runs/:runId/attributes` removed; the same body shape posts to `POST /v3/runs/:id/events` +- Pre-existing attribute values on MVP-era runs remain on the run entity but are not represented in the event log + +Skew protection means workflows started under the MVP will continue to run with the MVP dispatch path on their original deployment. New deployments use the new path. No in-place data migration is needed. From 6caf143b8d055b65cd9fae728b7066d1ad60047d Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 22 May 2026 19:55:25 +0200 Subject: [PATCH 02/26] feat(attributes): MVP implementation across SDK + world adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/attributes-mvp-plan.md | 8 +- .../docs/v5/changelog/attributes-mvp.mdx | 52 ++++- packages/core/src/index.ts | 1 + packages/core/src/set-attributes.test.ts | 181 ++++++++++++++++++ packages/core/src/set-attributes.ts | 123 ++++++++++++ packages/core/src/workflow/index.ts | 1 + .../world-local/src/storage/events-storage.ts | 4 + .../src/storage/runs-storage.test.ts | 163 ++++++++++++++++ .../world-local/src/storage/runs-storage.ts | 90 ++++++++- .../migrations/0013_add_attributes.sql | 1 + .../src/drizzle/migrations/meta/_journal.json | 7 + packages/world-postgres/src/drizzle/schema.ts | 11 ++ packages/world-postgres/src/storage.ts | 60 ++++++ packages/world-postgres/test/storage.test.ts | 51 +++++ packages/world-vercel/src/runs.ts | 42 ++++ packages/world-vercel/src/storage.ts | 8 +- packages/world/src/attributes.test.ts | 136 +++++++++++++ packages/world/src/attributes.ts | 177 +++++++++++++++++ packages/world/src/index.ts | 14 ++ packages/world/src/interfaces.ts | 29 +++ packages/world/src/runs.ts | 13 ++ 21 files changed, 1166 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/set-attributes.test.ts create mode 100644 packages/core/src/set-attributes.ts create mode 100644 packages/world-local/src/storage/runs-storage.test.ts create mode 100644 packages/world-postgres/src/drizzle/migrations/0013_add_attributes.sql create mode 100644 packages/world/src/attributes.test.ts create mode 100644 packages/world/src/attributes.ts diff --git a/.changeset/attributes-mvp-plan.md b/.changeset/attributes-mvp-plan.md index 0e371d82d9..88ef3eae72 100644 --- a/.changeset/attributes-mvp-plan.md +++ b/.changeset/attributes-mvp-plan.md @@ -1,4 +1,10 @@ --- +'@workflow/core': patch +'@workflow/world': patch +'@workflow/world-local': patch +'@workflow/world-postgres': patch +'@workflow/world-vercel': patch +'workflow': patch --- -Docs: changelog entry outlining the Workflow Attributes MVP design (write-only, no event-log representation) as a forward-compatible stopgap ahead of the full 5.0.0 attributes feature. +Add experimental `setAttributes()` for attaching plaintext string key/value metadata to a workflow run from workflow or step code. See the V5 `attributes-mvp` changelog entry for the design, trade-offs, and migration path to the full 5.0.0 attributes feature. diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx index 4a9143e79b..69b172b2f2 100644 --- a/docs/content/docs/v5/changelog/attributes-mvp.mdx +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -78,7 +78,7 @@ Add `attributes?: Record` to `WorkflowRunBaseSchema` in `package `world-vercel` calls into a remote endpoint to persist attributes: ``` -POST /v3/runs/:runId/attributes +POST /v2/runs/:runId/attributes { "changes": [ @@ -90,7 +90,7 @@ POST /v3/runs/:runId/attributes Response: `{ "attributes": { "phase": "done", "tenant": "t1" } }`. -The body shape **deliberately mirrors** the eventual `attr_set` event's `eventData.changes`. When the full feature ships, this endpoint goes away — the same body shape is posted to `POST /v3/runs/:id/events` with `eventType: 'attr_set'` (plus a `writer` discriminator). No SDK signature change, no client-side migration. +The body shape **deliberately mirrors** the eventual `attr_set` event's `eventData.changes`. When the full feature ships, this endpoint goes away — the same body shape is posted to `POST /v2/runs/:id/events` with `eventType: 'attr_set'` (plus a `writer` discriminator). No SDK signature change, no client-side migration. ### 3. `@workflow/core` — SDK surface @@ -221,7 +221,53 @@ When the full attributes feature ships: - `setAttributes` (SDK) — unchanged signature, new dispatch path - `runs.experimentalSetAttributes` (world interface) — deprecated, then removed; replaced by `events.create(runId, { eventType: 'attr_set', eventData: { changes, writer } })` -- Wire endpoint — `POST /v3/runs/:runId/attributes` removed; the same body shape posts to `POST /v3/runs/:id/events` +- Wire endpoint — `POST /v2/runs/:runId/attributes` removed; the same body shape posts to `POST /v2/runs/:id/events` - Pre-existing attribute values on MVP-era runs remain on the run entity but are not represented in the event log Skew protection means workflows started under the MVP will continue to run with the MVP dispatch path on their original deployment. New deployments use the new path. No in-place data migration is needed. + +## Implementation notes (decisions made during the MVP build-out) + +This section records concrete decisions taken while landing the MVP that weren't in the original plan, so the next iteration has them in one place. + +### Endpoint lives under `v2`, not a fresh namespace + +The initial draft placed the new endpoint at `POST /v3/runs/:runId/attributes`, on the assumption that introducing a new wire feature warranted a major namespace bump. In practice `world-vercel` mixes `/v1/...` and `/v2/...` endpoints already, and creating a `v3Api` subrouter just for a single endpoint would have required duplicating the auth / flags / rate-limit middleware stack. The MVP endpoint is therefore mounted under the existing `v2Api`. The wire body shape is unchanged, so the migration path described above (rerouting from `/v2/runs/:runId/attributes` to `/v2/runs/:id/events`) still holds — just within the same namespace. + +### Concurrent writes: read-modify-write, not per-key atomic + +The plan called for per-key atomic `UpdateExpression` updates (`SET #attrs.#k = :v` / `REMOVE #attrs.#k`) in the `world-vercel` backing store, on the basis that it eliminates the read-modify-write race. The MVP ships with the simpler read-modify-write path instead: + +- **In `world-postgres`** the SQL-side `jsonb_set` / `-` chain *is* used and is genuinely atomic on the run row, so the only race is the cap check (a separate `SELECT`). Documented as LWW-by-arrival for the cap; the merge itself is atomic. +- **In the `world-vercel` backing service** the attributes column is laid out as a native key-addressable map so the atomic `UpdateItem` variant can be enabled later without a data migration. The MVP commits the merged map via the existing entity update path. Two concurrent writers therefore race; whichever lands second wins on shared keys, and any write to a non-overlapping key is preserved. +- **In `world-local`** an in-process per-run mutex serializes the read-merge-write sequence so parallel `setAttributes` calls from concurrent steps do not lose writes within a single process. There is a corresponding test that exercises 20 parallel writes to the same run. + +This is consistent with the original "concurrent writes are LWW by arrival" caveat. Promoting to per-key atomic writes is a no-API-break change once the event-sourced path lands. + +### `WORKFLOW_ATTRIBUTES` usage-fact schema: not introduced + +The plan called for a dedicated `WORKFLOW_ATTRIBUTES` usage fact carrying the post-merge snapshot. Landing that schema would have required a coordinated change to the shared usage-facts package (used by `world-vercel`'s backing service) plus an ingest-side update before the endpoint could ship. + +For the MVP the endpoint reuses the existing `WORKFLOW_EVENT` fact with `eventType: 'attr_set'`. That mirrors how other run-lifecycle events are reported, and it's enough for "did an attribute mutation happen" debugging without adding an analytics dependency to the critical path. A dedicated fact carrying the full snapshot can land alongside the event-sourced path without touching the SDK wire contract. + +### Validation rules are shared between SDK and world + +Validation lives in a single helper exported from `@workflow/world` (`validateAttributeChanges`, `validateAttributeKey`, `validateAttributeValue`). Both the SDK `setAttributes` helper and the `world-local` / `world-postgres` implementations call it; the `world-vercel` backing service applies the same rules independently. The shared module is the authoritative spec for the limits (256-char keys, 256-byte values, max 64 attributes per run, `$`-prefixed keys reserved) — any future change goes through one file. + +### Run row reconstruction had to thread `attributes` through + +`world-local`'s events storage rebuilds the run row on every lifecycle event (`run_started`, `run_completed`, `run_failed`, `run_cancelled`) by explicitly listing fields rather than spreading. Without forwarding `attributes: currentRun.attributes` through each branch, any attribute set before the run completed would be silently dropped by the next lifecycle event. The fix was local to the four reconstruction sites — the field is otherwise untouched by event-sourced code. A subtler race remains: a `setAttributes` write landing in the same async window as a `run_completed` event read can be clobbered, but that's the same LWW-by-arrival semantic documented above. + +### Optional world method: feature-detect, warn once + +`runs.experimentalSetAttributes` is optional on the `World` interface so community worlds (Redis, MongoDB, Turso, etc.) continue to build and run without adopting the experimental API. The SDK helper feature-detects the method's presence on first dispatch; if absent, it logs a single `console.warn` for the lifetime of the process and resolves silently for that call and all subsequent calls. Users do not need to feature-detect in their own code — calling `setAttributes` against an unsupporting world is safe but ineffective. + +### Tests + +- **`@workflow/world`** — 18 unit tests covering validation rules (key length, reserved prefix, value byte cap, batch cap, duplicate keys) and the `applyAttributeChanges` merge helper. +- **`@workflow/core`** — 10 unit tests covering context detection (workflow body / step body / neither), undefined-to-null normalization, empty-record no-op, validation surface (throws `FatalError`), and feature-detect-with-single-warning when the world lacks support. +- **`@workflow/world-local`** — 10 integration tests covering upsert, merge across calls, unset via null, set-and-unset in a single batch, not-found errors, all four validation flavors, and concurrent writes (per-run mutex prevents lost writes). +- **`@workflow/world-postgres`** — 3 integration tests added under the existing Postgres container suite covering upsert, merge across calls, and unset via null. The SQL-side merge (`jsonb_set` / `-`) is exercised through these. + +End-to-end coverage in the workbench app is intentionally deferred — running through the full SWC plugin + workflow VM path can land once the workflow-server preview deployment carrying the endpoint is live. + diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1d969aeaa6..e5bbb0bb3b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,6 +25,7 @@ export { type WebhookOptions, } from './create-hook.js'; export { defineHook, type TypedHook } from './define-hook.js'; +export { setAttributes } from './set-attributes.js'; export { sleep } from './sleep.js'; export { getStepMetadata, diff --git a/packages/core/src/set-attributes.test.ts b/packages/core/src/set-attributes.test.ts new file mode 100644 index 0000000000..718b766a98 --- /dev/null +++ b/packages/core/src/set-attributes.test.ts @@ -0,0 +1,181 @@ +import { FatalError } from '@workflow/errors'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NotInWorkflowOrStepContextError } from './context-errors.js'; +import { setAttributes } from './set-attributes.js'; +import { contextStorage } from './step/context-storage.js'; +import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; + +// `setAttributesStep` resolves the World via `getWorldLazy`. We mock that +// so tests don't try to load the real world initializer chain (which +// pulls in world-local / world-vercel). +const dispatchCalls: Array<{ runId: string; changes: any[] }> = []; + +vi.mock('./runtime/get-world-lazy.js', () => ({ + getWorldLazy: vi.fn(async () => mockedWorld), +})); + +let supportsAttributes = true; +const mockedWorld: any = { + runs: { + get experimentalSetAttributes() { + if (!supportsAttributes) return undefined; + return async (runId: string, changes: any[]) => { + dispatchCalls.push({ runId, changes }); + return { attributes: {} }; + }; + }, + }, +}; + +function withWorkflowContext(runId: string, fn: () => T): T { + (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] = { workflowRunId: runId }; + try { + return fn(); + } finally { + delete (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL]; + } +} + +function withStepContext(runId: string, fn: () => Promise): Promise { + return contextStorage.run( + { + stepMetadata: {} as any, + workflowMetadata: { + workflowRunId: runId, + } as any, + ops: [], + }, + fn + ); +} + +describe('setAttributes', () => { + beforeEach(() => { + dispatchCalls.length = 0; + supportsAttributes = true; + }); + + afterEach(() => { + delete (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL]; + }); + + describe('context detection', () => { + it('dispatches when called inside a step context', async () => { + await withStepContext('wrun_step', async () => { + await setAttributes({ phase: 'init' }); + }); + expect(dispatchCalls).toEqual([ + { runId: 'wrun_step', changes: [{ key: 'phase', value: 'init' }] }, + ]); + }); + + it('dispatches when called inside a workflow VM context', async () => { + await withWorkflowContext('wrun_workflow', () => + setAttributes({ phase: 'init' }) + ); + expect(dispatchCalls).toEqual([ + { runId: 'wrun_workflow', changes: [{ key: 'phase', value: 'init' }] }, + ]); + }); + + it('throws NotInWorkflowOrStepContextError when called outside both', async () => { + await expect(setAttributes({ phase: 'init' })).rejects.toBeInstanceOf( + NotInWorkflowOrStepContextError + ); + }); + }); + + describe('normalization', () => { + it('translates undefined values into null on the wire (unset)', async () => { + await withStepContext('wrun_x', async () => { + await setAttributes({ phase: 'done', stale: undefined }); + }); + expect(dispatchCalls).toEqual([ + { + runId: 'wrun_x', + changes: [ + { key: 'phase', value: 'done' }, + { key: 'stale', value: null }, + ], + }, + ]); + }); + + it('is a no-op for an empty record (no dispatch, no warning)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await withStepContext('wrun_x', async () => { + await setAttributes({}); + }); + expect(dispatchCalls).toHaveLength(0); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + }); + + describe('validation', () => { + it('throws FatalError for keys starting with reserved prefix', async () => { + await withStepContext('wrun_x', async () => { + await expect(setAttributes({ $sys: 'x' })).rejects.toBeInstanceOf( + FatalError + ); + }); + expect(dispatchCalls).toHaveLength(0); + }); + + it('throws FatalError for empty keys', async () => { + await withStepContext('wrun_x', async () => { + await expect(setAttributes({ '': 'x' })).rejects.toBeInstanceOf( + FatalError + ); + }); + }); + + it('throws FatalError for values exceeding 256 bytes', async () => { + await withStepContext('wrun_x', async () => { + await expect( + setAttributes({ k: 'a'.repeat(257) }) + ).rejects.toBeInstanceOf(FatalError); + }); + }); + + it('throws FatalError when called with a non-object', async () => { + await withStepContext('wrun_x', async () => { + await expect( + setAttributes(null as unknown as Record) + ).rejects.toBeInstanceOf(FatalError); + await expect( + setAttributes('hi' as unknown as Record) + ).rejects.toBeInstanceOf(FatalError); + await expect( + setAttributes([] as unknown as Record) + ).rejects.toBeInstanceOf(FatalError); + }); + }); + }); + + describe('feature detection', () => { + it('no-ops with a single warning when world lacks experimentalSetAttributes', async () => { + supportsAttributes = false; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await withStepContext('wrun_x', async () => { + await setAttributes({ phase: 'init' }); + await setAttributes({ phase: 'done' }); + await setAttributes({ tenant: 't1' }); + }); + expect(dispatchCalls).toHaveLength(0); + // Single warning across multiple unsupported calls — the helper + // dedupes so callers don't flood logs. + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain( + 'does not implement experimentalSetAttributes' + ); + } finally { + warn.mockRestore(); + } + }); + }); +}); diff --git a/packages/core/src/set-attributes.ts b/packages/core/src/set-attributes.ts new file mode 100644 index 0000000000..113485e03f --- /dev/null +++ b/packages/core/src/set-attributes.ts @@ -0,0 +1,123 @@ +import { FatalError } from '@workflow/errors'; +import type { AttributeChange } from '@workflow/world'; +import { + AttributeValidationError, + validateAttributeChanges, +} from '@workflow/world'; +import { throwNotInWorkflowOrStepContext } from './context-errors.js'; +import { getWorldLazy } from './runtime/get-world-lazy.js'; +import { contextStorage } from './step/context-storage.js'; +import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; + +let unsupportedWorldWarned = false; + +function warnUnsupportedWorldOnce(worldName?: string): void { + if (unsupportedWorldWarned) return; + unsupportedWorldWarned = true; + // Use console.warn rather than the internal logger so the message + // surfaces in user terminals regardless of debug configuration. + // eslint-disable-next-line no-console + console.warn( + `[workflow] setAttributes: the current world implementation${ + worldName ? ` (${worldName})` : '' + } does not implement experimentalSetAttributes; this call (and any subsequent setAttributes calls in this process) is a no-op. Attributes will become available once the world adapter adds support.` + ); +} + +/** + * Internal step that performs the actual `experimentalSetAttributes` call + * against the World. The `'use step'` directive means: + * + * - Called from a workflow body: dispatched as a step through the workflow + * controller, recorded in the event log (`step_created`/`step_completed`), + * replayed deterministically on resume. + * - Called from a step body (or from Node.js outside a workflow): executes + * inline. Step bodies cannot nest steps; the directive is a no-op here + * and the function body runs as a plain async function. + * + * Both paths converge on the same world method, so the wire format is + * identical regardless of where the call originated. + * + * @internal + */ +async function setAttributesStep( + runId: string, + changes: AttributeChange[] +): Promise { + 'use step'; + const world = await getWorldLazy(); + if (typeof world.runs.experimentalSetAttributes !== 'function') { + warnUnsupportedWorldOnce((world as any)?.name); + return; + } + await world.runs.experimentalSetAttributes(runId, changes); +} + +/** + * Attach plaintext string key/value metadata to the current workflow run. + * + * Available in both workflow bodies and step bodies. Validation runs + * client-side; violations throw `FatalError`. An empty record is a no-op + * (no RPC, no events). + * + * `value: undefined` removes the key from the run's attribute map. + * + * EXPERIMENTAL (MVP): this is a write-only API in V5. Reads, list/filter + * endpoints, and initial attributes at `start()` ship with the full + * Workflow Attributes feature — see the `attributes-mvp` changelog entry + * for the migration path. + * + * @example + * ```ts + * await setAttributes({ phase: 'processing', orderId: 'ord_123' }); + * await setAttributes({ orderId: undefined }); // remove + * ``` + */ +export async function setAttributes( + attrs: Record +): Promise { + if (attrs === null || typeof attrs !== 'object' || Array.isArray(attrs)) { + throw new FatalError( + `setAttributes requires a plain object, got ${attrs === null ? 'null' : Array.isArray(attrs) ? 'array' : typeof attrs}` + ); + } + + // Resolve the run ID from whichever context we are in. Workflow VM + // context sets WORKFLOW_CONTEXT_SYMBOL on globalThis; step Node.js + // context uses AsyncLocalStorage. Either path exposes + // `workflowRunId` via the workflow metadata object. + const workflowCtx = (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] as + | { workflowRunId?: string } + | undefined; + const stepCtx = contextStorage.getStore(); + const runId = + workflowCtx?.workflowRunId ?? stepCtx?.workflowMetadata?.workflowRunId; + + if (!runId) { + throwNotInWorkflowOrStepContext( + 'setAttributes()', + 'https://workflow-sdk.dev/docs/api-reference/workflow/set-attributes', + setAttributes + ); + } + + const changes: AttributeChange[] = Object.entries(attrs).map( + ([key, value]) => ({ + key, + value: value === undefined ? null : value, + }) + ); + + if (changes.length === 0) return; + + try { + validateAttributeChanges(changes); + } catch (err) { + if (err instanceof AttributeValidationError) { + throw new FatalError(err.message); + } + throw err; + } + + await setAttributesStep(runId, changes); +} diff --git a/packages/core/src/workflow/index.ts b/packages/core/src/workflow/index.ts index 73b92f2fcb..2b5b341f49 100644 --- a/packages/core/src/workflow/index.ts +++ b/packages/core/src/workflow/index.ts @@ -10,6 +10,7 @@ export { type RetryableErrorOptions, } from '@workflow/errors'; export type { Hook, HookOptions } from '../create-hook.js'; +export { setAttributes } from '../set-attributes.js'; export { sleep } from '../sleep.js'; export { createHook, createWebhook } from './create-hook.js'; export { defineHook } from './define-hook.js'; diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index 91ebc82942..cfc9635edc 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -554,6 +554,7 @@ export function createEventsStorage( completedAt: undefined, startedAt: currentRun.startedAt ?? now, updatedAt: now, + attributes: currentRun.attributes, }; await writeJSON( taggedPath(basedir, 'runs', effectiveRunId, tag), @@ -580,6 +581,7 @@ export function createEventsStorage( error: undefined, completedAt: now, updatedAt: now, + attributes: currentRun.attributes, }; await writeJSON( taggedPath(basedir, 'runs', effectiveRunId, tag), @@ -617,6 +619,7 @@ export function createEventsStorage( errorCode: failedData.errorCode, completedAt: now, updatedAt: now, + attributes: currentRun.attributes, }; await writeJSON( taggedPath(basedir, 'runs', effectiveRunId, tag), @@ -646,6 +649,7 @@ export function createEventsStorage( error: undefined, completedAt: now, updatedAt: now, + attributes: currentRun.attributes, }; await writeJSON( taggedPath(basedir, 'runs', effectiveRunId, tag), diff --git a/packages/world-local/src/storage/runs-storage.test.ts b/packages/world-local/src/storage/runs-storage.test.ts new file mode 100644 index 0000000000..8eddf9d121 --- /dev/null +++ b/packages/world-local/src/storage/runs-storage.test.ts @@ -0,0 +1,163 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { WorkflowRunNotFoundError } from '@workflow/errors'; +import { + ATTRIBUTE_KEY_MAX_LENGTH, + AttributeValidationError, + RESERVED_ATTRIBUTE_KEY_PREFIX, + type Storage, +} from '@workflow/world'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createStorage } from '../storage.js'; +import { createRun } from '../test-helpers.js'; + +describe('runs.experimentalSetAttributes (world-local)', () => { + let testDir: string; + let storage: Storage; + + beforeEach(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'attrs-test-')); + storage = createStorage(testDir); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + async function newRun() { + return createRun(storage, { + deploymentId: 'dpl_test', + workflowName: 'test-workflow', + input: new Uint8Array([1]), + }); + } + + it('upserts new keys', async () => { + const run = await newRun(); + + const result = await storage.runs.experimentalSetAttributes!(run.runId, [ + { key: 'phase', value: 'init' }, + { key: 'tenant', value: 't1' }, + ]); + + expect(result.attributes).toEqual({ phase: 'init', tenant: 't1' }); + const refreshed = await storage.runs.get(run.runId); + expect(refreshed.attributes).toEqual({ phase: 'init', tenant: 't1' }); + }); + + it('updates existing keys (merge semantics)', async () => { + const run = await newRun(); + await storage.runs.experimentalSetAttributes!(run.runId, [ + { key: 'phase', value: 'init' }, + { key: 'tenant', value: 't1' }, + ]); + + const result = await storage.runs.experimentalSetAttributes!(run.runId, [ + { key: 'phase', value: 'done' }, + ]); + + expect(result.attributes).toEqual({ phase: 'done', tenant: 't1' }); + }); + + it('removes keys when value is null', async () => { + const run = await newRun(); + await storage.runs.experimentalSetAttributes!(run.runId, [ + { key: 'phase', value: 'init' }, + { key: 'orderId', value: 'ord_123' }, + ]); + + const result = await storage.runs.experimentalSetAttributes!(run.runId, [ + { key: 'orderId', value: null }, + ]); + + expect(result.attributes).toEqual({ phase: 'init' }); + expect(result.attributes).not.toHaveProperty('orderId'); + }); + + it('applies set and unset in a single call', async () => { + const run = await newRun(); + await storage.runs.experimentalSetAttributes!(run.runId, [ + { key: 'stale', value: 'yes' }, + ]); + + const result = await storage.runs.experimentalSetAttributes!(run.runId, [ + { key: 'stale', value: null }, + { key: 'fresh', value: 'yes' }, + ]); + + expect(result.attributes).toEqual({ fresh: 'yes' }); + }); + + it('throws WorkflowRunNotFoundError for unknown run', async () => { + await expect( + storage.runs.experimentalSetAttributes!('wrun_doesnotexist', [ + { key: 'phase', value: 'init' }, + ]) + ).rejects.toBeInstanceOf(WorkflowRunNotFoundError); + }); + + it('rejects keys starting with reserved prefix', async () => { + const run = await newRun(); + await expect( + storage.runs.experimentalSetAttributes!(run.runId, [ + { key: `${RESERVED_ATTRIBUTE_KEY_PREFIX}sys`, value: 'x' }, + ]) + ).rejects.toBeInstanceOf(AttributeValidationError); + }); + + it('rejects keys over the max length', async () => { + const run = await newRun(); + await expect( + storage.runs.experimentalSetAttributes!(run.runId, [ + { key: 'k'.repeat(ATTRIBUTE_KEY_MAX_LENGTH + 1), value: 'x' }, + ]) + ).rejects.toBeInstanceOf(AttributeValidationError); + }); + + it('rejects values exceeding the byte cap', async () => { + const run = await newRun(); + await expect( + storage.runs.experimentalSetAttributes!(run.runId, [ + { key: 'big', value: 'a'.repeat(257) }, + ]) + ).rejects.toBeInstanceOf(AttributeValidationError); + }); + + it('rejects when post-merge count exceeds limit', async () => { + const run = await newRun(); + // Pre-fill to within the cap. The MVP cap is 64. + const initial = Array.from({ length: 60 }, (_, i) => ({ + key: `k${i}`, + value: 'v', + })); + await storage.runs.experimentalSetAttributes!(run.runId, initial); + + // Adding 5 new keys would push us over. + const overflow = Array.from({ length: 5 }, (_, i) => ({ + key: `extra${i}`, + value: 'v', + })); + await expect( + storage.runs.experimentalSetAttributes!(run.runId, overflow) + ).rejects.toBeInstanceOf(AttributeValidationError); + }); + + it('serializes concurrent writes to the same run (no lost writes)', async () => { + const run = await newRun(); + + // 20 concurrent writes; each adds a unique key. Per-run mutex must + // serialize them so all keys land — without it, the read-merge-write + // race loses some. + await Promise.all( + Array.from({ length: 20 }, (_, i) => + storage.runs.experimentalSetAttributes!(run.runId, [ + { key: `k${i}`, value: `v${i}` }, + ]) + ) + ); + + const refreshed = await storage.runs.get(run.runId); + expect(Object.keys(refreshed.attributes ?? {})).toHaveLength(20); + }); +}); diff --git a/packages/world-local/src/storage/runs-storage.ts b/packages/world-local/src/storage/runs-storage.ts index 76e7891404..54c8eeff92 100644 --- a/packages/world-local/src/storage/runs-storage.ts +++ b/packages/world-local/src/storage/runs-storage.ts @@ -1,18 +1,27 @@ import path from 'node:path'; import { WorkflowRunNotFoundError } from '@workflow/errors'; import type { + AttributeChange, + ExperimentalSetAttributesResult, ListWorkflowRunsParams, PaginatedResponse, Storage, WorkflowRun, WorkflowRunWithoutData, } from '@workflow/world'; -import { WorkflowRunSchema } from '@workflow/world'; +import { + applyAttributeChanges, + AttributeValidationError, + validateAttributeChanges, + WorkflowRunSchema, +} from '@workflow/world'; import { DEFAULT_RESOLVE_DATA_OPTION } from '../config.js'; import { assertSafeEntityId, paginatedFileSystemQuery, readJSONWithFallback, + taggedPath, + writeJSON, } from '../fs.js'; import { filterRunData } from './filters.js'; import { getObjectCreatedAt } from './helpers.js'; @@ -40,6 +49,39 @@ export interface LocalRunsStorage { params?: LocalListWorkflowRunsParams ): Promise>; }; + experimentalSetAttributes( + runId: string, + changes: AttributeChange[] + ): Promise; +} + +/** + * Per-run in-process async mutex. Serializes concurrent attribute writes + * to the same run so the read-merge-write sequence is atomic. Without this + * two parallel `setAttributes` calls (e.g. from `Promise.all` steps) can + * both read the same prior snapshot and one of the updates is lost. + */ +const runAttributeLocks = new Map>(); + +function withRunAttributeLock( + key: string, + fn: () => Promise +): Promise { + const prev = runAttributeLocks.get(key); + const taskBox: { task?: Promise } = {}; + const task = (async () => { + if (prev) await prev.catch(() => undefined); + try { + return await fn(); + } finally { + if (runAttributeLocks.get(key) === taskBox.task) { + runAttributeLocks.delete(key); + } + } + })(); + taskBox.task = task; + runAttributeLocks.set(key, task); + return task; } /** @@ -107,5 +149,51 @@ export function createRunsStorage( return result; }) as LocalRunsStorage['list'], + + experimentalSetAttributes: async (runId, changes) => { + assertSafeEntityId('runId', runId); + + return withRunAttributeLock(runId, async () => { + const run = await readJSONWithFallback( + basedir, + 'runs', + runId, + WorkflowRunSchema, + tag + ); + if (!run) { + throw new WorkflowRunNotFoundError(runId); + } + + // Server-side validation. The SDK validates before sending, but + // the world is the final authority — re-check so direct callers + // (tests, other consumers) cannot bypass the limits. + try { + validateAttributeChanges(changes, { + existingCount: Object.keys(run.attributes ?? {}).length, + }); + } catch (err) { + if (err instanceof AttributeValidationError) { + // Re-throw as a plain error; callers (the SDK) wrap as + // FatalError on their side. + throw err; + } + throw err; + } + + const nextAttributes = applyAttributeChanges(run.attributes, changes); + const updatedRun = { + ...run, + attributes: nextAttributes, + updatedAt: new Date(), + }; + + await writeJSON(taggedPath(basedir, 'runs', runId, tag), updatedRun, { + overwrite: true, + }); + + return { attributes: nextAttributes }; + }); + }, }; } diff --git a/packages/world-postgres/src/drizzle/migrations/0013_add_attributes.sql b/packages/world-postgres/src/drizzle/migrations/0013_add_attributes.sql new file mode 100644 index 0000000000..de472fe108 --- /dev/null +++ b/packages/world-postgres/src/drizzle/migrations/0013_add_attributes.sql @@ -0,0 +1 @@ +ALTER TABLE "workflow"."workflow_runs" ADD COLUMN "attributes" jsonb DEFAULT '{}'::jsonb NOT NULL; diff --git a/packages/world-postgres/src/drizzle/migrations/meta/_journal.json b/packages/world-postgres/src/drizzle/migrations/meta/_journal.json index f55371e3c8..e2ad71c47e 100644 --- a/packages/world-postgres/src/drizzle/migrations/meta/_journal.json +++ b/packages/world-postgres/src/drizzle/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1775600000000, "tag": "0012_add_is_system", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1779609600000, + "tag": "0013_add_attributes", + "breakpoints": true } ] } diff --git a/packages/world-postgres/src/drizzle/schema.ts b/packages/world-postgres/src/drizzle/schema.ts index 44a2785f90..3449576ba4 100644 --- a/packages/world-postgres/src/drizzle/schema.ts +++ b/packages/world-postgres/src/drizzle/schema.ts @@ -95,6 +95,17 @@ export const runs = schema.table( * decryption or hydration. */ errorCode: varchar('error_code'), + /** + * Plaintext string-string metadata attached to the run via + * `setAttributes()`. EXPERIMENTAL MVP: stored as JSONB to allow + * SQL-side merge (`jsonb_set` / `jsonb_strip_nulls`) without a + * read-modify-write cycle. Defaults to `{}` so existing rows + * (pre-migration) read as the empty map. + */ + attributes: jsonb('attributes') + .$type>() + .default({}) + .notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at') .defaultNow() diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index 6383cf39de..72d81beb34 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -8,8 +8,10 @@ import { WorkflowWorldError, } from '@workflow/errors'; import type { + AttributeChange, Event, EventResult, + ExperimentalSetAttributesResult, GetEventParams, Hook, ListEventsParams, @@ -25,6 +27,7 @@ import type { WorkflowRunWithoutData, } from '@workflow/world'; import { + AttributeValidationError, EventSchema, HookSchema, isLegacySpecVersion, @@ -32,6 +35,7 @@ import { SPEC_VERSION_CURRENT, StepSchema, stripEventDataRefs, + validateAttributeChanges, validateUlidTimestamp, WorkflowRunSchema, } from '@workflow/world'; @@ -140,6 +144,62 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] { cursor: values.at(-1)?.runId ?? null, }; }) as Storage['runs']['list'], + + experimentalSetAttributes: async ( + runId: string, + changes: AttributeChange[] + ): Promise => { + // Load existing attributes for the per-run cap check. Postgres + // applies the merge atomically via the UPDATE below, but the + // count cap requires knowing the existing size — fetch it first. + // The narrow window between this read and the UPDATE is + // documented as last-write-wins by arrival (concurrent writes + // limitation in the MVP changelog). + const [existing] = await drizzle + .select({ attributes: runs.attributes }) + .from(runs) + .where(eq(runs.runId, runId)) + .limit(1); + if (!existing) { + throw new WorkflowRunNotFoundError(runId); + } + + try { + validateAttributeChanges(changes, { + existingCount: Object.keys(existing.attributes ?? {}).length, + }); + } catch (err) { + if (err instanceof AttributeValidationError) throw err; + throw err; + } + + // Build a single SQL expression that applies all changes atomically. + // Sets fold into nested `jsonb_set` calls; removes fold into + // chained `-` (delete) operators. Returns the post-merge map. + let expr = sql`COALESCE(${runs.attributes}, '{}'::jsonb)`; + for (const { key, value } of changes) { + if (value === null) { + expr = sql`${expr} - ${key}`; + } else { + expr = sql`jsonb_set(${expr}, ARRAY[${key}]::text[], to_jsonb(${value}::text), true)`; + } + } + + const [updated] = await drizzle + .update(runs) + .set({ + attributes: expr as any, + updatedAt: new Date(), + }) + .where(eq(runs.runId, runId)) + .returning({ attributes: runs.attributes }); + + if (!updated) { + throw new WorkflowRunNotFoundError(runId); + } + + return { attributes: updated.attributes ?? {} }; + }, }; } diff --git a/packages/world-postgres/test/storage.test.ts b/packages/world-postgres/test/storage.test.ts index 6879f5deb7..432785a9f5 100644 --- a/packages/world-postgres/test/storage.test.ts +++ b/packages/world-postgres/test/storage.test.ts @@ -352,6 +352,57 @@ describe('Storage (Postgres integration)', () => { expect(page2.data[0].runId).not.toBe(page1.data[0].runId); }); }); + + describe('experimentalSetAttributes', () => { + it('upserts new keys', async () => { + const run = await createRun(events, { + deploymentId: 'd', + workflowName: 'w', + input: new Uint8Array(), + }); + + const result = await runs.experimentalSetAttributes!(run.runId, [ + { key: 'phase', value: 'init' }, + { key: 'tenant', value: 't1' }, + ]); + expect(result.attributes).toEqual({ phase: 'init', tenant: 't1' }); + + const fresh = await runs.get(run.runId); + expect(fresh.attributes).toEqual({ phase: 'init', tenant: 't1' }); + }); + + it('merges across calls without clobbering prior keys', async () => { + const run = await createRun(events, { + deploymentId: 'd', + workflowName: 'w', + input: new Uint8Array(), + }); + + await runs.experimentalSetAttributes!(run.runId, [ + { key: 'a', value: '1' }, + ]); + const result = await runs.experimentalSetAttributes!(run.runId, [ + { key: 'b', value: '2' }, + ]); + expect(result.attributes).toEqual({ a: '1', b: '2' }); + }); + + it('removes keys when value is null', async () => { + const run = await createRun(events, { + deploymentId: 'd', + workflowName: 'w', + input: new Uint8Array(), + }); + await runs.experimentalSetAttributes!(run.runId, [ + { key: 'a', value: '1' }, + { key: 'b', value: '2' }, + ]); + const result = await runs.experimentalSetAttributes!(run.runId, [ + { key: 'a', value: null }, + ]); + expect(result.attributes).toEqual({ b: '2' }); + }); + }); }); describe('steps', () => { diff --git a/packages/world-vercel/src/runs.ts b/packages/world-vercel/src/runs.ts index 466d3ae661..bc1e723c3d 100644 --- a/packages/world-vercel/src/runs.ts +++ b/packages/world-vercel/src/runs.ts @@ -1,7 +1,9 @@ import { WorkflowRunNotFoundError, WorkflowWorldError } from '@workflow/errors'; import { + type AttributeChange, type CancelWorkflowRunParams, type CreateWorkflowRunRequest, + type ExperimentalSetAttributesResult, type GetWorkflowRunParams, type ListWorkflowRunsParams, type PaginatedResponse, @@ -254,3 +256,43 @@ export async function cancelWorkflowRunV1( throw error; } } + +/** + * Wire response schema for `experimentalSetAttributes`. The backend + * returns the post-merge attribute snapshot so callers don't need to + * issue a follow-up read. + */ +const ExperimentalSetAttributesResponseSchema = z.object({ + attributes: z.record(z.string(), z.string()), +}); + +/** + * Apply attribute changes to a workflow run. The body shape mirrors the + * future `attr_set` event's `eventData.changes`, so the wire contract is + * forward-compatible with the full 5.0.0 attributes feature — only the + * endpoint path changes. + * + * EXPERIMENTAL: tied to the MVP write-only attributes API. See + * `docs/content/docs/v5/changelog/attributes-mvp.mdx`. + */ +export async function experimentalSetAttributes( + runId: string, + changes: AttributeChange[], + config?: APIConfig +): Promise { + try { + const response = await makeRequest({ + endpoint: `/v2/runs/${encodeURIComponent(runId)}/attributes`, + options: { method: 'POST' }, + data: { changes }, + config, + schema: ExperimentalSetAttributesResponseSchema, + }); + return { attributes: response.attributes }; + } catch (error) { + if (error instanceof WorkflowWorldError && error.status === 404) { + throw new WorkflowRunNotFoundError(runId); + } + throw error; + } +} diff --git a/packages/world-vercel/src/storage.ts b/packages/world-vercel/src/storage.ts index 38c62b78cb..966a5691af 100644 --- a/packages/world-vercel/src/storage.ts +++ b/packages/world-vercel/src/storage.ts @@ -6,7 +6,11 @@ import { } from './events.js'; import { getHook, getHookByToken, listHooks } from './hooks.js'; import { instrumentObject } from './instrumentObject.js'; -import { getWorkflowRun, listWorkflowRuns } from './runs.js'; +import { + experimentalSetAttributes, + getWorkflowRun, + listWorkflowRuns, +} from './runs.js'; import { getStep, listWorkflowRunSteps } from './steps.js'; import type { APIConfig } from './utils.js'; @@ -18,6 +22,8 @@ export function createStorage(config?: APIConfig): Storage { getWorkflowRun(id, params, config)) as Storage['runs']['get'], list: ((params?: any) => listWorkflowRuns(params, config)) as Storage['runs']['list'], + experimentalSetAttributes: (runId, changes) => + experimentalSetAttributes(runId, changes, config), }, steps: { get: ((runId: string, stepId: string, params?: any) => diff --git a/packages/world/src/attributes.test.ts b/packages/world/src/attributes.test.ts new file mode 100644 index 0000000000..2d78366d3e --- /dev/null +++ b/packages/world/src/attributes.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { + applyAttributeChanges, + ATTRIBUTE_KEY_MAX_LENGTH, + ATTRIBUTE_MAX_PER_RUN, + AttributeValidationError, + validateAttributeChanges, + validateAttributeKey, + validateAttributeValue, +} from './attributes.js'; + +describe('validateAttributeKey', () => { + it('accepts a normal key', () => { + expect(validateAttributeKey('phase')).toBeNull(); + }); + + it('rejects empty keys', () => { + expect(validateAttributeKey('')).toBeInstanceOf(AttributeValidationError); + }); + + it('rejects keys over the length cap', () => { + expect( + validateAttributeKey('k'.repeat(ATTRIBUTE_KEY_MAX_LENGTH + 1)) + ).toBeInstanceOf(AttributeValidationError); + }); + + it('accepts keys exactly at the length cap', () => { + expect( + validateAttributeKey('k'.repeat(ATTRIBUTE_KEY_MAX_LENGTH)) + ).toBeNull(); + }); + + it('rejects keys starting with the reserved prefix', () => { + expect(validateAttributeKey('$internal')).toBeInstanceOf( + AttributeValidationError + ); + }); +}); + +describe('validateAttributeValue', () => { + it('accepts null (unset)', () => { + expect(validateAttributeValue(null)).toBeNull(); + }); + + it('accepts a normal string', () => { + expect(validateAttributeValue('hello')).toBeNull(); + }); + + it('rejects values over the byte cap', () => { + expect(validateAttributeValue('a'.repeat(257))).toBeInstanceOf( + AttributeValidationError + ); + }); + + it('counts UTF-8 bytes, not characters', () => { + // 4-byte UTF-8 emoji; 64 of them = 256 bytes exactly (at the cap) + const at = '💥'.repeat(64); + expect(validateAttributeValue(at)).toBeNull(); + const over = '💥'.repeat(65); // 260 bytes, over + expect(validateAttributeValue(over)).toBeInstanceOf( + AttributeValidationError + ); + }); +}); + +describe('validateAttributeChanges', () => { + it('accepts a small batch of valid changes', () => { + expect(() => + validateAttributeChanges([ + { key: 'phase', value: 'init' }, + { key: 'stale', value: null }, + ]) + ).not.toThrow(); + }); + + it('rejects duplicate keys within a single batch', () => { + expect(() => + validateAttributeChanges([ + { key: 'phase', value: 'init' }, + { key: 'phase', value: 'done' }, + ]) + ).toThrow(AttributeValidationError); + }); + + it('rejects when post-merge count exceeds the per-run cap', () => { + const changes = Array.from({ length: ATTRIBUTE_MAX_PER_RUN }, (_, i) => ({ + key: `k${i}`, + value: 'v', + })); + expect(() => + validateAttributeChanges(changes, { existingCount: 1 }) + ).toThrow(AttributeValidationError); + }); +}); + +describe('applyAttributeChanges', () => { + it('upserts new keys', () => { + expect( + applyAttributeChanges({ a: '1' }, [{ key: 'b', value: '2' }]) + ).toEqual({ a: '1', b: '2' }); + }); + + it('overwrites existing keys', () => { + expect( + applyAttributeChanges({ a: '1' }, [{ key: 'a', value: '2' }]) + ).toEqual({ a: '2' }); + }); + + it('removes keys when value is null', () => { + expect( + applyAttributeChanges({ a: '1', b: '2' }, [{ key: 'a', value: null }]) + ).toEqual({ b: '2' }); + }); + + it('applies set and unset in a single batch', () => { + expect( + applyAttributeChanges({ a: '1', stale: 'x' }, [ + { key: 'stale', value: null }, + { key: 'fresh', value: 'yes' }, + ]) + ).toEqual({ a: '1', fresh: 'yes' }); + }); + + it('returns a new object (does not mutate input)', () => { + const before = { a: '1' }; + const after = applyAttributeChanges(before, [{ key: 'b', value: '2' }]); + expect(before).toEqual({ a: '1' }); + expect(after).not.toBe(before); + }); + + it('treats undefined existing as the empty record', () => { + expect( + applyAttributeChanges(undefined, [{ key: 'a', value: '1' }]) + ).toEqual({ a: '1' }); + }); +}); diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts new file mode 100644 index 0000000000..18b86527a0 --- /dev/null +++ b/packages/world/src/attributes.ts @@ -0,0 +1,177 @@ +import { z } from 'zod'; + +/** + * Reserved key prefix for system-managed attributes. User code may not set + * keys starting with `$` — those are blocked at validation time so the + * namespace remains available for future system use. + */ +export const RESERVED_ATTRIBUTE_KEY_PREFIX = '$'; + +/** Max length of an attribute key, in characters. */ +export const ATTRIBUTE_KEY_MAX_LENGTH = 256; + +/** Max length of an attribute value, in bytes (UTF-8). */ +export const ATTRIBUTE_VALUE_MAX_BYTES = 256; + +/** Max number of attributes on a single run (post-merge). */ +export const ATTRIBUTE_MAX_PER_RUN = 64; + +/** + * A single change in an `experimentalSetAttributes` call. `value: null` + * means "remove this key from the run's attributes". + * + * The shape is deliberately the same as the future `attr_set` event's + * `eventData.changes` entries so the SDK and wire format do not change + * when the full attributes feature lands. + */ +export const AttributeChangeSchema = z.object({ + key: z.string(), + value: z.union([z.string(), z.null()]), +}); + +export type AttributeChange = z.infer; + +export const AttributeChangesSchema = z.array(AttributeChangeSchema); + +/** + * Result returned by `runs.experimentalSetAttributes` — the post-merge + * snapshot of all attributes on the run. Provided so callers (notably + * `setAttributes` and observability emitters) do not need a follow-up read. + */ +export interface ExperimentalSetAttributesResult { + attributes: Record; +} + +export interface AttributeValidationContext { + /** + * Existing attribute count on the run, used to enforce the per-run cap + * after merging in the incoming changes. Defaults to 0 so client-side + * validation (which does not know the existing snapshot) can still + * catch single-batch violations. + */ + existingCount?: number; +} + +/** + * Thrown when an attribute key or value violates one of the validation + * rules. Use a plain `Error` here so the world layer can decide whether + * to wrap as `FatalError` (SDK) or return a 400 (server endpoint). + */ +export class AttributeValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AttributeValidationError'; + } +} + +const valueByteLength = (value: string): number => + new TextEncoder().encode(value).length; + +/** + * Validate a single attribute key. Returns an `AttributeValidationError` + * on violation, or `null` if the key is valid. Returning instead of + * throwing lets callers aggregate or wrap the failure as needed. + */ +export function validateAttributeKey( + key: string +): AttributeValidationError | null { + if (typeof key !== 'string') { + return new AttributeValidationError( + `Attribute key must be a string, got ${typeof key}` + ); + } + if (key.length === 0) { + return new AttributeValidationError('Attribute key must not be empty'); + } + if (key.length > ATTRIBUTE_KEY_MAX_LENGTH) { + return new AttributeValidationError( + `Attribute key length ${key.length} exceeds limit ${ATTRIBUTE_KEY_MAX_LENGTH}: ${JSON.stringify(key.slice(0, 32))}…` + ); + } + if (key.startsWith(RESERVED_ATTRIBUTE_KEY_PREFIX)) { + return new AttributeValidationError( + `Attribute key ${JSON.stringify(key)} starts with reserved prefix "${RESERVED_ATTRIBUTE_KEY_PREFIX}"` + ); + } + return null; +} + +/** + * Validate a single attribute value. `null` represents an unset and is + * always valid. Returns an `AttributeValidationError` on violation or + * `null` if the value is valid. + */ +export function validateAttributeValue( + value: string | null +): AttributeValidationError | null { + if (value === null) return null; + if (typeof value !== 'string') { + return new AttributeValidationError( + `Attribute value must be a string or null, got ${typeof value}` + ); + } + const bytes = valueByteLength(value); + if (bytes > ATTRIBUTE_VALUE_MAX_BYTES) { + return new AttributeValidationError( + `Attribute value byte length ${bytes} exceeds limit ${ATTRIBUTE_VALUE_MAX_BYTES}` + ); + } + return null; +} + +/** + * Validate a batch of attribute changes. Throws `AttributeValidationError` + * on the first violation found. Use `existingCount` (in `context`) to + * enforce the per-run cap against the post-merge total. + */ +export function validateAttributeChanges( + changes: AttributeChange[], + context: AttributeValidationContext = {} +): void { + const seenKeys = new Set(); + let netAdds = 0; + for (const change of changes) { + const keyError = validateAttributeKey(change.key); + if (keyError) throw keyError; + const valueError = validateAttributeValue(change.value); + if (valueError) throw valueError; + if (seenKeys.has(change.key)) { + throw new AttributeValidationError( + `Attribute key ${JSON.stringify(change.key)} appears more than once in the same batch` + ); + } + seenKeys.add(change.key); + // Net adds counted optimistically — an existing key being set is also + // 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; + } + const existing = context.existingCount ?? 0; + if (existing + netAdds > ATTRIBUTE_MAX_PER_RUN) { + throw new AttributeValidationError( + `Run attribute count would exceed limit ${ATTRIBUTE_MAX_PER_RUN} (existing ${existing} + incoming ${netAdds})` + ); + } +} + +/** + * Apply a batch of validated changes to an existing attribute map. Returns + * a new map; does not mutate the input. The world layer uses this to + * compute the post-merge snapshot when the underlying store cannot do the + * merge in a single atomic operation. + */ +export function applyAttributeChanges( + existing: Record | undefined, + changes: AttributeChange[] +): Record { + const next: Record = { ...(existing ?? {}) }; + for (const { key, value } of changes) { + if (value === null) { + delete next[key]; + } else { + next[key] = value; + } + } + return next; +} diff --git a/packages/world/src/index.ts b/packages/world/src/index.ts index 06ce262df3..412d1df02b 100644 --- a/packages/world/src/index.ts +++ b/packages/world/src/index.ts @@ -1,3 +1,17 @@ +export type * from './attributes.js'; +export { + applyAttributeChanges, + ATTRIBUTE_KEY_MAX_LENGTH, + ATTRIBUTE_MAX_PER_RUN, + ATTRIBUTE_VALUE_MAX_BYTES, + AttributeChangeSchema, + AttributeChangesSchema, + AttributeValidationError, + RESERVED_ATTRIBUTE_KEY_PREFIX, + validateAttributeChanges, + validateAttributeKey, + validateAttributeValue, +} from './attributes.js'; export type * from './events.js'; export { BaseEventSchema, diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index 45d8143c4b..7dc2560ab9 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -1,3 +1,7 @@ +import type { + AttributeChange, + ExperimentalSetAttributesResult, +} from './attributes.js'; import type { CreateEventParams, CreateEventRequest, @@ -154,6 +158,31 @@ export interface Storage { list( params?: ListWorkflowRunsParams ): Promise>; + + /** + * Apply a batch of attribute changes to a run. Merge semantics: + * - `value: string` upserts the key + * - `value: null` removes the key + * - keys not listed in `changes` are untouched + * + * Returns the post-merge attribute snapshot on the run. + * + * OPTIONAL. World implementations may omit this method; the SDK + * helper (`setAttributes` in `@workflow/core`) feature-detects its + * absence and no-ops with a one-time warning so third-party / + * community worlds keep working without adopting the experimental + * API. + * + * EXPERIMENTAL: this method exists as a stopgap until the + * `attr_set` event type lands in a future spec version. When that + * happens, `setAttributes` will dispatch through `events.create` + * instead, and this method is expected to be removed. See the + * `attributes-mvp` changelog entry for the migration shape. + */ + experimentalSetAttributes?( + runId: string, + changes: AttributeChange[] + ): Promise; }; steps: { diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index 0aa05ddcdd..83c9c29c05 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -63,6 +63,19 @@ export const WorkflowRunBaseSchema = z.object({ * without needing to decrypt the full error payload. */ errorCode: z.string().optional(), + /** + * Plaintext string-string metadata attached to the run via + * `setAttributes()` (or, in the future, materialized from `attr_set` + * events). Stored unencrypted alongside other plaintext fields so + * observability surfaces can read it without going through the + * decryption pipeline. + * + * EXPERIMENTAL (MVP): runs created before this field landed read as + * `undefined`. The full Workflow Attributes feature replaces the + * direct-mutation MVP path with an event-sourced model — see the + * V5 attributes-mvp changelog entry. + */ + attributes: z.record(z.string(), z.string()).optional(), expiredAt: z.coerce.date().optional(), startedAt: z.coerce.date().optional(), completedAt: z.coerce.date().optional(), From c912dd753227b6577fcc09060183846d403f86bc Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 22 May 2026 19:58:20 +0200 Subject: [PATCH 03/26] fix(web-shared): add attribute-panel renderer for run attributes 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. --- .../web-shared/src/components/sidebar/attribute-panel.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/web-shared/src/components/sidebar/attribute-panel.tsx b/packages/web-shared/src/components/sidebar/attribute-panel.tsx index c708dc32ba..ebe42eabb7 100644 --- a/packages/web-shared/src/components/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/components/sidebar/attribute-panel.tsx @@ -417,6 +417,13 @@ const attributeToDisplayFn: Record< projectId: (_value: unknown) => null, environment: (_value: unknown) => null, executionContext: (_value: unknown) => null, + // V5 attributes MVP — string-string metadata attached to the run. + // Rendered as a JSON block; if empty/missing, hidden by the + // hasDisplayContent gate above. + attributes: (value: unknown) => { + if (!hasDisplayContent(value)) return null; + return JsonBlock(value); + }, // Dates — wrapped with TimestampTooltip showing UTC/local + relative time createdAt: timestampWithTooltipOrNull, startedAt: timestampWithTooltipOrNull, From bff53a680eb28dea53ead249864202db9d3dfdb4 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 22 May 2026 20:07:22 +0200 Subject: [PATCH 04/26] fix(core): split setAttributes for workflow VM bundle 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) --- packages/core/src/set-attributes-shared.ts | 91 ++++++++++++++++ packages/core/src/set-attributes.ts | 103 ++++--------------- packages/core/src/workflow/index.ts | 2 +- packages/core/src/workflow/set-attributes.ts | 43 ++++++++ 4 files changed, 155 insertions(+), 84 deletions(-) create mode 100644 packages/core/src/set-attributes-shared.ts create mode 100644 packages/core/src/workflow/set-attributes.ts diff --git a/packages/core/src/set-attributes-shared.ts b/packages/core/src/set-attributes-shared.ts new file mode 100644 index 0000000000..f1c0f53ece --- /dev/null +++ b/packages/core/src/set-attributes-shared.ts @@ -0,0 +1,91 @@ +import { FatalError } from '@workflow/errors'; +import type { AttributeChange } from '@workflow/world'; +import { + AttributeValidationError, + validateAttributeChanges, +} from '@workflow/world'; +import { getWorldLazy } from './runtime/get-world-lazy.js'; + +// IMPORTANT: this module is imported by both the workflow-VM bundle and +// the step/host bundle. It must NOT import anything that pulls in +// `node:async_hooks` (e.g. `step/context-storage.ts`) or any other +// Node-only module — the workflow VM bundle is built with a no-Node +// constraint, and an offending transitive import will fail the build +// with "You are attempting to use 'node:async_hooks' which is a Node.js +// module. Node.js modules are not available in workflow functions." + +let unsupportedWorldWarned = false; + +function warnUnsupportedWorldOnce(worldName?: string): void { + if (unsupportedWorldWarned) return; + unsupportedWorldWarned = true; + // biome-ignore lint/suspicious/noConsole: surface in user terminals + console.warn( + `[workflow] setAttributes: the current world implementation${ + worldName ? ` (${worldName})` : '' + } does not implement experimentalSetAttributes; this call (and any subsequent setAttributes calls in this process) is a no-op. Attributes will become available once the world adapter adds support.` + ); +} + +/** + * Internal step that performs the actual `experimentalSetAttributes` + * call against the World. The `'use step'` directive means the function + * body runs in a step (Node.js) context regardless of which surface + * invoked it: + * + * - Called from a workflow body: dispatched as a step through the + * workflow controller, recorded in the event log + * (`step_created`/`step_completed`), replayed deterministically on + * resume. + * - Called from a step body (or from Node.js outside a workflow): runs + * inline. Step bodies cannot nest steps; the directive is a no-op + * there and the function body executes as a plain async function. + * + * @internal + */ +export async function setAttributesStep( + runId: string, + changes: AttributeChange[] +): Promise { + 'use step'; + const world = await getWorldLazy(); + if (typeof world.runs.experimentalSetAttributes !== 'function') { + warnUnsupportedWorldOnce((world as any)?.name); + return; + } + await world.runs.experimentalSetAttributes(runId, changes); +} + +/** + * Validate and normalize a `setAttributes(record)` call. Returns the + * canonical `AttributeChange[]` shape (with `undefined → null`) or + * throws `FatalError` on a violation. Returns `null` if the input is + * empty (callers must short-circuit without dispatching a step). + * + * @internal + */ +export function normalizeSetAttributesInput( + attrs: Record +): AttributeChange[] | null { + if (attrs === null || typeof attrs !== 'object' || Array.isArray(attrs)) { + throw new FatalError( + `setAttributes requires a plain object, got ${attrs === null ? 'null' : Array.isArray(attrs) ? 'array' : typeof attrs}` + ); + } + const changes: AttributeChange[] = Object.entries(attrs).map( + ([key, value]) => ({ + key, + value: value === undefined ? null : value, + }) + ); + if (changes.length === 0) return null; + try { + validateAttributeChanges(changes); + } catch (err) { + if (err instanceof AttributeValidationError) { + throw new FatalError(err.message); + } + throw err; + } + return changes; +} diff --git a/packages/core/src/set-attributes.ts b/packages/core/src/set-attributes.ts index 113485e03f..be8fda9dbf 100644 --- a/packages/core/src/set-attributes.ts +++ b/packages/core/src/set-attributes.ts @@ -1,64 +1,22 @@ -import { FatalError } from '@workflow/errors'; -import type { AttributeChange } from '@workflow/world'; -import { - AttributeValidationError, - validateAttributeChanges, -} from '@workflow/world'; import { throwNotInWorkflowOrStepContext } from './context-errors.js'; -import { getWorldLazy } from './runtime/get-world-lazy.js'; +import { + normalizeSetAttributesInput, + setAttributesStep, +} from './set-attributes-shared.js'; import { contextStorage } from './step/context-storage.js'; import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; -let unsupportedWorldWarned = false; - -function warnUnsupportedWorldOnce(worldName?: string): void { - if (unsupportedWorldWarned) return; - unsupportedWorldWarned = true; - // Use console.warn rather than the internal logger so the message - // surfaces in user terminals regardless of debug configuration. - // eslint-disable-next-line no-console - console.warn( - `[workflow] setAttributes: the current world implementation${ - worldName ? ` (${worldName})` : '' - } does not implement experimentalSetAttributes; this call (and any subsequent setAttributes calls in this process) is a no-op. Attributes will become available once the world adapter adds support.` - ); -} - -/** - * Internal step that performs the actual `experimentalSetAttributes` call - * against the World. The `'use step'` directive means: - * - * - Called from a workflow body: dispatched as a step through the workflow - * controller, recorded in the event log (`step_created`/`step_completed`), - * replayed deterministically on resume. - * - Called from a step body (or from Node.js outside a workflow): executes - * inline. Step bodies cannot nest steps; the directive is a no-op here - * and the function body runs as a plain async function. - * - * Both paths converge on the same world method, so the wire format is - * identical regardless of where the call originated. - * - * @internal - */ -async function setAttributesStep( - runId: string, - changes: AttributeChange[] -): Promise { - 'use step'; - const world = await getWorldLazy(); - if (typeof world.runs.experimentalSetAttributes !== 'function') { - warnUnsupportedWorldOnce((world as any)?.name); - return; - } - await world.runs.experimentalSetAttributes(runId, changes); -} - /** * Attach plaintext string key/value metadata to the current workflow run. * - * Available in both workflow bodies and step bodies. Validation runs - * client-side; violations throw `FatalError`. An empty record is a no-op - * (no RPC, no events). + * Available in both workflow bodies and step bodies — this is the + * step/host-side entry point (re-exported from `@workflow/core` and + * `workflow`). The workflow-VM bundle exposes a parallel implementation + * that does not transitively import `node:async_hooks`; see + * `./workflow/set-attributes.ts`. + * + * Validation runs client-side; violations throw `FatalError`. An empty + * record is a no-op (no RPC, no events). * * `value: undefined` removes the key from the run's attribute map. * @@ -76,22 +34,19 @@ async function setAttributesStep( export async function setAttributes( attrs: Record ): Promise { - if (attrs === null || typeof attrs !== 'object' || Array.isArray(attrs)) { - throw new FatalError( - `setAttributes requires a plain object, got ${attrs === null ? 'null' : Array.isArray(attrs) ? 'array' : typeof attrs}` - ); - } + const changes = normalizeSetAttributesInput(attrs); + if (!changes) return; - // Resolve the run ID from whichever context we are in. Workflow VM - // context sets WORKFLOW_CONTEXT_SYMBOL on globalThis; step Node.js - // context uses AsyncLocalStorage. Either path exposes - // `workflowRunId` via the workflow metadata object. + // Resolve the run ID from whichever context we are in. Step Node.js + // context uses AsyncLocalStorage (`contextStorage`); the workflow VM + // context sets `WORKFLOW_CONTEXT_SYMBOL` on globalThis. Either path + // exposes `workflowRunId` via the workflow metadata object. + const stepCtx = contextStorage.getStore(); const workflowCtx = (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] as | { workflowRunId?: string } | undefined; - const stepCtx = contextStorage.getStore(); const runId = - workflowCtx?.workflowRunId ?? stepCtx?.workflowMetadata?.workflowRunId; + stepCtx?.workflowMetadata?.workflowRunId ?? workflowCtx?.workflowRunId; if (!runId) { throwNotInWorkflowOrStepContext( @@ -101,23 +56,5 @@ export async function setAttributes( ); } - const changes: AttributeChange[] = Object.entries(attrs).map( - ([key, value]) => ({ - key, - value: value === undefined ? null : value, - }) - ); - - if (changes.length === 0) return; - - try { - validateAttributeChanges(changes); - } catch (err) { - if (err instanceof AttributeValidationError) { - throw new FatalError(err.message); - } - throw err; - } - await setAttributesStep(runId, changes); } diff --git a/packages/core/src/workflow/index.ts b/packages/core/src/workflow/index.ts index 2b5b341f49..e8fe14323b 100644 --- a/packages/core/src/workflow/index.ts +++ b/packages/core/src/workflow/index.ts @@ -10,7 +10,7 @@ export { type RetryableErrorOptions, } from '@workflow/errors'; export type { Hook, HookOptions } from '../create-hook.js'; -export { setAttributes } from '../set-attributes.js'; +export { setAttributes } from './set-attributes.js'; export { sleep } from '../sleep.js'; export { createHook, createWebhook } from './create-hook.js'; export { defineHook } from './define-hook.js'; diff --git a/packages/core/src/workflow/set-attributes.ts b/packages/core/src/workflow/set-attributes.ts new file mode 100644 index 0000000000..51f2f365df --- /dev/null +++ b/packages/core/src/workflow/set-attributes.ts @@ -0,0 +1,43 @@ +import { NotInWorkflowOrStepContextError } from '../context-violation-error.js'; +import { + normalizeSetAttributesInput, + setAttributesStep, +} from '../set-attributes-shared.js'; +import { WORKFLOW_CONTEXT_SYMBOL } from './get-workflow-metadata.js'; + +/** + * Workflow-VM-side `setAttributes`. The exported symbol from + * `@workflow/core/_workflow` (which the umbrella `workflow` package + * re-exports under the workflow bundle). + * + * Only reads from `globalThis[WORKFLOW_CONTEXT_SYMBOL]` — the workflow + * VM bundle is built without `node:async_hooks`, so it must not import + * `contextStorage`. The step/host-side variant + * (`packages/core/src/set-attributes.ts`) handles both contexts. + * + * The dispatch step (`setAttributesStep`) carries the `'use step'` + * directive; the SWC plugin rewrites the call into a workflow + * controller request so the body executes in the step (Node) context. + * + * See the `attributes-mvp` changelog entry for the full API spec. + */ +export async function setAttributes( + attrs: Record +): Promise { + const changes = normalizeSetAttributesInput(attrs); + if (!changes) return; + + const workflowCtx = (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] as + | { workflowRunId?: string } + | undefined; + const runId = workflowCtx?.workflowRunId; + + if (!runId) { + throw new NotInWorkflowOrStepContextError( + 'setAttributes()', + 'https://workflow-sdk.dev/docs/api-reference/workflow/set-attributes' + ); + } + + await setAttributesStep(runId, changes); +} From e258aa4b213bc516ac65d231a01c1fa58854f7ab Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 22 May 2026 20:39:44 +0200 Subject: [PATCH 05/26] ci: retrigger From 0babcb5f57b10611b1635a823f0245fd987dc3c9 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 22 May 2026 21:18:21 +0200 Subject: [PATCH 06/26] fix(core): restrict setAttributes to step body in V5 MVP 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) --- .../docs/v5/changelog/attributes-mvp.mdx | 42 +++++++-- packages/core/src/set-attributes-shared.ts | 55 +++--------- packages/core/src/set-attributes.test.ts | 16 ++-- packages/core/src/set-attributes.ts | 87 +++++++++++++------ packages/core/src/workflow/set-attributes.ts | 66 +++++++------- 5 files changed, 147 insertions(+), 119 deletions(-) diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx index 69b172b2f2..c401bdfc4d 100644 --- a/docs/content/docs/v5/changelog/attributes-mvp.mdx +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -11,13 +11,14 @@ The MVP lets workflow and step code attach plaintext `string → string` metadat ## What MVP supports -- `setAttributes(record)` callable from a workflow body -- `setAttributes(record)` callable from a step body +- `setAttributes(record)` callable from a **step body** (`"use step"` function) +- From a workflow body: wrap the call in a one-line step (see "Runtime SDK" below) - Attributes are materialized onto the `WorkflowRun` entity, plaintext, and visible via `world.runs.get()` / `world.runs.list()` and any observability UI built on top of those - World implementations emit a side-channel observability record per write (in `world-vercel`, this hooks into the same observability/analytics pipeline already used for other run lifecycle events) ## What MVP does **not** support (deferred to 5.0.0) +- Direct workflow-body calls (V5 MVP requires a wrapping `"use step"` function — see "Why workflow-body dispatch is deferred" below) - Reading attributes from inside a workflow or step (`getAttribute` / `getAttributes`) - `start(workflow, input, { attributes })` (initial attributes at run creation) - Filtering runs by attribute value (`runs.list({ attributes: { ... } })`) @@ -113,12 +114,25 @@ Validation (shared helper, applied both client-side and server-side): - Maximum 64 attributes per run (validated against the post-merge snapshot when the server applies) - Violations throw `FatalError` from `@workflow/errors` -Two execution paths, one SDK surface: +**V5 MVP restriction: step body only.** `setAttributes` may only be called from inside a `"use step"` function. Calling it from a workflow body throws `FatalError`. To attach attributes from a workflow body, wrap the call in a step yourself: -- **From a workflow body**: `setAttributes` internally invokes an SDK-private `"use step"` function (for example `_setAttributesStep`) which calls `world.runs.experimentalSetAttributes`. Going through a step gives durable retry and replay determinism for free — the workflow VM records `step_created` / `step_completed` events, and on replay the call is not re-executed; the recorded completion is read from the event log. -- **From a step body**: `setAttributes` calls `world.runs.experimentalSetAttributes` directly. Step bodies cannot nest steps, so the internal `"use step"` indirection does not apply. The two paths converge on the same world method. +```ts +import { setAttributes } from 'workflow'; + +async function setAttrs(attrs: Record) { + 'use step'; + await setAttributes(attrs); +} + +export async function myWorkflow() { + 'use workflow'; + await setAttrs({ phase: 'init' }); +} +``` + +The restriction is an MVP shortcut, not a long-term design choice — see "Why workflow-body dispatch is deferred" below. The 5.0.0 attributes feature lifts it via the `attr_set` event mechanism; the SDK signature stays the same, so user code that wraps in a step today keeps working after the cutover. -Context detection uses existing patterns: presence of the workflow VM context indicates workflow body; presence of `contextStorage.getStore()` indicates step body. Calling `setAttributes` from outside either context throws. +Context detection: `contextStorage.getStore()` returns the step context (provides the run ID); `WORKFLOW_CONTEXT_SYMBOL` on globalThis indicates the workflow VM context (triggers the `FatalError`). Calling outside both contexts throws `NotInWorkflowOrStepContextError`. #### Optional world support @@ -138,9 +152,9 @@ if (typeof world.runs.experimentalSetAttributes !== 'function') { await world.runs.experimentalSetAttributes(runId, changes); ``` -The no-op-with-warning behaviour applies in **both** dispatch paths (workflow body and step body). User code does not need to feature-detect; calling `setAttributes` against an unsupporting world is safe but silently ineffective beyond the warning. +The no-op-with-warning behaviour fires on the step-body call path (the only supported path in MVP). User code does not need to feature-detect; calling `setAttributes` against an unsupporting world is safe but silently ineffective beyond the warning. -The warning is emitted once per process (deduped on the warning text) so high-frequency callers do not flood logs. +The warning is emitted once per process (deduped) so high-frequency callers do not flood logs. ### 4. World implementations @@ -230,6 +244,18 @@ Skew protection means workflows started under the MVP will continue to run with This section records concrete decisions taken while landing the MVP that weren't in the original plan, so the next iteration has them in one place. +### Why workflow-body dispatch is deferred + +The original plan was for `setAttributes` to work from both workflow bodies and step bodies behind a single SDK surface, with workflow-body calls dispatched as a step under the hood via an SDK-private `"use step"` function. We initially shipped that. Two problems landed it back to step-body-only: + +1. **`'use step'` inside `@workflow/core/dist/`** turned the helper file into a step file from the perspective of the deferred-entry discoverer in `nextjs-webpack` dev mode. The discoverer then traced transitive imports of the helper, ultimately reaching the host-side world adapter and `@vercel/queue` — at which point webpack's regex-based import extractor blew the call stack on tarball-installed deployments. The failure manifested as `RangeError: Maximum call stack size exceeded at RegExpStringIterator.next` and broke every `E2E Local Dev Tests (nextjs-webpack)` run on this branch while passing for every other framework. `runtime/start.ts` and `runtime/run.ts` get away with the same directive because they are never imported from the workflow-VM bundle entry (`packages/core/src/workflow/index.ts`); ours was reachable from that entry. + +2. **A host-side bridge** comparable to `sleep` (globalThis-injected dispatch + `registerStepFunction`) was the obvious workaround, but it's substantial wiring for a feature whose end state (event-sourced `attr_set`) replaces the bridge mechanism entirely. Spending that complexity twice did not seem worth it for the MVP. + +The pragmatic path is to restrict the MVP to step-body callers and let users wrap in a step explicitly. The full 5.0.0 feature dispatches via `attr_set` events through the workflow controller, which handles the workflow-body case natively without the SWC-discovery hazard. SDK signature is unchanged across the cutover. + +Tracked as a known limitation in the trade-offs section above. + ### Endpoint lives under `v2`, not a fresh namespace The initial draft placed the new endpoint at `POST /v3/runs/:runId/attributes`, on the assumption that introducing a new wire feature warranted a major namespace bump. In practice `world-vercel` mixes `/v1/...` and `/v2/...` endpoints already, and creating a `v3Api` subrouter just for a single endpoint would have required duplicating the auth / flags / rate-limit middleware stack. The MVP endpoint is therefore mounted under the existing `v2Api`. The wire body shape is unchanged, so the migration path described above (rerouting from `/v2/runs/:runId/attributes` to `/v2/runs/:id/events`) still holds — just within the same namespace. diff --git a/packages/core/src/set-attributes-shared.ts b/packages/core/src/set-attributes-shared.ts index f1c0f53ece..387b483b49 100644 --- a/packages/core/src/set-attributes-shared.ts +++ b/packages/core/src/set-attributes-shared.ts @@ -4,7 +4,6 @@ import { AttributeValidationError, validateAttributeChanges, } from '@workflow/world'; -import { getWorldLazy } from './runtime/get-world-lazy.js'; // IMPORTANT: this module is imported by both the workflow-VM bundle and // the step/host bundle. It must NOT import anything that pulls in @@ -13,48 +12,18 @@ import { getWorldLazy } from './runtime/get-world-lazy.js'; // constraint, and an offending transitive import will fail the build // with "You are attempting to use 'node:async_hooks' which is a Node.js // module. Node.js modules are not available in workflow functions." - -let unsupportedWorldWarned = false; - -function warnUnsupportedWorldOnce(worldName?: string): void { - if (unsupportedWorldWarned) return; - unsupportedWorldWarned = true; - // biome-ignore lint/suspicious/noConsole: surface in user terminals - console.warn( - `[workflow] setAttributes: the current world implementation${ - worldName ? ` (${worldName})` : '' - } does not implement experimentalSetAttributes; this call (and any subsequent setAttributes calls in this process) is a no-op. Attributes will become available once the world adapter adds support.` - ); -} - -/** - * Internal step that performs the actual `experimentalSetAttributes` - * call against the World. The `'use step'` directive means the function - * body runs in a step (Node.js) context regardless of which surface - * invoked it: - * - * - Called from a workflow body: dispatched as a step through the - * workflow controller, recorded in the event log - * (`step_created`/`step_completed`), replayed deterministically on - * resume. - * - Called from a step body (or from Node.js outside a workflow): runs - * inline. Step bodies cannot nest steps; the directive is a no-op - * there and the function body executes as a plain async function. - * - * @internal - */ -export async function setAttributesStep( - runId: string, - changes: AttributeChange[] -): Promise { - 'use step'; - const world = await getWorldLazy(); - if (typeof world.runs.experimentalSetAttributes !== 'function') { - warnUnsupportedWorldOnce((world as any)?.name); - return; - } - await world.runs.experimentalSetAttributes(runId, changes); -} +// +// It must ALSO not contain a `'use step'` directive: the deferred-entry +// discoverer in `@workflow/next/builder-deferred.ts` walks transitive +// imports from `'use step'` files, and adding a step file to +// `@workflow/core/dist/` puts the host-side `world.ts` (and its +// transitive `@vercel/queue` + adapter imports) inside the workflow +// step-discovery graph. That triggers a stack overflow inside webpack +// dev mode's regex-based import extractor on tarball-installed +// deployments. The MVP works around this by performing the world +// dispatch from the SDK helper (step body only); workflow-body use is +// supported only via the host-side bridge in `set-attributes.ts`, +// which calls into an actual step via `registerStepFunction`. /** * Validate and normalize a `setAttributes(record)` call. Returns the diff --git a/packages/core/src/set-attributes.test.ts b/packages/core/src/set-attributes.test.ts index 718b766a98..c3e5998991 100644 --- a/packages/core/src/set-attributes.test.ts +++ b/packages/core/src/set-attributes.test.ts @@ -5,7 +5,7 @@ import { setAttributes } from './set-attributes.js'; import { contextStorage } from './step/context-storage.js'; import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; -// `setAttributesStep` resolves the World via `getWorldLazy`. We mock that +// `setAttributes` resolves the World via `getWorldLazy`. We mock that // so tests don't try to load the real world initializer chain (which // pulls in world-local / world-vercel). const dispatchCalls: Array<{ runId: string; changes: any[] }> = []; @@ -69,13 +69,13 @@ describe('setAttributes', () => { ]); }); - it('dispatches when called inside a workflow VM context', async () => { - await withWorkflowContext('wrun_workflow', () => - setAttributes({ phase: 'init' }) - ); - expect(dispatchCalls).toEqual([ - { runId: 'wrun_workflow', changes: [{ key: 'phase', value: 'init' }] }, - ]); + it('throws FatalError when called from a workflow body without a wrapping step (V5 MVP restriction)', async () => { + await expect( + withWorkflowContext('wrun_workflow', () => + setAttributes({ phase: 'init' }) + ) + ).rejects.toBeInstanceOf(FatalError); + expect(dispatchCalls).toHaveLength(0); }); it('throws NotInWorkflowOrStepContextError when called outside both', async () => { diff --git a/packages/core/src/set-attributes.ts b/packages/core/src/set-attributes.ts index be8fda9dbf..879e855599 100644 --- a/packages/core/src/set-attributes.ts +++ b/packages/core/src/set-attributes.ts @@ -1,30 +1,53 @@ +import { FatalError } from '@workflow/errors'; import { throwNotInWorkflowOrStepContext } from './context-errors.js'; -import { - normalizeSetAttributesInput, - setAttributesStep, -} from './set-attributes-shared.js'; +import { getWorldLazy } from './runtime/get-world-lazy.js'; +import { normalizeSetAttributesInput } from './set-attributes-shared.js'; import { contextStorage } from './step/context-storage.js'; import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; +let unsupportedWorldWarned = false; + +function warnUnsupportedWorldOnce(worldName?: string): void { + if (unsupportedWorldWarned) return; + unsupportedWorldWarned = true; + // biome-ignore lint/suspicious/noConsole: surface in user terminals + console.warn( + `[workflow] setAttributes: the current world implementation${ + worldName ? ` (${worldName})` : '' + } does not implement experimentalSetAttributes; this call (and any subsequent setAttributes calls in this process) is a no-op. Attributes will become available once the world adapter adds support.` + ); +} + /** * Attach plaintext string key/value metadata to the current workflow run. * - * Available in both workflow bodies and step bodies — this is the - * step/host-side entry point (re-exported from `@workflow/core` and - * `workflow`). The workflow-VM bundle exposes a parallel implementation - * that does not transitively import `node:async_hooks`; see - * `./workflow/set-attributes.ts`. + * **EXPERIMENTAL (MVP).** In V5, `setAttributes` may only be called from + * a **step body**. Calling it from a workflow body throws `FatalError`. + * To attach attributes from a workflow body, wrap the call in a step + * yourself: + * + * ```ts + * async function setRunAttrs(attrs: Record) { + * 'use step'; + * await setAttributes(attrs); + * } + * + * export async function myWorkflow() { + * 'use workflow'; + * await setRunAttrs({ phase: 'init' }); + * } + * ``` + * + * The full Workflow Attributes feature (5.0.0) lifts this restriction by + * dispatching via `attr_set` events through the workflow controller; the + * SDK signature stays the same, so user code that wraps in a step today + * keeps working after the cutover. * * Validation runs client-side; violations throw `FatalError`. An empty - * record is a no-op (no RPC, no events). + * record is a no-op (no RPC). * * `value: undefined` removes the key from the run's attribute map. * - * EXPERIMENTAL (MVP): this is a write-only API in V5. Reads, list/filter - * endpoints, and initial attributes at `start()` ship with the full - * Workflow Attributes feature — see the `attributes-mvp` changelog entry - * for the migration path. - * * @example * ```ts * await setAttributes({ phase: 'processing', orderId: 'ord_123' }); @@ -34,20 +57,27 @@ import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; export async function setAttributes( attrs: Record ): Promise { - const changes = normalizeSetAttributesInput(attrs); - if (!changes) return; - - // Resolve the run ID from whichever context we are in. Step Node.js - // context uses AsyncLocalStorage (`contextStorage`); the workflow VM - // context sets `WORKFLOW_CONTEXT_SYMBOL` on globalThis. Either path - // exposes `workflowRunId` via the workflow metadata object. - const stepCtx = contextStorage.getStore(); + // Detect workflow-body context first so we can surface a clear error + // instead of silently failing inside the VM. (`WORKFLOW_CONTEXT_SYMBOL` + // is set by the host on the VM's globalThis during workflow eval.) const workflowCtx = (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] as | { workflowRunId?: string } | undefined; - const runId = - stepCtx?.workflowMetadata?.workflowRunId ?? workflowCtx?.workflowRunId; + const stepCtx = contextStorage.getStore(); + + if (workflowCtx && !stepCtx) { + throw new FatalError( + 'setAttributes() can only be called from a step body in the V5 MVP. ' + + "Wrap it: `async function setAttrs(a) { 'use step'; await setAttributes(a); }` " + + 'and call that from your workflow. The 5.0.0 attributes feature removes ' + + 'this restriction; see the attributes-mvp changelog entry.' + ); + } + const changes = normalizeSetAttributesInput(attrs); + if (!changes) return; + + const runId = stepCtx?.workflowMetadata?.workflowRunId; if (!runId) { throwNotInWorkflowOrStepContext( 'setAttributes()', @@ -56,5 +86,10 @@ export async function setAttributes( ); } - await setAttributesStep(runId, changes); + const world = await getWorldLazy(); + if (typeof world.runs.experimentalSetAttributes !== 'function') { + warnUnsupportedWorldOnce((world as any)?.name); + return; + } + await world.runs.experimentalSetAttributes(runId, changes); } diff --git a/packages/core/src/workflow/set-attributes.ts b/packages/core/src/workflow/set-attributes.ts index 51f2f365df..336dfee852 100644 --- a/packages/core/src/workflow/set-attributes.ts +++ b/packages/core/src/workflow/set-attributes.ts @@ -1,43 +1,41 @@ -import { NotInWorkflowOrStepContextError } from '../context-violation-error.js'; -import { - normalizeSetAttributesInput, - setAttributesStep, -} from '../set-attributes-shared.js'; -import { WORKFLOW_CONTEXT_SYMBOL } from './get-workflow-metadata.js'; +import { FatalError } from '@workflow/errors'; /** - * Workflow-VM-side `setAttributes`. The exported symbol from - * `@workflow/core/_workflow` (which the umbrella `workflow` package - * re-exports under the workflow bundle). + * Workflow-VM-side `setAttributes` for V5 MVP. * - * Only reads from `globalThis[WORKFLOW_CONTEXT_SYMBOL]` — the workflow - * VM bundle is built without `node:async_hooks`, so it must not import - * `contextStorage`. The step/host-side variant - * (`packages/core/src/set-attributes.ts`) handles both contexts. + * In V5 MVP, `setAttributes` is restricted to **step bodies** — calling + * it from inside a workflow body throws `FatalError`. The restriction + * exists because dispatching the call through the workflow controller + * requires either a `'use step'`-tagged helper inside `@workflow/core` + * (which trips the deferred-entry discoverer in `nextjs-webpack` dev + * mode by adding host-side world adapters to the step-discovery graph) + * or host-side bridge wiring comparable in scope to `sleep`. Neither is + * worth the complexity for the MVP, given that wrapping a single line + * in a `'use step'` function in user code is trivial: * - * The dispatch step (`setAttributesStep`) carries the `'use step'` - * directive; the SWC plugin rewrites the call into a workflow - * controller request so the body executes in the step (Node) context. + * ```ts + * async function setAttrs(attrs: Record) { + * 'use step'; + * await setAttributes(attrs); + * } * - * See the `attributes-mvp` changelog entry for the full API spec. + * export async function myWorkflow() { + * 'use workflow'; + * await setAttrs({ phase: 'init' }); + * } + * ``` + * + * The full Workflow Attributes feature in 5.0.0 dispatches via + * `attr_set` events through the workflow controller and lifts this + * restriction. The SDK signature does not change. */ export async function setAttributes( - attrs: Record + _attrs: Record ): Promise { - const changes = normalizeSetAttributesInput(attrs); - if (!changes) return; - - const workflowCtx = (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] as - | { workflowRunId?: string } - | undefined; - const runId = workflowCtx?.workflowRunId; - - if (!runId) { - throw new NotInWorkflowOrStepContextError( - 'setAttributes()', - 'https://workflow-sdk.dev/docs/api-reference/workflow/set-attributes' - ); - } - - await setAttributesStep(runId, changes); + throw new FatalError( + 'setAttributes() can only be called from a step body in the V5 MVP. ' + + "Wrap it: `async function setAttrs(a) { 'use step'; await setAttributes(a); }` " + + 'and call that from your workflow. The 5.0.0 attributes feature removes ' + + 'this restriction; see the attributes-mvp changelog entry.' + ); } From e64d6340ecde878f4030e60711fd7284f927d6d9 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 07:09:58 +0000 Subject: [PATCH 07/26] =?UTF-8?q?Fix:=20Comment=20in=20set-attributes-shar?= =?UTF-8?q?ed.ts=20falsely=20claims=20workflow-body=20setAttributes=20is?= =?UTF-8?q?=20"supported=20via=20the=20host-side=20bridge=20in=20`set-attr?= =?UTF-8?q?ibutes.ts`,=20which=20calls=20into=20an=20actual=20step=20via?= =?UTF-8?q?=20`registerStepFunction`"=20=E2=80=94=20no=20such=20bridge=20o?= =?UTF-8?q?r=20registerStepFunction=20usage=20exists.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: VaguelySerious --- packages/core/src/set-attributes-shared.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/set-attributes-shared.ts b/packages/core/src/set-attributes-shared.ts index 387b483b49..f9476fa777 100644 --- a/packages/core/src/set-attributes-shared.ts +++ b/packages/core/src/set-attributes-shared.ts @@ -21,9 +21,9 @@ import { // step-discovery graph. That triggers a stack overflow inside webpack // dev mode's regex-based import extractor on tarball-installed // deployments. The MVP works around this by performing the world -// dispatch from the SDK helper (step body only); workflow-body use is -// supported only via the host-side bridge in `set-attributes.ts`, -// which calls into an actual step via `registerStepFunction`. +// dispatch from the SDK helper (step body only); workflow-body use +// throws FatalError — users must wrap the call in their own +// `'use step'` function. /** * Validate and normalize a `setAttributes(record)` call. Returns the From 4a8d5bab67551bbc6c5e41778f959b678f83165f Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 25 May 2026 09:55:31 +0200 Subject: [PATCH 08/26] doc Signed-off-by: Peter Wielander --- docs/content/docs/v4/cookbook/common-patterns/timeouts.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/content/docs/v4/cookbook/common-patterns/timeouts.mdx b/docs/content/docs/v4/cookbook/common-patterns/timeouts.mdx index f5f05c7f16..7253d4d5b4 100644 --- a/docs/content/docs/v4/cookbook/common-patterns/timeouts.mdx +++ b/docs/content/docs/v4/cookbook/common-patterns/timeouts.mdx @@ -68,6 +68,9 @@ export async function waitForApproval(requestId: string) { throw new Error("Approval request expired after 7 days"); } + // You may see warnings like `Workflow run completed with 1 uncommitted operations` in your + // logs when the workflow completes. This is expected behavior. + return result.approved; } ``` From 13d698b5ab697f0704a5e4fdc3a3948e8448bc17 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 25 May 2026 09:56:24 +0200 Subject: [PATCH 09/26] chore(world-vercel): point URL override at attributes-mvp preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/world-vercel/src/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/world-vercel/src/utils.ts b/packages/world-vercel/src/utils.ts index 8acefbaa69..de96d16ecb 100644 --- a/packages/world-vercel/src/utils.ts +++ b/packages/world-vercel/src/utils.ts @@ -59,7 +59,8 @@ function httpLog( * `main` — rewritten by external CI for branch-deployment testing. * Prefer `VERCEL_WORKFLOW_SERVER_URL` for deployment-time configuration. */ -const WORKFLOW_SERVER_URL_OVERRIDE = ''; +const WORKFLOW_SERVER_URL_OVERRIDE = + 'https://workflow-server-git-peter-attributes-mvp.vercel.sh'; /** * Per-request timeout for HTTP calls to workflow-server (in ms). From 83f4ad3f9711b92223b98b176b6e8e8c63b5c1c6 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 25 May 2026 10:28:26 +0200 Subject: [PATCH 10/26] feat(attributes): support setAttributes from workflow body via builtin 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) --- .changeset/attributes-mvp-plan.md | 2 +- .../docs/v5/changelog/attributes-mvp.mdx | 40 ++++++----- packages/core/e2e/e2e.test.ts | 60 ++++++++++++++++ packages/core/package.json | 6 +- packages/core/src/runtime/helpers.ts | 2 +- packages/core/src/set-attributes.test.ts | 6 +- packages/core/src/set-attributes.ts | 69 +++++-------------- packages/core/src/step-set-attributes.ts | 48 +++++++++++++ packages/core/src/symbols.ts | 1 + packages/core/src/workflow.ts | 10 +++ .../core/src/workflow/set-attributes.test.ts | 69 +++++++++++++++++++ packages/core/src/workflow/set-attributes.ts | 56 +++++++-------- .../components/sidebar/attribute-panel.tsx | 2 +- packages/workflow/src/internal/builtins.ts | 20 ++++++ packages/world/src/runs.ts | 2 +- workbench/example/workflows/99_e2e.ts | 39 +++++++++++ 16 files changed, 328 insertions(+), 104 deletions(-) create mode 100644 packages/core/src/step-set-attributes.ts create mode 100644 packages/core/src/workflow/set-attributes.test.ts diff --git a/.changeset/attributes-mvp-plan.md b/.changeset/attributes-mvp-plan.md index 88ef3eae72..2f1055cb6a 100644 --- a/.changeset/attributes-mvp-plan.md +++ b/.changeset/attributes-mvp-plan.md @@ -7,4 +7,4 @@ 'workflow': patch --- -Add experimental `setAttributes()` for attaching plaintext string key/value metadata to a workflow run from workflow or step code. See the V5 `attributes-mvp` changelog entry for the design, trade-offs, and migration path to the full 5.0.0 attributes feature. +Add experimental `setAttributes()` for attaching plaintext string key/value metadata to a workflow run from workflow or step code. diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx index c401bdfc4d..9cde70c386 100644 --- a/docs/content/docs/v5/changelog/attributes-mvp.mdx +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -12,13 +12,12 @@ The MVP lets workflow and step code attach plaintext `string → string` metadat ## What MVP supports - `setAttributes(record)` callable from a **step body** (`"use step"` function) -- From a workflow body: wrap the call in a one-line step (see "Runtime SDK" below) +- `setAttributes(record)` callable from a **workflow body** (`"use workflow"` function), dispatched via an internal `__builtin_set_attributes` step bridge - Attributes are materialized onto the `WorkflowRun` entity, plaintext, and visible via `world.runs.get()` / `world.runs.list()` and any observability UI built on top of those - World implementations emit a side-channel observability record per write (in `world-vercel`, this hooks into the same observability/analytics pipeline already used for other run lifecycle events) ## What MVP does **not** support (deferred to 5.0.0) -- Direct workflow-body calls (V5 MVP requires a wrapping `"use step"` function — see "Why workflow-body dispatch is deferred" below) - Reading attributes from inside a workflow or step (`getAttribute` / `getAttributes`) - `start(workflow, input, { attributes })` (initial attributes at run creation) - Filtering runs by attribute value (`runs.list({ attributes: { ... } })`) @@ -114,25 +113,27 @@ Validation (shared helper, applied both client-side and server-side): - Maximum 64 attributes per run (validated against the post-merge snapshot when the server applies) - Violations throw `FatalError` from `@workflow/errors` -**V5 MVP restriction: step body only.** `setAttributes` may only be called from inside a `"use step"` function. Calling it from a workflow body throws `FatalError`. To attach attributes from a workflow body, wrap the call in a step yourself: +`setAttributes` is callable from both contexts: ```ts import { setAttributes } from 'workflow'; -async function setAttrs(attrs: Record) { - 'use step'; - await setAttributes(attrs); +export async function myWorkflow(orderId: string) { + 'use workflow'; + await setAttributes({ phase: 'init', orderId }); + // ... + await setAttributes({ phase: 'done' }); } -export async function myWorkflow() { - 'use workflow'; - await setAttrs({ phase: 'init' }); +async function someStep() { + 'use step'; + await setAttributes({ source: 'step' }); } ``` -The restriction is an MVP shortcut, not a long-term design choice — see "Why workflow-body dispatch is deferred" below. The 5.0.0 attributes feature lifts it via the `attr_set` event mechanism; the SDK signature stays the same, so user code that wraps in a step today keeps working after the cutover. +The step-body path calls the world adapter directly. The workflow-body path validates input inside the VM and then dispatches the canonical `AttributeChange[]` through a host-side step bridge — see "How workflow-body dispatch works" below. Either path is materialized identically on the run entity. -Context detection: `contextStorage.getStore()` returns the step context (provides the run ID); `WORKFLOW_CONTEXT_SYMBOL` on globalThis indicates the workflow VM context (triggers the `FatalError`). Calling outside both contexts throws `NotInWorkflowOrStepContextError`. +Context detection (step-side helper, after the workflow VM has resolved the import): `contextStorage.getStore()` returns the step context (provides the run ID). Calling outside both contexts throws `NotInWorkflowOrStepContextError`. The workflow VM resolves `setAttributes` to the workflow-side variant via package-exports condition matching, so the step-side helper is never reached from inside the VM. #### Optional world support @@ -244,17 +245,21 @@ Skew protection means workflows started under the MVP will continue to run with This section records concrete decisions taken while landing the MVP that weren't in the original plan, so the next iteration has them in one place. -### Why workflow-body dispatch is deferred +### How workflow-body dispatch works -The original plan was for `setAttributes` to work from both workflow bodies and step bodies behind a single SDK surface, with workflow-body calls dispatched as a step under the hood via an SDK-private `"use step"` function. We initially shipped that. Two problems landed it back to step-body-only: +`setAttributes` from a workflow body is wired through an internal built-in step, `__builtin_set_attributes`, defined in `packages/workflow/src/internal/builtins.ts` alongside the other workflow-side builtins (`__builtin_response_json`, etc.). The mechanism: -1. **`'use step'` inside `@workflow/core/dist/`** turned the helper file into a step file from the perspective of the deferred-entry discoverer in `nextjs-webpack` dev mode. The discoverer then traced transitive imports of the helper, ultimately reaching the host-side world adapter and `@vercel/queue` — at which point webpack's regex-based import extractor blew the call stack on tarball-installed deployments. The failure manifested as `RangeError: Maximum call stack size exceeded at RegExpStringIterator.next` and broke every `E2E Local Dev Tests (nextjs-webpack)` run on this branch while passing for every other framework. `runtime/start.ts` and `runtime/run.ts` get away with the same directive because they are never imported from the workflow-VM bundle entry (`packages/core/src/workflow/index.ts`); ours was reachable from that entry. +1. The workflow VM bundle resolves `setAttributes` to `packages/core/src/workflow/set-attributes.ts` (via the `workflow` package-exports condition). +2. That helper runs the shared validator on the input record and produces canonical `AttributeChange[]`. +3. It then calls a pre-bound `useStep('__builtin_set_attributes')` dispatcher stashed on `globalThis` under `WORKFLOW_SET_ATTRIBUTES` by the workflow runtime (in `packages/core/src/workflow.ts`, alongside the existing `WORKFLOW_USE_STEP` / `WORKFLOW_SLEEP` registrations). +4. The dispatch queues a step (`step_created`), the host runs `__builtin_set_attributes(changes)`, which loads `@workflow/core/_step-set-attributes` and calls `applySetAttributesChanges(changes)` against the active world. +5. The step completes (`step_completed`), the workflow resumes. -2. **A host-side bridge** comparable to `sleep` (globalThis-injected dispatch + `registerStepFunction`) was the obvious workaround, but it's substantial wiring for a feature whose end state (event-sourced `attr_set`) replaces the bridge mechanism entirely. Spending that complexity twice did not seem worth it for the MVP. +This puts the mutation on the event log as a normal `step_created → step_completed` pair without inventing a new event type — that stays for the full 5.0.0 cutover. The host-side dispatch helper (`applySetAttributesChanges`) is shared with the direct step-body path, so the two contexts converge on a single world call. -The pragmatic path is to restrict the MVP to step-body callers and let users wrap in a step explicitly. The full 5.0.0 feature dispatches via `attr_set` events through the workflow controller, which handles the workflow-body case natively without the SWC-discovery hazard. SDK signature is unchanged across the cutover. +The earlier draft tried to host the `'use step'` directive in `packages/core/src/set-attributes.ts` directly, which trips webpack's deferred-entry discoverer on `nextjs-webpack` tarball deployments (the discoverer walks transitive imports and pulls `@vercel/queue` + adapter imports into the step-discovery graph, blowing the call stack of webpack's regex-based extractor with `RangeError: Maximum call stack size exceeded at RegExpStringIterator.next`). Putting the directive in `packages/workflow/src/internal/builtins.ts` instead — i.e. inside the umbrella `workflow` package next to the existing `__builtin_response_*` steps, not `@workflow/core/dist/` — sidesteps that hazard because the builtins file is already a step-bundle entry and the discoverer doesn't graft its transitive imports onto the workflow-VM bundle graph. -Tracked as a known limitation in the trade-offs section above. +When the full 5.0.0 attributes feature lands, `__builtin_set_attributes` is replaced by an `events.create(runId, { eventType: 'attr_set', ... })` dispatch path; SDK signatures don't change. ### Endpoint lives under `v2`, not a fresh namespace @@ -296,4 +301,3 @@ Validation lives in a single helper exported from `@workflow/world` (`validateAt - **`@workflow/world-postgres`** — 3 integration tests added under the existing Postgres container suite covering upsert, merge across calls, and unset via null. The SQL-side merge (`jsonb_set` / `-`) is exercised through these. End-to-end coverage in the workbench app is intentionally deferred — running through the full SWC plugin + workflow VM path can land once the workflow-server preview deployment carrying the endpoint is live. - diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index 5f00f28ae1..c69095461a 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -3430,4 +3430,64 @@ describe('e2e', () => { expect(returnValue.reason).toBe('Test complete'); } ); + + // ========================================================================== + // setAttributes (experimental MVP) + // ========================================================================== + + describe('setAttributes', () => { + test( + 'setAttributesFromStepWorkflow: step-body calls land on the run entity', + { timeout: 30_000 }, + async () => { + const run = await start( + await e2e('setAttributesFromStepWorkflow'), + [5] + ); + const output = await run.returnValue; + expect(output).toBe(10); + + const world = await getWorld(); + const persisted = await world.runs.get(run.runId); + expect(persisted?.attributes).toEqual({ phase: 'done' }); + } + ); + + test( + 'setAttributesFromWorkflowBodyWorkflow: workflow-body calls dispatch through the step bridge and merge correctly', + { timeout: 30_000 }, + async () => { + const run = await start( + await e2e('setAttributesFromWorkflowBodyWorkflow'), + [7] + ); + const output = await run.returnValue; + expect(output).toBe(21); + + const world = await getWorld(); + const persisted = await world.runs.get(run.runId); + + // First call sets {phase: 'init', source: 'workflow-body'}; second + // overwrites phase; third unsets source via undefined → null. + expect(persisted?.attributes).toEqual({ phase: 'done' }); + expect(persisted?.attributes ?? {}).not.toHaveProperty('source'); + + // Dispatch is via a real step — verify at least one + // `step_created`/`step_completed` pair for the `__builtin_set_attributes` + // step exists on the run's event log. + const { data: events } = await world.events.list({ runId: run.runId }); + const attrStepEvents = events.filter( + (e) => + (e.eventType === 'step_created' || + e.eventType === 'step_completed') && + typeof (e.eventData as { stepName?: string } | undefined) + ?.stepName === 'string' && + (e.eventData as { stepName: string }).stepName.includes( + '__builtin_set_attributes' + ) + ); + expect(attrStepEvents.length).toBeGreaterThanOrEqual(2); + } + ); + }); }); diff --git a/packages/core/package.json b/packages/core/package.json index 2599e3fdc0..ff3095c0ac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -73,7 +73,11 @@ "types": "./dist/describe-error.d.ts", "default": "./dist/describe-error.js" }, - "./_workflow": "./dist/workflow/index.js" + "./_workflow": "./dist/workflow/index.js", + "./_step-set-attributes": { + "types": "./dist/step-set-attributes.d.ts", + "default": "./dist/step-set-attributes.js" + } }, "scripts": { "build": "genversion --es6 src/version.ts && tsc", diff --git a/packages/core/src/runtime/helpers.ts b/packages/core/src/runtime/helpers.ts index 582975af57..761df62bfb 100644 --- a/packages/core/src/runtime/helpers.ts +++ b/packages/core/src/runtime/helpers.ts @@ -354,7 +354,7 @@ export async function loadWorkflowRunEvents( // Preserve the last non-null cursor across pages. A World may // legitimately return `{ data: [], cursor: null, hasMore: false }` // on a trailing empty page — for example when the previous page's - // underlying DynamoDB query hit `Limit` exactly and returned a + // underlying DB query hit the limit exactly and returned a // `LastEvaluatedKey` "just in case". Overwriting with that null // would lose the position past the last real event we loaded and // force the runtime into the "no cursor after initial load" full- diff --git a/packages/core/src/set-attributes.test.ts b/packages/core/src/set-attributes.test.ts index c3e5998991..9b16994a75 100644 --- a/packages/core/src/set-attributes.test.ts +++ b/packages/core/src/set-attributes.test.ts @@ -69,7 +69,11 @@ describe('setAttributes', () => { ]); }); - it('throws FatalError when called from a workflow body without a wrapping step (V5 MVP restriction)', async () => { + it('throws FatalError when the step-side helper is reached from a workflow body (indicates bundling misconfig)', async () => { + // Reaching the step-side `setAttributes` from inside the workflow + // VM is a bundling failure — the VM should resolve to the + // workflow-side variant. Surface a clear error rather than + // silently misbehaving. await expect( withWorkflowContext('wrun_workflow', () => setAttributes({ phase: 'init' }) diff --git a/packages/core/src/set-attributes.ts b/packages/core/src/set-attributes.ts index 879e855599..b0d24a7df7 100644 --- a/packages/core/src/set-attributes.ts +++ b/packages/core/src/set-attributes.ts @@ -1,52 +1,21 @@ import { FatalError } from '@workflow/errors'; import { throwNotInWorkflowOrStepContext } from './context-errors.js'; -import { getWorldLazy } from './runtime/get-world-lazy.js'; import { normalizeSetAttributesInput } from './set-attributes-shared.js'; +import { applySetAttributesChanges } from './step-set-attributes.js'; import { contextStorage } from './step/context-storage.js'; import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; -let unsupportedWorldWarned = false; - -function warnUnsupportedWorldOnce(worldName?: string): void { - if (unsupportedWorldWarned) return; - unsupportedWorldWarned = true; - // biome-ignore lint/suspicious/noConsole: surface in user terminals - console.warn( - `[workflow] setAttributes: the current world implementation${ - worldName ? ` (${worldName})` : '' - } does not implement experimentalSetAttributes; this call (and any subsequent setAttributes calls in this process) is a no-op. Attributes will become available once the world adapter adds support.` - ); -} - /** * Attach plaintext string key/value metadata to the current workflow run. * - * **EXPERIMENTAL (MVP).** In V5, `setAttributes` may only be called from - * a **step body**. Calling it from a workflow body throws `FatalError`. - * To attach attributes from a workflow body, wrap the call in a step - * yourself: - * - * ```ts - * async function setRunAttrs(attrs: Record) { - * 'use step'; - * await setAttributes(attrs); - * } - * - * export async function myWorkflow() { - * 'use workflow'; - * await setRunAttrs({ phase: 'init' }); - * } - * ``` - * - * The full Workflow Attributes feature (5.0.0) lifts this restriction by - * dispatching via `attr_set` events through the workflow controller; the - * SDK signature stays the same, so user code that wraps in a step today - * keeps working after the cutover. + * **EXPERIMENTAL.** Callable from both a workflow body and a step body. + * Workflow-body calls dispatch through an internal step bridge so the + * mutation is recorded in the event log; step-body calls hit the world + * adapter directly. * * Validation runs client-side; violations throw `FatalError`. An empty - * record is a no-op (no RPC). - * - * `value: undefined` removes the key from the run's attribute map. + * record is a no-op (no RPC). `value: undefined` removes the key from + * the run's attribute map. * * @example * ```ts @@ -57,28 +26,27 @@ function warnUnsupportedWorldOnce(worldName?: string): void { export async function setAttributes( attrs: Record ): Promise { - // Detect workflow-body context first so we can surface a clear error - // instead of silently failing inside the VM. (`WORKFLOW_CONTEXT_SYMBOL` - // is set by the host on the VM's globalThis during workflow eval.) const workflowCtx = (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] as | { workflowRunId?: string } | undefined; const stepCtx = contextStorage.getStore(); + // Workflow-body invocation: this module is loaded in step/host bundles, + // so reaching here from the VM means the workflow-side + // `set-attributes` shim mistakenly resolved to this file. Surface a + // clear error rather than silently misbehaving. if (workflowCtx && !stepCtx) { throw new FatalError( - 'setAttributes() can only be called from a step body in the V5 MVP. ' + - "Wrap it: `async function setAttrs(a) { 'use step'; await setAttributes(a); }` " + - 'and call that from your workflow. The 5.0.0 attributes feature removes ' + - 'this restriction; see the attributes-mvp changelog entry.' + 'setAttributes(): unexpected workflow-VM invocation of the step-side helper. ' + + 'This indicates a bundling misconfiguration — the workflow VM should resolve ' + + '`setAttributes` through `@workflow/core/_workflow/set-attributes`.' ); } const changes = normalizeSetAttributesInput(attrs); if (!changes) return; - const runId = stepCtx?.workflowMetadata?.workflowRunId; - if (!runId) { + if (!stepCtx?.workflowMetadata?.workflowRunId) { throwNotInWorkflowOrStepContext( 'setAttributes()', 'https://workflow-sdk.dev/docs/api-reference/workflow/set-attributes', @@ -86,10 +54,5 @@ export async function setAttributes( ); } - const world = await getWorldLazy(); - if (typeof world.runs.experimentalSetAttributes !== 'function') { - warnUnsupportedWorldOnce((world as any)?.name); - return; - } - await world.runs.experimentalSetAttributes(runId, changes); + await applySetAttributesChanges(changes); } diff --git a/packages/core/src/step-set-attributes.ts b/packages/core/src/step-set-attributes.ts new file mode 100644 index 0000000000..8e6f2d7ca0 --- /dev/null +++ b/packages/core/src/step-set-attributes.ts @@ -0,0 +1,48 @@ +import type { AttributeChange } from '@workflow/world'; +import { getWorldLazy } from './runtime/get-world-lazy.js'; +import { contextStorage } from './step/context-storage.js'; + +let unsupportedWorldWarned = false; + +function warnUnsupportedWorldOnce(worldName?: string): void { + if (unsupportedWorldWarned) return; + unsupportedWorldWarned = true; + // biome-ignore lint/suspicious/noConsole: surface in user terminals + console.warn( + `[workflow] setAttributes: the current world implementation${ + worldName ? ` (${worldName})` : '' + } does not implement experimentalSetAttributes; this call (and any subsequent setAttributes calls in this process) is a no-op. Attributes will become available once the world adapter adds support.` + ); +} + +/** + * Host-side helper that applies a pre-normalized list of attribute + * changes to the current run via the world adapter. Used by both the + * step-body `setAttributes` entrypoint and the `__builtin_set_attributes` + * step bridge that workflow-body calls dispatch into. + * + * Reads the active run id from the step context (`contextStorage`), so + * it must be called from within a step body — either directly by the + * step-side `setAttributes`, or indirectly via the builtin step + * dispatched from a workflow body. + * + * @internal + */ +export async function applySetAttributesChanges( + changes: AttributeChange[] +): Promise { + if (changes.length === 0) return; + const stepCtx = contextStorage.getStore(); + const runId = stepCtx?.workflowMetadata?.workflowRunId; + if (!runId) { + throw new Error( + 'applySetAttributesChanges() called outside a step context' + ); + } + const world = await getWorldLazy(); + if (typeof world.runs.experimentalSetAttributes !== 'function') { + warnUnsupportedWorldOnce((world as { name?: string })?.name); + return; + } + await world.runs.experimentalSetAttributes(runId, changes); +} diff --git a/packages/core/src/symbols.ts b/packages/core/src/symbols.ts index 6be64a42d3..5da5c971d1 100644 --- a/packages/core/src/symbols.ts +++ b/packages/core/src/symbols.ts @@ -1,6 +1,7 @@ export const WORKFLOW_USE_STEP = Symbol.for('WORKFLOW_USE_STEP'); export const WORKFLOW_CREATE_HOOK = Symbol.for('WORKFLOW_CREATE_HOOK'); export const WORKFLOW_SLEEP = Symbol.for('WORKFLOW_SLEEP'); +export const WORKFLOW_SET_ATTRIBUTES = Symbol.for('WORKFLOW_SET_ATTRIBUTES'); export const WORKFLOW_CONTEXT = Symbol.for('WORKFLOW_CONTEXT'); export const WORKFLOW_GET_STREAM_ID = Symbol.for('WORKFLOW_GET_STREAM_ID'); export const STABLE_ULID = Symbol.for('WORKFLOW_STABLE_ULID'); diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 43fc54566e..2d4eb3fce6 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -29,6 +29,7 @@ import { STABLE_ULID, WORKFLOW_CREATE_HOOK, WORKFLOW_GET_STREAM_ID, + WORKFLOW_SET_ATTRIBUTES, WORKFLOW_SLEEP, WORKFLOW_USE_STEP, } from './symbols.js'; @@ -233,6 +234,15 @@ export async function runWorkflow( // @ts-expect-error - `@types/node` says symbol is not valid, but it does work vmGlobalThis[WORKFLOW_GET_STREAM_ID] = (namespace?: string) => getWorkflowRunStreamId(workflowRun.runId, namespace); + // Workflow-body `setAttributes` dispatches through the + // `__builtin_set_attributes` step. Pre-bind a useStep handle so the + // VM-side helper just has a callable function to invoke. See + // `packages/workflow/src/internal/builtins.ts` for the step body. + // @ts-expect-error - `@types/node` says symbol is not valid, but it does work + vmGlobalThis[WORKFLOW_SET_ATTRIBUTES] = useStep< + [Array<{ key: string; value: string | null }>], + void + >('__builtin_set_attributes'); // TODO: there should be a getUrl method on the world interface itself. This // solution only works for vercel + local worlds. diff --git a/packages/core/src/workflow/set-attributes.test.ts b/packages/core/src/workflow/set-attributes.test.ts new file mode 100644 index 0000000000..13b3b92ba1 --- /dev/null +++ b/packages/core/src/workflow/set-attributes.test.ts @@ -0,0 +1,69 @@ +import { FatalError } from '@workflow/errors'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WORKFLOW_SET_ATTRIBUTES } from '../symbols.js'; +import { setAttributes } from './set-attributes.js'; + +describe('workflow.setAttributes', () => { + const dispatchCalls: Array> = []; + + beforeEach(() => { + dispatchCalls.length = 0; + (globalThis as Record)[WORKFLOW_SET_ATTRIBUTES] = vi.fn( + async (changes: Array<{ key: string; value: string | null }>) => { + dispatchCalls.push(changes); + } + ); + }); + + afterEach(() => { + delete (globalThis as Record)[WORKFLOW_SET_ATTRIBUTES]; + }); + + it('dispatches normalized changes to the host-side step bridge', async () => { + await setAttributes({ phase: 'init', orderId: 'ord_1' }); + expect(dispatchCalls).toEqual([ + [ + { key: 'phase', value: 'init' }, + { key: 'orderId', value: 'ord_1' }, + ], + ]); + }); + + it('translates undefined values into null (unset semantics)', async () => { + await setAttributes({ phase: 'done', stale: undefined }); + expect(dispatchCalls).toEqual([ + [ + { key: 'phase', value: 'done' }, + { key: 'stale', value: null }, + ], + ]); + }); + + it('is a no-op for an empty record (no dispatch)', async () => { + await setAttributes({}); + expect(dispatchCalls).toHaveLength(0); + }); + + it('throws FatalError when the host has not initialized the bridge', async () => { + delete (globalThis as Record)[WORKFLOW_SET_ATTRIBUTES]; + await expect(setAttributes({ phase: 'init' })).rejects.toBeInstanceOf( + FatalError + ); + }); + + it('throws FatalError for reserved-prefix keys before any dispatch', async () => { + await expect(setAttributes({ $sys: 'x' })).rejects.toBeInstanceOf( + FatalError + ); + expect(dispatchCalls).toHaveLength(0); + }); + + it('throws FatalError when called with a non-object', async () => { + await expect( + setAttributes(null as unknown as Record) + ).rejects.toBeInstanceOf(FatalError); + await expect( + setAttributes([] as unknown as Record) + ).rejects.toBeInstanceOf(FatalError); + }); +}); diff --git a/packages/core/src/workflow/set-attributes.ts b/packages/core/src/workflow/set-attributes.ts index 336dfee852..15821da031 100644 --- a/packages/core/src/workflow/set-attributes.ts +++ b/packages/core/src/workflow/set-attributes.ts @@ -1,41 +1,43 @@ import { FatalError } from '@workflow/errors'; +import type { AttributeChange } from '@workflow/world'; +import { normalizeSetAttributesInput } from '../set-attributes-shared.js'; +import { WORKFLOW_SET_ATTRIBUTES } from '../symbols.js'; /** - * Workflow-VM-side `setAttributes` for V5 MVP. + * Workflow-VM-side `setAttributes`. Validates the input on the VM side + * (cheap, deterministic) and then dispatches the canonical + * `AttributeChange[]` through the host's `__builtin_set_attributes` + * step bridge — registered on `globalThis` under `WORKFLOW_SET_ATTRIBUTES` + * by the workflow runtime. The actual world call happens inside that + * step, which gives the mutation an event-log entry (`step_created` → + * `step_completed`) just like any other step. * - * In V5 MVP, `setAttributes` is restricted to **step bodies** — calling - * it from inside a workflow body throws `FatalError`. The restriction - * exists because dispatching the call through the workflow controller - * requires either a `'use step'`-tagged helper inside `@workflow/core` - * (which trips the deferred-entry discoverer in `nextjs-webpack` dev - * mode by adding host-side world adapters to the step-discovery graph) - * or host-side bridge wiring comparable in scope to `sleep`. Neither is - * worth the complexity for the MVP, given that wrapping a single line - * in a `'use step'` function in user code is trivial: + * Empty input is a no-op (no step dispatch). `value: undefined` removes + * the key from the run's attribute map. * + * @example * ```ts - * async function setAttrs(attrs: Record) { - * 'use step'; - * await setAttributes(attrs); - * } - * * export async function myWorkflow() { * 'use workflow'; - * await setAttrs({ phase: 'init' }); + * await setAttributes({ phase: 'init' }); + * // ... work ... + * await setAttributes({ phase: 'done' }); * } * ``` - * - * The full Workflow Attributes feature in 5.0.0 dispatches via - * `attr_set` events through the workflow controller and lifts this - * restriction. The SDK signature does not change. */ export async function setAttributes( - _attrs: Record + attrs: Record ): Promise { - throw new FatalError( - 'setAttributes() can only be called from a step body in the V5 MVP. ' + - "Wrap it: `async function setAttrs(a) { 'use step'; await setAttributes(a); }` " + - 'and call that from your workflow. The 5.0.0 attributes feature removes ' + - 'this restriction; see the attributes-mvp changelog entry.' - ); + const changes = normalizeSetAttributesInput(attrs); + if (!changes) return; + const dispatch = (globalThis as Record)[ + WORKFLOW_SET_ATTRIBUTES + ] as ((changes: AttributeChange[]) => Promise) | undefined; + if (!dispatch) { + throw new FatalError( + 'setAttributes() called outside a workflow runtime context. ' + + 'The workflow VM must be initialized before this function is invoked.' + ); + } + await dispatch(changes); } diff --git a/packages/web-shared/src/components/sidebar/attribute-panel.tsx b/packages/web-shared/src/components/sidebar/attribute-panel.tsx index ebe42eabb7..cf15f2f742 100644 --- a/packages/web-shared/src/components/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/components/sidebar/attribute-panel.tsx @@ -417,7 +417,7 @@ const attributeToDisplayFn: Record< projectId: (_value: unknown) => null, environment: (_value: unknown) => null, executionContext: (_value: unknown) => null, - // V5 attributes MVP — string-string metadata attached to the run. + // Attributes MVP — string-string metadata attached to the run. // Rendered as a JSON block; if empty/missing, hidden by the // hasDisplayContent gate above. attributes: (value: unknown) => { diff --git a/packages/workflow/src/internal/builtins.ts b/packages/workflow/src/internal/builtins.ts index 886686e50e..bb78f58cd0 100644 --- a/packages/workflow/src/internal/builtins.ts +++ b/packages/workflow/src/internal/builtins.ts @@ -20,3 +20,23 @@ export async function __builtin_response_text(this: Request | Response) { 'use step'; return this.text(); } + +/** + * Internal step bridge that lets workflow-body `setAttributes` dispatch + * the attribute change through the step queue. The workflow VM registers + * a `useStep('__builtin_set_attributes')` dispatcher under the + * `WORKFLOW_SET_ATTRIBUTES` global symbol; the workflow-side + * `setAttributes` validates input, then calls the dispatcher with + * canonical `{ key, value }[]` changes. This step runs in normal Node + * context with full world access and forwards to the same code path the + * step-body `setAttributes` uses. + */ +export async function __builtin_set_attributes( + changes: Array<{ key: string; value: string | null }> +) { + 'use step'; + const { applySetAttributesChanges } = await import( + '@workflow/core/_step-set-attributes' + ); + await applySetAttributesChanges(changes); +} diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index 83c9c29c05..932f0b1210 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -73,7 +73,7 @@ export const WorkflowRunBaseSchema = z.object({ * EXPERIMENTAL (MVP): runs created before this field landed read as * `undefined`. The full Workflow Attributes feature replaces the * direct-mutation MVP path with an event-sourced model — see the - * V5 attributes-mvp changelog entry. + * attributes-mvp changelog entry. */ attributes: z.record(z.string(), z.string()).optional(), expiredAt: z.coerce.date().optional(), diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index 41ed11de25..b227de36bb 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -11,6 +11,7 @@ import { getWritable, type RequestWithResponse, RetryableError, + setAttributes, sleep, } from 'workflow'; import { getHookByToken, getRun, Run, resumeHook, start } from 'workflow/api'; @@ -3169,3 +3170,41 @@ export async function writableForwardedFromStepWorkflow(payload: string) { const childRunId = await startChildWithStepWritable(payload); return { childRunId }; } + +////////////////////////////////////////////////////////// +// Workflow Attributes MVP — both contexts exercised end-to-end. + +async function tagStepPhase(phase: string) { + 'use step'; + await setAttributes({ phase }); + return phase; +} + +/** + * Calls `setAttributes` from inside a `'use step'` body, twice. The + * second call overwrites `phase` and the test verifies the final + * merged map matches what the world materialized on the run entity. + */ +export async function setAttributesFromStepWorkflow(input: number) { + 'use workflow'; + await tagStepPhase('init'); + const doubled = input * 2; + await tagStepPhase('done'); + return doubled; +} + +/** + * Calls `setAttributes` directly from the workflow body (no wrapping + * step). Exercises the `__builtin_set_attributes` step bridge wired + * through the `WORKFLOW_SET_ATTRIBUTES` symbol. The third call sets a + * key to `undefined` and the test verifies the key is absent from the + * final attribute map. + */ +export async function setAttributesFromWorkflowBodyWorkflow(input: number) { + 'use workflow'; + await setAttributes({ phase: 'init', source: 'workflow-body' }); + const tripled = input * 3; + await setAttributes({ phase: 'done' }); + await setAttributes({ source: undefined }); + return tripled; +} From 5083bb12fda6f7d8c7345e576d16e1d7e7eb9c4f Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 25 May 2026 10:29:35 +0200 Subject: [PATCH 11/26] chore(core): correct stale comment in set-attributes-shared 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) --- packages/core/src/set-attributes-shared.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/core/src/set-attributes-shared.ts b/packages/core/src/set-attributes-shared.ts index f9476fa777..9a7b5f1414 100644 --- a/packages/core/src/set-attributes-shared.ts +++ b/packages/core/src/set-attributes-shared.ts @@ -9,21 +9,16 @@ import { // the step/host bundle. It must NOT import anything that pulls in // `node:async_hooks` (e.g. `step/context-storage.ts`) or any other // Node-only module — the workflow VM bundle is built with a no-Node -// constraint, and an offending transitive import will fail the build -// with "You are attempting to use 'node:async_hooks' which is a Node.js -// module. Node.js modules are not available in workflow functions." +// constraint, and an offending transitive import will fail the build. // // It must ALSO not contain a `'use step'` directive: the deferred-entry // discoverer in `@workflow/next/builder-deferred.ts` walks transitive -// imports from `'use step'` files, and adding a step file to -// `@workflow/core/dist/` puts the host-side `world.ts` (and its -// transitive `@vercel/queue` + adapter imports) inside the workflow -// step-discovery graph. That triggers a stack overflow inside webpack -// dev mode's regex-based import extractor on tarball-installed -// deployments. The MVP works around this by performing the world -// dispatch from the SDK helper (step body only); workflow-body use -// throws FatalError — users must wrap the call in their own -// `'use step'` function. +// imports from `'use step'` files, and a step file inside +// `@workflow/core/dist/` puts host-side world adapters and +// `@vercel/queue` into the step-discovery graph, which has historically +// triggered a stack overflow inside webpack's regex-based extractor on +// tarball-installed deployments. The workflow-body step bridge lives +// in `packages/workflow/src/internal/builtins.ts` instead. /** * Validate and normalize a `setAttributes(record)` call. Returns the From 41ed14a49ede0be3069f67341ad974a90d7383ce Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 25 May 2026 11:05:59 +0200 Subject: [PATCH 12/26] fix(workflow): hide step bridge specifier from deferred-entries discoverer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `__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) --- packages/workflow/src/internal/builtins.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/workflow/src/internal/builtins.ts b/packages/workflow/src/internal/builtins.ts index bb78f58cd0..0db8820687 100644 --- a/packages/workflow/src/internal/builtins.ts +++ b/packages/workflow/src/internal/builtins.ts @@ -30,13 +30,23 @@ export async function __builtin_response_text(this: Request | Response) { * canonical `{ key, value }[]` changes. This step runs in normal Node * context with full world access and forwards to the same code path the * step-body `setAttributes` uses. + * + * NOTE: the `@workflow/core/_step-set-attributes` specifier is assembled + * at runtime so the Next.js deferred-entries discoverer can't follow it + * statically. If it did, the discovery walk would reach + * `step-set-attributes.ts` → world adapters → `@vercel/queue`, blowing + * up the regex-based extractor with a stack overflow on tarball- + * installed builds (same hazard documented in `set-attributes-shared.ts`). */ export async function __builtin_set_attributes( changes: Array<{ key: string; value: string | null }> ) { 'use step'; - const { applySetAttributesChanges } = await import( - '@workflow/core/_step-set-attributes' - ); + const specifier = ['@workflow/core', '_step-set-attributes'].join('/'); + const { applySetAttributesChanges } = (await import(specifier)) as { + applySetAttributesChanges: ( + changes: Array<{ key: string; value: string | null }> + ) => Promise; + }; await applySetAttributesChanges(changes); } From 5972c4f9b1b3f9f257567ae76c37b4be6bea1a36 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 25 May 2026 11:34:54 +0200 Subject: [PATCH 13/26] comments Signed-off-by: Peter Wielander --- packages/core/src/set-attributes-shared.ts | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/core/src/set-attributes-shared.ts b/packages/core/src/set-attributes-shared.ts index 9a7b5f1414..41dd0f2cea 100644 --- a/packages/core/src/set-attributes-shared.ts +++ b/packages/core/src/set-attributes-shared.ts @@ -5,27 +5,15 @@ import { validateAttributeChanges, } from '@workflow/world'; -// IMPORTANT: this module is imported by both the workflow-VM bundle and -// the step/host bundle. It must NOT import anything that pulls in -// `node:async_hooks` (e.g. `step/context-storage.ts`) or any other -// Node-only module — the workflow VM bundle is built with a no-Node -// constraint, and an offending transitive import will fail the build. -// -// It must ALSO not contain a `'use step'` directive: the deferred-entry -// discoverer in `@workflow/next/builder-deferred.ts` walks transitive -// imports from `'use step'` files, and a step file inside -// `@workflow/core/dist/` puts host-side world adapters and -// `@vercel/queue` into the step-discovery graph, which has historically -// triggered a stack overflow inside webpack's regex-based extractor on -// tarball-installed deployments. The workflow-body step bridge lives -// in `packages/workflow/src/internal/builtins.ts` instead. - /** * Validate and normalize a `setAttributes(record)` call. Returns the * canonical `AttributeChange[]` shape (with `undefined → null`) or * throws `FatalError` on a violation. Returns `null` if the input is * empty (callers must short-circuit without dispatching a step). * + * Note: this module is imported in VM context, so can't contain a `'use step'` directive, + * side-effects, or Node-only modules. + * * @internal */ export function normalizeSetAttributesInput( From e25730f65f515ca44e89b1fc145bc0b95e2e94c6 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 25 May 2026 11:39:18 +0200 Subject: [PATCH 14/26] comments Signed-off-by: Peter Wielander --- packages/core/src/set-attributes.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/set-attributes.ts b/packages/core/src/set-attributes.ts index b0d24a7df7..c6bb104df0 100644 --- a/packages/core/src/set-attributes.ts +++ b/packages/core/src/set-attributes.ts @@ -1,8 +1,8 @@ import { FatalError } from '@workflow/errors'; import { throwNotInWorkflowOrStepContext } from './context-errors.js'; import { normalizeSetAttributesInput } from './set-attributes-shared.js'; -import { applySetAttributesChanges } from './step-set-attributes.js'; import { contextStorage } from './step/context-storage.js'; +import { applySetAttributesChanges } from './step-set-attributes.js'; import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; /** @@ -13,6 +13,12 @@ import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; * mutation is recorded in the event log; step-body calls hit the world * adapter directly. * + * **WARNING**: While this features is experimental, calling e.g. + * `Promise.all([setAttributes({ a: '1' }), setAttributes({ a: '2' })])` + * is not guaranteed to be ordered consistently, but + * `await setAttributes({ a: '1' }).then(() => setAttributes({ a: '2' }))` + * is. + * * Validation runs client-side; violations throw `FatalError`. An empty * record is a no-op (no RPC). `value: undefined` removes the key from * the run's attribute map. From 860140b4afa6f6492e65c9752828dcda5cb97edd Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 25 May 2026 11:47:08 +0200 Subject: [PATCH 15/26] revert: restore static specifier for __builtin_set_attributes step bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 fbb0c598ca. 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) --- packages/workflow/src/internal/builtins.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/workflow/src/internal/builtins.ts b/packages/workflow/src/internal/builtins.ts index 0db8820687..bb78f58cd0 100644 --- a/packages/workflow/src/internal/builtins.ts +++ b/packages/workflow/src/internal/builtins.ts @@ -30,23 +30,13 @@ export async function __builtin_response_text(this: Request | Response) { * canonical `{ key, value }[]` changes. This step runs in normal Node * context with full world access and forwards to the same code path the * step-body `setAttributes` uses. - * - * NOTE: the `@workflow/core/_step-set-attributes` specifier is assembled - * at runtime so the Next.js deferred-entries discoverer can't follow it - * statically. If it did, the discovery walk would reach - * `step-set-attributes.ts` → world adapters → `@vercel/queue`, blowing - * up the regex-based extractor with a stack overflow on tarball- - * installed builds (same hazard documented in `set-attributes-shared.ts`). */ export async function __builtin_set_attributes( changes: Array<{ key: string; value: string | null }> ) { 'use step'; - const specifier = ['@workflow/core', '_step-set-attributes'].join('/'); - const { applySetAttributesChanges } = (await import(specifier)) as { - applySetAttributesChanges: ( - changes: Array<{ key: string; value: string | null }> - ) => Promise; - }; + const { applySetAttributesChanges } = await import( + '@workflow/core/_step-set-attributes' + ); await applySetAttributesChanges(changes); } From fb35e10236eae4ab1ea76b389144f9216dd8ff4e Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 25 May 2026 12:29:15 +0200 Subject: [PATCH 16/26] refactor(attributes): collapse setAttributes to workflow-body-only single dispatch path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/attributes-mvp-plan.md | 2 +- .../docs/v5/changelog/attributes-mvp.mdx | 55 ++--- packages/core/e2e/e2e.test.ts | 24 +-- packages/core/package.json | 6 +- packages/core/src/set-attributes-shared.ts | 43 ---- packages/core/src/set-attributes.test.ts | 194 ++---------------- packages/core/src/set-attributes.ts | 68 ++---- packages/core/src/step-set-attributes.ts | 48 ----- packages/core/src/symbols.ts | 1 - packages/core/src/workflow.ts | 18 +- .../core/src/workflow/set-attributes.test.ts | 46 +++-- packages/core/src/workflow/set-attributes.ts | 72 +++++-- packages/workflow/src/internal/builtins.ts | 55 +++-- workbench/example/workflows/99_e2e.ts | 33 +-- 14 files changed, 180 insertions(+), 485 deletions(-) delete mode 100644 packages/core/src/set-attributes-shared.ts delete mode 100644 packages/core/src/step-set-attributes.ts diff --git a/.changeset/attributes-mvp-plan.md b/.changeset/attributes-mvp-plan.md index 2f1055cb6a..fbe8426d1c 100644 --- a/.changeset/attributes-mvp-plan.md +++ b/.changeset/attributes-mvp-plan.md @@ -7,4 +7,4 @@ 'workflow': patch --- -Add experimental `setAttributes()` for attaching plaintext string key/value metadata to a workflow run from workflow or step code. +Add experimental `setAttributes()` for attaching plaintext string key/value metadata to a workflow run. Callable from a workflow body; the call is dispatched as a step so the mutation is recorded on the event log. diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx index 9cde70c386..225b344dc6 100644 --- a/docs/content/docs/v5/changelog/attributes-mvp.mdx +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -11,11 +11,12 @@ The MVP lets workflow and step code attach plaintext `string → string` metadat ## What MVP supports -- `setAttributes(record)` callable from a **step body** (`"use step"` function) -- `setAttributes(record)` callable from a **workflow body** (`"use workflow"` function), dispatched via an internal `__builtin_set_attributes` step bridge +- `setAttributes(record)` callable from a **workflow body** (`"use workflow"` function), dispatched via an internal `__builtin_set_attributes` step bridge so the mutation gets a `step_created → step_completed` event pair - Attributes are materialized onto the `WorkflowRun` entity, plaintext, and visible via `world.runs.get()` / `world.runs.list()` and any observability UI built on top of those - World implementations emit a side-channel observability record per write (in `world-vercel`, this hooks into the same observability/analytics pipeline already used for other run lifecycle events) +Calling `setAttributes` from a step body or plain host code is intentionally not supported in the MVP — the host-side export throws `FatalError` directing callers back to a workflow body. This keeps the implementation a single dispatch path; step-body support can be added later without breaking the workflow-body contract. + ## What MVP does **not** support (deferred to 5.0.0) - Reading attributes from inside a workflow or step (`getAttribute` / `getAttributes`) @@ -113,7 +114,7 @@ Validation (shared helper, applied both client-side and server-side): - Maximum 64 attributes per run (validated against the post-merge snapshot when the server applies) - Violations throw `FatalError` from `@workflow/errors` -`setAttributes` is callable from both contexts: +`setAttributes` is callable only from a workflow body: ```ts import { setAttributes } from 'workflow'; @@ -124,38 +125,15 @@ export async function myWorkflow(orderId: string) { // ... await setAttributes({ phase: 'done' }); } - -async function someStep() { - 'use step'; - await setAttributes({ source: 'step' }); -} ``` -The step-body path calls the world adapter directly. The workflow-body path validates input inside the VM and then dispatches the canonical `AttributeChange[]` through a host-side step bridge — see "How workflow-body dispatch works" below. Either path is materialized identically on the run entity. +The workflow-body path validates input inside the VM and then dispatches the canonical `AttributeChange[]` through an internal `__builtin_set_attributes` step bridge — see "How workflow-body dispatch works" below. The mutation is materialized on the run entity by the step body. -Context detection (step-side helper, after the workflow VM has resolved the import): `contextStorage.getStore()` returns the step context (provides the run ID). Calling outside both contexts throws `NotInWorkflowOrStepContextError`. The workflow VM resolves `setAttributes` to the workflow-side variant via package-exports condition matching, so the step-side helper is never reached from inside the VM. +The host-side export (the one resolved when the `workflow` package-exports condition is **not** `workflow`, i.e. step bodies and plain host code) throws `FatalError` with a message pointing callers back to the workflow body. There is no step-side dispatch path in the MVP. #### Optional world support -Because `runs.experimentalSetAttributes` is **optional** on the World interface (see §1), the SDK helper checks for its presence before dispatching: - -{/*@skip-typecheck - snippet, not runnable code*/} - -```ts -if (typeof world.runs.experimentalSetAttributes !== 'function') { - console.warn( - '[workflow] setAttributes: the current world implementation ' + - 'does not implement experimentalSetAttributes; this call is a no-op. ' + - 'Attributes will be available once the world adapter adds support.' - ); - return; -} -await world.runs.experimentalSetAttributes(runId, changes); -``` - -The no-op-with-warning behaviour fires on the step-body call path (the only supported path in MVP). User code does not need to feature-detect; calling `setAttributes` against an unsupporting world is safe but silently ineffective beyond the warning. - -The warning is emitted once per process (deduped) so high-frequency callers do not flood logs. +Because `runs.experimentalSetAttributes` is **optional** on the World interface (see §1), the `__builtin_set_attributes` step body checks for its presence before dispatching and silently no-ops when absent. User code does not need to feature-detect; calling `setAttributes` against an unsupporting world is safe but ineffective. ### 4. World implementations @@ -212,13 +190,12 @@ If you need behavior the MVP does not provide (read, list, filter, initial attri - Unit tests in `@workflow/core` for: - Validation rules (length, `$` prefix, post-merge count cap) - - `setAttributes({})` is a no-op (no RPC, no events) + - `setAttributes({})` is a no-op (no dispatch, no events) - `undefined` value normalizes to a `null`-valued change on the wire - - Calling from neither context throws - - World without `experimentalSetAttributes`: warning is emitted once, call resolves without dispatching, no events are written + - Workflow VM with no `useStep` bound (= called outside a workflow) throws `FatalError` + - Host-side stub (resolved from step or plain code) throws `FatalError` - `world-local` integration tests: - `setAttributes` from a workflow body materializes onto the run row - - `setAttributes` from a step body materializes onto the run row - Merge semantics: setting + unsetting in a single call leaves the run with the expected snapshot - Idempotency: repeated identical calls converge to the same final state - `world-postgres` integration tests: same shape as `world-local` @@ -250,14 +227,16 @@ This section records concrete decisions taken while landing the MVP that weren't `setAttributes` from a workflow body is wired through an internal built-in step, `__builtin_set_attributes`, defined in `packages/workflow/src/internal/builtins.ts` alongside the other workflow-side builtins (`__builtin_response_json`, etc.). The mechanism: 1. The workflow VM bundle resolves `setAttributes` to `packages/core/src/workflow/set-attributes.ts` (via the `workflow` package-exports condition). -2. That helper runs the shared validator on the input record and produces canonical `AttributeChange[]`. -3. It then calls a pre-bound `useStep('__builtin_set_attributes')` dispatcher stashed on `globalThis` under `WORKFLOW_SET_ATTRIBUTES` by the workflow runtime (in `packages/core/src/workflow.ts`, alongside the existing `WORKFLOW_USE_STEP` / `WORKFLOW_SLEEP` registrations). -4. The dispatch queues a step (`step_created`), the host runs `__builtin_set_attributes(changes)`, which loads `@workflow/core/_step-set-attributes` and calls `applySetAttributesChanges(changes)` against the active world. +2. That helper validates the input record inline (no shared helper, no cross-file dependency from a 'use step' file) and produces canonical `AttributeChange[]`. +3. It dispatches through the standard workflow-VM step mechanism: `globalThis[WORKFLOW_USE_STEP]('__builtin_set_attributes')(changes)`. The `useStep` dispatcher is the same one used by every other step call from a workflow body, populated by `packages/core/src/workflow.ts` at VM bootstrap. +4. The dispatch queues a step (`step_created`), the host runs `__builtin_set_attributes(changes)` from `packages/workflow/src/internal/builtins.ts`. The step body reads the active world and current run id directly from `globalThis` symbols (`Symbol.for('@workflow/world//cache')` and `Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')`) — populated by the host runtime — and calls `world.runs.experimentalSetAttributes(runId, changes)`. 5. The step completes (`step_completed`), the workflow resumes. -This puts the mutation on the event log as a normal `step_created → step_completed` pair without inventing a new event type — that stays for the full 5.0.0 cutover. The host-side dispatch helper (`applySetAttributesChanges`) is shared with the direct step-body path, so the two contexts converge on a single world call. +This puts the mutation on the event log as a normal `step_created → step_completed` pair without inventing a new event type — that stays for the full 5.0.0 cutover. + +The step body intentionally does **not** import anything from `@workflow/core`. That keeps the Next.js deferred-entries discoverer from walking a `__builtin_set_attributes` → `@workflow/core/...` → world adapter → `@vercel/queue` chain, which earlier drafts triggered (blowing the call stack of webpack's regex-based extractor with `RangeError: Maximum call stack size exceeded at RegExpStringIterator.next` on tarball-installed `nextjs-webpack` builds). -The earlier draft tried to host the `'use step'` directive in `packages/core/src/set-attributes.ts` directly, which trips webpack's deferred-entry discoverer on `nextjs-webpack` tarball deployments (the discoverer walks transitive imports and pulls `@vercel/queue` + adapter imports into the step-discovery graph, blowing the call stack of webpack's regex-based extractor with `RangeError: Maximum call stack size exceeded at RegExpStringIterator.next`). Putting the directive in `packages/workflow/src/internal/builtins.ts` instead — i.e. inside the umbrella `workflow` package next to the existing `__builtin_response_*` steps, not `@workflow/core/dist/` — sidesteps that hazard because the builtins file is already a step-bundle entry and the discoverer doesn't graft its transitive imports onto the workflow-VM bundle graph. +The host-side `setAttributes` export (`packages/core/src/set-attributes.ts`, resolved by everything that isn't the workflow VM) throws `FatalError` with a message pointing the caller back to a workflow body. Step-body support can be added in a follow-up without changing this contract. When the full 5.0.0 attributes feature lands, `__builtin_set_attributes` is replaced by an `events.create(runId, { eventType: 'attr_set', ... })` dispatch path; SDK signatures don't change. diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index c69095461a..6d3f60b63b 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -3437,30 +3437,10 @@ describe('e2e', () => { describe('setAttributes', () => { test( - 'setAttributesFromStepWorkflow: step-body calls land on the run entity', + 'setAttributesWorkflow: workflow-body calls dispatch through the step bridge and merge correctly', { timeout: 30_000 }, async () => { - const run = await start( - await e2e('setAttributesFromStepWorkflow'), - [5] - ); - const output = await run.returnValue; - expect(output).toBe(10); - - const world = await getWorld(); - const persisted = await world.runs.get(run.runId); - expect(persisted?.attributes).toEqual({ phase: 'done' }); - } - ); - - test( - 'setAttributesFromWorkflowBodyWorkflow: workflow-body calls dispatch through the step bridge and merge correctly', - { timeout: 30_000 }, - async () => { - const run = await start( - await e2e('setAttributesFromWorkflowBodyWorkflow'), - [7] - ); + const run = await start(await e2e('setAttributesWorkflow'), [7]); const output = await run.returnValue; expect(output).toBe(21); diff --git a/packages/core/package.json b/packages/core/package.json index ff3095c0ac..2599e3fdc0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -73,11 +73,7 @@ "types": "./dist/describe-error.d.ts", "default": "./dist/describe-error.js" }, - "./_workflow": "./dist/workflow/index.js", - "./_step-set-attributes": { - "types": "./dist/step-set-attributes.d.ts", - "default": "./dist/step-set-attributes.js" - } + "./_workflow": "./dist/workflow/index.js" }, "scripts": { "build": "genversion --es6 src/version.ts && tsc", diff --git a/packages/core/src/set-attributes-shared.ts b/packages/core/src/set-attributes-shared.ts deleted file mode 100644 index 41dd0f2cea..0000000000 --- a/packages/core/src/set-attributes-shared.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { FatalError } from '@workflow/errors'; -import type { AttributeChange } from '@workflow/world'; -import { - AttributeValidationError, - validateAttributeChanges, -} from '@workflow/world'; - -/** - * Validate and normalize a `setAttributes(record)` call. Returns the - * canonical `AttributeChange[]` shape (with `undefined → null`) or - * throws `FatalError` on a violation. Returns `null` if the input is - * empty (callers must short-circuit without dispatching a step). - * - * Note: this module is imported in VM context, so can't contain a `'use step'` directive, - * side-effects, or Node-only modules. - * - * @internal - */ -export function normalizeSetAttributesInput( - attrs: Record -): AttributeChange[] | null { - if (attrs === null || typeof attrs !== 'object' || Array.isArray(attrs)) { - throw new FatalError( - `setAttributes requires a plain object, got ${attrs === null ? 'null' : Array.isArray(attrs) ? 'array' : typeof attrs}` - ); - } - const changes: AttributeChange[] = Object.entries(attrs).map( - ([key, value]) => ({ - key, - value: value === undefined ? null : value, - }) - ); - if (changes.length === 0) return null; - try { - validateAttributeChanges(changes); - } catch (err) { - if (err instanceof AttributeValidationError) { - throw new FatalError(err.message); - } - throw err; - } - return changes; -} diff --git a/packages/core/src/set-attributes.test.ts b/packages/core/src/set-attributes.test.ts index 9b16994a75..f365c503e3 100644 --- a/packages/core/src/set-attributes.test.ts +++ b/packages/core/src/set-attributes.test.ts @@ -1,185 +1,19 @@ import { FatalError } from '@workflow/errors'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { NotInWorkflowOrStepContextError } from './context-errors.js'; +import { describe, expect, it } from 'vitest'; import { setAttributes } from './set-attributes.js'; -import { contextStorage } from './step/context-storage.js'; -import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; -// `setAttributes` resolves the World via `getWorldLazy`. We mock that -// so tests don't try to load the real world initializer chain (which -// pulls in world-local / world-vercel). -const dispatchCalls: Array<{ runId: string; changes: any[] }> = []; - -vi.mock('./runtime/get-world-lazy.js', () => ({ - getWorldLazy: vi.fn(async () => mockedWorld), -})); - -let supportsAttributes = true; -const mockedWorld: any = { - runs: { - get experimentalSetAttributes() { - if (!supportsAttributes) return undefined; - return async (runId: string, changes: any[]) => { - dispatchCalls.push({ runId, changes }); - return { attributes: {} }; - }; - }, - }, -}; - -function withWorkflowContext(runId: string, fn: () => T): T { - (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] = { workflowRunId: runId }; - try { - return fn(); - } finally { - delete (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL]; - } -} - -function withStepContext(runId: string, fn: () => Promise): Promise { - return contextStorage.run( - { - stepMetadata: {} as any, - workflowMetadata: { - workflowRunId: runId, - } as any, - ops: [], - }, - fn - ); -} - -describe('setAttributes', () => { - beforeEach(() => { - dispatchCalls.length = 0; - supportsAttributes = true; - }); - - afterEach(() => { - delete (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL]; - }); - - describe('context detection', () => { - it('dispatches when called inside a step context', async () => { - await withStepContext('wrun_step', async () => { - await setAttributes({ phase: 'init' }); - }); - expect(dispatchCalls).toEqual([ - { runId: 'wrun_step', changes: [{ key: 'phase', value: 'init' }] }, - ]); - }); - - it('throws FatalError when the step-side helper is reached from a workflow body (indicates bundling misconfig)', async () => { - // Reaching the step-side `setAttributes` from inside the workflow - // VM is a bundling failure — the VM should resolve to the - // workflow-side variant. Surface a clear error rather than - // silently misbehaving. - await expect( - withWorkflowContext('wrun_workflow', () => - setAttributes({ phase: 'init' }) - ) - ).rejects.toBeInstanceOf(FatalError); - expect(dispatchCalls).toHaveLength(0); - }); - - it('throws NotInWorkflowOrStepContextError when called outside both', async () => { - await expect(setAttributes({ phase: 'init' })).rejects.toBeInstanceOf( - NotInWorkflowOrStepContextError - ); - }); - }); - - describe('normalization', () => { - it('translates undefined values into null on the wire (unset)', async () => { - await withStepContext('wrun_x', async () => { - await setAttributes({ phase: 'done', stale: undefined }); - }); - expect(dispatchCalls).toEqual([ - { - runId: 'wrun_x', - changes: [ - { key: 'phase', value: 'done' }, - { key: 'stale', value: null }, - ], - }, - ]); - }); - - it('is a no-op for an empty record (no dispatch, no warning)', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - await withStepContext('wrun_x', async () => { - await setAttributes({}); - }); - expect(dispatchCalls).toHaveLength(0); - expect(warn).not.toHaveBeenCalled(); - } finally { - warn.mockRestore(); - } - }); - }); - - describe('validation', () => { - it('throws FatalError for keys starting with reserved prefix', async () => { - await withStepContext('wrun_x', async () => { - await expect(setAttributes({ $sys: 'x' })).rejects.toBeInstanceOf( - FatalError - ); - }); - expect(dispatchCalls).toHaveLength(0); - }); - - it('throws FatalError for empty keys', async () => { - await withStepContext('wrun_x', async () => { - await expect(setAttributes({ '': 'x' })).rejects.toBeInstanceOf( - FatalError - ); - }); - }); - - it('throws FatalError for values exceeding 256 bytes', async () => { - await withStepContext('wrun_x', async () => { - await expect( - setAttributes({ k: 'a'.repeat(257) }) - ).rejects.toBeInstanceOf(FatalError); - }); - }); - - it('throws FatalError when called with a non-object', async () => { - await withStepContext('wrun_x', async () => { - await expect( - setAttributes(null as unknown as Record) - ).rejects.toBeInstanceOf(FatalError); - await expect( - setAttributes('hi' as unknown as Record) - ).rejects.toBeInstanceOf(FatalError); - await expect( - setAttributes([] as unknown as Record) - ).rejects.toBeInstanceOf(FatalError); - }); - }); - }); - - describe('feature detection', () => { - it('no-ops with a single warning when world lacks experimentalSetAttributes', async () => { - supportsAttributes = false; - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - await withStepContext('wrun_x', async () => { - await setAttributes({ phase: 'init' }); - await setAttributes({ phase: 'done' }); - await setAttributes({ tenant: 't1' }); - }); - expect(dispatchCalls).toHaveLength(0); - // Single warning across multiple unsupported calls — the helper - // dedupes so callers don't flood logs. - expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0]?.[0]).toContain( - 'does not implement experimentalSetAttributes' - ); - } finally { - warn.mockRestore(); - } - }); +describe('setAttributes (host-side stub)', () => { + // The host-side `setAttributes` is the fallback resolved when callers + // are NOT in the workflow VM. The real implementation lives in + // `workflow/set-attributes.ts` and is selected via the `workflow` + // package-exports condition. Reaching this file from a step body or + // plain host code is unsupported and must surface a clear error. + it('throws FatalError telling the user setAttributes is workflow-body only', async () => { + await expect(setAttributes({ phase: 'init' })).rejects.toBeInstanceOf( + FatalError + ); + await expect(setAttributes({ phase: 'init' })).rejects.toThrow( + /workflow.*function/i + ); }); }); diff --git a/packages/core/src/set-attributes.ts b/packages/core/src/set-attributes.ts index c6bb104df0..8888fd6cad 100644 --- a/packages/core/src/set-attributes.ts +++ b/packages/core/src/set-attributes.ts @@ -1,64 +1,20 @@ import { FatalError } from '@workflow/errors'; -import { throwNotInWorkflowOrStepContext } from './context-errors.js'; -import { normalizeSetAttributesInput } from './set-attributes-shared.js'; -import { contextStorage } from './step/context-storage.js'; -import { applySetAttributesChanges } from './step-set-attributes.js'; -import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; /** - * Attach plaintext string key/value metadata to the current workflow run. + * Host-side stub for `setAttributes`. The real implementation lives in + * `./workflow/set-attributes.ts` and is selected by the `workflow` + * package-exports condition when the workflow VM bundle is resolved. * - * **EXPERIMENTAL.** Callable from both a workflow body and a step body. - * Workflow-body calls dispatch through an internal step bridge so the - * mutation is recorded in the event log; step-body calls hit the world - * adapter directly. - * - * **WARNING**: While this features is experimental, calling e.g. - * `Promise.all([setAttributes({ a: '1' }), setAttributes({ a: '2' })])` - * is not guaranteed to be ordered consistently, but - * `await setAttributes({ a: '1' }).then(() => setAttributes({ a: '2' }))` - * is. - * - * Validation runs client-side; violations throw `FatalError`. An empty - * record is a no-op (no RPC). `value: undefined` removes the key from - * the run's attribute map. - * - * @example - * ```ts - * await setAttributes({ phase: 'processing', orderId: 'ord_123' }); - * await setAttributes({ orderId: undefined }); // remove - * ``` + * Reaching this stub means `setAttributes` was called outside a workflow + * body — most likely from a `'use step'` function or plain host code. + * That isn't supported: attribute mutations must be event-sourced + * through the workflow runtime so they survive replay. */ export async function setAttributes( - attrs: Record + _attrs: Record ): Promise { - const workflowCtx = (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] as - | { workflowRunId?: string } - | undefined; - const stepCtx = contextStorage.getStore(); - - // Workflow-body invocation: this module is loaded in step/host bundles, - // so reaching here from the VM means the workflow-side - // `set-attributes` shim mistakenly resolved to this file. Surface a - // clear error rather than silently misbehaving. - if (workflowCtx && !stepCtx) { - throw new FatalError( - 'setAttributes(): unexpected workflow-VM invocation of the step-side helper. ' + - 'This indicates a bundling misconfiguration — the workflow VM should resolve ' + - '`setAttributes` through `@workflow/core/_workflow/set-attributes`.' - ); - } - - const changes = normalizeSetAttributesInput(attrs); - if (!changes) return; - - if (!stepCtx?.workflowMetadata?.workflowRunId) { - throwNotInWorkflowOrStepContext( - 'setAttributes()', - 'https://workflow-sdk.dev/docs/api-reference/workflow/set-attributes', - setAttributes - ); - } - - await applySetAttributesChanges(changes); + throw new FatalError( + "setAttributes() must be called from a 'use workflow' function. " + + 'Calling it from a step body or plain host code is not supported.' + ); } diff --git a/packages/core/src/step-set-attributes.ts b/packages/core/src/step-set-attributes.ts deleted file mode 100644 index 8e6f2d7ca0..0000000000 --- a/packages/core/src/step-set-attributes.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { AttributeChange } from '@workflow/world'; -import { getWorldLazy } from './runtime/get-world-lazy.js'; -import { contextStorage } from './step/context-storage.js'; - -let unsupportedWorldWarned = false; - -function warnUnsupportedWorldOnce(worldName?: string): void { - if (unsupportedWorldWarned) return; - unsupportedWorldWarned = true; - // biome-ignore lint/suspicious/noConsole: surface in user terminals - console.warn( - `[workflow] setAttributes: the current world implementation${ - worldName ? ` (${worldName})` : '' - } does not implement experimentalSetAttributes; this call (and any subsequent setAttributes calls in this process) is a no-op. Attributes will become available once the world adapter adds support.` - ); -} - -/** - * Host-side helper that applies a pre-normalized list of attribute - * changes to the current run via the world adapter. Used by both the - * step-body `setAttributes` entrypoint and the `__builtin_set_attributes` - * step bridge that workflow-body calls dispatch into. - * - * Reads the active run id from the step context (`contextStorage`), so - * it must be called from within a step body — either directly by the - * step-side `setAttributes`, or indirectly via the builtin step - * dispatched from a workflow body. - * - * @internal - */ -export async function applySetAttributesChanges( - changes: AttributeChange[] -): Promise { - if (changes.length === 0) return; - const stepCtx = contextStorage.getStore(); - const runId = stepCtx?.workflowMetadata?.workflowRunId; - if (!runId) { - throw new Error( - 'applySetAttributesChanges() called outside a step context' - ); - } - const world = await getWorldLazy(); - if (typeof world.runs.experimentalSetAttributes !== 'function') { - warnUnsupportedWorldOnce((world as { name?: string })?.name); - return; - } - await world.runs.experimentalSetAttributes(runId, changes); -} diff --git a/packages/core/src/symbols.ts b/packages/core/src/symbols.ts index 5da5c971d1..6be64a42d3 100644 --- a/packages/core/src/symbols.ts +++ b/packages/core/src/symbols.ts @@ -1,7 +1,6 @@ export const WORKFLOW_USE_STEP = Symbol.for('WORKFLOW_USE_STEP'); export const WORKFLOW_CREATE_HOOK = Symbol.for('WORKFLOW_CREATE_HOOK'); export const WORKFLOW_SLEEP = Symbol.for('WORKFLOW_SLEEP'); -export const WORKFLOW_SET_ATTRIBUTES = Symbol.for('WORKFLOW_SET_ATTRIBUTES'); export const WORKFLOW_CONTEXT = Symbol.for('WORKFLOW_CONTEXT'); export const WORKFLOW_GET_STREAM_ID = Symbol.for('WORKFLOW_GET_STREAM_ID'); export const STABLE_ULID = Symbol.for('WORKFLOW_STABLE_ULID'); diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 2d4eb3fce6..0990f8d573 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -6,7 +6,6 @@ import { WorkflowRuntimeError, } from '@workflow/errors'; import { withResolvers } from '@workflow/utils'; -import { getPortLazy } from './runtime/get-port-lazy.js'; import { parseWorkflowName } from '@workflow/utils/parse-name'; import type { Event, WorkflowRun } from '@workflow/world'; import * as nanoid from 'nanoid'; @@ -16,9 +15,10 @@ import { EventConsumerResult, EventsConsumer } from './events-consumer.js'; import type { QueueItem } from './global.js'; import { ENOTSUP, WorkflowSuspension } from './global.js'; import { runtimeLogger } from './logger.js'; +import type { WorkflowOrchestratorContext } from './private.js'; +import { getPortLazy } from './runtime/get-port-lazy.js'; import { handleSuspension } from './runtime/suspension-handler.js'; import { getWorld } from './runtime/world.js'; -import type { WorkflowOrchestratorContext } from './private.js'; import { dehydrateWorkflowReturnValue, hydrateWorkflowArguments, @@ -29,7 +29,6 @@ import { STABLE_ULID, WORKFLOW_CREATE_HOOK, WORKFLOW_GET_STREAM_ID, - WORKFLOW_SET_ATTRIBUTES, WORKFLOW_SLEEP, WORKFLOW_USE_STEP, } from './symbols.js'; @@ -37,12 +36,12 @@ import * as Attribute from './telemetry/semantic-conventions.js'; import { trace } from './telemetry.js'; import { getWorkflowRunStreamId } from './util.js'; import { createContext } from './vm/index.js'; -import type { WorkflowMetadata } from './workflow/get-workflow-metadata.js'; -import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; import { createAbortSignalStatics, createCreateAbortController, } from './workflow/abort-controller.js'; +import type { WorkflowMetadata } from './workflow/get-workflow-metadata.js'; +import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js'; import { createCreateHook } from './workflow/hook.js'; import { createSleep } from './workflow/sleep.js'; @@ -234,15 +233,6 @@ export async function runWorkflow( // @ts-expect-error - `@types/node` says symbol is not valid, but it does work vmGlobalThis[WORKFLOW_GET_STREAM_ID] = (namespace?: string) => getWorkflowRunStreamId(workflowRun.runId, namespace); - // Workflow-body `setAttributes` dispatches through the - // `__builtin_set_attributes` step. Pre-bind a useStep handle so the - // VM-side helper just has a callable function to invoke. See - // `packages/workflow/src/internal/builtins.ts` for the step body. - // @ts-expect-error - `@types/node` says symbol is not valid, but it does work - vmGlobalThis[WORKFLOW_SET_ATTRIBUTES] = useStep< - [Array<{ key: string; value: string | null }>], - void - >('__builtin_set_attributes'); // TODO: there should be a getUrl method on the world interface itself. This // solution only works for vercel + local worlds. diff --git a/packages/core/src/workflow/set-attributes.test.ts b/packages/core/src/workflow/set-attributes.test.ts index 13b3b92ba1..e3484d1023 100644 --- a/packages/core/src/workflow/set-attributes.test.ts +++ b/packages/core/src/workflow/set-attributes.test.ts @@ -1,41 +1,51 @@ import { FatalError } from '@workflow/errors'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { WORKFLOW_SET_ATTRIBUTES } from '../symbols.js'; +import { WORKFLOW_USE_STEP } from '../symbols.js'; import { setAttributes } from './set-attributes.js'; describe('workflow.setAttributes', () => { - const dispatchCalls: Array> = []; + const dispatchCalls: Array<{ + stepName: string; + changes: Array<{ key: string; value: string | null }>; + }> = []; beforeEach(() => { dispatchCalls.length = 0; - (globalThis as Record)[WORKFLOW_SET_ATTRIBUTES] = vi.fn( - async (changes: Array<{ key: string; value: string | null }>) => { - dispatchCalls.push(changes); - } + (globalThis as Record)[WORKFLOW_USE_STEP] = vi.fn( + (stepName: string) => + async (changes: Array<{ key: string; value: string | null }>) => { + dispatchCalls.push({ stepName, changes }); + } ); }); afterEach(() => { - delete (globalThis as Record)[WORKFLOW_SET_ATTRIBUTES]; + delete (globalThis as Record)[WORKFLOW_USE_STEP]; }); - it('dispatches normalized changes to the host-side step bridge', async () => { + it('dispatches normalized changes through __builtin_set_attributes', async () => { await setAttributes({ phase: 'init', orderId: 'ord_1' }); expect(dispatchCalls).toEqual([ - [ - { key: 'phase', value: 'init' }, - { key: 'orderId', value: 'ord_1' }, - ], + { + stepName: '__builtin_set_attributes', + changes: [ + { key: 'phase', value: 'init' }, + { key: 'orderId', value: 'ord_1' }, + ], + }, ]); }); it('translates undefined values into null (unset semantics)', async () => { await setAttributes({ phase: 'done', stale: undefined }); expect(dispatchCalls).toEqual([ - [ - { key: 'phase', value: 'done' }, - { key: 'stale', value: null }, - ], + { + stepName: '__builtin_set_attributes', + changes: [ + { key: 'phase', value: 'done' }, + { key: 'stale', value: null }, + ], + }, ]); }); @@ -44,8 +54,8 @@ describe('workflow.setAttributes', () => { expect(dispatchCalls).toHaveLength(0); }); - it('throws FatalError when the host has not initialized the bridge', async () => { - delete (globalThis as Record)[WORKFLOW_SET_ATTRIBUTES]; + it('throws FatalError when the workflow runtime has not initialized useStep', async () => { + delete (globalThis as Record)[WORKFLOW_USE_STEP]; await expect(setAttributes({ phase: 'init' })).rejects.toBeInstanceOf( FatalError ); diff --git a/packages/core/src/workflow/set-attributes.ts b/packages/core/src/workflow/set-attributes.ts index 15821da031..e2d54d8207 100644 --- a/packages/core/src/workflow/set-attributes.ts +++ b/packages/core/src/workflow/set-attributes.ts @@ -1,19 +1,28 @@ import { FatalError } from '@workflow/errors'; -import type { AttributeChange } from '@workflow/world'; -import { normalizeSetAttributesInput } from '../set-attributes-shared.js'; -import { WORKFLOW_SET_ATTRIBUTES } from '../symbols.js'; +import { + type AttributeChange, + AttributeValidationError, + validateAttributeChanges, +} from '@workflow/world'; +import { WORKFLOW_USE_STEP } from '../symbols.js'; /** - * Workflow-VM-side `setAttributes`. Validates the input on the VM side - * (cheap, deterministic) and then dispatches the canonical - * `AttributeChange[]` through the host's `__builtin_set_attributes` - * step bridge — registered on `globalThis` under `WORKFLOW_SET_ATTRIBUTES` - * by the workflow runtime. The actual world call happens inside that - * step, which gives the mutation an event-log entry (`step_created` → - * `step_completed`) just like any other step. + * Attach plaintext string key/value metadata to the current workflow run. * - * Empty input is a no-op (no step dispatch). `value: undefined` removes - * the key from the run's attribute map. + * **EXPERIMENTAL.** Callable only from a workflow body (`'use workflow'`). + * The call is dispatched through the workflow runtime as a step, so the + * mutation is recorded in the event log and survives replay. + * + * Validation runs in the VM (cheap, deterministic) before the step + * dispatch — violations throw `FatalError` without queuing a step. An + * empty record is a no-op. `value: undefined` removes the key from the + * run's attribute map. + * + * **WARNING**: While this feature is experimental, calling e.g. + * `Promise.all([setAttributes({ a: '1' }), setAttributes({ a: '2' })])` + * is not guaranteed to be ordered consistently, but + * `await setAttributes({ a: '1' }).then(() => setAttributes({ a: '2' }))` + * is. * * @example * ```ts @@ -21,23 +30,44 @@ import { WORKFLOW_SET_ATTRIBUTES } from '../symbols.js'; * 'use workflow'; * await setAttributes({ phase: 'init' }); * // ... work ... - * await setAttributes({ phase: 'done' }); + * await setAttributes({ phase: 'done', orderId: 'ord_123' }); + * await setAttributes({ orderId: undefined }); // remove * } * ``` */ export async function setAttributes( attrs: Record ): Promise { - const changes = normalizeSetAttributesInput(attrs); - if (!changes) return; - const dispatch = (globalThis as Record)[ - WORKFLOW_SET_ATTRIBUTES - ] as ((changes: AttributeChange[]) => Promise) | undefined; - if (!dispatch) { + if (attrs === null || typeof attrs !== 'object' || Array.isArray(attrs)) { + throw new FatalError( + `setAttributes requires a plain object, got ${ + attrs === null ? 'null' : Array.isArray(attrs) ? 'array' : typeof attrs + }` + ); + } + const changes: AttributeChange[] = Object.entries(attrs).map( + ([key, value]) => ({ + key, + value: value === undefined ? null : value, + }) + ); + if (changes.length === 0) return; + try { + validateAttributeChanges(changes); + } catch (err) { + if (err instanceof AttributeValidationError) { + throw new FatalError(err.message); + } + throw err; + } + const useStep = (globalThis as Record)[WORKFLOW_USE_STEP] as + | ((stepName: string) => (changes: AttributeChange[]) => Promise) + | undefined; + if (!useStep) { throw new FatalError( 'setAttributes() called outside a workflow runtime context. ' + - 'The workflow VM must be initialized before this function is invoked.' + 'It must be called from within a workflow body (`use workflow`).' ); } - await dispatch(changes); + await useStep('__builtin_set_attributes')(changes); } diff --git a/packages/workflow/src/internal/builtins.ts b/packages/workflow/src/internal/builtins.ts index bb78f58cd0..f7ea9cd465 100644 --- a/packages/workflow/src/internal/builtins.ts +++ b/packages/workflow/src/internal/builtins.ts @@ -22,21 +22,52 @@ export async function __builtin_response_text(this: Request | Response) { } /** - * Internal step bridge that lets workflow-body `setAttributes` dispatch - * the attribute change through the step queue. The workflow VM registers - * a `useStep('__builtin_set_attributes')` dispatcher under the - * `WORKFLOW_SET_ATTRIBUTES` global symbol; the workflow-side - * `setAttributes` validates input, then calls the dispatcher with - * canonical `{ key, value }[]` changes. This step runs in normal Node - * context with full world access and forwards to the same code path the - * step-body `setAttributes` uses. + * Step bridge for workflow-body `setAttributes` calls. The VM-side + * helper validates input and dispatches here via `useStep`. This step + * runs in normal Node context with full world access. + * + * The dispatch reads the world and current run id directly from + * `globalThis` symbols populated by the workflow/step runtime — this + * intentionally avoids importing `@workflow/core` so the Next.js + * deferred-entries discoverer can't walk a chain into world adapters + * and `@vercel/queue` from this step file. */ export async function __builtin_set_attributes( changes: Array<{ key: string; value: string | null }> ) { 'use step'; - const { applySetAttributesChanges } = await import( - '@workflow/core/_step-set-attributes' - ); - await applySetAttributesChanges(changes); + if (changes.length === 0) return; + const g = globalThis as Record; + + const contextStorage = g[Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')] as + | { + getStore: () => + | { workflowMetadata?: { workflowRunId?: string } } + | undefined; + } + | undefined; + const runId = contextStorage?.getStore?.()?.workflowMetadata?.workflowRunId; + if (!runId) { + throw new Error( + '__builtin_set_attributes: no workflow run id available in step context' + ); + } + + const world = g[Symbol.for('@workflow/world//cache')] as + | { + runs?: { + experimentalSetAttributes?: ( + runId: string, + changes: Array<{ key: string; value: string | null }> + ) => Promise; + }; + } + | 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. + return; + } + + await world.runs.experimentalSetAttributes(runId, changes); } diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index b227de36bb..07cbffbaa5 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -3172,35 +3172,16 @@ export async function writableForwardedFromStepWorkflow(payload: string) { } ////////////////////////////////////////////////////////// -// Workflow Attributes MVP — both contexts exercised end-to-end. - -async function tagStepPhase(phase: string) { - 'use step'; - await setAttributes({ phase }); - return phase; -} - -/** - * Calls `setAttributes` from inside a `'use step'` body, twice. The - * second call overwrites `phase` and the test verifies the final - * merged map matches what the world materialized on the run entity. - */ -export async function setAttributesFromStepWorkflow(input: number) { - 'use workflow'; - await tagStepPhase('init'); - const doubled = input * 2; - await tagStepPhase('done'); - return doubled; -} +// Workflow Attributes MVP — workflow-body-only API. /** - * Calls `setAttributes` directly from the workflow body (no wrapping - * step). Exercises the `__builtin_set_attributes` step bridge wired - * through the `WORKFLOW_SET_ATTRIBUTES` symbol. The third call sets a - * key to `undefined` and the test verifies the key is absent from the - * final attribute map. + * Calls `setAttributes` directly from the workflow body. The call is + * dispatched through the `__builtin_set_attributes` step bridge, so the + * mutation gets a `step_created`/`step_completed` event pair. The third + * call sets a key to `undefined` and the test verifies the key is + * absent from the final attribute map. */ -export async function setAttributesFromWorkflowBodyWorkflow(input: number) { +export async function setAttributesWorkflow(input: number) { 'use workflow'; await setAttributes({ phase: 'init', source: 'workflow-body' }); const tripled = input * 3; From 7cc364a24381f34b828efdd7509cb97da5aef271 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 27 May 2026 12:15:04 +0200 Subject: [PATCH 17/26] fix(next): replace base64 sourcemap regex with string scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- packages/next/src/builder-deferred.ts | 66 +++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index f4170ca966..788ad22468 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -965,12 +965,70 @@ export async function getNextBuilderDeferred() { const baseDirectory = dirname(bundleFilePath); const localSourceFiles = new Set(); - const sourceMapMatches = bundleContents.matchAll( - /\/\/# sourceMappingURL=data:application\/json[^,]*;base64,([A-Za-z0-9+/=]+)/g - ); + + // Extract inline base64 sourcemaps via string scanning. A previous + // implementation used `bundleContents.matchAll(/...[A-Za-z0-9+/=]+.../g)` + // which overflows V8's regex stack + // (`RangeError: Maximum call stack size exceeded at RegExpStringIterator.next`) + // on bundles with large inline sourcemaps — V8's irregexp uses + // recursion for greedy character-class quantifiers and certain long + // base64 inputs exhaust the call stack. + const sourceMapPrefix = '//# sourceMappingURL=data:application/json'; + const base64Marker = ';base64,'; + const sourceMapMatches: Array<{ base64Value: string }> = []; + let scanFrom = 0; + while (scanFrom < bundleContents.length) { + const prefixIdx = bundleContents.indexOf(sourceMapPrefix, scanFrom); + if (prefixIdx === -1) break; + const base64Start = bundleContents.indexOf( + base64Marker, + prefixIdx + sourceMapPrefix.length + ); + if (base64Start === -1) { + scanFrom = prefixIdx + sourceMapPrefix.length; + continue; + } + // Bail if a comma appears before `;base64,` — that means this + // wasn't a `data:application/json[;params];base64,...` URL after + // all (the comma would terminate the data URL parameters). + const commaBefore = bundleContents.indexOf(',', prefixIdx); + if (commaBefore !== -1 && commaBefore < base64Start) { + scanFrom = base64Start; + continue; + } + const valueStart = base64Start + base64Marker.length; + // Base64 payload terminates at first non-base64 char (newline, + // closing `*/`, etc.). Scanning a small alphabet by char is far + // cheaper than backtracking a regex over a multi-MB capture. + let valueEnd = valueStart; + while (valueEnd < bundleContents.length) { + const code = bundleContents.charCodeAt(valueEnd); + // A-Z 0x41-0x5A, a-z 0x61-0x7A, 0-9 0x30-0x39, '+' 0x2B, + // '/' 0x2F, '=' 0x3D + if ( + !( + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) || + (code >= 0x30 && code <= 0x39) || + code === 0x2b || + code === 0x2f || + code === 0x3d + ) + ) { + break; + } + valueEnd++; + } + if (valueEnd > valueStart) { + sourceMapMatches.push({ + base64Value: bundleContents.slice(valueStart, valueEnd), + }); + } + scanFrom = valueEnd; + } for (const match of sourceMapMatches) { - const base64Value = match[1]; + const base64Value = match.base64Value; if (!base64Value) { continue; } From 85ecadc872e70d12a3f443f8b0d01951547986fb Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 27 May 2026 14:03:58 +0200 Subject: [PATCH 18/26] =?UTF-8?q?attributes:=20address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20rename,=20world=20fixes,=20warn-once,=20post-mer?= =?UTF-8?q?ge=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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`. 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) --- .../docs/v5/changelog/attributes-mvp.mdx | 48 ++--- packages/core/e2e/e2e.test.ts | 11 +- packages/core/src/index.ts | 2 +- packages/core/src/set-attributes.test.ts | 12 +- packages/core/src/set-attributes.ts | 17 +- packages/core/src/workflow/index.ts | 2 +- .../core/src/workflow/set-attributes.test.ts | 18 +- packages/core/src/workflow/set-attributes.ts | 32 ++-- packages/workflow/src/internal/builtins.ts | 23 ++- .../world-local/src/storage/events-storage.ts | 169 +++++++++--------- .../world-local/src/storage/runs-storage.ts | 31 ++-- packages/world-postgres/src/storage.ts | 49 +++-- packages/world/src/attributes.test.ts | 17 +- packages/world/src/attributes.ts | 50 ++++-- workbench/example/workflows/99_e2e.ts | 20 +-- 15 files changed, 298 insertions(+), 203 deletions(-) diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx index 225b344dc6..21e63326f5 100644 --- a/docs/content/docs/v5/changelog/attributes-mvp.mdx +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -11,11 +11,11 @@ The MVP lets workflow and step code attach plaintext `string → string` metadat ## What MVP supports -- `setAttributes(record)` callable from a **workflow body** (`"use workflow"` function), dispatched via an internal `__builtin_set_attributes` step bridge so the mutation gets a `step_created → step_completed` event pair +- `experimental_setAttributes(record)` callable from a **workflow body** (`"use workflow"` function), dispatched via an internal `__builtin_set_attributes` step bridge so the mutation gets a `step_created → step_completed` event pair - Attributes are materialized onto the `WorkflowRun` entity, plaintext, and visible via `world.runs.get()` / `world.runs.list()` and any observability UI built on top of those - World implementations emit a side-channel observability record per write (in `world-vercel`, this hooks into the same observability/analytics pipeline already used for other run lifecycle events) -Calling `setAttributes` from a step body or plain host code is intentionally not supported in the MVP — the host-side export throws `FatalError` directing callers back to a workflow body. This keeps the implementation a single dispatch path; step-body support can be added later without breaking the workflow-body contract. +Calling `experimental_setAttributes` from a step body or plain host code is intentionally not supported in the MVP — the host-side export throws `FatalError` directing callers back to a workflow body. This keeps the implementation a single dispatch path; step-body support can be added later without breaking the workflow-body contract. ## What MVP does **not** support (deferred to 5.0.0) @@ -56,7 +56,7 @@ runs: { * are removed. Other keys on the run are untouched. * * OPTIONAL. World implementations may omit this method; the SDK - * detects absence and no-ops `setAttributes` with a warning so that + * detects absence and no-ops `experimental_setAttributes` with a warning so that * third-party / community worlds continue to function without * adopting the experimental API. * @@ -100,7 +100,7 @@ A new export from `@workflow/core` (re-exported by `workflow`): {/*@skip-typecheck - snippet, not runnable code*/} ```ts -function setAttributes( +function experimental_setAttributes( attrs: Record ): Promise ``` @@ -114,16 +114,16 @@ Validation (shared helper, applied both client-side and server-side): - Maximum 64 attributes per run (validated against the post-merge snapshot when the server applies) - Violations throw `FatalError` from `@workflow/errors` -`setAttributes` is callable only from a workflow body: +`experimental_setAttributes` is callable only from a workflow body: ```ts -import { setAttributes } from 'workflow'; +import { experimental_setAttributes } from 'workflow'; export async function myWorkflow(orderId: string) { 'use workflow'; - await setAttributes({ phase: 'init', orderId }); + await experimental_setAttributes({ phase: 'init', orderId }); // ... - await setAttributes({ phase: 'done' }); + await experimental_setAttributes({ phase: 'done' }); } ``` @@ -133,7 +133,7 @@ The host-side export (the one resolved when the `workflow` package-exports condi #### Optional world support -Because `runs.experimentalSetAttributes` is **optional** on the World interface (see §1), the `__builtin_set_attributes` step body checks for its presence before dispatching and silently no-ops when absent. User code does not need to feature-detect; calling `setAttributes` against an unsupporting world is safe but ineffective. +Because `runs.experimentalSetAttributes` is **optional** on the World interface (see §1), the `__builtin_set_attributes` step body checks for its presence before dispatching and silently no-ops when absent. User code does not need to feature-detect; calling `experimental_setAttributes` against an unsupporting world is safe but ineffective. ### 4. World implementations @@ -159,7 +159,7 @@ Because attributes are stored plaintext on the `WorkflowRun` entity, any UI that ### Concurrent writes to the same run -The MVP applies **last-write-wins by arrival order**. Two concurrent `setAttributes` calls writing to the same key produce a final state matching whichever request the world processes second. There is no conditional / `expectedValue` semantic and no `unique: true` mode. +The MVP applies **last-write-wins by arrival order**. Two concurrent `experimental_setAttributes` calls writing to the same key produce a final state matching whichever request the world processes second. There is no conditional / `expectedValue` semantic and no `unique: true` mode. Workflow body writes are serialized by the workflow VM (one step at a time within a single workflow body), so the concurrent case in practice arises when: @@ -182,7 +182,7 @@ The 5.0.0 path closes this gap. ### SDK surface stability -`setAttributes(record)` is intended to be stable across MVP and 5.0.0. User code calling it under the MVP will continue to work after the full feature lands; only the runtime dispatch path changes (`"use step"` indirection → workflow-VM-native intercept, parallel to `sleep`). +`experimental_setAttributes(record)` is intended to be stable across MVP and 5.0.0. User code calling it under the MVP will continue to work after the full feature lands; only the runtime dispatch path changes (`"use step"` indirection → workflow-VM-native intercept, parallel to `sleep`). 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. @@ -190,28 +190,28 @@ If you need behavior the MVP does not provide (read, list, filter, initial attri - Unit tests in `@workflow/core` for: - Validation rules (length, `$` prefix, post-merge count cap) - - `setAttributes({})` is a no-op (no dispatch, no events) + - `experimental_setAttributes({})` is a no-op (no dispatch, no events) - `undefined` value normalizes to a `null`-valued change on the wire - Workflow VM with no `useStep` bound (= called outside a workflow) throws `FatalError` - Host-side stub (resolved from step or plain code) throws `FatalError` - `world-local` integration tests: - - `setAttributes` from a workflow body materializes onto the run row + - `experimental_setAttributes` from a workflow body materializes onto the run row - Merge semantics: setting + unsetting in a single call leaves the run with the expected snapshot - Idempotency: repeated identical calls converge to the same final state - `world-postgres` integration tests: same shape as `world-local` - `world-vercel` contract test: verifies the request body shape matches the documented wire format - E2E in `workbench/nextjs-turbopack`: 1. Start a workflow - 2. Workflow body calls `setAttributes({ tenant: 't1', phase: 'init' })` - 3. Awaits a step that calls `setAttributes({ phase: 'processing', orderId: 'ord_123' })` - 4. Then `setAttributes({ phase: 'done', orderId: undefined })` + 2. Workflow body calls `experimental_setAttributes({ tenant: 't1', phase: 'init' })` + 3. Awaits a step that calls `experimental_setAttributes({ phase: 'processing', orderId: 'ord_123' })` + 4. Then `experimental_setAttributes({ phase: 'done', orderId: undefined })` 5. Assert via `world.runs.get(runId)`: `attributes === { tenant: 't1', phase: 'done' }` ## Migration to 5.0.0 When the full attributes feature ships: -- `setAttributes` (SDK) — unchanged signature, new dispatch path +- `experimental_setAttributes` (SDK) — unchanged signature, new dispatch path - `runs.experimentalSetAttributes` (world interface) — deprecated, then removed; replaced by `events.create(runId, { eventType: 'attr_set', eventData: { changes, writer } })` - Wire endpoint — `POST /v2/runs/:runId/attributes` removed; the same body shape posts to `POST /v2/runs/:id/events` - Pre-existing attribute values on MVP-era runs remain on the run entity but are not represented in the event log @@ -224,9 +224,9 @@ This section records concrete decisions taken while landing the MVP that weren't ### How workflow-body dispatch works -`setAttributes` from a workflow body is wired through an internal built-in step, `__builtin_set_attributes`, defined in `packages/workflow/src/internal/builtins.ts` alongside the other workflow-side builtins (`__builtin_response_json`, etc.). The mechanism: +`experimental_setAttributes` from a workflow body is wired through an internal built-in step, `__builtin_set_attributes`, defined in `packages/workflow/src/internal/builtins.ts` alongside the other workflow-side builtins (`__builtin_response_json`, etc.). The mechanism: -1. The workflow VM bundle resolves `setAttributes` to `packages/core/src/workflow/set-attributes.ts` (via the `workflow` package-exports condition). +1. The workflow VM bundle resolves `experimental_setAttributes` to `packages/core/src/workflow/set-attributes.ts` (via the `workflow` package-exports condition). 2. That helper validates the input record inline (no shared helper, no cross-file dependency from a 'use step' file) and produces canonical `AttributeChange[]`. 3. It dispatches through the standard workflow-VM step mechanism: `globalThis[WORKFLOW_USE_STEP]('__builtin_set_attributes')(changes)`. The `useStep` dispatcher is the same one used by every other step call from a workflow body, populated by `packages/core/src/workflow.ts` at VM bootstrap. 4. The dispatch queues a step (`step_created`), the host runs `__builtin_set_attributes(changes)` from `packages/workflow/src/internal/builtins.ts`. The step body reads the active world and current run id directly from `globalThis` symbols (`Symbol.for('@workflow/world//cache')` and `Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')`) — populated by the host runtime — and calls `world.runs.experimentalSetAttributes(runId, changes)`. @@ -236,7 +236,7 @@ This puts the mutation on the event log as a normal `step_created → step_compl The step body intentionally does **not** import anything from `@workflow/core`. That keeps the Next.js deferred-entries discoverer from walking a `__builtin_set_attributes` → `@workflow/core/...` → world adapter → `@vercel/queue` chain, which earlier drafts triggered (blowing the call stack of webpack's regex-based extractor with `RangeError: Maximum call stack size exceeded at RegExpStringIterator.next` on tarball-installed `nextjs-webpack` builds). -The host-side `setAttributes` export (`packages/core/src/set-attributes.ts`, resolved by everything that isn't the workflow VM) throws `FatalError` with a message pointing the caller back to a workflow body. Step-body support can be added in a follow-up without changing this contract. +The host-side `experimental_setAttributes` export (`packages/core/src/set-attributes.ts`, resolved by everything that isn't the workflow VM) throws `FatalError` with a message pointing the caller back to a workflow body. Step-body support can be added in a follow-up without changing this contract. When the full 5.0.0 attributes feature lands, `__builtin_set_attributes` is replaced by an `events.create(runId, { eventType: 'attr_set', ... })` dispatch path; SDK signatures don't change. @@ -250,7 +250,7 @@ The plan called for per-key atomic `UpdateExpression` updates (`SET #attrs.#k = - **In `world-postgres`** the SQL-side `jsonb_set` / `-` chain *is* used and is genuinely atomic on the run row, so the only race is the cap check (a separate `SELECT`). Documented as LWW-by-arrival for the cap; the merge itself is atomic. - **In the `world-vercel` backing service** the attributes column is laid out as a native key-addressable map so the atomic `UpdateItem` variant can be enabled later without a data migration. The MVP commits the merged map via the existing entity update path. Two concurrent writers therefore race; whichever lands second wins on shared keys, and any write to a non-overlapping key is preserved. -- **In `world-local`** an in-process per-run mutex serializes the read-merge-write sequence so parallel `setAttributes` calls from concurrent steps do not lose writes within a single process. There is a corresponding test that exercises 20 parallel writes to the same run. +- **In `world-local`** an in-process per-run mutex serializes the read-merge-write sequence so parallel `experimental_setAttributes` calls from concurrent steps do not lose writes within a single process. There is a corresponding test that exercises 20 parallel writes to the same run. This is consistent with the original "concurrent writes are LWW by arrival" caveat. Promoting to per-key atomic writes is a no-API-break change once the event-sourced path lands. @@ -262,15 +262,15 @@ For the MVP the endpoint reuses the existing `WORKFLOW_EVENT` fact with `eventTy ### Validation rules are shared between SDK and world -Validation lives in a single helper exported from `@workflow/world` (`validateAttributeChanges`, `validateAttributeKey`, `validateAttributeValue`). Both the SDK `setAttributes` helper and the `world-local` / `world-postgres` implementations call it; the `world-vercel` backing service applies the same rules independently. The shared module is the authoritative spec for the limits (256-char keys, 256-byte values, max 64 attributes per run, `$`-prefixed keys reserved) — any future change goes through one file. +Validation lives in a single helper exported from `@workflow/world` (`validateAttributeChanges`, `validateAttributeKey`, `validateAttributeValue`). Both the SDK `experimental_setAttributes` helper and the `world-local` / `world-postgres` implementations call it; the `world-vercel` backing service applies the same rules independently. The shared module is the authoritative spec for the limits (256-char keys, 256-byte values, max 64 attributes per run, `$`-prefixed keys reserved) — any future change goes through one file. ### Run row reconstruction had to thread `attributes` through -`world-local`'s events storage rebuilds the run row on every lifecycle event (`run_started`, `run_completed`, `run_failed`, `run_cancelled`) by explicitly listing fields rather than spreading. Without forwarding `attributes: currentRun.attributes` through each branch, any attribute set before the run completed would be silently dropped by the next lifecycle event. The fix was local to the four reconstruction sites — the field is otherwise untouched by event-sourced code. A subtler race remains: a `setAttributes` write landing in the same async window as a `run_completed` event read can be clobbered, but that's the same LWW-by-arrival semantic documented above. +`world-local`'s events storage rebuilds the run row on every lifecycle event (`run_started`, `run_completed`, `run_failed`, `run_cancelled`) by explicitly listing fields rather than spreading. Without forwarding `attributes: currentRun.attributes` through each branch, any attribute set before the run completed would be silently dropped by the next lifecycle event. The fix was local to the four reconstruction sites — the field is otherwise untouched by event-sourced code. A subtler race remains: a `experimental_setAttributes` write landing in the same async window as a `run_completed` event read can be clobbered, but that's the same LWW-by-arrival semantic documented above. ### Optional world method: feature-detect, warn once -`runs.experimentalSetAttributes` is optional on the `World` interface so community worlds (Redis, MongoDB, Turso, etc.) continue to build and run without adopting the experimental API. The SDK helper feature-detects the method's presence on first dispatch; if absent, it logs a single `console.warn` for the lifetime of the process and resolves silently for that call and all subsequent calls. Users do not need to feature-detect in their own code — calling `setAttributes` against an unsupporting world is safe but ineffective. +`runs.experimentalSetAttributes` is optional on the `World` interface so community worlds (Redis, MongoDB, Turso, etc.) continue to build and run without adopting the experimental API. The SDK helper feature-detects the method's presence on first dispatch; if absent, it logs a single `console.warn` for the lifetime of the process and resolves silently for that call and all subsequent calls. Users do not need to feature-detect in their own code — calling `experimental_setAttributes` against an unsupporting world is safe but ineffective. ### Tests diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index 6d3f60b63b..9dbbba42e6 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -3432,15 +3432,18 @@ describe('e2e', () => { ); // ========================================================================== - // setAttributes (experimental MVP) + // experimental_setAttributes (experimental MVP) // ========================================================================== - describe('setAttributes', () => { + describe('experimental_setAttributes', () => { test( - 'setAttributesWorkflow: workflow-body calls dispatch through the step bridge and merge correctly', + 'experimentalSetAttributesWorkflow: workflow-body calls dispatch through the step bridge and merge correctly', { timeout: 30_000 }, async () => { - const run = await start(await e2e('setAttributesWorkflow'), [7]); + const run = await start( + await e2e('experimentalSetAttributesWorkflow'), + [7] + ); const output = await run.returnValue; expect(output).toBe(21); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e5bbb0bb3b..3e22b7767f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,7 +25,7 @@ export { type WebhookOptions, } from './create-hook.js'; export { defineHook, type TypedHook } from './define-hook.js'; -export { setAttributes } from './set-attributes.js'; +export { experimental_setAttributes } from './set-attributes.js'; export { sleep } from './sleep.js'; export { getStepMetadata, diff --git a/packages/core/src/set-attributes.test.ts b/packages/core/src/set-attributes.test.ts index f365c503e3..7705089652 100644 --- a/packages/core/src/set-attributes.test.ts +++ b/packages/core/src/set-attributes.test.ts @@ -1,18 +1,18 @@ import { FatalError } from '@workflow/errors'; import { describe, expect, it } from 'vitest'; -import { setAttributes } from './set-attributes.js'; +import { experimental_setAttributes } from './set-attributes.js'; -describe('setAttributes (host-side stub)', () => { - // The host-side `setAttributes` is the fallback resolved when callers +describe('experimental_setAttributes (host-side stub)', () => { + // The host-side `experimental_setAttributes` is the fallback resolved when callers // are NOT in the workflow VM. The real implementation lives in // `workflow/set-attributes.ts` and is selected via the `workflow` // package-exports condition. Reaching this file from a step body or // plain host code is unsupported and must surface a clear error. - it('throws FatalError telling the user setAttributes is workflow-body only', async () => { - await expect(setAttributes({ phase: 'init' })).rejects.toBeInstanceOf( + it('throws FatalError telling the user experimental_setAttributes is workflow-body only', async () => { + await expect(experimental_setAttributes({ phase: 'init' })).rejects.toBeInstanceOf( FatalError ); - await expect(setAttributes({ phase: 'init' })).rejects.toThrow( + await expect(experimental_setAttributes({ phase: 'init' })).rejects.toThrow( /workflow.*function/i ); }); diff --git a/packages/core/src/set-attributes.ts b/packages/core/src/set-attributes.ts index 8888fd6cad..c7154d6d43 100644 --- a/packages/core/src/set-attributes.ts +++ b/packages/core/src/set-attributes.ts @@ -1,20 +1,21 @@ import { FatalError } from '@workflow/errors'; /** - * Host-side stub for `setAttributes`. The real implementation lives in - * `./workflow/set-attributes.ts` and is selected by the `workflow` - * package-exports condition when the workflow VM bundle is resolved. + * Host-side stub for `experimental_setAttributes`. The real + * implementation lives in `./workflow/set-attributes.ts` and is + * selected by the `workflow` package-exports condition when the + * workflow VM bundle is resolved. * - * Reaching this stub means `setAttributes` was called outside a workflow + * Reaching this stub means the function was called outside a workflow * body — most likely from a `'use step'` function or plain host code. - * That isn't supported: attribute mutations must be event-sourced - * through the workflow runtime so they survive replay. + * That isn't supported in the MVP: attribute mutations must be + * event-sourced through the workflow runtime so they survive replay. */ -export async function setAttributes( +export async function experimental_setAttributes( _attrs: Record ): Promise { throw new FatalError( - "setAttributes() must be called from a 'use workflow' function. " + + "experimental_setAttributes() must be called from a 'use workflow' function. " + 'Calling it from a step body or plain host code is not supported.' ); } diff --git a/packages/core/src/workflow/index.ts b/packages/core/src/workflow/index.ts index e8fe14323b..a9fe74ca3c 100644 --- a/packages/core/src/workflow/index.ts +++ b/packages/core/src/workflow/index.ts @@ -10,7 +10,7 @@ export { type RetryableErrorOptions, } from '@workflow/errors'; export type { Hook, HookOptions } from '../create-hook.js'; -export { setAttributes } from './set-attributes.js'; +export { experimental_setAttributes } from './set-attributes.js'; export { sleep } from '../sleep.js'; export { createHook, createWebhook } from './create-hook.js'; export { defineHook } from './define-hook.js'; diff --git a/packages/core/src/workflow/set-attributes.test.ts b/packages/core/src/workflow/set-attributes.test.ts index e3484d1023..8ffa0d3e1b 100644 --- a/packages/core/src/workflow/set-attributes.test.ts +++ b/packages/core/src/workflow/set-attributes.test.ts @@ -1,9 +1,9 @@ import { FatalError } from '@workflow/errors'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { WORKFLOW_USE_STEP } from '../symbols.js'; -import { setAttributes } from './set-attributes.js'; +import { experimental_setAttributes } from './set-attributes.js'; -describe('workflow.setAttributes', () => { +describe('workflow.experimental_setAttributes', () => { const dispatchCalls: Array<{ stepName: string; changes: Array<{ key: string; value: string | null }>; @@ -24,7 +24,7 @@ describe('workflow.setAttributes', () => { }); it('dispatches normalized changes through __builtin_set_attributes', async () => { - await setAttributes({ phase: 'init', orderId: 'ord_1' }); + await experimental_setAttributes({ phase: 'init', orderId: 'ord_1' }); expect(dispatchCalls).toEqual([ { stepName: '__builtin_set_attributes', @@ -37,7 +37,7 @@ describe('workflow.setAttributes', () => { }); it('translates undefined values into null (unset semantics)', async () => { - await setAttributes({ phase: 'done', stale: undefined }); + await experimental_setAttributes({ phase: 'done', stale: undefined }); expect(dispatchCalls).toEqual([ { stepName: '__builtin_set_attributes', @@ -50,19 +50,19 @@ describe('workflow.setAttributes', () => { }); it('is a no-op for an empty record (no dispatch)', async () => { - await setAttributes({}); + await experimental_setAttributes({}); expect(dispatchCalls).toHaveLength(0); }); it('throws FatalError when the workflow runtime has not initialized useStep', async () => { delete (globalThis as Record)[WORKFLOW_USE_STEP]; - await expect(setAttributes({ phase: 'init' })).rejects.toBeInstanceOf( + await expect(experimental_setAttributes({ phase: 'init' })).rejects.toBeInstanceOf( FatalError ); }); it('throws FatalError for reserved-prefix keys before any dispatch', async () => { - await expect(setAttributes({ $sys: 'x' })).rejects.toBeInstanceOf( + await expect(experimental_setAttributes({ $sys: 'x' })).rejects.toBeInstanceOf( FatalError ); expect(dispatchCalls).toHaveLength(0); @@ -70,10 +70,10 @@ describe('workflow.setAttributes', () => { it('throws FatalError when called with a non-object', async () => { await expect( - setAttributes(null as unknown as Record) + experimental_setAttributes(null as unknown as Record) ).rejects.toBeInstanceOf(FatalError); await expect( - setAttributes([] as unknown as Record) + experimental_setAttributes([] as unknown as Record) ).rejects.toBeInstanceOf(FatalError); }); }); diff --git a/packages/core/src/workflow/set-attributes.ts b/packages/core/src/workflow/set-attributes.ts index e2d54d8207..571659a488 100644 --- a/packages/core/src/workflow/set-attributes.ts +++ b/packages/core/src/workflow/set-attributes.ts @@ -9,9 +9,14 @@ import { WORKFLOW_USE_STEP } from '../symbols.js'; /** * Attach plaintext string key/value metadata to the current workflow run. * - * **EXPERIMENTAL.** Callable only from a workflow body (`'use workflow'`). - * The call is dispatched through the workflow runtime as a step, so the - * mutation is recorded in the event log and survives replay. + * **EXPERIMENTAL.** The `experimental_` prefix is deliberate — the + * shape, semantics, and dispatch path are likely to change before this + * is renamed to a stable export. Use only when you can absorb a + * breaking rename later. + * + * Callable only from a workflow body (`'use workflow'`). The call is + * dispatched through the workflow runtime as a step, so the mutation + * is recorded in the event log and survives replay. * * Validation runs in the VM (cheap, deterministic) before the step * dispatch — violations throw `FatalError` without queuing a step. An @@ -19,28 +24,29 @@ import { WORKFLOW_USE_STEP } from '../symbols.js'; * run's attribute map. * * **WARNING**: While this feature is experimental, calling e.g. - * `Promise.all([setAttributes({ a: '1' }), setAttributes({ a: '2' })])` - * is not guaranteed to be ordered consistently, but - * `await setAttributes({ a: '1' }).then(() => setAttributes({ a: '2' }))` - * is. + * `Promise.all([experimental_setAttributes({ a: '1' }), experimental_setAttributes({ a: '2' })])` + * is not guaranteed to be ordered consistently, but the equivalent + * sequential `.then()` chain is. * * @example * ```ts + * import { experimental_setAttributes } from 'workflow'; + * * export async function myWorkflow() { * 'use workflow'; - * await setAttributes({ phase: 'init' }); + * await experimental_setAttributes({ phase: 'init' }); * // ... work ... - * await setAttributes({ phase: 'done', orderId: 'ord_123' }); - * await setAttributes({ orderId: undefined }); // remove + * await experimental_setAttributes({ phase: 'done', orderId: 'ord_123' }); + * await experimental_setAttributes({ orderId: undefined }); // remove * } * ``` */ -export async function setAttributes( +export async function experimental_setAttributes( attrs: Record ): Promise { if (attrs === null || typeof attrs !== 'object' || Array.isArray(attrs)) { throw new FatalError( - `setAttributes requires a plain object, got ${ + `experimental_setAttributes requires a plain object, got ${ attrs === null ? 'null' : Array.isArray(attrs) ? 'array' : typeof attrs }` ); @@ -65,7 +71,7 @@ export async function setAttributes( | undefined; if (!useStep) { throw new FatalError( - 'setAttributes() called outside a workflow runtime context. ' + + 'experimental_setAttributes() called outside a workflow runtime context. ' + 'It must be called from within a workflow body (`use workflow`).' ); } diff --git a/packages/workflow/src/internal/builtins.ts b/packages/workflow/src/internal/builtins.ts index f7ea9cd465..6aaf63a559 100644 --- a/packages/workflow/src/internal/builtins.ts +++ b/packages/workflow/src/internal/builtins.ts @@ -21,6 +21,14 @@ export async function __builtin_response_text(this: Request | Response) { return this.text(); } +/** + * Process-wide dedupe for the unsupported-world warning so high-volume + * callers don't flood logs. + */ +const UNSUPPORTED_WORLD_WARNED = Symbol.for( + '@workflow/setAttributes//unsupportedWorldWarned' +); + /** * Step bridge for workflow-body `setAttributes` calls. The VM-side * helper validates input and dispatches here via `useStep`. This step @@ -55,6 +63,7 @@ export async function __builtin_set_attributes( const world = g[Symbol.for('@workflow/world//cache')] as | { + name?: string; runs?: { experimentalSetAttributes?: ( runId: string, @@ -64,8 +73,18 @@ export async function __builtin_set_attributes( } | 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. + // World adapter doesn't implement attributes yet — no-op the call, + // but emit one process-wide warning so users know their writes are + // being dropped. The VM-side validation already ran so the input + // is well-formed. + if (!g[UNSUPPORTED_WORLD_WARNED]) { + g[UNSUPPORTED_WORLD_WARNED] = true; + const worldName = world?.name ? ` (${world.name})` : ''; + // biome-ignore lint/suspicious/noConsole: surface in user terminals + console.warn( + `[workflow] setAttributes: the current world implementation${worldName} does not implement experimentalSetAttributes; this call (and any subsequent setAttributes calls in this process) is a no-op. Attributes will become available once the world adapter adds support.` + ); + } return; } diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index cfc9635edc..dd8a453160 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -49,6 +49,7 @@ import { stripEventDataRefs } from './filters.js'; import { getObjectCreatedAt, hashToken, monotonicUlid } from './helpers.js'; import { deleteAllHooksForRun } from './hooks-storage.js'; import { handleLegacyEvent } from './legacy.js'; +import { withRunFileLock } from './runs-storage.js'; /** * Per-step in-process async mutex. Serializes concurrent `events.create` calls @@ -123,6 +124,39 @@ async function deleteAllWaitsForRun( } } +/** + * Persist a lifecycle-driven run update (run_started / run_completed / + * run_failed / run_cancelled) under the shared per-run file lock, + * re-reading the on-disk run inside the lock so any attribute writes + * that landed between the pre-validation `currentRun` read and this + * write are preserved. Without the re-read, an `experimentalSetAttributes` + * call sandwiched between the lifecycle read and write would be + * silently overwritten by the lifecycle write's stale attribute snapshot. + */ +async function writeRunUnderLifecycleLock( + basedir: string, + runId: string, + tag: string | undefined, + baselineRun: WorkflowRun, + overrides: Partial +): Promise { + return withRunFileLock(runId, async () => { + const fresh = await readJSON( + taggedPath(basedir, 'runs', runId, tag), + WorkflowRunSchema + ); + const next: WorkflowRun = { + ...baselineRun, + ...overrides, + attributes: fresh?.attributes ?? baselineRun.attributes, + }; + await writeJSON(taggedPath(basedir, 'runs', runId, tag), next, { + overwrite: true, + }); + return next; + }); +} + /** * Creates the events storage implementation using the filesystem. * Implements the Storage['events'] interface with create, list, and listByCorrelationId operations. @@ -539,54 +573,37 @@ export function createEventsStorage( return { run: currentRun }; } - run = { - runId: currentRun.runId, - deploymentId: currentRun.deploymentId, - workflowName: currentRun.workflowName, - specVersion: currentRun.specVersion, - executionContext: currentRun.executionContext, - input: currentRun.input, - createdAt: currentRun.createdAt, - expiredAt: currentRun.expiredAt, - status: 'running', - output: undefined, - error: undefined, - completedAt: undefined, - startedAt: currentRun.startedAt ?? now, - updatedAt: now, - attributes: currentRun.attributes, - }; - await writeJSON( - taggedPath(basedir, 'runs', effectiveRunId, tag), - run, - { overwrite: true } + run = await writeRunUnderLifecycleLock( + basedir, + effectiveRunId, + tag, + currentRun, + { + status: 'running', + output: undefined, + error: undefined, + completedAt: undefined, + startedAt: currentRun.startedAt ?? now, + updatedAt: now, + } ); } } else if (data.eventType === 'run_completed' && 'eventData' in data) { const completedData = data.eventData as { output?: any }; // Reuse currentRun from validation (already read above) if (currentRun) { - run = { - runId: currentRun.runId, - deploymentId: currentRun.deploymentId, - workflowName: currentRun.workflowName, - specVersion: currentRun.specVersion, - executionContext: currentRun.executionContext, - input: currentRun.input, - createdAt: currentRun.createdAt, - expiredAt: currentRun.expiredAt, - startedAt: currentRun.startedAt, - status: 'completed', - output: completedData.output, - error: undefined, - completedAt: now, - updatedAt: now, - attributes: currentRun.attributes, - }; - await writeJSON( - taggedPath(basedir, 'runs', effectiveRunId, tag), - run, - { overwrite: true } + run = await writeRunUnderLifecycleLock( + basedir, + effectiveRunId, + tag, + currentRun, + { + status: 'completed', + output: completedData.output, + error: undefined, + completedAt: now, + updatedAt: now, + } ); await Promise.all([ deleteAllHooksForRun(basedir, effectiveRunId), @@ -603,28 +620,19 @@ export function createEventsStorage( // The error field is SerializedData (Uint8Array) produced by // dehydrateRunError. We store it verbatim — consumers hydrate it // via hydrateRunError to reconstruct the original thrown value. - run = { - runId: currentRun.runId, - deploymentId: currentRun.deploymentId, - workflowName: currentRun.workflowName, - specVersion: currentRun.specVersion, - executionContext: currentRun.executionContext, - input: currentRun.input, - createdAt: currentRun.createdAt, - expiredAt: currentRun.expiredAt, - startedAt: currentRun.startedAt, - status: 'failed', - output: undefined, - error: failedData.error as Uint8Array, - errorCode: failedData.errorCode, - completedAt: now, - updatedAt: now, - attributes: currentRun.attributes, - }; - await writeJSON( - taggedPath(basedir, 'runs', effectiveRunId, tag), - run, - { overwrite: true } + run = await writeRunUnderLifecycleLock( + basedir, + effectiveRunId, + tag, + currentRun, + { + status: 'failed', + output: undefined, + error: failedData.error as Uint8Array, + errorCode: failedData.errorCode, + completedAt: now, + updatedAt: now, + } ); await Promise.all([ deleteAllHooksForRun(basedir, effectiveRunId), @@ -634,27 +642,18 @@ export function createEventsStorage( } else if (data.eventType === 'run_cancelled') { // Reuse currentRun from validation (already read above) if (currentRun) { - run = { - runId: currentRun.runId, - deploymentId: currentRun.deploymentId, - workflowName: currentRun.workflowName, - specVersion: currentRun.specVersion, - executionContext: currentRun.executionContext, - input: currentRun.input, - createdAt: currentRun.createdAt, - expiredAt: currentRun.expiredAt, - startedAt: currentRun.startedAt, - status: 'cancelled', - output: undefined, - error: undefined, - completedAt: now, - updatedAt: now, - attributes: currentRun.attributes, - }; - await writeJSON( - taggedPath(basedir, 'runs', effectiveRunId, tag), - run, - { overwrite: true } + run = await writeRunUnderLifecycleLock( + basedir, + effectiveRunId, + tag, + currentRun, + { + status: 'cancelled', + output: undefined, + error: undefined, + completedAt: now, + updatedAt: now, + } ); await Promise.all([ deleteAllHooksForRun(basedir, effectiveRunId), diff --git a/packages/world-local/src/storage/runs-storage.ts b/packages/world-local/src/storage/runs-storage.ts index 54c8eeff92..40e2b4d46a 100644 --- a/packages/world-local/src/storage/runs-storage.ts +++ b/packages/world-local/src/storage/runs-storage.ts @@ -56,31 +56,38 @@ export interface LocalRunsStorage { } /** - * Per-run in-process async mutex. Serializes concurrent attribute writes - * to the same run so the read-merge-write sequence is atomic. Without this - * two parallel `setAttributes` calls (e.g. from `Promise.all` steps) can - * both read the same prior snapshot and one of the updates is lost. + * Per-run in-process async mutex. Serializes concurrent writes that + * touch the same run JSON file — both attribute writes via + * `experimentalSetAttributes` and run-lifecycle writes (run_started, + * run_completed, run_failed, run_cancelled) acquire it. Without the + * shared lock, an attribute write that lands between a lifecycle + * handler's read and write would be silently overwritten by the + * lifecycle write's stale attribute snapshot. + * + * Lifecycle writers acquire the lock and re-read the run file inside + * the critical section to pick up any attributes that landed since + * their pre-validation read. */ -const runAttributeLocks = new Map>(); +const runFileLocks = new Map>(); -function withRunAttributeLock( +export function withRunFileLock( key: string, fn: () => Promise ): Promise { - const prev = runAttributeLocks.get(key); + const prev = runFileLocks.get(key); const taskBox: { task?: Promise } = {}; const task = (async () => { if (prev) await prev.catch(() => undefined); try { return await fn(); } finally { - if (runAttributeLocks.get(key) === taskBox.task) { - runAttributeLocks.delete(key); + if (runFileLocks.get(key) === taskBox.task) { + runFileLocks.delete(key); } } })(); taskBox.task = task; - runAttributeLocks.set(key, task); + runFileLocks.set(key, task); return task; } @@ -153,7 +160,7 @@ export function createRunsStorage( experimentalSetAttributes: async (runId, changes) => { assertSafeEntityId('runId', runId); - return withRunAttributeLock(runId, async () => { + return withRunFileLock(runId, async () => { const run = await readJSONWithFallback( basedir, 'runs', @@ -170,7 +177,7 @@ export function createRunsStorage( // (tests, other consumers) cannot bypass the limits. try { validateAttributeChanges(changes, { - existingCount: Object.keys(run.attributes ?? {}).length, + existingKeys: Object.keys(run.attributes ?? {}), }); } catch (err) { if (err instanceof AttributeValidationError) { diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index 72d81beb34..fcddf23c39 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -27,6 +27,7 @@ import type { WorkflowRunWithoutData, } from '@workflow/world'; import { + ATTRIBUTE_MAX_PER_RUN, AttributeValidationError, EventSchema, HookSchema, @@ -149,12 +150,12 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] { runId: string, changes: AttributeChange[] ): Promise => { - // Load existing attributes for the per-run cap check. Postgres - // applies the merge atomically via the UPDATE below, but the - // count cap requires knowing the existing size — fetch it first. - // The narrow window between this read and the UPDATE is - // documented as last-write-wins by arrival (concurrent writes - // limitation in the MVP changelog). + // Load existing attributes so the SDK-shape validator can produce + // a precise error message (cap, duplicate keys, reserved prefix, + // byte length). The authoritative cap enforcement happens inside + // the UPDATE statement below — see the `WHERE` clause — so the + // race between this read and the UPDATE cannot push the row past + // the per-run cap. const [existing] = await drizzle .select({ attributes: runs.attributes }) .from(runs) @@ -166,16 +167,16 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] { try { validateAttributeChanges(changes, { - existingCount: Object.keys(existing.attributes ?? {}).length, + existingKeys: Object.keys(existing.attributes ?? {}), }); } catch (err) { if (err instanceof AttributeValidationError) throw err; throw err; } - // Build a single SQL expression that applies all changes atomically. - // Sets fold into nested `jsonb_set` calls; removes fold into - // chained `-` (delete) operators. Returns the post-merge map. + // Build a single SQL expression that applies all changes + // atomically. Sets fold into nested `jsonb_set` calls; removes + // fold into chained `-` (delete) operators. let expr = sql`COALESCE(${runs.attributes}, '{}'::jsonb)`; for (const { key, value } of changes) { if (value === null) { @@ -185,17 +186,41 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] { } } + // Atomic cap enforcement: only commit the UPDATE if the + // post-merge key count fits the per-run cap. Computed against + // the *current* row state, so two concurrent writers adding + // disjoint keys at the cap boundary cannot both succeed. + // Drizzle re-renders `expr` twice in the SQL (`SET attributes = + // ...` + the count check); `jsonb_set` is cheap so the + // duplication is harmless. const [updated] = await drizzle .update(runs) .set({ attributes: expr as any, updatedAt: new Date(), }) - .where(eq(runs.runId, runId)) + .where( + and( + eq(runs.runId, runId), + sql`(SELECT COUNT(*) FROM jsonb_object_keys(${expr})) <= ${ATTRIBUTE_MAX_PER_RUN}` + ) + ) .returning({ attributes: runs.attributes }); if (!updated) { - throw new WorkflowRunNotFoundError(runId); + // Either the run vanished mid-call, or the cap-check WHERE + // clause rejected the UPDATE. Re-read to disambiguate. + const [stillThere] = await drizzle + .select({ attributes: runs.attributes }) + .from(runs) + .where(eq(runs.runId, runId)) + .limit(1); + if (!stillThere) { + throw new WorkflowRunNotFoundError(runId); + } + throw new AttributeValidationError( + `Run attribute count would exceed limit ${ATTRIBUTE_MAX_PER_RUN} after concurrent write` + ); } return { attributes: updated.attributes ?? {} }; diff --git a/packages/world/src/attributes.test.ts b/packages/world/src/attributes.test.ts index 2d78366d3e..3185142c29 100644 --- a/packages/world/src/attributes.test.ts +++ b/packages/world/src/attributes.test.ts @@ -88,9 +88,24 @@ describe('validateAttributeChanges', () => { value: 'v', })); expect(() => - validateAttributeChanges(changes, { existingCount: 1 }) + validateAttributeChanges(changes, { existingKeys: ['preexisting'] }) ).toThrow(AttributeValidationError); }); + + it('does not count upserts on already-present keys against the cap', () => { + // 64 keys already exist; the call updates one of them. Post-merge + // size is still 64 so the cap must accept it. + const existingKeys = Array.from( + { length: ATTRIBUTE_MAX_PER_RUN }, + (_, i) => `k${i}` + ); + expect(() => + validateAttributeChanges( + [{ key: 'k0', value: 'updated' }], + { existingKeys } + ) + ).not.toThrow(); + }); }); describe('applyAttributeChanges', () => { diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts index 18b86527a0..83fa3ef7da 100644 --- a/packages/world/src/attributes.ts +++ b/packages/world/src/attributes.ts @@ -44,12 +44,16 @@ export interface ExperimentalSetAttributesResult { export interface AttributeValidationContext { /** - * Existing attribute count on the run, used to enforce the per-run cap - * after merging in the incoming changes. Defaults to 0 so client-side - * validation (which does not know the existing snapshot) can still - * catch single-batch violations. + * Existing attribute keys on the run, used to enforce the per-run + * cap accurately against the post-merge total — an incoming change + * that updates an already-present key contributes zero net adds. + * + * If omitted, the cap check assumes every non-null change is a fresh + * add, which is conservative but still safe (the only false-positive + * shape rejects updates to existing keys at the cap boundary; the + * authoritative server-side check uses the real post-merge size). */ - existingCount?: number; + existingKeys?: Iterable; } /** @@ -121,15 +125,24 @@ export function validateAttributeValue( /** * Validate a batch of attribute changes. Throws `AttributeValidationError` - * on the first violation found. Use `existingCount` (in `context`) to - * enforce the per-run cap against the post-merge total. + * on the first violation found. Pass `existingKeys` (in `context`) so + * the per-run cap check can use the real post-merge total — without it + * the check is conservative and may reject an update to an + * already-present key when the run is at the cap. */ export function validateAttributeChanges( changes: AttributeChange[], context: AttributeValidationContext = {} ): void { const seenKeys = new Set(); + const existingKeys = + context.existingKeys === undefined + ? undefined + : context.existingKeys instanceof Set + ? (context.existingKeys as Set) + : new Set(context.existingKeys); let netAdds = 0; + let netDeletes = 0; for (const change of changes) { const keyError = validateAttributeKey(change.key); if (keyError) throw keyError; @@ -141,16 +154,23 @@ export function validateAttributeChanges( ); } seenKeys.add(change.key); - // Net adds counted optimistically — an existing key being set is also - // 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; + // Per-run cap accounting: an upsert on an already-present key is + // a zero-net change; a delete on an absent key is also zero-net. + // When `existingKeys` is undefined the cap check falls back to the + // conservative "every upsert is +1" shape, documented above. + if (change.value !== null) { + if (existingKeys === undefined || !existingKeys.has(change.key)) { + netAdds += 1; + } + } else if (existingKeys === undefined || existingKeys.has(change.key)) { + netDeletes += 1; + } } - const existing = context.existingCount ?? 0; - if (existing + netAdds > ATTRIBUTE_MAX_PER_RUN) { + const existing = existingKeys === undefined ? 0 : existingKeys.size; + const postMerge = existing + netAdds - netDeletes; + if (postMerge > ATTRIBUTE_MAX_PER_RUN) { throw new AttributeValidationError( - `Run attribute count would exceed limit ${ATTRIBUTE_MAX_PER_RUN} (existing ${existing} + incoming ${netAdds})` + `Run attribute count would exceed limit ${ATTRIBUTE_MAX_PER_RUN} (post-merge ${postMerge})` ); } } diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index 07cbffbaa5..cd0edd7359 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -4,6 +4,7 @@ import { pathsAliasHelper } from '@repo/lib/steps/paths-alias-test'; import { createHook, createWebhook, + experimental_setAttributes, FatalError, fetch, getStepMetadata, @@ -11,7 +12,6 @@ import { getWritable, type RequestWithResponse, RetryableError, - setAttributes, sleep, } from 'workflow'; import { getHookByToken, getRun, Run, resumeHook, start } from 'workflow/api'; @@ -3175,17 +3175,17 @@ export async function writableForwardedFromStepWorkflow(payload: string) { // Workflow Attributes MVP — workflow-body-only API. /** - * Calls `setAttributes` directly from the workflow body. The call is - * dispatched through the `__builtin_set_attributes` step bridge, so the - * mutation gets a `step_created`/`step_completed` event pair. The third - * call sets a key to `undefined` and the test verifies the key is - * absent from the final attribute map. + * Calls `experimental_setAttributes` directly from the workflow body. + * The call is dispatched through the `__builtin_set_attributes` step + * bridge, so the mutation gets a `step_created`/`step_completed` event + * pair. The third call sets a key to `undefined` and the test verifies + * the key is absent from the final attribute map. */ -export async function setAttributesWorkflow(input: number) { +export async function experimentalSetAttributesWorkflow(input: number) { 'use workflow'; - await setAttributes({ phase: 'init', source: 'workflow-body' }); + await experimental_setAttributes({ phase: 'init', source: 'workflow-body' }); const tripled = input * 3; - await setAttributes({ phase: 'done' }); - await setAttributes({ source: undefined }); + await experimental_setAttributes({ phase: 'done' }); + await experimental_setAttributes({ source: undefined }); return tripled; } From 2d5099b035e286acaa5556d365f4e9c7e85e6203 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 27 May 2026 14:34:57 +0200 Subject: [PATCH 19/26] fix(world-local): preserve discriminated-union narrowing in writeRunUnderLifecycleLock 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 `` 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) --- .../world-local/src/storage/events-storage.ts | 142 ++++++++++-------- 1 file changed, 80 insertions(+), 62 deletions(-) diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index dd8a453160..b020cd42fd 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -132,23 +132,26 @@ async function deleteAllWaitsForRun( * write are preserved. Without the re-read, an `experimentalSetAttributes` * call sandwiched between the lifecycle read and write would be * silently overwritten by the lifecycle write's stale attribute snapshot. + * + * `proposed` is the fully-constructed run row the caller wants to + * write (with the correct discriminated-union status branch). Only the + * `attributes` field is replaced with the freshest version inside the + * lock. */ -async function writeRunUnderLifecycleLock( +async function writeRunUnderLifecycleLock( basedir: string, runId: string, tag: string | undefined, - baselineRun: WorkflowRun, - overrides: Partial -): Promise { + proposed: T +): Promise { return withRunFileLock(runId, async () => { const fresh = await readJSON( taggedPath(basedir, 'runs', runId, tag), WorkflowRunSchema ); - const next: WorkflowRun = { - ...baselineRun, - ...overrides, - attributes: fresh?.attributes ?? baselineRun.attributes, + const next: T = { + ...proposed, + attributes: fresh?.attributes ?? proposed.attributes, }; await writeJSON(taggedPath(basedir, 'runs', runId, tag), next, { overwrite: true, @@ -573,38 +576,45 @@ export function createEventsStorage( return { run: currentRun }; } - run = await writeRunUnderLifecycleLock( - basedir, - effectiveRunId, - tag, - currentRun, - { - status: 'running', - output: undefined, - error: undefined, - completedAt: undefined, - startedAt: currentRun.startedAt ?? now, - updatedAt: now, - } - ); + run = await writeRunUnderLifecycleLock(basedir, effectiveRunId, tag, { + runId: currentRun.runId, + deploymentId: currentRun.deploymentId, + workflowName: currentRun.workflowName, + specVersion: currentRun.specVersion, + executionContext: currentRun.executionContext, + input: currentRun.input, + createdAt: currentRun.createdAt, + expiredAt: currentRun.expiredAt, + status: 'running', + output: undefined, + error: undefined, + completedAt: undefined, + startedAt: currentRun.startedAt ?? now, + updatedAt: now, + attributes: currentRun.attributes, + }); } } else if (data.eventType === 'run_completed' && 'eventData' in data) { const completedData = data.eventData as { output?: any }; // Reuse currentRun from validation (already read above) if (currentRun) { - run = await writeRunUnderLifecycleLock( - basedir, - effectiveRunId, - tag, - currentRun, - { - status: 'completed', - output: completedData.output, - error: undefined, - completedAt: now, - updatedAt: now, - } - ); + run = await writeRunUnderLifecycleLock(basedir, effectiveRunId, tag, { + runId: currentRun.runId, + deploymentId: currentRun.deploymentId, + workflowName: currentRun.workflowName, + specVersion: currentRun.specVersion, + executionContext: currentRun.executionContext, + input: currentRun.input, + createdAt: currentRun.createdAt, + expiredAt: currentRun.expiredAt, + startedAt: currentRun.startedAt, + status: 'completed', + output: completedData.output, + error: undefined, + completedAt: now, + updatedAt: now, + attributes: currentRun.attributes, + }); await Promise.all([ deleteAllHooksForRun(basedir, effectiveRunId), deleteAllWaitsForRun(basedir, effectiveRunId), @@ -620,20 +630,24 @@ export function createEventsStorage( // The error field is SerializedData (Uint8Array) produced by // dehydrateRunError. We store it verbatim — consumers hydrate it // via hydrateRunError to reconstruct the original thrown value. - run = await writeRunUnderLifecycleLock( - basedir, - effectiveRunId, - tag, - currentRun, - { - status: 'failed', - output: undefined, - error: failedData.error as Uint8Array, - errorCode: failedData.errorCode, - completedAt: now, - updatedAt: now, - } - ); + run = await writeRunUnderLifecycleLock(basedir, effectiveRunId, tag, { + runId: currentRun.runId, + deploymentId: currentRun.deploymentId, + workflowName: currentRun.workflowName, + specVersion: currentRun.specVersion, + executionContext: currentRun.executionContext, + input: currentRun.input, + createdAt: currentRun.createdAt, + expiredAt: currentRun.expiredAt, + startedAt: currentRun.startedAt, + status: 'failed', + output: undefined, + error: failedData.error as Uint8Array, + errorCode: failedData.errorCode, + completedAt: now, + updatedAt: now, + attributes: currentRun.attributes, + }); await Promise.all([ deleteAllHooksForRun(basedir, effectiveRunId), deleteAllWaitsForRun(basedir, effectiveRunId), @@ -642,19 +656,23 @@ export function createEventsStorage( } else if (data.eventType === 'run_cancelled') { // Reuse currentRun from validation (already read above) if (currentRun) { - run = await writeRunUnderLifecycleLock( - basedir, - effectiveRunId, - tag, - currentRun, - { - status: 'cancelled', - output: undefined, - error: undefined, - completedAt: now, - updatedAt: now, - } - ); + run = await writeRunUnderLifecycleLock(basedir, effectiveRunId, tag, { + runId: currentRun.runId, + deploymentId: currentRun.deploymentId, + workflowName: currentRun.workflowName, + specVersion: currentRun.specVersion, + executionContext: currentRun.executionContext, + input: currentRun.input, + createdAt: currentRun.createdAt, + expiredAt: currentRun.expiredAt, + startedAt: currentRun.startedAt, + status: 'cancelled', + output: undefined, + error: undefined, + completedAt: now, + updatedAt: now, + attributes: currentRun.attributes, + }); await Promise.all([ deleteAllHooksForRun(basedir, effectiveRunId), deleteAllWaitsForRun(basedir, effectiveRunId), From 284e90d8db8671bdd7984146898026ede700304a Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Wed, 27 May 2026 15:23:15 -0700 Subject: [PATCH 20/26] attributes: revert preview URL, add fire-and-forget/Promise.all/throw-after e2e tests, normalize empty attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .changeset/attributes-mvp-plan.md | 2 +- .../docs/v5/changelog/attributes-mvp.mdx | 116 +++++++++---- packages/core/e2e/e2e.test.ts | 85 ++++++++++ .../world-local/src/storage/events-storage.ts | 160 ++++++++++-------- packages/world-local/src/storage/legacy.ts | 1 + .../src/storage/runs-storage.test.ts | 44 +++++ packages/world-vercel/src/utils.ts | 3 +- packages/world/src/runs.ts | 23 ++- workbench/example/workflows/99_e2e.ts | 60 +++++++ 9 files changed, 377 insertions(+), 117 deletions(-) diff --git a/.changeset/attributes-mvp-plan.md b/.changeset/attributes-mvp-plan.md index fbe8426d1c..789b92e193 100644 --- a/.changeset/attributes-mvp-plan.md +++ b/.changeset/attributes-mvp-plan.md @@ -7,4 +7,4 @@ 'workflow': patch --- -Add experimental `setAttributes()` for attaching plaintext string key/value metadata to a workflow run. Callable from a workflow body; the call is dispatched as a step so the mutation is recorded on the event log. +Add `experimental_setAttributes()` for attaching plaintext string key/value metadata to a workflow run. Callable from a workflow body; the call is dispatched as a step so the mutation is recorded on the event log. `run.attributes` always reads as a record across worlds (defaults to `{}` after schema parsing). diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx index 21e63326f5..d667b14d93 100644 --- a/docs/content/docs/v5/changelog/attributes-mvp.mdx +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -129,7 +129,48 @@ export async function myWorkflow(orderId: string) { The workflow-body path validates input inside the VM and then dispatches the canonical `AttributeChange[]` through an internal `__builtin_set_attributes` step bridge — see "How workflow-body dispatch works" below. The mutation is materialized on the run entity by the step body. -The host-side export (the one resolved when the `workflow` package-exports condition is **not** `workflow`, i.e. step bodies and plain host code) throws `FatalError` with a message pointing callers back to the workflow body. There is no step-side dispatch path in the MVP. +The host-side export (the one resolved when the `workflow` package-exports condition is **not** `workflow`, i.e. step bodies and plain host code) throws `FatalError` with a message pointing callers back to the workflow body. There is no step-side dispatch path in the MVP — the workflow-body-only restriction is a scope cut for the MVP, not an architectural constraint, and may be relaxed in a follow-up. + +#### Usage patterns + +Three call patterns are supported. Pick by whether you need ordering and whether you need to know the write has landed before continuing: + +**Awaited (default).** The workflow blocks on the write before continuing. Use this when later workflow logic depends on the attribute being persisted, or when you want errors (validation, world rejection) to surface immediately. + +```ts +'use workflow'; +await experimental_setAttributes({ phase: 'init' }); +const result = await processOrder(); +await experimental_setAttributes({ phase: 'done', orderId: result.id }); +``` + +**Fire-and-forget (`void`).** Drop the `await` to let the workflow proceed without blocking. The pending step queues at the next workflow suspension (the next `await` on a runtime primitive such as a step result, `sleep`, hook, etc.). This is the canonical pattern for observability / tracking metadata where the workflow doesn't depend on the write — and works the same way as any other fire-and-forget step call. + +```ts +'use workflow'; +void experimental_setAttributes({ phase: 'init', orderId }); +// Workflow doesn't block. The step queues during the next await. +const result = await processOrder(); +void experimental_setAttributes({ phase: 'done' }); +return result; +``` + +There are two trade-offs to know about: + +1. **Order of arrival at the world is not workflow-source order.** Fire-and-forget steps run out-of-band on the queue worker. A `void` write to one key followed by an `await` write to the same key may race; LWW-by-arrival applies (see "Concurrent writes" below). + +2. **The last `void` before `return` may not land.** If you place a `void experimental_setAttributes(...)` immediately before returning, with no intervening `await` on a runtime primitive, the workflow runtime's drain-on-completion path commits the `step_created` event but does *not* queue the step body for execution — the step never runs. In practice workflows almost always have a meaningful `await` somewhere after the last fire-and-forget call (a step, a sleep, a hook); if you don't, add `await sleep('0s')` before returning, or use the awaited form for that final write. + +**Parallel (`Promise.all`).** Multiple calls dispatch concurrently. Writes to disjoint keys all land. Writes to the same key resolve last-write-wins by *arrival order at the world* (not the order the workflow body issued the calls) — so don't use `Promise.all` for writes that must observe a specific order to the same key. + +```ts +'use workflow'; +await Promise.all([ + experimental_setAttributes({ phase: 'init' }), // disjoint keys — all land + experimental_setAttributes({ orderId: id }), + experimental_setAttributes({ tenant: 't1' }), +]); +``` #### Optional world support @@ -157,16 +198,17 @@ Because attributes are stored plaintext on the `WorkflowRun` entity, any UI that ## Trade-offs and known limitations -### Concurrent writes to the same run +### Concurrent writes to the same key -The MVP applies **last-write-wins by arrival order**. Two concurrent `experimental_setAttributes` calls writing to the same key produce a final state matching whichever request the world processes second. There is no conditional / `expectedValue` semantic and no `unique: true` mode. +The MVP applies **last-write-wins by arrival order at the world**. Two concurrent `experimental_setAttributes` calls writing the same key produce a final state matching whichever request the world processes second. There is no conditional / `expectedValue` semantic and no `unique: true` mode. -Workflow body writes are serialized by the workflow VM (one step at a time within a single workflow body), so the concurrent case in practice arises when: +Writes from a single `await`-ed call chain are serialized by the workflow VM and land in workflow-source order. The concurrent / racy case applies to: -- Multiple steps within the same workflow run write the same key in parallel (`Promise.all`). -- A step writes a key while another concurrent step is also writing. +- Multiple `experimental_setAttributes` calls inside one `Promise.all` writing the same key (the workflow VM dispatches them concurrently; the world sees them in scheduler order, not source order). +- `void experimental_setAttributes(...)` followed by another call to the same key — the fire-and-forget step may still be in flight when the next call lands. +- Multiple workflows writing the same key on the same run (rare — usually one workflow owns a run). -Both are well-defined under LWW-by-arrival, but applications that need conditional semantics should wait for the 5.0.0 release. We will not retrofit conditional writes onto the MVP path. +Disjoint-key writes are unaffected: every call lands, regardless of pattern. Applications that need conditional semantics on a shared key should wait for the 5.0.0 release; we will not retrofit conditional writes onto the MVP path. ### No event-log history @@ -186,26 +228,35 @@ The 5.0.0 path closes this gap. 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 - -- Unit tests in `@workflow/core` for: - - Validation rules (length, `$` prefix, post-merge count cap) - - `experimental_setAttributes({})` is a no-op (no dispatch, no events) - - `undefined` value normalizes to a `null`-valued change on the wire - - Workflow VM with no `useStep` bound (= called outside a workflow) throws `FatalError` - - Host-side stub (resolved from step or plain code) throws `FatalError` -- `world-local` integration tests: - - `experimental_setAttributes` from a workflow body materializes onto the run row - - Merge semantics: setting + unsetting in a single call leaves the run with the expected snapshot - - Idempotency: repeated identical calls converge to the same final state -- `world-postgres` integration tests: same shape as `world-local` -- `world-vercel` contract test: verifies the request body shape matches the documented wire format -- E2E in `workbench/nextjs-turbopack`: - 1. Start a workflow - 2. Workflow body calls `experimental_setAttributes({ tenant: 't1', phase: 'init' })` - 3. Awaits a step that calls `experimental_setAttributes({ phase: 'processing', orderId: 'ord_123' })` - 4. Then `experimental_setAttributes({ phase: 'done', orderId: undefined })` - 5. Assert via `world.runs.get(runId)`: `attributes === { tenant: 't1', phase: 'done' }` +## Test coverage + +Unit tests in `@workflow/world` (validation surface) and `@workflow/core` (VM-side dispatch + host-side stub): + +- Validation rules — key length, value byte cap, `$` prefix, per-batch duplicates, post-merge count cap (with `existingKeys` so updates of present keys don't falsely trip the cap) +- `experimental_setAttributes({})` is a no-op (no dispatch, no events) +- `undefined` value normalizes to a `null`-valued change on the wire +- Workflow VM with no `WORKFLOW_USE_STEP` bound throws `FatalError` +- Host-side stub (resolved when not in the workflow VM) throws `FatalError` directing the caller back to a workflow body + +Integration tests in `world-local`: + +- Upsert, merge across calls, unset via `null`, set-and-unset in a single batch +- Validation rejection (reserved prefix, oversize key, oversize value, post-merge cap) +- Cap-boundary updates: a write that only updates existing keys must succeed even when the run is exactly at the cap +- Idempotency: repeated identical calls converge to the same final snapshot +- Concurrent writes serialize via the per-run mutex; no lost writes across 20 parallel calls + +Integration tests in `world-postgres`: + +- Upsert, merge across calls, unset via `null` (the SQL `jsonb_set` / `-` chain is exercised through these) +- Atomic cap enforcement inside the `UPDATE`'s `WHERE` clause; concurrent writers cannot collectively push past the per-run cap + +End-to-end 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 (the test inspects the run's event log to confirm a `step_created` / `step_completed` pair was emitted) +- Fire-and-forget (`void experimental_setAttributes`) attributes land before the run terminates +- `Promise.all` of disjoint-key writes — every key persists +- Workflow throws after an awaited `experimental_setAttributes` — the attribute persists on the now-`failed` run (the per-run file lock on `run_failed` re-reads inside the critical section so the attribute snapshot survives the lifecycle write) ## Migration to 5.0.0 @@ -266,17 +317,10 @@ Validation lives in a single helper exported from `@workflow/world` (`validateAt ### Run row reconstruction had to thread `attributes` through -`world-local`'s events storage rebuilds the run row on every lifecycle event (`run_started`, `run_completed`, `run_failed`, `run_cancelled`) by explicitly listing fields rather than spreading. Without forwarding `attributes: currentRun.attributes` through each branch, any attribute set before the run completed would be silently dropped by the next lifecycle event. The fix was local to the four reconstruction sites — the field is otherwise untouched by event-sourced code. A subtler race remains: a `experimental_setAttributes` write landing in the same async window as a `run_completed` event read can be clobbered, but that's the same LWW-by-arrival semantic documented above. +`world-local`'s events storage rebuilds the run row on every lifecycle event (`run_started`, `run_completed`, `run_failed`, `run_cancelled`) by explicitly listing fields rather than spreading. Without forwarding `attributes` through each branch, any attribute set before the run completed would be silently dropped by the next lifecycle event. Each lifecycle write now also takes the per-run file lock (`withRunFileLock`) and re-reads the on-disk run inside the critical section, so an `experimental_setAttributes` call that lands in the same async window as a `run_completed` event is no longer clobbered. ### Optional world method: feature-detect, warn once `runs.experimentalSetAttributes` is optional on the `World` interface so community worlds (Redis, MongoDB, Turso, etc.) continue to build and run without adopting the experimental API. The SDK helper feature-detects the method's presence on first dispatch; if absent, it logs a single `console.warn` for the lifetime of the process and resolves silently for that call and all subsequent calls. Users do not need to feature-detect in their own code — calling `experimental_setAttributes` against an unsupporting world is safe but ineffective. -### Tests - -- **`@workflow/world`** — 18 unit tests covering validation rules (key length, reserved prefix, value byte cap, batch cap, duplicate keys) and the `applyAttributeChanges` merge helper. -- **`@workflow/core`** — 10 unit tests covering context detection (workflow body / step body / neither), undefined-to-null normalization, empty-record no-op, validation surface (throws `FatalError`), and feature-detect-with-single-warning when the world lacks support. -- **`@workflow/world-local`** — 10 integration tests covering upsert, merge across calls, unset via null, set-and-unset in a single batch, not-found errors, all four validation flavors, and concurrent writes (per-run mutex prevents lost writes). -- **`@workflow/world-postgres`** — 3 integration tests added under the existing Postgres container suite covering upsert, merge across calls, and unset via null. The SQL-side merge (`jsonb_set` / `-`) is exercised through these. - -End-to-end coverage in the workbench app is intentionally deferred — running through the full SWC plugin + workflow VM path can land once the workflow-server preview deployment carrying the endpoint is live. +See "Test coverage" above for the full test surface that ships with this change. diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index 9dbbba42e6..347a283fbe 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -3472,5 +3472,90 @@ describe('e2e', () => { expect(attrStepEvents.length).toBeGreaterThanOrEqual(2); } ); + + test( + 'fire-and-forget: void experimental_setAttributes lands without awaiting', + { timeout: 30_000 }, + async () => { + const run = await start( + await e2e('experimentalSetAttributesFireAndForgetWorkflow'), + [] + ); + const output = await run.returnValue; + expect(output).toBe('completed'); + + const world = await getWorld(); + + // The workflow returned `'completed'` without awaiting any of + // the three `experimental_setAttributes` calls. The third call + // (`phase: 'done'`) is dispatched immediately before `return` + // and may not have landed by the time `run.returnValue` + // resolves — the drain-on-completion path commits the + // step_created event before run_completed lands, but the step + // body itself runs out-of-band on the queue worker. Poll + // until the eventual state converges. + let persisted = await world.runs.get(run.runId); + const deadline = Date.now() + 15_000; + while ( + persisted?.attributes?.phase !== 'done' && + Date.now() < deadline + ) { + await new Promise((resolve) => setTimeout(resolve, 250)); + persisted = await world.runs.get(run.runId); + } + + expect(persisted?.attributes).toEqual({ + phase: 'done', + mode: 'fire-and-forget', + }); + } + ); + + test( + 'Promise.all of disjoint-key writes: every key lands', + { timeout: 30_000 }, + async () => { + const run = await start( + await e2e('experimentalSetAttributesParallelWorkflow'), + [] + ); + const output = await run.returnValue; + expect(output).toBe('done'); + + const world = await getWorld(); + const persisted = await world.runs.get(run.runId); + + // Disjoint-key writes never collide, so all three keys must + // land regardless of dispatch ordering at the world. + expect(persisted?.attributes).toEqual({ a: '1', b: '2', c: '3' }); + } + ); + + test( + 'workflow throws after awaited setAttributes: attribute still persists on the failed run', + { timeout: 30_000 }, + async () => { + const run = await start( + await e2e('experimentalSetAttributesThrowsAfterWorkflow'), + [] + ); + // The workflow throws — `returnValue` rejects. + await expect(run.returnValue).rejects.toThrow(/intentional failure/); + + const world = await getWorld(); + const persisted = await world.runs.get(run.runId); + + expect(persisted?.status).toBe('failed'); + // The attribute was awaited and therefore landed before the + // throw. The `run_failed` lifecycle write must preserve the + // attribute snapshot — the per-run file lock guarantees the + // lifecycle handler reads the fresh value off disk inside its + // critical section before writing the failed state back. + expect(persisted?.attributes).toEqual({ + phase: 'about-to-fail', + reason: 'intentional', + }); + } + ); }); }); diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index b020cd42fd..31740df540 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -298,6 +298,7 @@ export function createEventsStorage( error: undefined, startedAt: undefined, completedAt: undefined, + attributes: {}, createdAt: now, updatedAt: now, }; @@ -547,6 +548,7 @@ export function createEventsStorage( error: undefined, startedAt: undefined, completedAt: undefined, + attributes: {}, createdAt: now, updatedAt: now, }; @@ -576,45 +578,55 @@ export function createEventsStorage( return { run: currentRun }; } - run = await writeRunUnderLifecycleLock(basedir, effectiveRunId, tag, { - runId: currentRun.runId, - deploymentId: currentRun.deploymentId, - workflowName: currentRun.workflowName, - specVersion: currentRun.specVersion, - executionContext: currentRun.executionContext, - input: currentRun.input, - createdAt: currentRun.createdAt, - expiredAt: currentRun.expiredAt, - status: 'running', - output: undefined, - error: undefined, - completedAt: undefined, - startedAt: currentRun.startedAt ?? now, - updatedAt: now, - attributes: currentRun.attributes, - }); + run = await writeRunUnderLifecycleLock( + basedir, + effectiveRunId, + tag, + { + runId: currentRun.runId, + deploymentId: currentRun.deploymentId, + workflowName: currentRun.workflowName, + specVersion: currentRun.specVersion, + executionContext: currentRun.executionContext, + input: currentRun.input, + createdAt: currentRun.createdAt, + expiredAt: currentRun.expiredAt, + status: 'running', + output: undefined, + error: undefined, + completedAt: undefined, + startedAt: currentRun.startedAt ?? now, + updatedAt: now, + attributes: currentRun.attributes, + } + ); } } else if (data.eventType === 'run_completed' && 'eventData' in data) { const completedData = data.eventData as { output?: any }; // Reuse currentRun from validation (already read above) if (currentRun) { - run = await writeRunUnderLifecycleLock(basedir, effectiveRunId, tag, { - runId: currentRun.runId, - deploymentId: currentRun.deploymentId, - workflowName: currentRun.workflowName, - specVersion: currentRun.specVersion, - executionContext: currentRun.executionContext, - input: currentRun.input, - createdAt: currentRun.createdAt, - expiredAt: currentRun.expiredAt, - startedAt: currentRun.startedAt, - status: 'completed', - output: completedData.output, - error: undefined, - completedAt: now, - updatedAt: now, - attributes: currentRun.attributes, - }); + run = await writeRunUnderLifecycleLock( + basedir, + effectiveRunId, + tag, + { + runId: currentRun.runId, + deploymentId: currentRun.deploymentId, + workflowName: currentRun.workflowName, + specVersion: currentRun.specVersion, + executionContext: currentRun.executionContext, + input: currentRun.input, + createdAt: currentRun.createdAt, + expiredAt: currentRun.expiredAt, + startedAt: currentRun.startedAt, + status: 'completed', + output: completedData.output, + error: undefined, + completedAt: now, + updatedAt: now, + attributes: currentRun.attributes, + } + ); await Promise.all([ deleteAllHooksForRun(basedir, effectiveRunId), deleteAllWaitsForRun(basedir, effectiveRunId), @@ -630,24 +642,29 @@ export function createEventsStorage( // The error field is SerializedData (Uint8Array) produced by // dehydrateRunError. We store it verbatim — consumers hydrate it // via hydrateRunError to reconstruct the original thrown value. - run = await writeRunUnderLifecycleLock(basedir, effectiveRunId, tag, { - runId: currentRun.runId, - deploymentId: currentRun.deploymentId, - workflowName: currentRun.workflowName, - specVersion: currentRun.specVersion, - executionContext: currentRun.executionContext, - input: currentRun.input, - createdAt: currentRun.createdAt, - expiredAt: currentRun.expiredAt, - startedAt: currentRun.startedAt, - status: 'failed', - output: undefined, - error: failedData.error as Uint8Array, - errorCode: failedData.errorCode, - completedAt: now, - updatedAt: now, - attributes: currentRun.attributes, - }); + run = await writeRunUnderLifecycleLock( + basedir, + effectiveRunId, + tag, + { + runId: currentRun.runId, + deploymentId: currentRun.deploymentId, + workflowName: currentRun.workflowName, + specVersion: currentRun.specVersion, + executionContext: currentRun.executionContext, + input: currentRun.input, + createdAt: currentRun.createdAt, + expiredAt: currentRun.expiredAt, + startedAt: currentRun.startedAt, + status: 'failed', + output: undefined, + error: failedData.error as Uint8Array, + errorCode: failedData.errorCode, + completedAt: now, + updatedAt: now, + attributes: currentRun.attributes, + } + ); await Promise.all([ deleteAllHooksForRun(basedir, effectiveRunId), deleteAllWaitsForRun(basedir, effectiveRunId), @@ -656,23 +673,28 @@ export function createEventsStorage( } else if (data.eventType === 'run_cancelled') { // Reuse currentRun from validation (already read above) if (currentRun) { - run = await writeRunUnderLifecycleLock(basedir, effectiveRunId, tag, { - runId: currentRun.runId, - deploymentId: currentRun.deploymentId, - workflowName: currentRun.workflowName, - specVersion: currentRun.specVersion, - executionContext: currentRun.executionContext, - input: currentRun.input, - createdAt: currentRun.createdAt, - expiredAt: currentRun.expiredAt, - startedAt: currentRun.startedAt, - status: 'cancelled', - output: undefined, - error: undefined, - completedAt: now, - updatedAt: now, - attributes: currentRun.attributes, - }); + run = await writeRunUnderLifecycleLock( + basedir, + effectiveRunId, + tag, + { + runId: currentRun.runId, + deploymentId: currentRun.deploymentId, + workflowName: currentRun.workflowName, + specVersion: currentRun.specVersion, + executionContext: currentRun.executionContext, + input: currentRun.input, + createdAt: currentRun.createdAt, + expiredAt: currentRun.expiredAt, + startedAt: currentRun.startedAt, + status: 'cancelled', + output: undefined, + error: undefined, + completedAt: now, + updatedAt: now, + attributes: currentRun.attributes, + } + ); await Promise.all([ deleteAllHooksForRun(basedir, effectiveRunId), deleteAllWaitsForRun(basedir, effectiveRunId), diff --git a/packages/world-local/src/storage/legacy.ts b/packages/world-local/src/storage/legacy.ts index bd1915c066..a20bef2941 100644 --- a/packages/world-local/src/storage/legacy.ts +++ b/packages/world-local/src/storage/legacy.ts @@ -48,6 +48,7 @@ export async function handleLegacyEvent( error: undefined, completedAt: now, updatedAt: now, + attributes: currentRun.attributes, }; const runPath = resolveWithinBase(basedir, 'runs', `${runId}.json`); await writeJSON(runPath, run, { overwrite: true }); diff --git a/packages/world-local/src/storage/runs-storage.test.ts b/packages/world-local/src/storage/runs-storage.test.ts index 8eddf9d121..c749799d21 100644 --- a/packages/world-local/src/storage/runs-storage.test.ts +++ b/packages/world-local/src/storage/runs-storage.test.ts @@ -124,6 +124,50 @@ describe('runs.experimentalSetAttributes (world-local)', () => { ).rejects.toBeInstanceOf(AttributeValidationError); }); + it('updates at the cap boundary do not falsely trip the limit', async () => { + const run = await newRun(); + // Fill to exactly the cap. + const initial = Array.from({ length: 64 }, (_, i) => ({ + key: `k${i}`, + value: 'v', + })); + await storage.runs.experimentalSetAttributes!(run.runId, initial); + + // Updating an existing key (no growth) must succeed even at the cap. + const result = await storage.runs.experimentalSetAttributes!(run.runId, [ + { key: 'k0', value: 'updated' }, + ]); + expect(result.attributes.k0).toBe('updated'); + expect(Object.keys(result.attributes)).toHaveLength(64); + }); + + it('repeated identical calls are idempotent', async () => { + const run = await newRun(); + const changes = [ + { key: 'phase', value: 'init' as string | null }, + { key: 'tenant', value: 't1' as string | null }, + ]; + + const first = await storage.runs.experimentalSetAttributes!( + run.runId, + changes + ); + const second = await storage.runs.experimentalSetAttributes!( + run.runId, + changes + ); + const third = await storage.runs.experimentalSetAttributes!( + run.runId, + changes + ); + + // All three converge on the same snapshot — second/third are no-op + // upserts of the same values. + expect(first.attributes).toEqual({ phase: 'init', tenant: 't1' }); + expect(second.attributes).toEqual(first.attributes); + expect(third.attributes).toEqual(first.attributes); + }); + it('rejects when post-merge count exceeds limit', async () => { const run = await newRun(); // Pre-fill to within the cap. The MVP cap is 64. diff --git a/packages/world-vercel/src/utils.ts b/packages/world-vercel/src/utils.ts index de96d16ecb..8acefbaa69 100644 --- a/packages/world-vercel/src/utils.ts +++ b/packages/world-vercel/src/utils.ts @@ -59,8 +59,7 @@ function httpLog( * `main` — rewritten by external CI for branch-deployment testing. * Prefer `VERCEL_WORKFLOW_SERVER_URL` for deployment-time configuration. */ -const WORKFLOW_SERVER_URL_OVERRIDE = - 'https://workflow-server-git-peter-attributes-mvp.vercel.sh'; +const WORKFLOW_SERVER_URL_OVERRIDE = ''; /** * Per-request timeout for HTTP calls to workflow-server (in ms). diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index 932f0b1210..37f8e868a4 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -65,17 +65,22 @@ export const WorkflowRunBaseSchema = z.object({ errorCode: z.string().optional(), /** * Plaintext string-string metadata attached to the run via - * `setAttributes()` (or, in the future, materialized from `attr_set` - * events). Stored unencrypted alongside other plaintext fields so - * observability surfaces can read it without going through the - * decryption pipeline. + * `experimental_setAttributes()` (or, in the future, materialized + * from `attr_set` events). Stored unencrypted alongside other + * plaintext fields so observability surfaces can read it without + * going through the decryption pipeline. * - * EXPERIMENTAL (MVP): runs created before this field landed read as - * `undefined`. The full Workflow Attributes feature replaces the - * direct-mutation MVP path with an event-sourced model — see the - * attributes-mvp changelog entry. + * Defaults to `{}` after schema parsing so consumers always receive + * a record regardless of world. World adapters need not initialize + * the field on disk — `world-local` JSON files written before this + * field existed, and rows from any other adapter that omits the + * column, both read as `{}` after Zod parses them. + * + * EXPERIMENTAL (MVP): the full Workflow Attributes feature replaces + * the direct-mutation MVP path with an event-sourced model — see + * the attributes-mvp changelog entry. */ - attributes: z.record(z.string(), z.string()).optional(), + attributes: z.record(z.string(), z.string()).default({}), expiredAt: z.coerce.date().optional(), startedAt: z.coerce.date().optional(), completedAt: z.coerce.date().optional(), diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index cd0edd7359..13f47818d7 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -3189,3 +3189,63 @@ export async function experimentalSetAttributesWorkflow(input: number) { await experimental_setAttributes({ source: undefined }); return tripled; } + +/** + * Fire-and-forget pattern: `void experimental_setAttributes(...)` lets + * the workflow body proceed without blocking on the attribute write. + * The step is queued on the next workflow suspension (any `await` that + * yields back to the runtime). This is the canonical observability / + * tracking pattern — the caller doesn't care about ordering, just that + * the data eventually appears on the run. + * + * Caveat: a `void` call placed immediately before `return` (with no + * intervening `await` on a runtime primitive) may not land — drain on + * completion commits the step_created event but does not queue the + * step body itself. Real workflows almost always have a real await + * after the last `void`, but it's worth knowing. + */ +export async function experimentalSetAttributesFireAndForgetWorkflow() { + 'use workflow'; + void experimental_setAttributes({ phase: 'init', mode: 'fire-and-forget' }); + // The next `await sleep` forces a suspension; the init step queues + // and executes during that window. + await sleep('100ms'); + void experimental_setAttributes({ phase: 'mid' }); + // Same — this sleep queues the `phase: 'mid'` step. + await sleep('100ms'); + void experimental_setAttributes({ phase: 'done' }); + // Final sleep gives the `phase: 'done'` step a suspension to queue + // on before we return. + await sleep('100ms'); + return 'completed'; +} + +/** + * `Promise.all` of multiple `experimental_setAttributes` calls writing + * disjoint keys: every key must land. The world-side per-run mutex (or + * per-row atomic SQL update) serializes the writes; LWW-by-arrival only + * matters when two calls touch the same key. + */ +export async function experimentalSetAttributesParallelWorkflow() { + 'use workflow'; + await Promise.all([ + experimental_setAttributes({ a: '1' }), + experimental_setAttributes({ b: '2' }), + experimental_setAttributes({ c: '3' }), + ]); + return 'done'; +} + +/** + * Workflow throws after awaiting `experimental_setAttributes`. The + * attribute write completes before the throw, so the persisted run row + * should carry the attribute even though the run ends up `failed`. + */ +export async function experimentalSetAttributesThrowsAfterWorkflow() { + 'use workflow'; + await experimental_setAttributes({ + phase: 'about-to-fail', + reason: 'intentional', + }); + throw new FatalError('intentional failure to test attribute persistence'); +} From 6ab1a11f7a533a680be141fc52717773a7d4fda4 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Wed, 27 May 2026 15:57:58 -0700 Subject: [PATCH 21/26] fix(core): drain queues pending step messages, not just creates step_created events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../docs/v5/changelog/attributes-mvp.mdx | 8 +-- packages/core/src/workflow.ts | 62 ++++++++++++++++--- workbench/example/workflows/99_e2e.ts | 21 ++----- 3 files changed, 61 insertions(+), 30 deletions(-) diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx index d667b14d93..def484fca6 100644 --- a/docs/content/docs/v5/changelog/attributes-mvp.mdx +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -144,7 +144,7 @@ const result = await processOrder(); await experimental_setAttributes({ phase: 'done', orderId: result.id }); ``` -**Fire-and-forget (`void`).** Drop the `await` to let the workflow proceed without blocking. The pending step queues at the next workflow suspension (the next `await` on a runtime primitive such as a step result, `sleep`, hook, etc.). This is the canonical pattern for observability / tracking metadata where the workflow doesn't depend on the write — and works the same way as any other fire-and-forget step call. +**Fire-and-forget (`void`).** Drop the `await` to let the workflow proceed without blocking. The pending step queues at the next workflow suspension; a `void` call placed right before `return` is queued by drain-on-completion. Either way, the step body executes — making this the canonical pattern for observability / tracking metadata where the workflow doesn't depend on the write. ```ts 'use workflow'; @@ -155,11 +155,7 @@ void experimental_setAttributes({ phase: 'done' }); return result; ``` -There are two trade-offs to know about: - -1. **Order of arrival at the world is not workflow-source order.** Fire-and-forget steps run out-of-band on the queue worker. A `void` write to one key followed by an `await` write to the same key may race; LWW-by-arrival applies (see "Concurrent writes" below). - -2. **The last `void` before `return` may not land.** If you place a `void experimental_setAttributes(...)` immediately before returning, with no intervening `await` on a runtime primitive, the workflow runtime's drain-on-completion path commits the `step_created` event but does *not* queue the step body for execution — the step never runs. In practice workflows almost always have a meaningful `await` somewhere after the last fire-and-forget call (a step, a sleep, a hook); if you don't, add `await sleep('0s')` before returning, or use the awaited form for that final write. +Trade-off: order of arrival at the world is not workflow-source order. Fire-and-forget steps run out-of-band on the queue worker. A `void` write to one key followed by an `await` write to the same key may race; LWW-by-arrival applies (see "Concurrent writes" below). **Parallel (`Promise.all`).** Multiple calls dispatch concurrently. Writes to disjoint keys all land. Writes to the same key resolve last-write-wins by *arrival order at the world* (not the order the workflow body issued the calls) — so don't use `Promise.all` for writes that must observe a specific order to the same key. diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 0990f8d573..83d6040978 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -17,6 +17,7 @@ import { ENOTSUP, WorkflowSuspension } from './global.js'; import { runtimeLogger } from './logger.js'; import type { WorkflowOrchestratorContext } from './private.js'; import { getPortLazy } from './runtime/get-port-lazy.js'; +import { getWorkflowQueueName, queueMessage } from './runtime/helpers.js'; import { handleSuspension } from './runtime/suspension-handler.js'; import { getWorld } from './runtime/world.js'; import { @@ -33,7 +34,7 @@ import { WORKFLOW_USE_STEP, } from './symbols.js'; import * as Attribute from './telemetry/semantic-conventions.js'; -import { trace } from './telemetry.js'; +import { serializeTraceCarrier, trace } from './telemetry.js'; import { getWorkflowRunStreamId } from './util.js'; import { createContext } from './vm/index.js'; import { @@ -51,14 +52,22 @@ import { createSleep } from './workflow/sleep.js'; * Treats end-of-run like a final suspension: any operation the workflow code * spawned but didn't `await` — abort hook resumes, hook creations/disposals, * sleep waits, step queueings — gets committed to the event log via the - * suspension handler before the run is marked terminal. + * suspension handler, and pending step bodies are enqueued to the workflow + * queue so they actually execute, before the run is marked terminal. * * This matches normal JS semantics where `setTimeout(fn, ...)` etc. continue - * running after the surrounding function returns. Most importantly, it ensures - * `controller.abort()` called as the last statement of a workflow actually - * propagates to in-flight steps on other compute instances — without this, - * the abort hook is created but never resumed and the cancellation never - * reaches the running step. + * running after the surrounding function returns. Two side effects this is + * load-bearing for: + * + * - `controller.abort()` called as the last statement of a workflow + * propagates to in-flight steps on other compute instances — without the + * drain the abort hook is created but never resumed and the cancellation + * never reaches the running step. + * - Fire-and-forget step calls (`void someStep()` / + * `void experimental_setAttributes(...)`) that produce real side effects + * actually run — without the queueing step here, the step_created event + * would land but no worker would pick up the body, and the side effect + * would silently never happen. * * Drain failures are swallowed: the workflow's own outcome (the user's return * value or thrown error) is the source of truth; secondary cleanup that fails @@ -91,11 +100,48 @@ async function drainPendingQueueItems( try { const world = await getWorld(); const synthesized = new WorkflowSuspension(pendingQueue, vmGlobalThis); - await handleSuspension({ + const suspensionResult = await handleSuspension({ suspension: synthesized, world, run: workflowRun, }); + + // `handleSuspension` commits step_created / hook_created / wait_created + // events but does not enqueue step bodies for execution — the normal + // runtime loop (`runtime.ts`) handles that, choosing between inline and + // queued execution. At workflow completion we have no inline path, so + // every newly-owned step needs to land on the queue or its body + // (e.g. the `__builtin_set_attributes` write) never runs. + // + // Mirror the runtime's enqueue pattern: idempotency keyed on + // correlationId so concurrent drains (crash recovery, queue redelivery) + // dedupe cleanly. Only enqueue steps THIS drain wrote step_created for — + // if another writer already owned the step (e.g. via flow redelivery), + // they'll enqueue it themselves. + const stepsToQueue = suspensionResult.pendingSteps.filter((step) => + suspensionResult.createdStepCorrelationIds.has(step.correlationId) + ); + if (stepsToQueue.length > 0) { + const queueName = getWorkflowQueueName(workflowRun.workflowName); + const traceCarrier = await serializeTraceCarrier(); + const requestedAt = new Date(); + await Promise.all( + stepsToQueue.map((step) => + queueMessage( + world, + queueName, + { + runId, + stepId: step.correlationId, + stepName: step.stepName, + traceCarrier, + requestedAt, + }, + { idempotencyKey: step.correlationId } + ) + ) + ); + } } catch (err) { runtimeLogger.warn( `Failed to drain pending queue items for ${outcome} workflow run`, diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index 13f47818d7..e44fb9be51 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -3193,30 +3193,19 @@ export async function experimentalSetAttributesWorkflow(input: number) { /** * Fire-and-forget pattern: `void experimental_setAttributes(...)` lets * the workflow body proceed without blocking on the attribute write. - * The step is queued on the next workflow suspension (any `await` that - * yields back to the runtime). This is the canonical observability / - * tracking pattern — the caller doesn't care about ordering, just that - * the data eventually appears on the run. - * - * Caveat: a `void` call placed immediately before `return` (with no - * intervening `await` on a runtime primitive) may not land — drain on - * completion commits the step_created event but does not queue the - * step body itself. Real workflows almost always have a real await - * after the last `void`, but it's worth knowing. + * Calls placed before a workflow suspension queue immediately; calls + * placed right before `return` are queued by drain-on-completion. + * Either way, every fire-and-forget step body executes — making this + * the canonical pattern for observability / tracking metadata. */ export async function experimentalSetAttributesFireAndForgetWorkflow() { 'use workflow'; void experimental_setAttributes({ phase: 'init', mode: 'fire-and-forget' }); - // The next `await sleep` forces a suspension; the init step queues - // and executes during that window. await sleep('100ms'); void experimental_setAttributes({ phase: 'mid' }); - // Same — this sleep queues the `phase: 'mid'` step. await sleep('100ms'); + // No `await` after this call — drain queues it on workflow completion. void experimental_setAttributes({ phase: 'done' }); - // Final sleep gives the `phase: 'done'` step a suspension to queue - // on before we return. - await sleep('100ms'); return 'completed'; } From 6a5ef8624d6e6900447bc0eff726a651798f5cfd Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Wed, 27 May 2026 16:26:02 -0700 Subject: [PATCH 22/26] attributes: skip fire-and-forget last-call-before-return test as .todo, revert drain queueing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigation showed the drain queueMessage addition (f46faa1d7) 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) --- .../docs/v5/changelog/attributes-mvp.mdx | 10 ++- packages/core/e2e/e2e.test.ts | 57 ++++++--------- packages/core/src/workflow.ts | 69 +++++-------------- workbench/example/workflows/99_e2e.ts | 14 ++-- 4 files changed, 53 insertions(+), 97 deletions(-) diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx index def484fca6..fb7a7fd2f4 100644 --- a/docs/content/docs/v5/changelog/attributes-mvp.mdx +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -144,18 +144,22 @@ const result = await processOrder(); await experimental_setAttributes({ phase: 'done', orderId: result.id }); ``` -**Fire-and-forget (`void`).** Drop the `await` to let the workflow proceed without blocking. The pending step queues at the next workflow suspension; a `void` call placed right before `return` is queued by drain-on-completion. Either way, the step body executes — making this the canonical pattern for observability / tracking metadata where the workflow doesn't depend on the write. +**Fire-and-forget (`void`).** Drop the `await` to let the workflow proceed without blocking. The pending step queues on the workflow's next suspension (any `await` on a runtime primitive — a step, `sleep`, a hook). This is the canonical pattern for observability / tracking metadata where the workflow doesn't depend on the write. ```ts 'use workflow'; void experimental_setAttributes({ phase: 'init', orderId }); -// Workflow doesn't block. The step queues during the next await. +// Workflow doesn't block. The step queues on the next runtime await. const result = await processOrder(); void experimental_setAttributes({ phase: 'done' }); return result; ``` -Trade-off: order of arrival at the world is not workflow-source order. Fire-and-forget steps run out-of-band on the queue worker. A `void` write to one key followed by an `await` write to the same key may race; LWW-by-arrival applies (see "Concurrent writes" below). +Two trade-offs to know about: + +1. **Order of arrival at the world is not workflow-source order.** Fire-and-forget steps run out-of-band on the queue worker. A `void` write to one key followed by an `await` write to the same key may race; LWW-by-arrival applies (see "Concurrent writes" below). + +2. **The last `void` before `return` may not land.** If you place a `void experimental_setAttributes(...)` immediately before returning, with no intervening `await` on a runtime primitive, drain-on-completion commits the `step_created` event but the step body is not reliably dispatched before the run transitions to its terminal status — see the architectural note in the "Implementation notes" section. In practice workflows almost always have an `await` after the last fire-and-forget call (a step, a sleep, a hook); if you don't, add `await sleep('0s')` before returning, or use the awaited form for that final write. **Parallel (`Promise.all`).** Multiple calls dispatch concurrently. Writes to disjoint keys all land. Writes to the same key resolve last-write-wins by *arrival order at the world* (not the order the workflow body issued the calls) — so don't use `Promise.all` for writes that must observe a specific order to the same key. diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index 347a283fbe..2936d2542c 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -3473,42 +3473,27 @@ describe('e2e', () => { } ); - test( - 'fire-and-forget: void experimental_setAttributes lands without awaiting', - { timeout: 30_000 }, - async () => { - const run = await start( - await e2e('experimentalSetAttributesFireAndForgetWorkflow'), - [] - ); - const output = await run.returnValue; - expect(output).toBe('completed'); - - const world = await getWorld(); - - // The workflow returned `'completed'` without awaiting any of - // the three `experimental_setAttributes` calls. The third call - // (`phase: 'done'`) is dispatched immediately before `return` - // and may not have landed by the time `run.returnValue` - // resolves — the drain-on-completion path commits the - // step_created event before run_completed lands, but the step - // body itself runs out-of-band on the queue worker. Poll - // until the eventual state converges. - let persisted = await world.runs.get(run.runId); - const deadline = Date.now() + 15_000; - while ( - persisted?.attributes?.phase !== 'done' && - Date.now() < deadline - ) { - await new Promise((resolve) => setTimeout(resolve, 250)); - persisted = await world.runs.get(run.runId); - } - - expect(persisted?.attributes).toEqual({ - phase: 'done', - mode: 'fire-and-forget', - }); - } + // TODO(attributes): un-skip once the platform supports executing + // step bodies queued by `drainPendingQueueItems`. Today the step + // worker calls `executeStep` → `world.events.create('step_started')`, + // which the server rejects with `RunExpiredError` (HTTP 410) once + // the run has transitioned to a terminal state. Drain commits the + // `step_created` event and enqueues the message, but by the time + // the queue worker picks it up `run_completed` has landed and the + // worker skips the step ("Workflow run X has already completed, + // skipping step Y" in step-executor.ts). + // + // The fire-and-forget pattern itself works for `void` calls placed + // before any later `await` on a runtime primitive (the suspension + // queues the step before the run terminates) — see the awaited + // workflow-body test above for that coverage. What's broken is + // specifically "last void immediately before return". Either the + // platform needs to keep accepting `step_started` for steps the + // workflow itself queued at drain time, or attribute writes need + // a non-step dispatch path (planned for the full V1 attributes + // feature where attr_set is a first-class event type). + test.todo( + 'fire-and-forget: void experimental_setAttributes lands without awaiting' ); test( diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 83d6040978..bb37110418 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -17,7 +17,6 @@ import { ENOTSUP, WorkflowSuspension } from './global.js'; import { runtimeLogger } from './logger.js'; import type { WorkflowOrchestratorContext } from './private.js'; import { getPortLazy } from './runtime/get-port-lazy.js'; -import { getWorkflowQueueName, queueMessage } from './runtime/helpers.js'; import { handleSuspension } from './runtime/suspension-handler.js'; import { getWorld } from './runtime/world.js'; import { @@ -34,7 +33,7 @@ import { WORKFLOW_USE_STEP, } from './symbols.js'; import * as Attribute from './telemetry/semantic-conventions.js'; -import { serializeTraceCarrier, trace } from './telemetry.js'; +import { trace } from './telemetry.js'; import { getWorkflowRunStreamId } from './util.js'; import { createContext } from './vm/index.js'; import { @@ -52,22 +51,23 @@ import { createSleep } from './workflow/sleep.js'; * Treats end-of-run like a final suspension: any operation the workflow code * spawned but didn't `await` — abort hook resumes, hook creations/disposals, * sleep waits, step queueings — gets committed to the event log via the - * suspension handler, and pending step bodies are enqueued to the workflow - * queue so they actually execute, before the run is marked terminal. + * suspension handler before the run is marked terminal. * * This matches normal JS semantics where `setTimeout(fn, ...)` etc. continue - * running after the surrounding function returns. Two side effects this is - * load-bearing for: + * running after the surrounding function returns. Most importantly, it ensures + * `controller.abort()` called as the last statement of a workflow actually + * propagates to in-flight steps on other compute instances — without this, + * the abort hook is created but never resumed and the cancellation never + * reaches the running step. * - * - `controller.abort()` called as the last statement of a workflow - * propagates to in-flight steps on other compute instances — without the - * drain the abort hook is created but never resumed and the cancellation - * never reaches the running step. - * - Fire-and-forget step calls (`void someStep()` / - * `void experimental_setAttributes(...)`) that produce real side effects - * actually run — without the queueing step here, the step_created event - * would land but no worker would pick up the body, and the side effect - * would silently never happen. + * NOTE: drain only commits the `*_created` events; it does NOT enqueue step + * bodies for execution. The platform's step worker rejects `step_started` + * for runs that have already transitioned to terminal (`RunExpiredError`), + * so a step queued here would be skipped anyway. Fire-and-forget step calls + * with side effects therefore work only when followed by some later `await` + * on a runtime primitive that triggers a real suspension (the normal + * runtime loop in `runtime.ts` queues the step there). A `void` placed + * immediately before `return` is not reliably executed. * * Drain failures are swallowed: the workflow's own outcome (the user's return * value or thrown error) is the source of truth; secondary cleanup that fails @@ -100,48 +100,11 @@ async function drainPendingQueueItems( try { const world = await getWorld(); const synthesized = new WorkflowSuspension(pendingQueue, vmGlobalThis); - const suspensionResult = await handleSuspension({ + await handleSuspension({ suspension: synthesized, world, run: workflowRun, }); - - // `handleSuspension` commits step_created / hook_created / wait_created - // events but does not enqueue step bodies for execution — the normal - // runtime loop (`runtime.ts`) handles that, choosing between inline and - // queued execution. At workflow completion we have no inline path, so - // every newly-owned step needs to land on the queue or its body - // (e.g. the `__builtin_set_attributes` write) never runs. - // - // Mirror the runtime's enqueue pattern: idempotency keyed on - // correlationId so concurrent drains (crash recovery, queue redelivery) - // dedupe cleanly. Only enqueue steps THIS drain wrote step_created for — - // if another writer already owned the step (e.g. via flow redelivery), - // they'll enqueue it themselves. - const stepsToQueue = suspensionResult.pendingSteps.filter((step) => - suspensionResult.createdStepCorrelationIds.has(step.correlationId) - ); - if (stepsToQueue.length > 0) { - const queueName = getWorkflowQueueName(workflowRun.workflowName); - const traceCarrier = await serializeTraceCarrier(); - const requestedAt = new Date(); - await Promise.all( - stepsToQueue.map((step) => - queueMessage( - world, - queueName, - { - runId, - stepId: step.correlationId, - stepName: step.stepName, - traceCarrier, - requestedAt, - }, - { idempotencyKey: step.correlationId } - ) - ) - ); - } } catch (err) { runtimeLogger.warn( `Failed to drain pending queue items for ${outcome} workflow run`, diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index e44fb9be51..324bd29f02 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -3193,10 +3193,15 @@ export async function experimentalSetAttributesWorkflow(input: number) { /** * Fire-and-forget pattern: `void experimental_setAttributes(...)` lets * the workflow body proceed without blocking on the attribute write. - * Calls placed before a workflow suspension queue immediately; calls - * placed right before `return` are queued by drain-on-completion. - * Either way, every fire-and-forget step body executes — making this - * the canonical pattern for observability / tracking metadata. + * Each `void` call queues a step on the workflow's next suspension — + * any later `await` on a runtime primitive (a step, a sleep, a hook). + * This is the canonical pattern for observability / tracking metadata + * where the workflow doesn't depend on the write. + * + * Note: a `void` call placed *immediately before* `return` (with no + * later `await`) is currently unreliable — the step is committed but + * the queue worker skips it once `run_completed` lands. See the + * `test.todo(...)` for `fire-and-forget` in `packages/core/e2e/e2e.test.ts`. */ export async function experimentalSetAttributesFireAndForgetWorkflow() { 'use workflow'; @@ -3204,7 +3209,6 @@ export async function experimentalSetAttributesFireAndForgetWorkflow() { await sleep('100ms'); void experimental_setAttributes({ phase: 'mid' }); await sleep('100ms'); - // No `await` after this call — drain queues it on workflow completion. void experimental_setAttributes({ phase: 'done' }); return 'completed'; } From 9be88c39001d87f2697b6eec62217800373971e5 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Wed, 27 May 2026 16:46:26 -0700 Subject: [PATCH 23/26] docs(attributes): mark new usage-pattern snippets as skip-typecheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/content/docs/v5/changelog/attributes-mvp.mdx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx index fb7a7fd2f4..4b3a6e30ee 100644 --- a/docs/content/docs/v5/changelog/attributes-mvp.mdx +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -137,6 +137,8 @@ Three call patterns are supported. Pick by whether you need ordering and whether **Awaited (default).** The workflow blocks on the write before continuing. Use this when later workflow logic depends on the attribute being persisted, or when you want errors (validation, world rejection) to surface immediately. +{/*@skip-typecheck - snippet, not runnable code*/} + ```ts 'use workflow'; await experimental_setAttributes({ phase: 'init' }); @@ -146,6 +148,8 @@ await experimental_setAttributes({ phase: 'done', orderId: result.id }); **Fire-and-forget (`void`).** Drop the `await` to let the workflow proceed without blocking. The pending step queues on the workflow's next suspension (any `await` on a runtime primitive — a step, `sleep`, a hook). This is the canonical pattern for observability / tracking metadata where the workflow doesn't depend on the write. +{/*@skip-typecheck - snippet, not runnable code*/} + ```ts 'use workflow'; void experimental_setAttributes({ phase: 'init', orderId }); @@ -163,11 +167,13 @@ Two trade-offs to know about: **Parallel (`Promise.all`).** Multiple calls dispatch concurrently. Writes to disjoint keys all land. Writes to the same key resolve last-write-wins by *arrival order at the world* (not the order the workflow body issued the calls) — so don't use `Promise.all` for writes that must observe a specific order to the same key. +{/*@skip-typecheck - snippet, not runnable code*/} + ```ts 'use workflow'; await Promise.all([ - experimental_setAttributes({ phase: 'init' }), // disjoint keys — all land - experimental_setAttributes({ orderId: id }), + experimental_setAttributes({ phase: 'init' }), // disjoint keys — all land + experimental_setAttributes({ orderId: 'ord_123' }), experimental_setAttributes({ tenant: 't1' }), ]); ``` From f66576bf841ec99e56db34796d485cf97fcd9201 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Wed, 27 May 2026 19:04:26 -0700 Subject: [PATCH 24/26] attributes: add { allowReservedAttributes } opt-in for framework callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../docs/v5/changelog/attributes-mvp.mdx | 23 +++++++- packages/core/src/set-attributes.ts | 6 +- .../core/src/workflow/set-attributes.test.ts | 44 ++++++++++++--- packages/core/src/workflow/set-attributes.ts | 56 +++++++++++++++++-- packages/workflow/src/internal/builtins.ts | 8 ++- .../src/storage/runs-storage.test.ts | 27 +++++++++ .../world-local/src/storage/runs-storage.ts | 6 +- packages/world-postgres/src/storage.ts | 4 +- packages/world-vercel/src/runs.ts | 9 ++- packages/world-vercel/src/storage.ts | 4 +- packages/world/src/attributes.test.ts | 38 ++++++++++++- packages/world/src/attributes.ts | 38 +++++++++++-- packages/world/src/interfaces.ts | 9 ++- 13 files changed, 241 insertions(+), 31 deletions(-) diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx index 4b3a6e30ee..c0430dff8f 100644 --- a/docs/content/docs/v5/changelog/attributes-mvp.mdx +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -25,7 +25,6 @@ Calling `experimental_setAttributes` from a step body or plain host code is inte - Enumerating attribute keys or values (`listAttributeKeys`, `listAttributeValues`) - Writer attribution / event-log history of attribute changes - Any non-string value type -- Reserved-key (`$`-prefixed) namespace See [PR #1933](https://github.com/vercel/workflow/pull/1933) for the design of those features. @@ -109,11 +108,28 @@ function experimental_setAttributes( Validation (shared helper, applied both client-side and server-side): -- Key: 1–256 chars, must not start with `$` (reserved) +- Key: 1–256 chars, must not start with `$` (reserved — see "Reserved `$` namespace" below) - Value: ≤ 256 bytes UTF-8 - Maximum 64 attributes per run (validated against the post-merge snapshot when the server applies) - Violations throw `FatalError` from `@workflow/errors` +#### Reserved `$` namespace + +Keys starting with `$` are reserved for framework and library code built on top of the workflow SDK (telemetry tags, agent metadata, future platform-emitted attributes, etc.). User code calling `experimental_setAttributes({ '$foo': 'bar' })` throws `FatalError` so accidental collisions with tooling-owned keys can't slip through. + +Framework / library authors that own a `$`-prefixed sub-namespace can opt in per-call: + +{/*@skip-typecheck - snippet, not runnable code*/} + +```ts +await experimental_setAttributes( + { '$agent.kind': 'durable-agent' }, + { allowReservedAttributes: true } +); +``` + +The flag is per-call (no run-level "this run accepts reserved keys" mode), so each framework call site explicitly declares intent. Don't enable it from user code — misuse can conflict with observability surfaces, agent dashboards, or future platform features that rely on the reserved namespace. + `experimental_setAttributes` is callable only from a workflow body: ```ts @@ -239,8 +255,10 @@ If you need behavior the MVP does not provide (read, list, filter, initial attri Unit tests in `@workflow/world` (validation surface) and `@workflow/core` (VM-side dispatch + host-side stub): - Validation rules — key length, value byte cap, `$` prefix, per-batch duplicates, post-merge count cap (with `existingKeys` so updates of present keys don't falsely trip the cap) +- Reserved `$` namespace — rejected by default, accepted when `allowReservedAttributes: true` is passed (both for `validateAttributeKey` and at the batch level via `validateAttributeChanges`) - `experimental_setAttributes({})` is a no-op (no dispatch, no events) - `undefined` value normalizes to a `null`-valued change on the wire +- The `{ allowReservedAttributes: true }` opt-in is forwarded through the step bridge so the world receives the flag - Workflow VM with no `WORKFLOW_USE_STEP` bound throws `FatalError` - Host-side stub (resolved when not in the workflow VM) throws `FatalError` directing the caller back to a workflow body @@ -248,6 +266,7 @@ Integration tests in `world-local`: - Upsert, merge across calls, unset via `null`, set-and-unset in a single batch - Validation rejection (reserved prefix, oversize key, oversize value, post-merge cap) +- Reserved-prefix escape hatch via `{ allowReservedAttributes: true }` (per-call, not sticky on the run) - Cap-boundary updates: a write that only updates existing keys must succeed even when the run is exactly at the cap - Idempotency: repeated identical calls converge to the same final snapshot - Concurrent writes serialize via the per-run mutex; no lost writes across 20 parallel calls diff --git a/packages/core/src/set-attributes.ts b/packages/core/src/set-attributes.ts index c7154d6d43..95676f3994 100644 --- a/packages/core/src/set-attributes.ts +++ b/packages/core/src/set-attributes.ts @@ -1,4 +1,7 @@ import { FatalError } from '@workflow/errors'; +import type { ExperimentalSetAttributesOptions } from './workflow/set-attributes.js'; + +export type { ExperimentalSetAttributesOptions }; /** * Host-side stub for `experimental_setAttributes`. The real @@ -12,7 +15,8 @@ import { FatalError } from '@workflow/errors'; * event-sourced through the workflow runtime so they survive replay. */ export async function experimental_setAttributes( - _attrs: Record + _attrs: Record, + _options?: ExperimentalSetAttributesOptions ): Promise { throw new FatalError( "experimental_setAttributes() must be called from a 'use workflow' function. " + diff --git a/packages/core/src/workflow/set-attributes.test.ts b/packages/core/src/workflow/set-attributes.test.ts index 8ffa0d3e1b..3f5aa1e0a8 100644 --- a/packages/core/src/workflow/set-attributes.test.ts +++ b/packages/core/src/workflow/set-attributes.test.ts @@ -7,14 +7,18 @@ describe('workflow.experimental_setAttributes', () => { const dispatchCalls: Array<{ stepName: string; changes: Array<{ key: string; value: string | null }>; + options: { allowReservedAttributes?: boolean } | undefined; }> = []; beforeEach(() => { dispatchCalls.length = 0; (globalThis as Record)[WORKFLOW_USE_STEP] = vi.fn( (stepName: string) => - async (changes: Array<{ key: string; value: string | null }>) => { - dispatchCalls.push({ stepName, changes }); + async ( + changes: Array<{ key: string; value: string | null }>, + options?: { allowReservedAttributes?: boolean } + ) => { + dispatchCalls.push({ stepName, changes, options }); } ); }); @@ -32,6 +36,7 @@ describe('workflow.experimental_setAttributes', () => { { key: 'phase', value: 'init' }, { key: 'orderId', value: 'ord_1' }, ], + options: {}, }, ]); }); @@ -45,6 +50,7 @@ describe('workflow.experimental_setAttributes', () => { { key: 'phase', value: 'done' }, { key: 'stale', value: null }, ], + options: {}, }, ]); }); @@ -56,15 +62,39 @@ describe('workflow.experimental_setAttributes', () => { it('throws FatalError when the workflow runtime has not initialized useStep', async () => { delete (globalThis as Record)[WORKFLOW_USE_STEP]; - await expect(experimental_setAttributes({ phase: 'init' })).rejects.toBeInstanceOf( - FatalError - ); + await expect( + experimental_setAttributes({ phase: 'init' }) + ).rejects.toBeInstanceOf(FatalError); }); it('throws FatalError for reserved-prefix keys before any dispatch', async () => { - await expect(experimental_setAttributes({ $sys: 'x' })).rejects.toBeInstanceOf( - FatalError + await expect( + experimental_setAttributes({ $sys: 'x' }) + ).rejects.toBeInstanceOf(FatalError); + expect(dispatchCalls).toHaveLength(0); + }); + + it('dispatches reserved-prefix keys when allowReservedAttributes opt-in is set, and forwards the flag to the step', async () => { + await experimental_setAttributes( + { '$framework.kind': 'agent' }, + { allowReservedAttributes: true } ); + expect(dispatchCalls).toEqual([ + { + stepName: '__builtin_set_attributes', + changes: [{ key: '$framework.kind', value: 'agent' }], + options: { allowReservedAttributes: true }, + }, + ]); + }); + + it('still rejects reserved-prefix keys when allowReservedAttributes is explicitly false', async () => { + await expect( + experimental_setAttributes( + { '$framework.kind': 'agent' }, + { allowReservedAttributes: false } + ) + ).rejects.toBeInstanceOf(FatalError); expect(dispatchCalls).toHaveLength(0); }); diff --git a/packages/core/src/workflow/set-attributes.ts b/packages/core/src/workflow/set-attributes.ts index 571659a488..7d50783435 100644 --- a/packages/core/src/workflow/set-attributes.ts +++ b/packages/core/src/workflow/set-attributes.ts @@ -6,6 +6,29 @@ import { } from '@workflow/world'; import { WORKFLOW_USE_STEP } from '../symbols.js'; +/** + * Options accepted by `experimental_setAttributes`. + */ +export interface ExperimentalSetAttributesOptions { + /** + * Permit attribute keys that start with the reserved `$` prefix. + * **Default: `false`.** + * + * The `$` namespace is reserved for framework and library code that + * is built on top of the workflow SDK (telemetry, agent metadata, + * platform-emitted tags, etc.). User code MUST NOT write keys in + * this namespace; validation rejects them so accidental collisions + * with tooling-owned keys can't slip through. + * + * Only flip this to `true` if your caller is itself a framework or + * library that owns a `$`-prefixed sub-namespace and knows the + * conventions of any other tools writing into it. Misuse can + * conflict with observability surfaces, agent dashboards, or future + * platform features that rely on the reserved namespace. + */ + allowReservedAttributes?: boolean; +} + /** * Attach plaintext string key/value metadata to the current workflow run. * @@ -23,6 +46,13 @@ import { WORKFLOW_USE_STEP } from '../symbols.js'; * empty record is a no-op. `value: undefined` removes the key from the * run's attribute map. * + * **Reserved namespace.** Keys starting with `$` are reserved for + * framework/library code (telemetry, agent metadata, etc.). User code + * trying to write a `$`-prefixed key throws `FatalError`. If you are a + * framework author and need to set a reserved key, pass + * `{ allowReservedAttributes: true }` as the second argument — see + * `ExperimentalSetAttributesOptions` for the trade-offs. + * * **WARNING**: While this feature is experimental, calling e.g. * `Promise.all([experimental_setAttributes({ a: '1' }), experimental_setAttributes({ a: '2' })])` * is not guaranteed to be ordered consistently, but the equivalent @@ -40,9 +70,18 @@ import { WORKFLOW_USE_STEP } from '../symbols.js'; * await experimental_setAttributes({ orderId: undefined }); // remove * } * ``` + * + * @example Framework / library code writing into the reserved namespace. + * ```ts + * await experimental_setAttributes( + * { '$agent.kind': 'durable-agent' }, + * { allowReservedAttributes: true } + * ); + * ``` */ export async function experimental_setAttributes( - attrs: Record + attrs: Record, + options: ExperimentalSetAttributesOptions = {} ): Promise { if (attrs === null || typeof attrs !== 'object' || Array.isArray(attrs)) { throw new FatalError( @@ -58,8 +97,9 @@ export async function experimental_setAttributes( }) ); if (changes.length === 0) return; + const allowReservedAttributes = options.allowReservedAttributes === true; try { - validateAttributeChanges(changes); + validateAttributeChanges(changes, { allowReservedAttributes }); } catch (err) { if (err instanceof AttributeValidationError) { throw new FatalError(err.message); @@ -67,7 +107,12 @@ export async function experimental_setAttributes( throw err; } const useStep = (globalThis as Record)[WORKFLOW_USE_STEP] as - | ((stepName: string) => (changes: AttributeChange[]) => Promise) + | (( + stepName: string + ) => ( + changes: AttributeChange[], + options?: { allowReservedAttributes?: boolean } + ) => Promise) | undefined; if (!useStep) { throw new FatalError( @@ -75,5 +120,8 @@ export async function experimental_setAttributes( 'It must be called from within a workflow body (`use workflow`).' ); } - await useStep('__builtin_set_attributes')(changes); + await useStep('__builtin_set_attributes')( + changes, + allowReservedAttributes ? { allowReservedAttributes: true } : {} + ); } diff --git a/packages/workflow/src/internal/builtins.ts b/packages/workflow/src/internal/builtins.ts index 6aaf63a559..b89d46b83a 100644 --- a/packages/workflow/src/internal/builtins.ts +++ b/packages/workflow/src/internal/builtins.ts @@ -41,7 +41,8 @@ const UNSUPPORTED_WORLD_WARNED = Symbol.for( * and `@vercel/queue` from this step file. */ export async function __builtin_set_attributes( - changes: Array<{ key: string; value: string | null }> + changes: Array<{ key: string; value: string | null }>, + options?: { allowReservedAttributes?: boolean } ) { 'use step'; if (changes.length === 0) return; @@ -67,7 +68,8 @@ export async function __builtin_set_attributes( runs?: { experimentalSetAttributes?: ( runId: string, - changes: Array<{ key: string; value: string | null }> + changes: Array<{ key: string; value: string | null }>, + options?: { allowReservedAttributes?: boolean } ) => Promise; }; } @@ -88,5 +90,5 @@ export async function __builtin_set_attributes( return; } - await world.runs.experimentalSetAttributes(runId, changes); + await world.runs.experimentalSetAttributes(runId, changes, options); } diff --git a/packages/world-local/src/storage/runs-storage.test.ts b/packages/world-local/src/storage/runs-storage.test.ts index c749799d21..fe06a0dc32 100644 --- a/packages/world-local/src/storage/runs-storage.test.ts +++ b/packages/world-local/src/storage/runs-storage.test.ts @@ -106,6 +106,33 @@ describe('runs.experimentalSetAttributes (world-local)', () => { ).rejects.toBeInstanceOf(AttributeValidationError); }); + it('accepts reserved-prefix keys when allowReservedAttributes is set (framework escape hatch)', async () => { + const run = await newRun(); + const result = await storage.runs.experimentalSetAttributes!( + run.runId, + [ + { + key: `${RESERVED_ATTRIBUTE_KEY_PREFIX}framework.kind`, + value: 'agent', + }, + { key: 'phase', value: 'init' }, + ], + { allowReservedAttributes: true } + ); + expect(result.attributes).toEqual({ + [`${RESERVED_ATTRIBUTE_KEY_PREFIX}framework.kind`]: 'agent', + phase: 'init', + }); + + // Without the flag on a follow-up write, the rejection still + // fires — the opt-in is per-call, not sticky on the run. + await expect( + storage.runs.experimentalSetAttributes!(run.runId, [ + { key: `${RESERVED_ATTRIBUTE_KEY_PREFIX}other`, value: 'x' }, + ]) + ).rejects.toBeInstanceOf(AttributeValidationError); + }); + it('rejects keys over the max length', async () => { const run = await newRun(); await expect( diff --git a/packages/world-local/src/storage/runs-storage.ts b/packages/world-local/src/storage/runs-storage.ts index 40e2b4d46a..c88538d072 100644 --- a/packages/world-local/src/storage/runs-storage.ts +++ b/packages/world-local/src/storage/runs-storage.ts @@ -51,7 +51,8 @@ export interface LocalRunsStorage { }; experimentalSetAttributes( runId: string, - changes: AttributeChange[] + changes: AttributeChange[], + options?: { allowReservedAttributes?: boolean } ): Promise; } @@ -157,7 +158,7 @@ export function createRunsStorage( return result; }) as LocalRunsStorage['list'], - experimentalSetAttributes: async (runId, changes) => { + experimentalSetAttributes: async (runId, changes, options) => { assertSafeEntityId('runId', runId); return withRunFileLock(runId, async () => { @@ -178,6 +179,7 @@ export function createRunsStorage( try { validateAttributeChanges(changes, { existingKeys: Object.keys(run.attributes ?? {}), + allowReservedAttributes: options?.allowReservedAttributes, }); } catch (err) { if (err instanceof AttributeValidationError) { diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index fcddf23c39..35982a4f7c 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -148,7 +148,8 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] { experimentalSetAttributes: async ( runId: string, - changes: AttributeChange[] + changes: AttributeChange[], + options?: { allowReservedAttributes?: boolean } ): Promise => { // Load existing attributes so the SDK-shape validator can produce // a precise error message (cap, duplicate keys, reserved prefix, @@ -168,6 +169,7 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] { try { validateAttributeChanges(changes, { existingKeys: Object.keys(existing.attributes ?? {}), + allowReservedAttributes: options?.allowReservedAttributes, }); } catch (err) { if (err instanceof AttributeValidationError) throw err; diff --git a/packages/world-vercel/src/runs.ts b/packages/world-vercel/src/runs.ts index bc1e723c3d..b2390a148f 100644 --- a/packages/world-vercel/src/runs.ts +++ b/packages/world-vercel/src/runs.ts @@ -272,19 +272,26 @@ const ExperimentalSetAttributesResponseSchema = z.object({ * forward-compatible with the full 5.0.0 attributes feature — only the * endpoint path changes. * + * `options.allowReservedAttributes` opts the request into permitting + * `$`-prefixed keys (framework-only — see the SDK helper for details). + * The flag is forwarded to the server via the request body. + * * EXPERIMENTAL: tied to the MVP write-only attributes API. See * `docs/content/docs/v5/changelog/attributes-mvp.mdx`. */ export async function experimentalSetAttributes( runId: string, changes: AttributeChange[], + options?: { allowReservedAttributes?: boolean }, config?: APIConfig ): Promise { try { const response = await makeRequest({ endpoint: `/v2/runs/${encodeURIComponent(runId)}/attributes`, options: { method: 'POST' }, - data: { changes }, + data: options?.allowReservedAttributes + ? { changes, allowReservedAttributes: true } + : { changes }, config, schema: ExperimentalSetAttributesResponseSchema, }); diff --git a/packages/world-vercel/src/storage.ts b/packages/world-vercel/src/storage.ts index 966a5691af..e8bb54834d 100644 --- a/packages/world-vercel/src/storage.ts +++ b/packages/world-vercel/src/storage.ts @@ -22,8 +22,8 @@ export function createStorage(config?: APIConfig): Storage { getWorkflowRun(id, params, config)) as Storage['runs']['get'], list: ((params?: any) => listWorkflowRuns(params, config)) as Storage['runs']['list'], - experimentalSetAttributes: (runId, changes) => - experimentalSetAttributes(runId, changes, config), + experimentalSetAttributes: (runId, changes, options) => + experimentalSetAttributes(runId, changes, options, config), }, steps: { get: ((runId: string, stepId: string, params?: any) => diff --git a/packages/world/src/attributes.test.ts b/packages/world/src/attributes.test.ts index 3185142c29..5cdbd000ae 100644 --- a/packages/world/src/attributes.test.ts +++ b/packages/world/src/attributes.test.ts @@ -30,11 +30,23 @@ describe('validateAttributeKey', () => { ).toBeNull(); }); - it('rejects keys starting with the reserved prefix', () => { + it('rejects keys starting with the reserved prefix by default', () => { expect(validateAttributeKey('$internal')).toBeInstanceOf( AttributeValidationError ); }); + + it('accepts reserved-prefix keys when allowReservedAttributes is set', () => { + expect( + validateAttributeKey('$internal', { allowReservedAttributes: true }) + ).toBeNull(); + }); + + it('still rejects reserved-prefix keys when allowReservedAttributes is explicitly false', () => { + expect( + validateAttributeKey('$internal', { allowReservedAttributes: false }) + ).toBeInstanceOf(AttributeValidationError); + }); }); describe('validateAttributeValue', () => { @@ -99,10 +111,30 @@ describe('validateAttributeChanges', () => { { length: ATTRIBUTE_MAX_PER_RUN }, (_, i) => `k${i}` ); + expect(() => + validateAttributeChanges([{ key: 'k0', value: 'updated' }], { + existingKeys, + }) + ).not.toThrow(); + }); + + it('rejects reserved-prefix keys in a batch by default', () => { + expect(() => + validateAttributeChanges([ + { key: 'phase', value: 'init' }, + { key: '$framework.kind', value: 'agent' }, + ]) + ).toThrow(AttributeValidationError); + }); + + it('accepts reserved-prefix keys when allowReservedAttributes is set', () => { expect(() => validateAttributeChanges( - [{ key: 'k0', value: 'updated' }], - { existingKeys } + [ + { key: 'phase', value: 'init' }, + { key: '$framework.kind', value: 'agent' }, + ], + { allowReservedAttributes: true } ) ).not.toThrow(); }); diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts index 83fa3ef7da..178b1a3973 100644 --- a/packages/world/src/attributes.ts +++ b/packages/world/src/attributes.ts @@ -54,6 +54,27 @@ export interface AttributeValidationContext { * authoritative server-side check uses the real post-merge size). */ existingKeys?: Iterable; + /** + * Permit keys that start with the reserved `$` prefix. Default `false`. + * + * The `$` namespace is reserved for framework / library code built on + * top of the workflow SDK (telemetry, agent metadata, etc.). User code + * MUST NOT set it; if a user tries, validation rejects the call so + * accidental conflicts with tooling-owned keys can't slip through. + * + * Set this to `true` only from framework-level code that is aware of + * the namespace conventions in use. Misuse can collide with tooling + * keys and break observability surfaces. + */ + allowReservedAttributes?: boolean; +} + +export interface AttributeKeyValidationOptions { + /** + * Permit keys that start with the reserved `$` prefix. See the + * `allowReservedAttributes` note on `AttributeValidationContext`. + */ + allowReservedAttributes?: boolean; } /** @@ -75,9 +96,13 @@ const valueByteLength = (value: string): number => * Validate a single attribute key. Returns an `AttributeValidationError` * on violation, or `null` if the key is valid. Returning instead of * throwing lets callers aggregate or wrap the failure as needed. + * + * The reserved `$`-prefix rule is enforced by default; framework code + * may pass `allowReservedAttributes: true` to opt out. */ export function validateAttributeKey( - key: string + key: string, + options: AttributeKeyValidationOptions = {} ): AttributeValidationError | null { if (typeof key !== 'string') { return new AttributeValidationError( @@ -92,9 +117,12 @@ export function validateAttributeKey( `Attribute key length ${key.length} exceeds limit ${ATTRIBUTE_KEY_MAX_LENGTH}: ${JSON.stringify(key.slice(0, 32))}…` ); } - if (key.startsWith(RESERVED_ATTRIBUTE_KEY_PREFIX)) { + if ( + !options.allowReservedAttributes && + key.startsWith(RESERVED_ATTRIBUTE_KEY_PREFIX) + ) { return new AttributeValidationError( - `Attribute key ${JSON.stringify(key)} starts with reserved prefix "${RESERVED_ATTRIBUTE_KEY_PREFIX}"` + `Attribute key ${JSON.stringify(key)} starts with reserved prefix "${RESERVED_ATTRIBUTE_KEY_PREFIX}" — that namespace is reserved for framework/library code. Set { allowReservedAttributes: true } only if your caller is framework-level.` ); } return null; @@ -144,7 +172,9 @@ export function validateAttributeChanges( let netAdds = 0; let netDeletes = 0; for (const change of changes) { - const keyError = validateAttributeKey(change.key); + const keyError = validateAttributeKey(change.key, { + allowReservedAttributes: context.allowReservedAttributes, + }); if (keyError) throw keyError; const valueError = validateAttributeValue(change.value); if (valueError) throw valueError; diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index 7dc2560ab9..755dfea54b 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -167,6 +167,12 @@ export interface Storage { * * Returns the post-merge attribute snapshot on the run. * + * Pass `options.allowReservedAttributes: true` to permit keys + * starting with the reserved `$` prefix. Default behavior rejects + * those keys so user code can't accidentally collide with + * framework / tooling namespaces; framework callers that own a + * sub-namespace flip this on. + * * OPTIONAL. World implementations may omit this method; the SDK * helper (`setAttributes` in `@workflow/core`) feature-detects its * absence and no-ops with a one-time warning so third-party / @@ -181,7 +187,8 @@ export interface Storage { */ experimentalSetAttributes?( runId: string, - changes: AttributeChange[] + changes: AttributeChange[], + options?: { allowReservedAttributes?: boolean } ): Promise; }; From 4edc59ceb2b531719ae2bf168e2ca69b5433074c Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 28 May 2026 10:43:28 +0200 Subject: [PATCH 25/26] Apply suggestion from @VaguelySerious Signed-off-by: Peter Wielander --- .changeset/attributes-mvp-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/attributes-mvp-plan.md b/.changeset/attributes-mvp-plan.md index 789b92e193..4ef2114216 100644 --- a/.changeset/attributes-mvp-plan.md +++ b/.changeset/attributes-mvp-plan.md @@ -7,4 +7,4 @@ 'workflow': patch --- -Add `experimental_setAttributes()` for attaching plaintext string key/value metadata to a workflow run. Callable from a workflow body; the call is dispatched as a step so the mutation is recorded on the event log. `run.attributes` always reads as a record across worlds (defaults to `{}` after schema parsing). +Add `experimental_setAttributes()` workflow-level helper for attaching string key/value metadata to a workflow run, surfaced as `run.attributes` From 45db5ae594b7f86cb0122dee68ced031b37cab9a Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 28 May 2026 13:26:09 +0200 Subject: [PATCH 26/26] fix(workflow): drop failed internal attribute writes --- .../docs/v5/changelog/attributes-mvp.mdx | 38 ++++-- .../workflow/src/internal/builtins.test.ts | 109 ++++++++++++++++++ packages/workflow/src/internal/builtins.ts | 54 +++++++-- 3 files changed, 180 insertions(+), 21 deletions(-) create mode 100644 packages/workflow/src/internal/builtins.test.ts diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx index c0430dff8f..2d1a71ad4b 100644 --- a/docs/content/docs/v5/changelog/attributes-mvp.mdx +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -1,19 +1,19 @@ --- title: Workflow Attributes (MVP, experimental) -description: A minimal, write-only subset of the planned Workflow Attributes feature, forward-compatible with the full 5.0.0 release. Lets workflow and step code attach plaintext string key/value metadata to a run. +description: A minimal, write-only subset of the planned Workflow Attributes feature, forward-compatible with the full 5.0.0 release. Lets workflow code attach plaintext string key/value metadata to a run. --- # Workflow Attributes (MVP) This is a minimal, **experimental** subset of the [planned Workflow Attributes feature for 5.0.0](https://github.com/vercel/workflow/pull/1933). See [discussion #132](https://github.com/vercel/workflow/discussions/132) for broader background on the use cases and the full design space. -The MVP lets workflow and step code attach plaintext `string → string` metadata to a run, viewable in any observability surface that reads the `WorkflowRun` entity. It is deliberately narrow: write-only, no reads from inside a run, no list/filter endpoints, no event-log representation. The wire format and SDK surface are chosen so the full 5.0.0 implementation replaces this without source-level breaking changes for end users. +The MVP lets workflow code attach plaintext `string → string` metadata to a run, viewable in any observability surface that reads the `WorkflowRun` entity. It is deliberately narrow: write-only, no reads from inside a run, no list/filter endpoints, no event-log representation. The wire format and SDK surface are chosen so the full 5.0.0 implementation replaces this without source-level breaking changes for end users. ## What MVP supports - `experimental_setAttributes(record)` callable from a **workflow body** (`"use workflow"` function), dispatched via an internal `__builtin_set_attributes` step bridge so the mutation gets a `step_created → step_completed` event pair - Attributes are materialized onto the `WorkflowRun` entity, plaintext, and visible via `world.runs.get()` / `world.runs.list()` and any observability UI built on top of those -- World implementations emit a side-channel observability record per write (in `world-vercel`, this hooks into the same observability/analytics pipeline already used for other run lifecycle events) +- World implementations emit a side-channel observability record per successful write (in `world-vercel`, this hooks into the same observability/analytics pipeline already used for other run lifecycle events) Calling `experimental_setAttributes` from a step body or plain host code is intentionally not supported in the MVP — the host-side export throws `FatalError` directing callers back to a workflow body. This keeps the implementation a single dispatch path; step-body support can be added later without breaking the workflow-body contract. @@ -54,6 +54,9 @@ runs: { * keys with a string value are upserted, keys with `value: null` * are removed. Other keys on the run are untouched. * + * `options.allowReservedAttributes` permits `$`-prefixed keys for + * framework-level callers that own a reserved sub-namespace. + * * OPTIONAL. World implementations may omit this method; the SDK * detects absence and no-ops `experimental_setAttributes` with a warning so that * third-party / community worlds continue to function without @@ -64,7 +67,8 @@ runs: { */ experimentalSetAttributes?( runId: string, - changes: Array<{ key: string; value: string | null }> + changes: Array<{ key: string; value: string | null }>, + options?: { allowReservedAttributes?: boolean } ): Promise<{ attributes: Record }>; } ``` @@ -84,13 +88,14 @@ POST /v2/runs/:runId/attributes "changes": [ { "key": "phase", "value": "done" }, { "key": "stale", "value": null } - ] + ], + "allowReservedAttributes": true } ``` Response: `{ "attributes": { "phase": "done", "tenant": "t1" } }`. -The body shape **deliberately mirrors** the eventual `attr_set` event's `eventData.changes`. When the full feature ships, this endpoint goes away — the same body shape is posted to `POST /v2/runs/:id/events` with `eventType: 'attr_set'` (plus a `writer` discriminator). No SDK signature change, no client-side migration. +The `changes` field **deliberately mirrors** the eventual `attr_set` event's `eventData.changes`. `allowReservedAttributes` is optional and framework-only; omit it for user-authored attributes. When the full feature ships, this endpoint goes away — the same `changes` shape is posted to `POST /v2/runs/:id/events` with `eventType: 'attr_set'` (plus a `writer` discriminator). No SDK signature change, no client-side migration. ### 3. `@workflow/core` — SDK surface @@ -100,7 +105,8 @@ A new export from `@workflow/core` (re-exported by `workflow`): ```ts function experimental_setAttributes( - attrs: Record + attrs: Record, + options?: { allowReservedAttributes?: boolean } ): Promise ``` @@ -111,7 +117,7 @@ Validation (shared helper, applied both client-side and server-side): - Key: 1–256 chars, must not start with `$` (reserved — see "Reserved `$` namespace" below) - Value: ≤ 256 bytes UTF-8 - Maximum 64 attributes per run (validated against the post-merge snapshot when the server applies) -- Violations throw `FatalError` from `@workflow/errors` +- SDK-side violations throw `FatalError` from `@workflow/errors` before the internal step is dispatched; worlds revalidate as the final authority before mutating storage #### Reserved `$` namespace @@ -151,7 +157,7 @@ The host-side export (the one resolved when the `workflow` package-exports condi Three call patterns are supported. Pick by whether you need ordering and whether you need to know the write has landed before continuing: -**Awaited (default).** The workflow blocks on the write before continuing. Use this when later workflow logic depends on the attribute being persisted, or when you want errors (validation, world rejection) to surface immediately. +**Awaited (default).** The workflow blocks on the internal write step before continuing. Use this when later workflow logic should wait until the attribute write has either succeeded or exhausted the MVP's best-effort retry budget. Deterministic SDK validation errors still throw before dispatch; persistence failures from the world are retried by the internal step and then logged/dropped after three attempts so failing to post tags does not fail a run during the experimental phase. {/*@skip-typecheck - snippet, not runnable code*/} @@ -196,7 +202,7 @@ await Promise.all([ #### Optional world support -Because `runs.experimentalSetAttributes` is **optional** on the World interface (see §1), the `__builtin_set_attributes` step body checks for its presence before dispatching and silently no-ops when absent. User code does not need to feature-detect; calling `experimental_setAttributes` against an unsupporting world is safe but ineffective. +Because `runs.experimentalSetAttributes` is **optional** on the World interface (see §1), the `__builtin_set_attributes` step body checks for its presence before dispatching and no-ops when absent after logging a single process-wide warning. User code does not need to feature-detect; calling `experimental_setAttributes` against an unsupporting world is safe but ineffective. ### 4. World implementations @@ -262,6 +268,12 @@ Unit tests in `@workflow/world` (validation surface) and `@workflow/core` (VM-si - Workflow VM with no `WORKFLOW_USE_STEP` bound throws `FatalError` - Host-side stub (resolved when not in the workflow VM) throws `FatalError` directing the caller back to a workflow body +Unit tests in `workflow` (internal built-in step behavior): + +- `__builtin_set_attributes` rethrows world write failures on attempts 1 and 2 so normal step retry semantics apply +- On attempt 3, `__builtin_set_attributes` logs a `console.error` and resolves so retry exhaustion does not turn the internal attribute write into a `FatalError` +- The internal step is configured for three total attempts (`maxRetries = 2`) + Integration tests in `world-local`: - Upsert, merge across calls, unset via `null`, set-and-unset in a single batch @@ -289,7 +301,7 @@ When the full attributes feature ships: - `experimental_setAttributes` (SDK) — unchanged signature, new dispatch path - `runs.experimentalSetAttributes` (world interface) — deprecated, then removed; replaced by `events.create(runId, { eventType: 'attr_set', eventData: { changes, writer } })` -- Wire endpoint — `POST /v2/runs/:runId/attributes` removed; the same body shape posts to `POST /v2/runs/:id/events` +- Wire endpoint — `POST /v2/runs/:runId/attributes` removed; the same `changes` shape posts to `POST /v2/runs/:id/events` - Pre-existing attribute values on MVP-era runs remain on the run entity but are not represented in the event log Skew protection means workflows started under the MVP will continue to run with the MVP dispatch path on their original deployment. New deployments use the new path. No in-place data migration is needed. @@ -305,11 +317,13 @@ This section records concrete decisions taken while landing the MVP that weren't 1. The workflow VM bundle resolves `experimental_setAttributes` to `packages/core/src/workflow/set-attributes.ts` (via the `workflow` package-exports condition). 2. That helper validates the input record inline (no shared helper, no cross-file dependency from a 'use step' file) and produces canonical `AttributeChange[]`. 3. It dispatches through the standard workflow-VM step mechanism: `globalThis[WORKFLOW_USE_STEP]('__builtin_set_attributes')(changes)`. The `useStep` dispatcher is the same one used by every other step call from a workflow body, populated by `packages/core/src/workflow.ts` at VM bootstrap. -4. The dispatch queues a step (`step_created`), the host runs `__builtin_set_attributes(changes)` from `packages/workflow/src/internal/builtins.ts`. The step body reads the active world and current run id directly from `globalThis` symbols (`Symbol.for('@workflow/world//cache')` and `Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')`) — populated by the host runtime — and calls `world.runs.experimentalSetAttributes(runId, changes)`. +4. The dispatch queues a step (`step_created`), the host runs `__builtin_set_attributes(changes, options)` from `packages/workflow/src/internal/builtins.ts`. The step body reads the active world, current run id, and attempt number directly from `globalThis` symbols (`Symbol.for('@workflow/world//cache')` and `Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')`) — populated by the host runtime — and calls `world.runs.experimentalSetAttributes(runId, changes, options)`. 5. The step completes (`step_completed`), the workflow resumes. This puts the mutation on the event log as a normal `step_created → step_completed` pair without inventing a new event type — that stays for the full 5.0.0 cutover. +The internal step is best-effort during the experimental phase. It sets `maxRetries = 2`, for three total attempts. If `world.runs.experimentalSetAttributes` fails on attempts 1 or 2, the error is rethrown so the runtime retries the step normally. If it still fails on attempt 3, the step logs `console.error` and returns; the workflow run continues instead of receiving a retry-exhaustion `FatalError` for failed tag posting. + The step body intentionally does **not** import anything from `@workflow/core`. That keeps the Next.js deferred-entries discoverer from walking a `__builtin_set_attributes` → `@workflow/core/...` → world adapter → `@vercel/queue` chain, which earlier drafts triggered (blowing the call stack of webpack's regex-based extractor with `RangeError: Maximum call stack size exceeded at RegExpStringIterator.next` on tarball-installed `nextjs-webpack` builds). The host-side `experimental_setAttributes` export (`packages/core/src/set-attributes.ts`, resolved by everything that isn't the workflow VM) throws `FatalError` with a message pointing the caller back to a workflow body. Step-body support can be added in a follow-up without changing this contract. diff --git a/packages/workflow/src/internal/builtins.test.ts b/packages/workflow/src/internal/builtins.test.ts new file mode 100644 index 0000000000..ee0f5fe4c2 --- /dev/null +++ b/packages/workflow/src/internal/builtins.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { __builtin_set_attributes } from './builtins.js'; + +const STEP_CONTEXT_STORAGE = Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE'); +const WORLD_CACHE = Symbol.for('@workflow/world//cache'); +const UNSUPPORTED_WORLD_WARNED = Symbol.for( + '@workflow/setAttributes//unsupportedWorldWarned' +); + +const globals = globalThis as Record; + +function setStepAttempt(attempt: number) { + globals[STEP_CONTEXT_STORAGE] = { + getStore: () => ({ + stepMetadata: { attempt }, + workflowMetadata: { workflowRunId: 'run_123' }, + }), + }; +} + +describe('__builtin_set_attributes', () => { + let originalContextStorage: unknown; + let originalWorld: unknown; + let originalUnsupportedWorldWarned: unknown; + + beforeEach(() => { + originalContextStorage = globals[STEP_CONTEXT_STORAGE]; + originalWorld = globals[WORLD_CACHE]; + originalUnsupportedWorldWarned = globals[UNSUPPORTED_WORLD_WARNED]; + }); + + afterEach(() => { + vi.restoreAllMocks(); + + if (originalContextStorage === undefined) { + delete globals[STEP_CONTEXT_STORAGE]; + } else { + globals[STEP_CONTEXT_STORAGE] = originalContextStorage; + } + + if (originalWorld === undefined) { + delete globals[WORLD_CACHE]; + } else { + globals[WORLD_CACHE] = originalWorld; + } + + if (originalUnsupportedWorldWarned === undefined) { + delete globals[UNSUPPORTED_WORLD_WARNED]; + } else { + globals[UNSUPPORTED_WORLD_WARNED] = originalUnsupportedWorldWarned; + } + }); + + it('rethrows attribute write failures before the third attempt', async () => { + const error = new Error('world unavailable'); + const experimentalSetAttributes = vi.fn().mockRejectedValue(error); + globals[WORLD_CACHE] = { + runs: { experimentalSetAttributes }, + }; + + for (const attempt of [1, 2]) { + setStepAttempt(attempt); + + await expect( + __builtin_set_attributes([{ key: '$tag.kind', value: 'agent' }], { + allowReservedAttributes: true, + }) + ).rejects.toBe(error); + } + + expect(experimentalSetAttributes).toHaveBeenCalledTimes(2); + }); + + it('logs and completes after the third failed attempt', async () => { + const experimentalSetAttributes = vi + .fn() + .mockRejectedValue(new Error('world unavailable')); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + globals[WORLD_CACHE] = { + runs: { experimentalSetAttributes }, + }; + setStepAttempt(3); + + await expect( + __builtin_set_attributes([{ key: '$tag.kind', value: 'agent' }], { + allowReservedAttributes: true, + }) + ).resolves.toBeUndefined(); + + expect(experimentalSetAttributes).toHaveBeenCalledWith( + 'run_123', + [{ key: '$tag.kind', value: 'agent' }], + { allowReservedAttributes: true } + ); + expect(consoleError).toHaveBeenCalledTimes(1); + expect(consoleError.mock.calls[0]?.[0]).toContain( + 'failed to post tags after 3 attempts' + ); + expect( + ( + __builtin_set_attributes as typeof __builtin_set_attributes & { + maxRetries: number; + } + ).maxRetries + ).toBe(2); + }); +}); diff --git a/packages/workflow/src/internal/builtins.ts b/packages/workflow/src/internal/builtins.ts index b89d46b83a..1371fa5212 100644 --- a/packages/workflow/src/internal/builtins.ts +++ b/packages/workflow/src/internal/builtins.ts @@ -29,6 +29,15 @@ const UNSUPPORTED_WORLD_WARNED = Symbol.for( '@workflow/setAttributes//unsupportedWorldWarned' ); +const INTERNAL_ATTRIBUTES_MAX_ATTEMPTS = 3; + +function formatUnknownError(error: unknown) { + if (error instanceof Error) { + return error.stack ?? `${error.name}: ${error.message}`; + } + return String(error); +} + /** * Step bridge for workflow-body `setAttributes` calls. The VM-side * helper validates input and dispatches here via `useStep`. This step @@ -51,16 +60,18 @@ export async function __builtin_set_attributes( const contextStorage = g[Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')] as | { getStore: () => - | { workflowMetadata?: { workflowRunId?: string } } + | { + stepMetadata?: { attempt?: number }; + workflowMetadata?: { workflowRunId?: string }; + } | undefined; } | undefined; - const runId = contextStorage?.getStore?.()?.workflowMetadata?.workflowRunId; - if (!runId) { - throw new Error( - '__builtin_set_attributes: no workflow run id available in step context' - ); - } + const store = contextStorage?.getStore?.(); + const attempt = + typeof store?.stepMetadata?.attempt === 'number' + ? store.stepMetadata.attempt + : INTERNAL_ATTRIBUTES_MAX_ATTEMPTS; const world = g[Symbol.for('@workflow/world//cache')] as | { @@ -82,7 +93,6 @@ export async function __builtin_set_attributes( if (!g[UNSUPPORTED_WORLD_WARNED]) { g[UNSUPPORTED_WORLD_WARNED] = true; const worldName = world?.name ? ` (${world.name})` : ''; - // biome-ignore lint/suspicious/noConsole: surface in user terminals console.warn( `[workflow] setAttributes: the current world implementation${worldName} does not implement experimentalSetAttributes; this call (and any subsequent setAttributes calls in this process) is a no-op. Attributes will become available once the world adapter adds support.` ); @@ -90,5 +100,31 @@ export async function __builtin_set_attributes( return; } - await world.runs.experimentalSetAttributes(runId, changes, options); + try { + const runId = store?.workflowMetadata?.workflowRunId; + if (!runId) { + throw new Error( + '__builtin_set_attributes: no workflow run id available in step context' + ); + } + + await world.runs.experimentalSetAttributes(runId, changes, options); + } catch (error) { + if (attempt < INTERNAL_ATTRIBUTES_MAX_ATTEMPTS) { + throw error; + } + + // Failing to post tags should not fail a run during the experimental phase. + // After three attempts, log and let the internal step complete so the + // runtime does not convert retry exhaustion into a FatalError. + console.error( + `[workflow] setAttributes: failed to post tags after ${INTERNAL_ATTRIBUTES_MAX_ATTEMPTS} attempts; dropping the internal attribute write. ${formatUnknownError(error)}` + ); + } } + +( + __builtin_set_attributes as typeof __builtin_set_attributes & { + maxRetries: number; + } +).maxRetries = INTERNAL_ATTRIBUTES_MAX_ATTEMPTS - 1;