Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rename-set-attributes-world.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/world": patch
---

Update doc comments to reference `setAttributes` (renamed from `experimental_setAttributes`).
6 changes: 6 additions & 0 deletions .changeset/rename-set-attributes.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="info">
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).
Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/v5/api-reference/workflow/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ Workflow SDK contains the following functions you can use inside your workflow f
<Card href="/docs/api-reference/workflow/get-writable" title="getWritable()">
Access the current workflow run's default stream.
</Card>
<Card href="/docs/api-reference/workflow/experimental-set-attributes" title="experimental_setAttributes()">
Attach experimental string metadata to the current workflow run.
<Card href="/docs/api-reference/workflow/set-attributes" title="setAttributes()">
Attach string metadata to the current workflow run.
</Card>
</Cards>

Expand Down
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -12,17 +12,13 @@ related:

Attaches string metadata to the current workflow run.

<Callout>
This API is experimental and may change before the stable attributes API is released.
</Callout>

```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,
})
Expand All @@ -35,24 +31,24 @@ export async function orderWorkflow(orderId: string) {

<TSDoc
definition={`
import { experimental_setAttributes } from "workflow";
export default experimental_setAttributes;`}
import { setAttributes } from "workflow";
export default setAttributes;`}
showSections={['parameters']}
/>

## 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 })
}
```

Expand All @@ -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.

<Callout>
This function was previously exported as `experimental_setAttributes`. The old name still works as a deprecated alias — update imports to `setAttributes`.
</Callout>
30 changes: 12 additions & 18 deletions docs/content/docs/v5/observability/attributes.mdx
Original file line number Diff line number Diff line change
@@ -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
---

<Callout type="warn">
This feature is experimental and may change before the stable attributes API is released.
</Callout>

[`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:
Expand All @@ -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]
}
```

Expand All @@ -68,20 +64,18 @@ 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)

Expanding an `attr_set` event — in the run sidebar or the Events tab — shows the changed keys, removed keys, and whether the write came from the workflow body or a step (with the attempt number):

![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.
13 changes: 13 additions & 0 deletions docs/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
50 changes: 19 additions & 31 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand All @@ -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);

Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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');

Expand All @@ -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.
Expand All @@ -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<string, string>;
Expand Down Expand Up @@ -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/);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/attribute-changes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
});
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/attribute-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}`
);
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/runtime/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/runtime/step-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading