From ee88cbe9289e881b22093c31415231536e882f3e Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 10 Jul 2026 18:15:32 -0700 Subject: [PATCH] Rename experimental_setAttributes to setAttributes The attributes feature is no longer experimental. The old experimental_setAttributes export remains as a deprecated alias. Docs page moved to api-reference/workflow/set-attributes with redirects from the old URL. Co-Authored-By: Claude Fable 5 --- .changeset/rename-set-attributes-world.md | 5 ++ .changeset/rename-set-attributes.md | 6 ++ .../v5/api-reference/workflow-api/start.mdx | 2 +- .../docs/v5/api-reference/workflow/index.mdx | 4 +- ...-set-attributes.mdx => set-attributes.mdx} | 28 +++++----- .../docs/v5/observability/attributes.mdx | 30 ++++------ docs/next.config.ts | 13 +++++ packages/core/e2e/e2e.test.ts | 50 +++++++---------- packages/core/src/attribute-changes.test.ts | 2 +- packages/core/src/attribute-changes.ts | 2 +- packages/core/src/index.ts | 6 +- packages/core/src/runtime/start.ts | 2 +- packages/core/src/runtime/step-executor.ts | 2 +- packages/core/src/runtime/step-latency.ts | 4 +- .../core/src/runtime/suspension-handler.ts | 2 +- packages/core/src/set-attributes.test.ts | 26 +++++---- packages/core/src/set-attributes.ts | 27 ++++++--- packages/core/src/step/context-storage.ts | 2 +- packages/core/src/workflow/index.ts | 6 +- .../core/src/workflow/set-attributes.test.ts | 38 ++++++++----- packages/core/src/workflow/set-attributes.ts | 48 +++++++++------- packages/world/src/runs.ts | 2 +- workbench/example/workflows/99_e2e.ts | 56 +++++++++---------- 23 files changed, 201 insertions(+), 162 deletions(-) create mode 100644 .changeset/rename-set-attributes-world.md create mode 100644 .changeset/rename-set-attributes.md rename docs/content/docs/v5/api-reference/workflow/{experimental-set-attributes.mdx => set-attributes.mdx} (55%) diff --git a/.changeset/rename-set-attributes-world.md b/.changeset/rename-set-attributes-world.md new file mode 100644 index 0000000000..1010301b81 --- /dev/null +++ b/.changeset/rename-set-attributes-world.md @@ -0,0 +1,5 @@ +--- +"@workflow/world": patch +--- + +Update doc comments to reference `setAttributes` (renamed from `experimental_setAttributes`). diff --git a/.changeset/rename-set-attributes.md b/.changeset/rename-set-attributes.md new file mode 100644 index 0000000000..ba44ebb101 --- /dev/null +++ b/.changeset/rename-set-attributes.md @@ -0,0 +1,6 @@ +--- +"@workflow/core": minor +"workflow": minor +--- + +Rename `experimental_setAttributes` to `setAttributes` — the attributes feature is no longer experimental. The old name remains available as a deprecated alias. diff --git a/docs/content/docs/v5/api-reference/workflow-api/start.mdx b/docs/content/docs/v5/api-reference/workflow-api/start.mdx index 2291fc16a3..29cca86579 100644 --- a/docs/content/docs/v5/api-reference/workflow-api/start.mdx +++ b/docs/content/docs/v5/api-reference/workflow-api/start.mdx @@ -59,7 +59,7 @@ Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow- * Each call to `start()` creates a new workflow run. If retried requests must route to one active workflow, have the workflow create a deterministic hook token and use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) to reuse an already-registered active hook. The lookup is not atomic with `start()`, so concurrent callers can still create extra runs before the hook is registered; handle that race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work — on a conflict it resolves with the run that owns the token, so the duplicate can return the active owner to the caller. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Idempotency](/docs/foundations/idempotency#run-idempotency). * All arguments must be [serializable](/docs/foundations/serialization). * When `deploymentId` is provided, the argument types and return type become `unknown` since there is no guarantee the workflow function's types will be consistent across different deployments. -* `attributes` seeds plaintext run metadata as part of creation and requires a World implementing spec version 4 or later. Keys that start with `$` are reserved for framework and library code; framework-level callers can pass `allowReservedAttributes: true` to seed reserved keys, with the same semantics as the [`experimental_setAttributes`](/docs/api-reference/workflow/experimental-set-attributes) option of the same name. +* `attributes` seeds plaintext run metadata as part of creation and requires a World implementing spec version 4 or later. Keys that start with `$` are reserved for framework and library code; framework-level callers can pass `allowReservedAttributes: true` to seed reserved keys, with the same semantics as the [`setAttributes`](/docs/api-reference/workflow/set-attributes) option of the same name. If `start()` throws `'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.`, the passed function was not transformed as a workflow. The two most common causes are a missing `"use workflow"` directive or missing framework integration. See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function). diff --git a/docs/content/docs/v5/api-reference/workflow/index.mdx b/docs/content/docs/v5/api-reference/workflow/index.mdx index 4ef26f6c44..4ac0c22b55 100644 --- a/docs/content/docs/v5/api-reference/workflow/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow/index.mdx @@ -47,8 +47,8 @@ Workflow SDK contains the following functions you can use inside your workflow f Access the current workflow run's default stream. - - Attach experimental string metadata to the current workflow run. + + Attach string metadata to the current workflow run. diff --git a/docs/content/docs/v5/api-reference/workflow/experimental-set-attributes.mdx b/docs/content/docs/v5/api-reference/workflow/set-attributes.mdx similarity index 55% rename from docs/content/docs/v5/api-reference/workflow/experimental-set-attributes.mdx rename to docs/content/docs/v5/api-reference/workflow/set-attributes.mdx index 8cf8b53b7d..428c094e0d 100644 --- a/docs/content/docs/v5/api-reference/workflow/experimental-set-attributes.mdx +++ b/docs/content/docs/v5/api-reference/workflow/set-attributes.mdx @@ -1,8 +1,8 @@ --- -title: experimental_setAttributes +title: setAttributes description: Attach string metadata to workflow run for observability. type: reference -summary: Use experimental_setAttributes inside a workflow or step function to set run attributes. +summary: Use setAttributes inside a workflow or step function to set run attributes. prerequisites: - /docs/foundations/workflows-and-steps related: @@ -12,17 +12,13 @@ related: Attaches string metadata to the current workflow run. - -This API is experimental and may change before the stable attributes API is released. - - ```typescript lineNumbers -import { experimental_setAttributes } from "workflow" +import { setAttributes } from "workflow" export async function orderWorkflow(orderId: string) { "use workflow" - await experimental_setAttributes({ + await setAttributes({ phase: "received", orderId, }) @@ -35,24 +31,24 @@ export async function orderWorkflow(orderId: string) { ## Usage -Call `experimental_setAttributes` from a `"use workflow"` function or a `"use step"` function. Calling it from plain application code is not supported because there is no active workflow run. +Call `setAttributes` from a `"use workflow"` function or a `"use step"` function. Calling it from plain application code is not supported because there is no active workflow run. Attribute values must be strings. Pass `undefined` to remove an attribute: ```typescript lineNumbers -import { experimental_setAttributes } from "workflow" +import { setAttributes } from "workflow" export async function cleanupAttributes() { "use workflow" - await experimental_setAttributes({ staleKey: undefined }) + await setAttributes({ staleKey: undefined }) } ``` @@ -62,4 +58,8 @@ Validation errors throw [`FatalError`](/docs/api-reference/workflow/fatal-error) Calls from both workflow and step bodies append a native `attr_set` event, which the World materializes onto `run.attributes`. Workflow-originated events record a workflow writer; step-originated events record the originating step ID and attempt. -Native attributes require spec version 4 or later. Step-body storage errors throw from `experimental_setAttributes`; catch them inside the step if the write should be best-effort. Workflow-body writes are committed when the workflow suspends: transient storage errors are retried with the suspension, while a write the World rejects as invalid — such as exceeding the per-run attribute cap across multiple calls — fails the run with the validation error. +Native attributes require spec version 4 or later. Step-body storage errors throw from `setAttributes`; catch them inside the step if the write should be best-effort. Workflow-body writes are committed when the workflow suspends: transient storage errors are retried with the suspension, while a write the World rejects as invalid — such as exceeding the per-run attribute cap across multiple calls — fails the run with the validation error. + + +This function was previously exported as `experimental_setAttributes`. The old name still works as a deprecated alias — update imports to `setAttributes`. + diff --git a/docs/content/docs/v5/observability/attributes.mdx b/docs/content/docs/v5/observability/attributes.mdx index 3d62bfd023..7e7607561f 100644 --- a/docs/content/docs/v5/observability/attributes.mdx +++ b/docs/content/docs/v5/observability/attributes.mdx @@ -1,21 +1,17 @@ --- title: Attributes -description: Attach experimental metadata to workflow runs for observability. +description: Attach metadata to workflow runs for observability. type: reference summary: Add string attributes to a workflow run. prerequisites: - /docs/foundations/workflows-and-steps related: - /docs/observability - - /docs/api-reference/workflow/experimental-set-attributes + - /docs/api-reference/workflow/set-attributes - /docs/api-reference/workflow-errors/workflow-world-error --- - -This feature is experimental and may change before the stable attributes API is released. - - -[`experimental_setAttributes`](/docs/api-reference/workflow/experimental-set-attributes) attaches plaintext string metadata to the current workflow run. These attributes are displayed in observability CLI/UI. +[`setAttributes`](/docs/api-reference/workflow/set-attributes) attaches plaintext string metadata to the current workflow run. These attributes are displayed in observability CLI/UI. In the future, you'll be able to search and filter runs by attributes. You can also seed any attributes directly when starting a run: @@ -28,35 +24,35 @@ const run = await start(orderWorkflow, ["ord_123"], { ``` ```typescript lineNumbers -import { experimental_setAttributes } from "workflow" +import { setAttributes } from "workflow" export async function orderWorkflow(orderId: string) { "use workflow" - await experimental_setAttributes({ // [!code highlight] + await setAttributes({ // [!code highlight] phase: "received", // [!code highlight] orderId, // [!code highlight] }) // [!code highlight] // ...work... - await experimental_setAttributes({ phase: "complete" }) // [!code highlight] + await setAttributes({ phase: "complete" }) // [!code highlight] } ``` ## Usage -Call [`experimental_setAttributes`](/docs/api-reference/workflow/experimental-set-attributes) from a `"use workflow"` function or a `"use step"` function. Plain application code is not supported because there is no active workflow run to attach attributes to. +Call [`setAttributes`](/docs/api-reference/workflow/set-attributes) from a `"use workflow"` function or a `"use step"` function. Plain application code is not supported because there is no active workflow run to attach attributes to. Values must be strings. Pass `undefined` to remove a key: ```typescript lineNumbers -import { experimental_setAttributes } from "workflow" +import { setAttributes } from "workflow" export async function cleanupAttributes() { "use workflow" - await experimental_setAttributes({ staleKey: undefined }) // [!code highlight] + await setAttributes({ staleKey: undefined }) // [!code highlight] } ``` @@ -68,7 +64,7 @@ The run details panel in the observability UI shows the run's current attributes ![Run details panel showing the Attributes card with reserved keys badged](/screenshots/attributes/run-details-attributes.png) -Each `experimental_setAttributes` call appears on the trace timeline as a diamond marker at the moment the attributes were written: +Each `setAttributes` call appears on the trace timeline as a diamond marker at the moment the attributes were written: ![Trace timeline with attr_set diamond markers on the run row](/screenshots/attributes/trace-timeline.png) @@ -76,12 +72,10 @@ Expanding an `attr_set` event — in the run sidebar or the Events tab — shows ![Expanded attr_set events showing changes and the writer](/screenshots/attributes/run-details-attr-set-events.png) -## Experimental Behavior - -While attributes are experimental: +## Behavior - Attributes require a World implementing spec version 4 or later. - Writes from workflow and step bodies append native `attr_set` events and immediately materialize `run.attributes`. - Storage errors surface rather than being silently ignored: transient errors on workflow-body writes are retried, and a write the World rejects as invalid (for example, exceeding the per-run attribute cap across multiple calls) fails the run with the validation error. -- Step-body storage errors throw from `experimental_setAttributes` like any other step-side network write. Catch the error inside the step if the attribute is best-effort. +- Step-body storage errors throw from `setAttributes` like any other step-side network write. Catch the error inside the step if the attribute is best-effort. - Reading and querying attributes is not available yet. A query API is planned. diff --git a/docs/next.config.ts b/docs/next.config.ts index f1ab9961fd..be1847b6bb 100644 --- a/docs/next.config.ts +++ b/docs/next.config.ts @@ -161,6 +161,19 @@ const config: NextConfig = { destination: '/cookbook/agent-patterns/agent-cancellation', permanent: true, }, + // setAttributes graduated from experimental_setAttributes; the API + // reference page moved with it. Cover both the versioned (v5) path and + // the unversioned path so links keep working once v5 becomes default. + { + source: '/v5/docs/api-reference/workflow/experimental-set-attributes', + destination: '/v5/docs/api-reference/workflow/set-attributes', + permanent: true, + }, + { + source: '/docs/api-reference/workflow/experimental-set-attributes', + destination: '/docs/api-reference/workflow/set-attributes', + permanent: true, + }, { source: '/python', destination: '/docs/getting-started/python', diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index 3dc0e27e6c..582240e381 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -4090,19 +4090,17 @@ describe('e2e', () => { ); // ========================================================================== - // experimental_setAttributes (experimental MVP) + // setAttributes // ========================================================================== - describe('experimental_setAttributes', () => { + describe('setAttributes', () => { test( 'start: initial attributes are seeded on run creation', { timeout: 30_000 }, async () => { - const run = await start( - await e2e('experimentalSetAttributesWorkflow'), - [2], - { attributes: { sourceAtStart: 'api' } } - ); + const run = await start(await e2e('setAttributesWorkflow'), [2], { + attributes: { sourceAtStart: 'api' }, + }); await run.returnValue; const world = await getWorld(); @@ -4118,14 +4116,10 @@ describe('e2e', () => { 'start: reserved-prefix initial attributes are seeded with allowReservedAttributes', { timeout: 30_000 }, async () => { - const run = await start( - await e2e('experimentalSetAttributesWorkflow'), - [2], - { - attributes: { $seededByFramework: 'e2e', tenant: 't1' }, - allowReservedAttributes: true, - } - ); + const run = await start(await e2e('setAttributesWorkflow'), [2], { + attributes: { $seededByFramework: 'e2e', tenant: 't1' }, + allowReservedAttributes: true, + }); await run.returnValue; // The reserved key passes validation on the client and at the @@ -4142,13 +4136,10 @@ describe('e2e', () => { ); test( - 'experimentalSetAttributesWorkflow: workflow-body calls append native attr_set events and merge correctly', + 'setAttributesWorkflow: workflow-body calls append native attr_set events and merge correctly', { timeout: 30_000 }, async () => { - const run = await start( - await e2e('experimentalSetAttributesWorkflow'), - [7] - ); + const run = await start(await e2e('setAttributesWorkflow'), [7]); const output = await run.returnValue; expect(output).toBe(21); @@ -4170,11 +4161,11 @@ describe('e2e', () => { ); test( - 'experimentalSetAttributesInsideStepWorkflow: step-body calls append attributed native events', + 'setAttributesInsideStepWorkflow: step-body calls append attributed native events', { timeout: 30_000 }, async () => { const run = await start( - await e2e('experimentalSetAttributesInsideStepWorkflow'), + await e2e('setAttributesInsideStepWorkflow'), [9] ); const output = await run.returnValue; @@ -4200,11 +4191,11 @@ describe('e2e', () => { ); test( - 'fire-and-forget: void experimental_setAttributes lands without awaiting', + 'fire-and-forget: void setAttributes lands without awaiting', { timeout: 30_000 }, async () => { const run = await start( - await e2e('experimentalSetAttributesFireAndForgetWorkflow'), + await e2e('setAttributesFireAndForgetWorkflow'), [] ); await run.returnValue; @@ -4221,10 +4212,7 @@ describe('e2e', () => { 'Promise.all of disjoint-key writes: every key lands', { timeout: 30_000 }, async () => { - const run = await start( - await e2e('experimentalSetAttributesParallelWorkflow'), - [] - ); + const run = await start(await e2e('setAttributesParallelWorkflow'), []); const output = await run.returnValue; expect(output).toBe('done'); @@ -4242,7 +4230,7 @@ describe('e2e', () => { { timeout: 30_000 }, async () => { const run = await start( - await e2e('experimentalSetAttributesThrowsAfterWorkflow'), + await e2e('setAttributesThrowsAfterWorkflow'), [] ); // The workflow throws — `returnValue` rejects. @@ -4269,7 +4257,7 @@ describe('e2e', () => { { timeout: 30_000 }, async () => { const run = await start( - await e2e('experimentalSetAttributesValidationWorkflow'), + await e2e('setAttributesValidationWorkflow'), [] ); const outcomes = (await run.returnValue) as Record; @@ -4306,7 +4294,7 @@ describe('e2e', () => { 'start: invalid initial attributes are rejected before a run is created', { timeout: 30_000 }, async () => { - const workflow = await e2e('experimentalSetAttributesWorkflow'); + const workflow = await e2e('setAttributesWorkflow'); await expect( start(workflow, [1], { attributes: { $reserved: 'x' } }) ).rejects.toThrow(/reserved prefix/); diff --git a/packages/core/src/attribute-changes.test.ts b/packages/core/src/attribute-changes.test.ts index caad527564..271444f4dc 100644 --- a/packages/core/src/attribute-changes.test.ts +++ b/packages/core/src/attribute-changes.test.ts @@ -37,7 +37,7 @@ describe('normalizeAttributeChanges', () => { expect(caught).toBeInstanceOf(FatalError); expect((caught as Error).message).toMatch(re); expect((caught as Error).message).toContain( - 'experimental_setAttributes requires a plain object' + 'setAttributes requires a plain object' ); }); }); diff --git a/packages/core/src/attribute-changes.ts b/packages/core/src/attribute-changes.ts index ece258c006..5f53d39308 100644 --- a/packages/core/src/attribute-changes.ts +++ b/packages/core/src/attribute-changes.ts @@ -15,7 +15,7 @@ export function normalizeAttributeChanges( ): AttributeChange[] { if (attrs === null || typeof attrs !== 'object' || Array.isArray(attrs)) { throw new FatalError( - `experimental_setAttributes requires a plain object, got ${ + `setAttributes requires a plain object, got ${ attrs === null ? 'null' : Array.isArray(attrs) ? 'array' : typeof attrs }` ); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3e22b7767f..19698cd9d1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,7 +25,11 @@ export { type WebhookOptions, } from './create-hook.js'; export { defineHook, type TypedHook } from './define-hook.js'; -export { experimental_setAttributes } from './set-attributes.js'; +export { + experimental_setAttributes, + type SetAttributesOptions, + setAttributes, +} from './set-attributes.js'; export { sleep } from './sleep.js'; export { getStepMetadata, diff --git a/packages/core/src/runtime/start.ts b/packages/core/src/runtime/start.ts index 76b4fc3eb7..189b5f5815 100644 --- a/packages/core/src/runtime/start.ts +++ b/packages/core/src/runtime/start.ts @@ -87,7 +87,7 @@ export interface StartOptionsBase { * 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. Same semantics as - * the `experimental_setAttributes` option of the same name. + * the `setAttributes` option of the same name. */ allowReservedAttributes?: boolean; diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts index 428868e5fe..fe2404011d 100644 --- a/packages/core/src/runtime/step-executor.ts +++ b/packages/core/src/runtime/step-executor.ts @@ -696,7 +696,7 @@ export async function executeStep( encryptionKey, // Turbo optimistic start runs this body before `run_started` is // durable. Expose the barrier so a direct step-body world write - // (e.g. `experimental_setAttributes`) can order itself after the + // (e.g. `setAttributes`) can order itself after the // run exists. Undefined on the await path (run already durable). runReadyBarrier: optimisticStart ? params.runReadyBarrier diff --git a/packages/core/src/runtime/step-latency.ts b/packages/core/src/runtime/step-latency.ts index 5dece6fb1f..c697a4f47b 100644 --- a/packages/core/src/runtime/step-latency.ts +++ b/packages/core/src/runtime/step-latency.ts @@ -32,7 +32,7 @@ export interface StepLatencyTracking { * Epoch ms the first pre-step `attr_set` write began (its client-stamped * `occurredAt`, falling back to `createdAt`). When present, the TTFS * measurement ENDS here instead of at the step's code start: a - * workflow-body `experimental_setAttributes` before the first step + * workflow-body `setAttributes` before the first step * resolves through an extra replay (see the `hasAttributeEvents` branch in * runtime.ts), so everything from this write until the step body runs is * the duration of the setAttributes call — which is subtracted by ending @@ -77,7 +77,7 @@ export interface StepLatencyEventData { * - `hook_created`: a fire-and-forget `createHook()` before the first step * commits in the same invocation, whose measured duration is subtracted via * {@link StepLatencyTracking.preStepBlockingMs}. - * - `attr_set` (workflow-body `experimental_setAttributes`): resolves through + * - `attr_set` (workflow-body `setAttributes`): resolves through * an extra replay before steps run. Subtracted by ending the measurement at * the first attr write's timestamp instead — see * {@link StepLatencyTracking.preStepAttrStartMs}. diff --git a/packages/core/src/runtime/suspension-handler.ts b/packages/core/src/runtime/suspension-handler.ts index c54889bed2..2a817f6c81 100644 --- a/packages/core/src/runtime/suspension-handler.ts +++ b/packages/core/src/runtime/suspension-handler.ts @@ -616,7 +616,7 @@ export async function handleSuspension({ // succeed. Surface it as a FatalError so the caller fails the // run with a clear error instead of wedging it in redelivery. const fatal = new FatalError( - `experimental_setAttributes failed World validation: ${ + `setAttributes failed World validation: ${ err instanceof Error ? err.message : String(err) }` ); diff --git a/packages/core/src/set-attributes.test.ts b/packages/core/src/set-attributes.test.ts index d13d3da035..96c5861199 100644 --- a/packages/core/src/set-attributes.test.ts +++ b/packages/core/src/set-attributes.test.ts @@ -1,7 +1,7 @@ import { FatalError } from '@workflow/errors'; import { SPEC_VERSION_CURRENT } from '@workflow/world'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { experimental_setAttributes } from './set-attributes.js'; +import { experimental_setAttributes, setAttributes } from './set-attributes.js'; import { contextStorage, type StepContext } from './step/context-storage.js'; const WORLD_CACHE = Symbol.for('@workflow/world//cache'); @@ -33,7 +33,7 @@ async function runInStepContext( return contextStorage.run(stepContext(runId), callback); } -describe('experimental_setAttributes (host-side)', () => { +describe('setAttributes (host-side)', () => { let originalWorld: unknown; beforeEach(() => { @@ -51,10 +51,10 @@ describe('experimental_setAttributes (host-side)', () => { }); it('throws FatalError when called from plain host code', async () => { - await expect( - experimental_setAttributes({ phase: 'init' }) - ).rejects.toBeInstanceOf(FatalError); - await expect(experimental_setAttributes({ phase: 'init' })).rejects.toThrow( + await expect(setAttributes({ phase: 'init' })).rejects.toBeInstanceOf( + FatalError + ); + await expect(setAttributes({ phase: 'init' })).rejects.toThrow( /workflow.*step.*function/i ); }); @@ -68,7 +68,7 @@ describe('experimental_setAttributes (host-side)', () => { }; await runInStepContext(() => - experimental_setAttributes({ phase: 'ready', stale: undefined }) + setAttributes({ phase: 'ready', stale: undefined }) ); expect(create).toHaveBeenCalledWith( @@ -94,7 +94,7 @@ describe('experimental_setAttributes (host-side)', () => { }; await runInStepContext(() => - experimental_setAttributes( + setAttributes( { '$agent.kind': 'durable-agent' }, { allowReservedAttributes: true } ) @@ -133,7 +133,7 @@ describe('experimental_setAttributes (host-side)', () => { }); const call = contextStorage.run({ ...stepContext(), runReadyBarrier }, () => - experimental_setAttributes({ phase: 'ready' }) + setAttributes({ phase: 'ready' }) ); // The body ran before run_started is durable: the write must not fire yet. @@ -159,7 +159,7 @@ describe('experimental_setAttributes (host-side)', () => { runReadyBarrier.catch(() => {}); await contextStorage.run({ ...stepContext(), runReadyBarrier }, () => - experimental_setAttributes({ phase: 'ready' }) + setAttributes({ phase: 'ready' }) ); // Barrier rejection is swallowed for ordering only — the write still fires @@ -167,6 +167,10 @@ describe('experimental_setAttributes (host-side)', () => { expect(create).toHaveBeenCalledTimes(1); }); + it('keeps the deprecated experimental_setAttributes alias working', async () => { + expect(experimental_setAttributes).toBe(setAttributes); + }); + it('rejects validation errors before posting from a step', async () => { const create = vi.fn(); globals[WORLD_CACHE] = { @@ -175,7 +179,7 @@ describe('experimental_setAttributes (host-side)', () => { }; await expect( - runInStepContext(() => experimental_setAttributes({ $sys: 'x' })) + runInStepContext(() => setAttributes({ $sys: 'x' })) ).rejects.toBeInstanceOf(FatalError); expect(create).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/set-attributes.ts b/packages/core/src/set-attributes.ts index a33961896f..072714a616 100644 --- a/packages/core/src/set-attributes.ts +++ b/packages/core/src/set-attributes.ts @@ -3,28 +3,31 @@ import { SPEC_VERSION_CURRENT } from '@workflow/world'; import { normalizeAttributeChanges } from './attribute-changes.js'; import { getWorldLazy } from './runtime/get-world-lazy.js'; import { contextStorage } from './step/context-storage.js'; -import type { ExperimentalSetAttributesOptions } from './workflow/set-attributes.js'; +import type { + ExperimentalSetAttributesOptions, + SetAttributesOptions, +} from './workflow/set-attributes.js'; -export type { ExperimentalSetAttributesOptions }; +export type { ExperimentalSetAttributesOptions, SetAttributesOptions }; /** - * Host-side implementation for `experimental_setAttributes`. Workflow - * bodies resolve to `./workflow/set-attributes.ts` via the `workflow` - * package-exports condition; step bodies resolve here and can perform - * the world write directly because they already run in host context. + * Host-side implementation for `setAttributes`. Workflow bodies resolve + * to `./workflow/set-attributes.ts` via the `workflow` package-exports + * condition; step bodies resolve here and can perform the world write + * directly because they already run in host context. * * Plain application code still has no active workflow run, so it throws * a clear `FatalError`. */ -export async function experimental_setAttributes( +export async function setAttributes( attrs: Record, - options: ExperimentalSetAttributesOptions = {} + options: SetAttributesOptions = {} ): Promise { const store = contextStorage.getStore(); const runId = store?.workflowMetadata?.workflowRunId; if (!runId) { throw new FatalError( - "experimental_setAttributes() must be called from a 'use workflow' or 'use step' function. " + + "setAttributes() must be called from a 'use workflow' or 'use step' function. " + 'Calling it from plain host code is not supported.' ); } @@ -63,3 +66,9 @@ export async function experimental_setAttributes( }, }); } + +/** + * @deprecated The feature is no longer experimental — use + * {@link setAttributes} instead. + */ +export const experimental_setAttributes = setAttributes; diff --git a/packages/core/src/step/context-storage.ts b/packages/core/src/step/context-storage.ts index ef43c70d63..5e7f4640a3 100644 --- a/packages/core/src/step/context-storage.ts +++ b/packages/core/src/step/context-storage.ts @@ -57,7 +57,7 @@ export type StepContext = { * Turbo mode only: a promise that resolves once the backgrounded * `run_started` has landed (the run exists). Set when the step body runs * optimistically — before `run_started`/`step_started` are confirmed — so a - * direct step-body world write (e.g. `experimental_setAttributes`, which + * direct step-body world write (e.g. `setAttributes`, which * resolves to a host-side `attr_set` create) can gate on it and never race * ahead of the run's creation. `undefined` outside turbo and on the await * path, where `run_started` was already durable before the body ran. diff --git a/packages/core/src/workflow/index.ts b/packages/core/src/workflow/index.ts index a9fe74ca3c..e7a54ac70f 100644 --- a/packages/core/src/workflow/index.ts +++ b/packages/core/src/workflow/index.ts @@ -10,11 +10,15 @@ export { type RetryableErrorOptions, } from '@workflow/errors'; export type { Hook, HookOptions } from '../create-hook.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'; export { getWorkflowMetadata } from './get-workflow-metadata.js'; +export { + experimental_setAttributes, + type SetAttributesOptions, + setAttributes, +} from './set-attributes.js'; export { getWritable } from './writable-stream.js'; // workflows can't use these functions, but we still need to provide diff --git a/packages/core/src/workflow/set-attributes.test.ts b/packages/core/src/workflow/set-attributes.test.ts index f220486c8f..b7a691b358 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_SET_ATTRIBUTES } from '../symbols.js'; -import { experimental_setAttributes } from './set-attributes.js'; +import { experimental_setAttributes, setAttributes } from './set-attributes.js'; -describe('workflow.experimental_setAttributes', () => { +describe('workflow.setAttributes', () => { const dispatchCalls: Array<{ changes: Array<{ key: string; value: string | null }>; options: { allowReservedAttributes?: boolean } | undefined; @@ -26,7 +26,7 @@ describe('workflow.experimental_setAttributes', () => { }); it('dispatches normalized changes through the native attribute primitive', async () => { - await experimental_setAttributes({ phase: 'init', orderId: 'ord_1' }); + await setAttributes({ phase: 'init', orderId: 'ord_1' }); expect(dispatchCalls).toEqual([ { changes: [ @@ -39,7 +39,7 @@ describe('workflow.experimental_setAttributes', () => { }); it('translates undefined values into null (unset semantics)', async () => { - await experimental_setAttributes({ phase: 'done', stale: undefined }); + await setAttributes({ phase: 'done', stale: undefined }); expect(dispatchCalls).toEqual([ { changes: [ @@ -52,26 +52,26 @@ describe('workflow.experimental_setAttributes', () => { }); it('is a no-op for an empty record (no dispatch)', async () => { - await experimental_setAttributes({}); + await setAttributes({}); expect(dispatchCalls).toHaveLength(0); }); it('throws FatalError when the workflow runtime has not initialized attribute dispatch', async () => { delete (globalThis as Record)[WORKFLOW_SET_ATTRIBUTES]; - await expect( - experimental_setAttributes({ phase: 'init' }) - ).rejects.toBeInstanceOf(FatalError); + await expect(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(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', async () => { - await experimental_setAttributes( + await setAttributes( { '$framework.kind': 'agent' }, { allowReservedAttributes: true } ); @@ -85,7 +85,7 @@ describe('workflow.experimental_setAttributes', () => { it('still rejects reserved-prefix keys when allowReservedAttributes is explicitly false', async () => { await expect( - experimental_setAttributes( + setAttributes( { '$framework.kind': 'agent' }, { allowReservedAttributes: false } ) @@ -93,12 +93,20 @@ describe('workflow.experimental_setAttributes', () => { expect(dispatchCalls).toHaveLength(0); }); + it('keeps the deprecated experimental_setAttributes alias working', async () => { + expect(experimental_setAttributes).toBe(setAttributes); + await experimental_setAttributes({ phase: 'init' }); + expect(dispatchCalls).toEqual([ + { changes: [{ key: 'phase', value: 'init' }], options: {} }, + ]); + }); + it('throws FatalError when called with a non-object', async () => { await expect( - experimental_setAttributes(null as unknown as Record) + setAttributes(null as unknown as Record) ).rejects.toBeInstanceOf(FatalError); await expect( - experimental_setAttributes([] as unknown as Record) + 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 3d942e665d..fa3fd31ac9 100644 --- a/packages/core/src/workflow/set-attributes.ts +++ b/packages/core/src/workflow/set-attributes.ts @@ -4,9 +4,9 @@ import { normalizeAttributeChanges } from '../attribute-changes.js'; import { WORKFLOW_SET_ATTRIBUTES } from '../symbols.js'; /** - * Options accepted by `experimental_setAttributes`. + * Options accepted by `setAttributes`. */ -export interface ExperimentalSetAttributesOptions { +export interface SetAttributesOptions { /** * Permit attribute keys that start with the reserved `$` prefix. * **Default: `false`.** @@ -26,14 +26,14 @@ export interface ExperimentalSetAttributesOptions { allowReservedAttributes?: boolean; } +/** + * @deprecated Use {@link SetAttributesOptions} instead. + */ +export type ExperimentalSetAttributesOptions = SetAttributesOptions; + /** * Attach plaintext string key/value metadata to the current workflow run. * - * **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 as a native `attr_set` event and materialized on the run. * @@ -47,42 +47,42 @@ export interface ExperimentalSetAttributesOptions { * 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. + * `SetAttributesOptions` for the trade-offs. * - * **WARNING**: While this feature is experimental, calling e.g. - * `Promise.all([experimental_setAttributes({ a: '1' }), experimental_setAttributes({ a: '2' })])` + * **WARNING**: Calling e.g. + * `Promise.all([setAttributes({ a: '1' }), setAttributes({ a: '2' })])` * is not guaranteed to be ordered consistently, but the equivalent * sequential `.then()` chain is. * * @example * ```ts - * import { experimental_setAttributes } from 'workflow'; + * import { setAttributes } from 'workflow'; * * export async function myWorkflow() { * 'use workflow'; - * await experimental_setAttributes({ phase: 'init' }); + * await setAttributes({ phase: 'init' }); * // ... work ... - * await experimental_setAttributes({ phase: 'done', orderId: 'ord_123' }); - * await experimental_setAttributes({ orderId: undefined }); // remove + * await setAttributes({ phase: 'done', orderId: 'ord_123' }); + * await setAttributes({ orderId: undefined }); // remove * } * ``` * * @example Framework / library code writing into the reserved namespace. * ```ts - * await experimental_setAttributes( + * await setAttributes( * { '$agent.kind': 'durable-agent' }, * { allowReservedAttributes: true } * ); * ``` */ -export async function experimental_setAttributes( +export async function setAttributes( attrs: Record, - options: ExperimentalSetAttributesOptions = {} + options: SetAttributesOptions = {} ): Promise { const changes = normalizeAttributeChanges(attrs, options); if (changes.length === 0) return; const allowReservedAttributes = options.allowReservedAttributes === true; - const setAttributes = (globalThis as Record)[ + const dispatchSetAttributes = (globalThis as Record)[ WORKFLOW_SET_ATTRIBUTES ] as | (( @@ -90,14 +90,20 @@ export async function experimental_setAttributes( options?: { allowReservedAttributes?: boolean } ) => Promise) | undefined; - if (!setAttributes) { + if (!dispatchSetAttributes) { throw new FatalError( - 'experimental_setAttributes() called outside a workflow runtime context. ' + + 'setAttributes() called outside a workflow runtime context. ' + 'It must be called from within a workflow body (`use workflow`).' ); } - await setAttributes( + await dispatchSetAttributes( changes, allowReservedAttributes ? { allowReservedAttributes: true } : {} ); } + +/** + * @deprecated The feature is no longer experimental — use + * {@link setAttributes} instead. + */ +export const experimental_setAttributes = setAttributes; diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index f2cd1de462..0e83f79827 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -84,7 +84,7 @@ export const WorkflowRunBaseSchema = z.object({ errorCode: z.string().optional(), /** * Plaintext string-string metadata attached to the run via - * `experimental_setAttributes()` (or, in the future, materialized + * `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. diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index 5750636a82..88d7a21a0e 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -4,7 +4,6 @@ import { pathsAliasHelper } from '@repo/lib/steps/paths-alias-test'; import { createHook, createWebhook, - experimental_setAttributes, FatalError, fetch, getStepMetadata, @@ -12,6 +11,7 @@ import { getWritable, type RequestWithResponse, RetryableError, + setAttributes, sleep, } from 'workflow'; import { getHookByToken, getRun, Run, resumeHook, start } from 'workflow/api'; @@ -3479,83 +3479,81 @@ export async function writableForwardedFromStepWorkflow(payload: string) { // Workflow Attributes - native workflow and step events. /** - * Calls `experimental_setAttributes` directly from the workflow body. + * Calls `setAttributes` directly from the workflow body. * Each call appends a native `attr_set` event. The third call sets a key * to `undefined` and the test verifies * the key is absent from the final attribute map. */ -export async function experimentalSetAttributesWorkflow(input: number) { +export async function setAttributesWorkflow(input: number) { 'use workflow'; - await experimental_setAttributes({ phase: 'init', source: 'workflow-body' }); + await setAttributes({ phase: 'init', source: 'workflow-body' }); const tripled = input * 3; - await experimental_setAttributes({ phase: 'done' }); - await experimental_setAttributes({ source: undefined }); + await setAttributes({ phase: 'done' }); + await setAttributes({ source: undefined }); return tripled; } async function setAttributesFromStep(input: number) { 'use step'; - await experimental_setAttributes({ + await setAttributes({ phase: 'step-started', source: 'step-body', input: String(input), }); - await experimental_setAttributes({ phase: 'step-done' }); + await setAttributes({ phase: 'step-done' }); return input * 4; } /** - * Calls `experimental_setAttributes` from inside a normal user step. Step + * Calls `setAttributes` from inside a normal user step. Step * bodies already run in host context, so the helper appends an attributed * `attr_set` event without creating a nested internal step. */ -export async function experimentalSetAttributesInsideStepWorkflow( - input: number -) { +export async function setAttributesInsideStepWorkflow(input: number) { 'use workflow'; return setAttributesFromStep(input); } /** - * Fire-and-forget pattern: `void experimental_setAttributes(...)` lets + * Fire-and-forget pattern: `void setAttributes(...)` lets * the workflow body proceed without blocking on the attribute write. * Each `void` call commits a native event on suspension or final drain, * including the final write immediately before return. */ -export async function experimentalSetAttributesFireAndForgetWorkflow() { +export async function setAttributesFireAndForgetWorkflow() { 'use workflow'; - void experimental_setAttributes({ phase: 'init', mode: 'fire-and-forget' }); + void setAttributes({ phase: 'init', mode: 'fire-and-forget' }); await sleep('100ms'); - void experimental_setAttributes({ phase: 'mid' }); + void setAttributes({ phase: 'mid' }); await sleep('100ms'); - void experimental_setAttributes({ phase: 'done' }); + void setAttributes({ phase: 'done' }); return 'completed'; } /** - * `Promise.all` of multiple `experimental_setAttributes` calls writing + * `Promise.all` of multiple `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() { +export async function setAttributesParallelWorkflow() { 'use workflow'; await Promise.all([ - experimental_setAttributes({ a: '1' }), - experimental_setAttributes({ b: '2' }), - experimental_setAttributes({ c: '3' }), + setAttributes({ a: '1' }), + setAttributes({ b: '2' }), + setAttributes({ c: '3' }), ]); return 'done'; } /** - * Workflow throws after awaiting `experimental_setAttributes`. The + * Workflow throws after awaiting `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() { +export async function setAttributesThrowsAfterWorkflow() { 'use workflow'; - await experimental_setAttributes({ + await setAttributes({ phase: 'about-to-fail', reason: 'intentional', }); @@ -3563,14 +3561,14 @@ export async function experimentalSetAttributesThrowsAfterWorkflow() { } /** - * Validation DX: every invalid `experimental_setAttributes` call must + * Validation DX: every invalid `setAttributes` call must * throw a catchable `FatalError` in the workflow body — before any event * is written — with a message that names the violated rule and the limit. * The workflow records each error's name and message, then writes one * valid attribute and completes, so the e2e test can assert on error * quality without wedging the run. */ -export async function experimentalSetAttributesValidationWorkflow() { +export async function setAttributesValidationWorkflow() { 'use workflow'; const outcomes: Record = {}; @@ -3579,7 +3577,7 @@ export async function experimentalSetAttributesValidationWorkflow() { attrs: Record ) => { try { - await experimental_setAttributes(attrs); + await setAttributes(attrs); outcomes[label] = 'no-error'; } catch (err) { const e = err as Error; @@ -3599,7 +3597,7 @@ export async function experimentalSetAttributesValidationWorkflow() { await attempt('nonObject', 'phase=init' as any); // The run must remain healthy after every rejected call. - await experimental_setAttributes({ phase: 'validated' }); + await setAttributes({ phase: 'validated' }); return outcomes; }