Skip to content
Draft
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/deprecate-experimental-set-attributes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/world": patch
---

Deprecate the optional `runs.experimentalSetAttributes` World method and `ExperimentalSetAttributesResult`. The SDK no longer calls it; `setAttributes()` writes `attr_set` events through `events.create`.
5 changes: 5 additions & 0 deletions .changeset/remove-set-attributes-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"workflow": patch
---

Remove the unused `__builtin_set_attributes` step, the attributes MVP bridge that wrote through `runs.experimentalSetAttributes`.
6 changes: 2 additions & 4 deletions docs/content/worlds/v5/building-a-world.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,6 @@ interface Storage {
runs: {
get(id: string, params?: GetWorkflowRunParams): Promise<WorkflowRun>;
list(params?: ListWorkflowRunsParams): Promise<PaginatedResponse<WorkflowRun>>;
// Optional: backs setAttributes(). Omitting it means run attributes are
// unsupported on this World. The SDK helper no-ops with a warning.
experimentalSetAttributes?(runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean }): Promise<ExperimentalSetAttributesResult>;

// Optional: long poll for a terminal status (see below)
waitForTerminalStatus?(id: string, params?: WaitForTerminalRunStatusParams): Promise<WorkflowRun>;
};
Expand Down Expand Up @@ -170,6 +166,8 @@ interface Storage {

**Run creation:** For `run_created` events, the `runId` parameter may be a client-provided string or `null`. When `null`, your World generates and returns a new `runId`.

**Run attributes:** there is no Storage method for attributes. `setAttributes()` writes an `attr_set` event, and `start(..., { attributes })` passes initial attributes on `run_created`. Validate them with `validateAttributeChanges()` from `@workflow/world` and merge them onto the run's `attributes` in the same atomic update as the event: a string value sets a key, `null` removes it. Reject `attr_set` on a run in a terminal state.

**Event data resolution:** `events.list()` and `events.create()` accept `resolveData: 'skip-step-inputs'`, and the runtime replays with it. The World may leave `input` out of `step_created` and `step_started` events, and returns everything else as for `'all'`. Treat any value other than `'none'` as `'all'`, because a World that tests `resolveData === 'all'` strips step results and breaks every replay. Map the value with `entityResolveData()` from `@workflow/world` before passing it to an entity read.

**Run listing:** `runs.list({ status })` accepts a single status or an array. With an array, return runs whose status matches any listed value, and return no runs for an empty array (the same semantics as SQL `IN ()`). Treat an omitted `status` as no filter.
Expand Down
1 change: 0 additions & 1 deletion docs/content/worlds/v5/upgrading-to-v5.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ None of this is required. Each entry is a hook the runtime uses if your World pr
| `events.createBatch` | Appends an ordered list of events in one durable write, with a per-event outcome for each. Implementing the method *is* the declaration: the runtime folds a suspension's `step_created` / `wait_created` writes into batches only when it exists, and otherwise takes the single-event path unchanged. Implement it with real atomicity per attempt, so a lost race leaves nothing behind, or leave it out. |
| `runs.waitForTerminalStatus` | Long-polls until a run reaches a terminal status. `await run.returnValue` uses it when present, instead of polling on an interval. |
| `analytics` | A metadata-only read namespace for observability surfaces. Payload-bearing reads stay on `runs`, `steps`, `events`, and `hooks`. |
| `runs.experimentalSetAttributes` | Backs `setAttributes()` from application code. Without it, run attributes are unavailable. |
| `runs.cancelMany` | Bulk cancellation: up to 500 unique run IDs per request (`BULK_CANCEL_MAX_RUN_IDS`), an optional `cancelReason` of at most 512 characters, and a per-run outcome for every ID. Without it, the runtime falls back to bounded-concurrency individual cancels. |
| `getRuntimeDeadline()` | The absolute time the current invocation will be terminated. The runtime derives its inline replay budget from this, so a host with a long function timeout gets more work per invocation. Without it, the budget is a flat two minutes. See [`WORKFLOW_V2_TIMEOUT_MS`](/docs/configuration/runtime-tuning#workflow_v2_timeout_ms). |
| `getEnvironment()` | The environment your World's writes are attributed to. Must be synchronous, side-effect free, and match what the backend will actually apply. A wrong answer is worse than `undefined`, because callers use it to detect cross-environment mismatches. Worlds with a single tenant should omit it. |
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/attributes-mvp.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ The v4 implementation now completes the planned dispatch migration while preserv
- The World spec is bumped to v4; attributes are supported for v4 runs rather than being backported into older run specifications.
- The direct server attributes endpoint remains available only as a deprecated compatibility path for clients that shipped with the MVP.

## Retiring the MVP write path

The MVP write path shipped in `workflow` 5.0.0-beta.8 through 5.0.0-beta.14 (step-body calls from beta.9). From beta.15 on, the SDK writes attributes only as `attr_set` events, so nothing in the current SDK calls `runs.experimentalSetAttributes`. The `__builtin_set_attributes` step bridge has been removed: no current workflow body dispatches it, and a run whose log still records it (an MVP-era run, spec version 3) cannot be replayed by a v5 runtime at all, because its event IDs are not slot-numbered.

`runs.experimentalSetAttributes` and `ExperimentalSetAttributesResult` stay on the World interface, optional and `@deprecated`, so existing implementations keep compiling. The first-party Worlds keep implementing the method, and `world-vercel` keeps posting to `POST /v2/runs/:runId/attributes`, because a newer World package can still be installed next to one of those betas: `world-postgres` has no peer dependency on `workflow` or `@workflow/core`, and any World package can be selected by name through `WORKFLOW_TARGET_WORLD`. Drop the implementations once those betas no longer need supporting.

The remainder of this document documents the original MVP motivation and implementation.

## What MVP supports
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/set-attributes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ describe('setAttributes (host-side)', () => {
);
});

it('writes an attr_set event, not the deprecated runs.experimentalSetAttributes', async () => {
const create = vi.fn().mockResolvedValue({});
const experimentalSetAttributes = vi.fn();
globals[WORLD_CACHE] = {
specVersion: SPEC_VERSION_CURRENT,
name: 'test-world',
events: { create },
runs: { experimentalSetAttributes },
};

await runInStepContext(() => setAttributes({ phase: 'ready' }));

expect(create).toHaveBeenCalledTimes(1);
expect(experimentalSetAttributes).not.toHaveBeenCalled();
});

it('forwards allowReservedAttributes for step-side reserved namespace writes', async () => {
const create = vi.fn().mockResolvedValue({});
globals[WORLD_CACHE] = {
Expand Down
109 changes: 0 additions & 109 deletions packages/workflow/src/internal/builtins.test.ts

This file was deleted.

108 changes: 0 additions & 108 deletions packages/workflow/src/internal/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,111 +20,3 @@ export async function __builtin_response_text(this: Request | Response) {
'use step';
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'
);

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
* 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 }>,
options?: { allowReservedAttributes?: boolean }
) {
'use step';
if (changes.length === 0) return;
const g = globalThis as Record<symbol, unknown>;

const contextStorage = g[Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')] as
| {
getStore: () =>
| {
stepMetadata?: { attempt?: number };
workflowMetadata?: { workflowRunId?: string };
}
| undefined;
}
| undefined;
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
| {
name?: string;
runs?: {
experimentalSetAttributes?: (
runId: string,
changes: Array<{ key: string; value: string | null }>,
options?: { allowReservedAttributes?: boolean }
) => Promise<unknown>;
};
}
| undefined;
if (typeof world?.runs?.experimentalSetAttributes !== 'function') {
// World adapter doesn't implement attributes yet, so 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})` : '';
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;
}

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;
8 changes: 7 additions & 1 deletion packages/world/src/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ export const AttributeChangesSchema = z
}
});

/** The post-merge attribute snapshot returned by a World. */
/**
* The post-merge attribute snapshot returned by the deprecated
* `runs.experimentalSetAttributes`.
*
* @deprecated The SDK no longer calls `runs.experimentalSetAttributes`; run
* attributes are written as `attr_set` events. See that method on `Storage`.
*/
export interface ExperimentalSetAttributesResult {
attributes: Record<string, string>;
}
34 changes: 12 additions & 22 deletions packages/world/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,30 +293,20 @@ export interface Storage {
): Promise<PaginatedResponse<WorkflowRun | WorkflowRunWithoutData>>;

/**
* 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
* Apply a batch of attribute changes directly to a run's materialized
* `attributes`, outside the event log, and return the post-merge
* snapshot.
*
* Returns the post-merge attribute snapshot on the run.
* @deprecated The current SDK never calls this method. `setAttributes()`
* writes `attr_set` events through `events.create`, and
* `start(..., { attributes })` seeds attributes on `run_created`; every
* World already handles both. New Worlds should not implement it.
*
* 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 /
* 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.
* It remains on the interface, optional, so existing implementations keep
* compiling. The only callers were `workflow` 5.0.0-beta.8 through
* 5.0.0-beta.14, the attributes MVP, which wrote through this method. A
* World that may still be paired with one of those releases can keep its
* implementation. See `packages/core/src/attributes-mvp.md`.
*/
experimentalSetAttributes?(
runId: string,
Expand Down
4 changes: 2 additions & 2 deletions skills/migrating-workflow-v4-to-v5/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: >-
Upgrades an app from Workflow SDK 4.x to 5.0. Use when bumping the `workflow` / `@workflow/*` dependencies to v5, or when hitting removed v4 APIs — `runStep`, `stepEntrypoint`, `workflow/internal/private`, `@workflow/core/private`, `writeToStream` / `closeStream` / `readFromStream` on a World, `world.steps.get` without a runId, `hook.getConflict()` returning `{ runId }`, `hook.metadata` read synchronously off a `getHookByToken()` result, `experimental_setAttributes`, `createLocalWorld` / `createVercelWorld`, `NestLocalBuilder` imported from `@workflow/nest`, or an SWC transform invoked with `mode: 'client'`.
metadata:
author: Vercel Inc.
version: '0.2.11'
version: '0.2.12'
---

# Migrating Workflow SDK 4.x to 5.0
Expand Down Expand Up @@ -241,7 +241,7 @@ Beyond the stream and step signatures above, the interface gained:

- `streams` as a namespace (see the table in step 2).
- `analytics` — metadata-only listings for runs, steps, events, hooks, waits, and attributes.
- attribute support on runs, including `experimentalSetAttributes`.
- attribute support on runs: `attr_set` events and attributes on `run_created`.
- capability advertisement, so the runtime can gate optimizations. Unadvertised capabilities fail closed, which means an incomplete World stays correct but slower — advertise a capability only once it is genuinely implemented. Event ID allocation is not among them: it is a requirement, not a capability. A World that rejected stale writes behind the old `preconditionGuard` flag can delete that code, since the flag is gone and nothing replaced it.
- an optional per-run event ceiling returned on run reads, which the runtime enforces.
- optional `createRunId()` and a `region` on queue options, for worlds that place run state regionally.
Expand Down
Loading
Loading