From c8254e6dc8984e465aa51de2c5d690ea077a6fd5 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 12 Nov 2026 19:32:28 -0700 Subject: [PATCH 1/2] [world] Rename runs.experimentalSetAttributes to runs.setAttributes Keep experimentalSetAttributes as a deprecated optional alias on the World interface; callers prefer runs.setAttributes and fall back to the old name. Signed-off-by: Pranay Prakash Co-authored-by: vercel-v[bot] <353008837+vercel-v[bot]@users.noreply.github.com> --- .changeset/runs-set-attributes-callers.md | 8 ++ .changeset/world-runs-set-attributes.md | 5 ++ docs/content/worlds/v5/building-a-world.mdx | 2 +- docs/content/worlds/v5/upgrading-to-v5.mdx | 2 +- packages/core/src/attributes-mvp.md | 6 ++ .../workflow/src/internal/builtins.test.ts | 73 +++++++++++++++++-- packages/workflow/src/internal/builtins.ts | 28 +++++-- packages/world-local/src/instrumentObject.ts | 1 + .../src/storage/run-retention.test.ts | 2 +- .../src/storage/runs-storage.test.ts | 65 +++++++++-------- .../world-local/src/storage/runs-storage.ts | 16 ++-- packages/world-postgres/src/storage.ts | 11 ++- packages/world-postgres/test/storage.test.ts | 27 +++++-- packages/world-vercel/src/instrumentObject.ts | 1 + packages/world-vercel/src/runs.test.ts | 40 ++++++++++ packages/world-vercel/src/runs.ts | 16 ++-- packages/world-vercel/src/storage.ts | 7 +- packages/world/src/attributes.ts | 5 +- packages/world/src/interfaces.ts | 34 +++++---- skills/migrating-workflow-v4-to-v5/SKILL.md | 4 +- skills/migrating-world-v4-to-v5/SKILL.md | 6 +- 21 files changed, 264 insertions(+), 95 deletions(-) create mode 100644 .changeset/runs-set-attributes-callers.md create mode 100644 .changeset/world-runs-set-attributes.md diff --git a/.changeset/runs-set-attributes-callers.md b/.changeset/runs-set-attributes-callers.md new file mode 100644 index 0000000000..49fbda29a9 --- /dev/null +++ b/.changeset/runs-set-attributes-callers.md @@ -0,0 +1,8 @@ +--- +"workflow": patch +"@workflow/world-local": patch +"@workflow/world-postgres": patch +"@workflow/world-vercel": patch +--- + +Implement and call `runs.setAttributes`, falling back to the deprecated `runs.experimentalSetAttributes` for Worlds that only provide the old name. The first-party Worlds keep the old name as an alias. diff --git a/.changeset/world-runs-set-attributes.md b/.changeset/world-runs-set-attributes.md new file mode 100644 index 0000000000..9e2e1dd01f --- /dev/null +++ b/.changeset/world-runs-set-attributes.md @@ -0,0 +1,5 @@ +--- +"@workflow/world": minor +--- + +Rename the optional World method `runs.experimentalSetAttributes` to `runs.setAttributes` and `ExperimentalSetAttributesResult` to `SetAttributesResult`. The old names remain as deprecated aliases. diff --git a/docs/content/worlds/v5/building-a-world.mdx b/docs/content/worlds/v5/building-a-world.mdx index 728105f375..c06a2b496a 100644 --- a/docs/content/worlds/v5/building-a-world.mdx +++ b/docs/content/worlds/v5/building-a-world.mdx @@ -121,7 +121,7 @@ interface Storage { list(params?: ListWorkflowRunsParams): Promise>; // 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; + setAttributes?(runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean }): Promise; // Optional: long poll for a terminal status (see below) waitForTerminalStatus?(id: string, params?: WaitForTerminalRunStatusParams): Promise; diff --git a/docs/content/worlds/v5/upgrading-to-v5.mdx b/docs/content/worlds/v5/upgrading-to-v5.mdx index 403600cba2..4eeab6f12b 100644 --- a/docs/content/worlds/v5/upgrading-to-v5.mdx +++ b/docs/content/worlds/v5/upgrading-to-v5.mdx @@ -140,7 +140,7 @@ 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.setAttributes` | 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. | diff --git a/packages/core/src/attributes-mvp.md b/packages/core/src/attributes-mvp.md index 13136cd960..061b7b0ba7 100644 --- a/packages/core/src/attributes-mvp.md +++ b/packages/core/src/attributes-mvp.md @@ -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. +## World method name + +The direct-write World method is `runs.setAttributes`, matching the public `setAttributes()` API, and returns `SetAttributesResult`. It was introduced as `runs.experimentalSetAttributes` (returning `ExperimentalSetAttributesResult`); both old names stay as deprecated aliases so community Worlds that implemented them keep working. `__builtin_set_attributes` calls `runs.setAttributes` when present and falls back to `runs.experimentalSetAttributes`, and the first-party Worlds expose both names so older SDKs that only call the old one keep working. The rename does not touch the wire contract: `world-vercel` still posts to `POST /v2/runs/:runId/attributes`, and no storage column or table changed. + +Where the rest of this document says `experimentalSetAttributes`, read `setAttributes`. + The remainder of this document documents the original MVP motivation and implementation. ## What MVP supports diff --git a/packages/workflow/src/internal/builtins.test.ts b/packages/workflow/src/internal/builtins.test.ts index ee0f5fe4c2..41f356f8a9 100644 --- a/packages/workflow/src/internal/builtins.test.ts +++ b/packages/workflow/src/internal/builtins.test.ts @@ -53,9 +53,9 @@ describe('__builtin_set_attributes', () => { it('rethrows attribute write failures before the third attempt', async () => { const error = new Error('world unavailable'); - const experimentalSetAttributes = vi.fn().mockRejectedValue(error); + const setAttributes = vi.fn().mockRejectedValue(error); globals[WORLD_CACHE] = { - runs: { experimentalSetAttributes }, + runs: { setAttributes }, }; for (const attempt of [1, 2]) { @@ -68,18 +68,18 @@ describe('__builtin_set_attributes', () => { ).rejects.toBe(error); } - expect(experimentalSetAttributes).toHaveBeenCalledTimes(2); + expect(setAttributes).toHaveBeenCalledTimes(2); }); it('logs and completes after the third failed attempt', async () => { - const experimentalSetAttributes = vi + const setAttributes = vi .fn() .mockRejectedValue(new Error('world unavailable')); const consoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}); globals[WORLD_CACHE] = { - runs: { experimentalSetAttributes }, + runs: { setAttributes }, }; setStepAttempt(3); @@ -89,7 +89,7 @@ describe('__builtin_set_attributes', () => { }) ).resolves.toBeUndefined(); - expect(experimentalSetAttributes).toHaveBeenCalledWith( + expect(setAttributes).toHaveBeenCalledWith( 'run_123', [{ key: '$tag.kind', value: 'agent' }], { allowReservedAttributes: true } @@ -106,4 +106,65 @@ describe('__builtin_set_attributes', () => { ).maxRetries ).toBe(2); }); + it('prefers runs.setAttributes over the deprecated name', async () => { + const setAttributes = vi.fn().mockResolvedValue({ attributes: {} }); + const experimentalSetAttributes = vi.fn(); + globals[WORLD_CACHE] = { + runs: { setAttributes, experimentalSetAttributes }, + }; + setStepAttempt(1); + + await __builtin_set_attributes([{ key: 'phase', value: 'init' }]); + + expect(setAttributes).toHaveBeenCalledWith( + 'run_123', + [{ key: 'phase', value: 'init' }], + undefined + ); + expect(experimentalSetAttributes).not.toHaveBeenCalled(); + }); + + it('falls back to runs.experimentalSetAttributes for older Worlds', async () => { + const runs = { + calls: [] as unknown[][], + // Uses `this` to check the fallback is still invoked as a method. + async experimentalSetAttributes(...args: unknown[]) { + this.calls.push(args); + return { attributes: {} }; + }, + }; + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + globals[WORLD_CACHE] = { runs }; + setStepAttempt(1); + + await __builtin_set_attributes([{ key: '$tag.kind', value: 'agent' }], { + allowReservedAttributes: true, + }); + + expect(runs.calls).toEqual([ + [ + 'run_123', + [{ key: '$tag.kind', value: 'agent' }], + { allowReservedAttributes: true }, + ], + ]); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it('warns once and no-ops when the World implements neither name', async () => { + delete globals[UNSUPPORTED_WORLD_WARNED]; + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + globals[WORLD_CACHE] = { name: 'community', runs: {} }; + setStepAttempt(1); + + await expect( + __builtin_set_attributes([{ key: 'phase', value: 'init' }]) + ).resolves.toBeUndefined(); + await __builtin_set_attributes([{ key: 'phase', value: 'done' }]); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn.mock.calls[0]?.[0]).toContain( + 'does not implement runs.setAttributes' + ); + }); }); diff --git a/packages/workflow/src/internal/builtins.ts b/packages/workflow/src/internal/builtins.ts index 7b1d57015e..c358851184 100644 --- a/packages/workflow/src/internal/builtins.ts +++ b/packages/workflow/src/internal/builtins.ts @@ -31,6 +31,12 @@ const UNSUPPORTED_WORLD_WARNED = Symbol.for( const INTERNAL_ATTRIBUTES_MAX_ATTEMPTS = 3; +type SetAttributesMethod = ( + runId: string, + changes: Array<{ key: string; value: string | null }>, + options?: { allowReservedAttributes?: boolean } +) => Promise; + function formatUnknownError(error: unknown) { if (error instanceof Error) { return error.stack ?? `${error.name}: ${error.message}`; @@ -77,15 +83,21 @@ export async function __builtin_set_attributes( | { name?: string; runs?: { - experimentalSetAttributes?: ( - runId: string, - changes: Array<{ key: string; value: string | null }>, - options?: { allowReservedAttributes?: boolean } - ) => Promise; + setAttributes?: SetAttributesMethod; + /** Deprecated name, still implemented by older World adapters. */ + experimentalSetAttributes?: SetAttributesMethod; }; } | undefined; - if (typeof world?.runs?.experimentalSetAttributes !== 'function') { + const runs = world?.runs; + // Bind to `runs` so implementations that rely on `this` keep working. + const setAttributes = + typeof runs?.setAttributes === 'function' + ? runs.setAttributes.bind(runs) + : typeof runs?.experimentalSetAttributes === 'function' + ? runs.experimentalSetAttributes.bind(runs) + : undefined; + if (!setAttributes) { // 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 @@ -94,7 +106,7 @@ export async function __builtin_set_attributes( 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.` + `[workflow] setAttributes: the current world implementation${worldName} does not implement runs.setAttributes; 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; @@ -108,7 +120,7 @@ export async function __builtin_set_attributes( ); } - await world.runs.experimentalSetAttributes(runId, changes, options); + await setAttributes(runId, changes, options); } catch (error) { if (attempt < INTERNAL_ATTRIBUTES_MAX_ATTEMPTS) { throw error; diff --git a/packages/world-local/src/instrumentObject.ts b/packages/world-local/src/instrumentObject.ts index fadfea31be..aed2611d08 100644 --- a/packages/world-local/src/instrumentObject.ts +++ b/packages/world-local/src/instrumentObject.ts @@ -22,6 +22,7 @@ const WORLD_LOCAL_SERVICE = { const RUN_ID_ARG_INDEX_BY_METHOD: Record = { 'world.runs.get': 0, + 'world.runs.setAttributes': 0, 'world.runs.experimentalSetAttributes': 0, 'world.steps.get': 0, 'world.events.create': 0, diff --git a/packages/world-local/src/storage/run-retention.test.ts b/packages/world-local/src/storage/run-retention.test.ts index 68645c46c0..39300e228d 100644 --- a/packages/world-local/src/storage/run-retention.test.ts +++ b/packages/world-local/src/storage/run-retention.test.ts @@ -404,7 +404,7 @@ describe('run retention (world-local)', () => { // write. The decision is made inside the run file lock against the // re-read attributes for exactly that reason. const run = await startRun(); - await storage.runs.experimentalSetAttributes?.( + await storage.runs.setAttributes?.( run.runId, [{ key: RETENTION_ATTRIBUTE, value: '0' }], { allowReservedAttributes: true } diff --git a/packages/world-local/src/storage/runs-storage.test.ts b/packages/world-local/src/storage/runs-storage.test.ts index bbb70839b5..1098a973a7 100644 --- a/packages/world-local/src/storage/runs-storage.test.ts +++ b/packages/world-local/src/storage/runs-storage.test.ts @@ -13,7 +13,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createStorage } from '../storage.js'; import { createRun } from '../test-helpers.js'; -describe('runs.experimentalSetAttributes (world-local)', () => { +describe('runs.setAttributes (world-local)', () => { let testDir: string; let storage: Storage; @@ -37,7 +37,7 @@ describe('runs.experimentalSetAttributes (world-local)', () => { it('upserts new keys', async () => { const run = await newRun(); - const result = await storage.runs.experimentalSetAttributes!(run.runId, [ + const result = await storage.runs.setAttributes!(run.runId, [ { key: 'phase', value: 'init' }, { key: 'tenant', value: 't1' }, ]); @@ -49,12 +49,12 @@ describe('runs.experimentalSetAttributes (world-local)', () => { it('updates existing keys (merge semantics)', async () => { const run = await newRun(); - await storage.runs.experimentalSetAttributes!(run.runId, [ + await storage.runs.setAttributes!(run.runId, [ { key: 'phase', value: 'init' }, { key: 'tenant', value: 't1' }, ]); - const result = await storage.runs.experimentalSetAttributes!(run.runId, [ + const result = await storage.runs.setAttributes!(run.runId, [ { key: 'phase', value: 'done' }, ]); @@ -63,12 +63,12 @@ describe('runs.experimentalSetAttributes (world-local)', () => { it('removes keys when value is null', async () => { const run = await newRun(); - await storage.runs.experimentalSetAttributes!(run.runId, [ + await storage.runs.setAttributes!(run.runId, [ { key: 'phase', value: 'init' }, { key: 'orderId', value: 'ord_123' }, ]); - const result = await storage.runs.experimentalSetAttributes!(run.runId, [ + const result = await storage.runs.setAttributes!(run.runId, [ { key: 'orderId', value: null }, ]); @@ -78,11 +78,11 @@ describe('runs.experimentalSetAttributes (world-local)', () => { it('applies set and unset in a single call', async () => { const run = await newRun(); - await storage.runs.experimentalSetAttributes!(run.runId, [ + await storage.runs.setAttributes!(run.runId, [ { key: 'stale', value: 'yes' }, ]); - const result = await storage.runs.experimentalSetAttributes!(run.runId, [ + const result = await storage.runs.setAttributes!(run.runId, [ { key: 'stale', value: null }, { key: 'fresh', value: 'yes' }, ]); @@ -92,7 +92,7 @@ describe('runs.experimentalSetAttributes (world-local)', () => { it('throws WorkflowRunNotFoundError for unknown run', async () => { await expect( - storage.runs.experimentalSetAttributes!('wrun_doesnotexist', [ + storage.runs.setAttributes!('wrun_doesnotexist', [ { key: 'phase', value: 'init' }, ]) ).rejects.toBeInstanceOf(WorkflowRunNotFoundError); @@ -101,7 +101,7 @@ describe('runs.experimentalSetAttributes (world-local)', () => { it('rejects keys starting with reserved prefix', async () => { const run = await newRun(); await expect( - storage.runs.experimentalSetAttributes!(run.runId, [ + storage.runs.setAttributes!(run.runId, [ { key: `${RESERVED_ATTRIBUTE_KEY_PREFIX}sys`, value: 'x' }, ]) ).rejects.toBeInstanceOf(AttributeValidationError); @@ -109,7 +109,7 @@ describe('runs.experimentalSetAttributes (world-local)', () => { it('accepts reserved-prefix keys when allowReservedAttributes is set (framework escape hatch)', async () => { const run = await newRun(); - const result = await storage.runs.experimentalSetAttributes!( + const result = await storage.runs.setAttributes!( run.runId, [ { @@ -128,7 +128,7 @@ describe('runs.experimentalSetAttributes (world-local)', () => { // 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, [ + storage.runs.setAttributes!(run.runId, [ { key: `${RESERVED_ATTRIBUTE_KEY_PREFIX}other`, value: 'x' }, ]) ).rejects.toBeInstanceOf(AttributeValidationError); @@ -137,7 +137,7 @@ describe('runs.experimentalSetAttributes (world-local)', () => { it('rejects keys over the max length', async () => { const run = await newRun(); await expect( - storage.runs.experimentalSetAttributes!(run.runId, [ + storage.runs.setAttributes!(run.runId, [ { key: 'k'.repeat(ATTRIBUTE_KEY_MAX_LENGTH + 1), value: 'x' }, ]) ).rejects.toBeInstanceOf(AttributeValidationError); @@ -146,7 +146,7 @@ describe('runs.experimentalSetAttributes (world-local)', () => { it('rejects values exceeding the byte cap', async () => { const run = await newRun(); await expect( - storage.runs.experimentalSetAttributes!(run.runId, [ + storage.runs.setAttributes!(run.runId, [ { key: 'big', value: 'a'.repeat(257) }, ]) ).rejects.toBeInstanceOf(AttributeValidationError); @@ -159,10 +159,10 @@ describe('runs.experimentalSetAttributes (world-local)', () => { key: `k${i}`, value: 'v', })); - await storage.runs.experimentalSetAttributes!(run.runId, initial); + await storage.runs.setAttributes!(run.runId, initial); // Updating an existing key (no growth) must succeed even at the cap. - const result = await storage.runs.experimentalSetAttributes!(run.runId, [ + const result = await storage.runs.setAttributes!(run.runId, [ { key: 'k0', value: 'updated' }, ]); expect(result.attributes.k0).toBe('updated'); @@ -176,18 +176,9 @@ describe('runs.experimentalSetAttributes (world-local)', () => { { 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 - ); + const first = await storage.runs.setAttributes!(run.runId, changes); + const second = await storage.runs.setAttributes!(run.runId, changes); + const third = await storage.runs.setAttributes!(run.runId, changes); // All three converge on the same snapshot — second/third are no-op // upserts of the same values. @@ -203,7 +194,7 @@ describe('runs.experimentalSetAttributes (world-local)', () => { key: `k${i}`, value: 'v', })); - await storage.runs.experimentalSetAttributes!(run.runId, initial); + await storage.runs.setAttributes!(run.runId, initial); // Adding 5 new keys would push us over. const overflow = Array.from({ length: 5 }, (_, i) => ({ @@ -211,7 +202,7 @@ describe('runs.experimentalSetAttributes (world-local)', () => { value: 'v', })); await expect( - storage.runs.experimentalSetAttributes!(run.runId, overflow) + storage.runs.setAttributes!(run.runId, overflow) ).rejects.toBeInstanceOf(AttributeValidationError); }); @@ -223,7 +214,7 @@ describe('runs.experimentalSetAttributes (world-local)', () => { // race loses some. await Promise.all( Array.from({ length: 20 }, (_, i) => - storage.runs.experimentalSetAttributes!(run.runId, [ + storage.runs.setAttributes!(run.runId, [ { key: `k${i}`, value: `v${i}` }, ]) ) @@ -232,6 +223,18 @@ describe('runs.experimentalSetAttributes (world-local)', () => { const refreshed = await storage.runs.get(run.runId); expect(Object.keys(refreshed.attributes ?? {})).toHaveLength(20); }); + + it('keeps the deprecated experimentalSetAttributes alias for older SDKs', async () => { + const run = await newRun(); + + const result = await storage.runs.experimentalSetAttributes!(run.runId, [ + { key: 'phase', value: 'init' }, + ]); + + expect(result.attributes).toEqual({ phase: 'init' }); + const refreshed = await storage.runs.get(run.runId); + expect(refreshed.attributes).toEqual({ phase: 'init' }); + }); }); describe("runs resolveData: 'none' (world-local)", () => { diff --git a/packages/world-local/src/storage/runs-storage.ts b/packages/world-local/src/storage/runs-storage.ts index c22e185629..d7fa8ec24e 100644 --- a/packages/world-local/src/storage/runs-storage.ts +++ b/packages/world-local/src/storage/runs-storage.ts @@ -1,9 +1,9 @@ import { WorkflowRunNotFoundError } from '@workflow/errors'; import type { AttributeChange, - ExperimentalSetAttributesResult, ListWorkflowRunsParams, PaginatedResponse, + SetAttributesResult, Storage, WorkflowRun, WorkflowRunWithoutData, @@ -55,11 +55,13 @@ export interface LocalRunsStorage { params?: LocalListWorkflowRunsParams ): Promise>; }; - experimentalSetAttributes( + setAttributes( runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean } - ): Promise; + ): Promise; + /** @deprecated Alias of `setAttributes` for SDK versions that only call the old name. */ + experimentalSetAttributes: LocalRunsStorage['setAttributes']; } export function createRunsStorage( @@ -78,7 +80,7 @@ export function createRunsStorage( ); }) as Storage['runs']['get']; - return { + const runsStorage: Omit = { get, /** @@ -167,7 +169,7 @@ export function createRunsStorage( }; }) as LocalRunsStorage['list'], - experimentalSetAttributes: async (runId, changes, options) => { + setAttributes: async (runId, changes, options) => { assertSafeEntityId('runId', runId); return db.transactAsync(() => { const row = store.getRun(runId); @@ -184,6 +186,10 @@ export function createRunsStorage( }); }, }; + return { + ...runsStorage, + experimentalSetAttributes: runsStorage.setAttributes, + }; } export { tagClause, tagParams }; diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index 43c4b05b80..a7771f0cfd 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -22,7 +22,6 @@ import type { EventResult, EventsResolveData, EventsSubscribeOptions, - ExperimentalSetAttributesResult, GetEventParams, Hook, ListEventsParams, @@ -30,6 +29,7 @@ import type { PaginatedResponse, ResolveData, SerializedData, + SetAttributesResult, StartHook, Step, StepWithoutData, @@ -899,7 +899,7 @@ export function createRunsStorage( return filterRunData(parsed, resolveData); }) as Storage['runs']['get']; - return { + const runsStorage: Storage['runs'] = { get: getRun, /** @@ -956,11 +956,11 @@ export function createRunsStorage( }) as NonNullable, list: createRunsList(drizzle), - experimentalSetAttributes: async ( + setAttributes: async ( runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean } - ): Promise => { + ): Promise => { // 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 @@ -1038,6 +1038,9 @@ export function createRunsStorage( return { attributes: updated.attributes ?? {} }; }, }; + // Deprecated alias for SDK versions that only call the old name. + runsStorage.experimentalSetAttributes = runsStorage.setAttributes; + return runsStorage; } function map(obj: T | null | undefined, fn: (v: T) => R): undefined | R { diff --git a/packages/world-postgres/test/storage.test.ts b/packages/world-postgres/test/storage.test.ts index 0d361866e6..f164386031 100644 --- a/packages/world-postgres/test/storage.test.ts +++ b/packages/world-postgres/test/storage.test.ts @@ -665,7 +665,7 @@ describe('Storage (Postgres integration)', () => { }); }); - describe('experimentalSetAttributes', () => { + describe('setAttributes', () => { it('upserts new keys', async () => { const run = await createRun(events, { deploymentId: 'd', @@ -673,7 +673,7 @@ describe('Storage (Postgres integration)', () => { input: new Uint8Array(), }); - const result = await runs.experimentalSetAttributes!(run.runId, [ + const result = await runs.setAttributes!(run.runId, [ { key: 'phase', value: 'init' }, { key: 'tenant', value: 't1' }, ]); @@ -690,10 +690,8 @@ describe('Storage (Postgres integration)', () => { input: new Uint8Array(), }); - await runs.experimentalSetAttributes!(run.runId, [ - { key: 'a', value: '1' }, - ]); - const result = await runs.experimentalSetAttributes!(run.runId, [ + await runs.setAttributes!(run.runId, [{ key: 'a', value: '1' }]); + const result = await runs.setAttributes!(run.runId, [ { key: 'b', value: '2' }, ]); expect(result.attributes).toEqual({ a: '1', b: '2' }); @@ -705,15 +703,28 @@ describe('Storage (Postgres integration)', () => { workflowName: 'w', input: new Uint8Array(), }); - await runs.experimentalSetAttributes!(run.runId, [ + await runs.setAttributes!(run.runId, [ { key: 'a', value: '1' }, { key: 'b', value: '2' }, ]); - const result = await runs.experimentalSetAttributes!(run.runId, [ + const result = await runs.setAttributes!(run.runId, [ { key: 'a', value: null }, ]); expect(result.attributes).toEqual({ b: '2' }); }); + + it('keeps the deprecated experimentalSetAttributes alias for older SDKs', async () => { + const run = await createRun(events, { + deploymentId: 'd', + workflowName: 'w', + input: new Uint8Array(), + }); + + const result = await runs.experimentalSetAttributes!(run.runId, [ + { key: 'phase', value: 'init' }, + ]); + expect(result.attributes).toEqual({ phase: 'init' }); + }); }); describe('native attr_set events', () => { diff --git a/packages/world-vercel/src/instrumentObject.ts b/packages/world-vercel/src/instrumentObject.ts index 0162d9071c..85ae71c0c3 100644 --- a/packages/world-vercel/src/instrumentObject.ts +++ b/packages/world-vercel/src/instrumentObject.ts @@ -22,6 +22,7 @@ const WORKFLOW_SERVER_SERVICE = { const RUN_ID_ARG_INDEX_BY_METHOD: Record = { 'world.runs.get': 0, + 'world.runs.setAttributes': 0, 'world.runs.experimentalSetAttributes': 0, 'world.steps.get': 0, 'world.events.create': 0, diff --git a/packages/world-vercel/src/runs.test.ts b/packages/world-vercel/src/runs.test.ts index 3988fc002f..10a9b19659 100644 --- a/packages/world-vercel/src/runs.test.ts +++ b/packages/world-vercel/src/runs.test.ts @@ -7,6 +7,7 @@ import { getWorkflowRuns, listWorkflowRuns, } from './runs.js'; +import { createStorage } from './storage.js'; import { WORKFLOW_SERVER_URL_OVERRIDE } from './utils.js'; const ORIGIN = WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; @@ -326,3 +327,42 @@ describe('cancelWorkflowRuns', () => { agent.assertNoPendingInterceptors(); }); }); + +describe('runs.setAttributes', () => { + it('posts to the attributes endpoint under both the new and deprecated names', async () => { + const agent = new MockAgent(); + agent.disableNetConnect(); + + const bodies: unknown[] = []; + agent + .get(ORIGIN) + .intercept({ path: '/api/v2/runs/wrun_a/attributes', method: 'POST' }) + .reply( + 200, + (opts: { body?: unknown }) => { + bodies.push(decode(new Uint8Array(opts.body as ArrayBufferLike))); + return encode({ attributes: { phase: 'init' } }); + }, + { headers: { 'content-type': 'application/cbor' } } + ) + .times(2); + + const storage = createStorage({ token: 'test-token', dispatcher: agent }); + const changes = [{ key: 'phase', value: 'init' }]; + + await expect( + storage.runs.setAttributes!('wrun_a', changes) + ).resolves.toEqual({ attributes: { phase: 'init' } }); + await expect( + storage.runs.experimentalSetAttributes!('wrun_a', changes, { + allowReservedAttributes: true, + }) + ).resolves.toEqual({ attributes: { phase: 'init' } }); + + expect(bodies).toEqual([ + { changes }, + { changes, allowReservedAttributes: true }, + ]); + agent.assertNoPendingInterceptors(); + }); +}); diff --git a/packages/world-vercel/src/runs.ts b/packages/world-vercel/src/runs.ts index 1cac734282..1dddb2bb9a 100644 --- a/packages/world-vercel/src/runs.ts +++ b/packages/world-vercel/src/runs.ts @@ -8,7 +8,7 @@ import { BulkCancelWorkflowRunsResultSchema, type CancelWorkflowRunParams, type CreateWorkflowRunRequest, - type ExperimentalSetAttributesResult, + type SetAttributesResult, type GetWorkflowRunParams, type ListWorkflowRunsParams, type PaginatedResponse, @@ -586,11 +586,11 @@ export async function cancelWorkflowRuns( } /** - * Wire response schema for `experimentalSetAttributes`. The backend + * Wire response schema for `setAttributes`. The backend * returns the post-merge attribute snapshot so callers don't need to * issue a follow-up read. */ -const ExperimentalSetAttributesResponseSchema = z.object({ +const SetAttributesResponseSchema = z.object({ attributes: z.record(z.string(), z.string()), }); @@ -604,15 +604,15 @@ const ExperimentalSetAttributesResponseSchema = z.object({ * `$`-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`. + * The endpoint path is the wire contract with the backend and is not + * tied to this function's name. See `packages/core/src/attributes-mvp.md`. */ -export async function experimentalSetAttributes( +export async function setAttributes( runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean }, config?: APIConfig -): Promise { +): Promise { try { const response = await makeRequest({ endpoint: `/v2/runs/${encodeURIComponent(runId)}/attributes`, @@ -621,7 +621,7 @@ export async function experimentalSetAttributes( ? { changes, allowReservedAttributes: true } : { changes }, config, - schema: ExperimentalSetAttributesResponseSchema, + schema: SetAttributesResponseSchema, }); return { attributes: response.attributes }; } catch (error) { diff --git a/packages/world-vercel/src/storage.ts b/packages/world-vercel/src/storage.ts index 91caa3017c..448cb9bf5c 100644 --- a/packages/world-vercel/src/storage.ts +++ b/packages/world-vercel/src/storage.ts @@ -19,10 +19,10 @@ import { getHook, getHookByToken, listHooks } from './hooks.js'; import { instrumentObject } from './instrumentObject.js'; import { cancelWorkflowRuns, - experimentalSetAttributes, getWorkflowRun, getWorkflowRuns, listWorkflowRuns, + setAttributes, waitForWorkflowRunTerminalStatus, } from './runs.js'; import { createSnapshotsStorage } from './snapshots.js'; @@ -46,8 +46,11 @@ export function createStorage(config?: APIConfig): Storage { >, list: ((params?: any) => listWorkflowRuns(params, config)) as Storage['runs']['list'], + setAttributes: (runId, changes, options) => + setAttributes(runId, changes, options, config), + // Deprecated alias for SDK versions that only know the old name. experimentalSetAttributes: (runId, changes, options) => - experimentalSetAttributes(runId, changes, options, config), + setAttributes(runId, changes, options, config), cancelMany: (request) => cancelWorkflowRuns(request, config), }, steps: { diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts index 177e870492..518c5d4fc3 100644 --- a/packages/world/src/attributes.ts +++ b/packages/world/src/attributes.ts @@ -54,6 +54,9 @@ export const AttributeChangesSchema = z }); /** The post-merge attribute snapshot returned by a World. */ -export interface ExperimentalSetAttributesResult { +export interface SetAttributesResult { attributes: Record; } + +/** @deprecated Use {@link SetAttributesResult}. */ +export type ExperimentalSetAttributesResult = SetAttributesResult; diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index 9d5427a275..95e40b755a 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -1,8 +1,5 @@ import type { Analytics } from './analytics.js'; -import type { - AttributeChange, - ExperimentalSetAttributesResult, -} from './attributes.js'; +import type { AttributeChange, SetAttributesResult } from './attributes.js'; import type { BatchEventRequest, CreateEventBatchParams, @@ -307,22 +304,31 @@ export interface Storage { * 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. + * feature-detects its absence and no-ops with a one-time warning so + * third-party / community worlds keep working without adopting it. + * Callers prefer this method and fall back to the deprecated + * {@link experimentalSetAttributes} when only that one exists. * - * 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. + * This direct entity write predates the `attr_set` event type, which + * is how `setAttributes()` records changes on current spec versions. + * See `packages/core/src/attributes-mvp.md`. + */ + setAttributes?( + runId: string, + changes: AttributeChange[], + options?: { allowReservedAttributes?: boolean } + ): Promise; + + /** + * @deprecated Renamed to {@link setAttributes}. Implement + * `setAttributes` instead; callers still fall back to this method + * when a World provides only the old name. */ experimentalSetAttributes?( runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean } - ): Promise; + ): Promise; /** * Cancel many runs in a single operation, returning a per-run outcome diff --git a/skills/migrating-workflow-v4-to-v5/SKILL.md b/skills/migrating-workflow-v4-to-v5/SKILL.md index 00b2bc21da..cf53355923 100644 --- a/skills/migrating-workflow-v4-to-v5/SKILL.md +++ b/skills/migrating-workflow-v4-to-v5/SKILL.md @@ -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 @@ -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, including `runs.setAttributes` (the deprecated `experimentalSetAttributes` name is still called as a fallback). - 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. diff --git a/skills/migrating-world-v4-to-v5/SKILL.md b/skills/migrating-world-v4-to-v5/SKILL.md index decb339c0c..d28ae0e811 100644 --- a/skills/migrating-world-v4-to-v5/SKILL.md +++ b/skills/migrating-world-v4-to-v5/SKILL.md @@ -4,7 +4,7 @@ description: >- Upgrades a custom Workflow SDK World implementation from the v4 spec, or from a 5.0.0 beta, to the current v5 spec (spec version 8). Use when a package implements the `World` interface from `@workflow/world` and is moving to 5.x, or is on a 5.0.0 beta and the runtime reports `This Workflow runtime supports Worlds with spec version 6 through 8, but the configured World declares spec version N`; or when it shows event IDs that are ULIDs rather than slot positions, `Event id is not slot-numbered` at replay time, a `specVersion` the runtime refuses, `writeToStream` / `closeStream` / `readFromStream` as top-level World methods, `steps.get` or `events.listByCorrelationId` without a `runId`, a `'step'` queue kind or `__wkf_step_*` topics, a `preconditionGuard` capability, or a `createLocalWorld` / `createVercelWorld` factory. metadata: author: Vercel Inc. - version: '0.4.1' + version: '0.4.2' --- # Migrating a World from the v4 spec to v5 @@ -40,7 +40,7 @@ Before editing, establish and report each of these: 2. **How event IDs are minted today.** Grep for `eventId`, `ulid`, `uuid`, `nanoid`, `nextval`, `AUTO_INCREMENT`, `IDENTITY`. Find the exact line that produces the ID written to storage. Note any place an ID is chosen before the write that stores it, for example an ID planned in advance and saved in a claim record so redeliveries reuse it. That design hands out a position before the commit and needs reworking, not porting. 3. **What settles a write race.** Read the `events.create` implementation. Note whether the ID or ordering is decided in process (read-then-write, an in-memory counter, a `Math.max` over loaded events) or in the store (unique constraint, conditional write, `INSERT ... ON CONFLICT`, a transaction). 4. **Which `specVersion` it declares.** Grep for `specVersion`. Note whether it is a literal or an imported constant. -5. **Which optional members exist.** Grep for `capabilities`, `analytics`, `getRuntimeDeadline`, `getEnvironment`, `createRunId`, `describeRun`, `getEncryptionKeyForRun`, `resolveLatestDeploymentId`, `cancelMany`, `experimentalSetAttributes`. +5. **Which optional members exist.** Grep for `capabilities`, `analytics`, `getRuntimeDeadline`, `getEnvironment`, `createRunId`, `describeRun`, `getEncryptionKeyForRun`, `resolveLatestDeploymentId`, `cancelMany`, `setAttributes`, `experimentalSetAttributes`. 6. **Whether it provisions step topics.** Grep for `'step'`, `__wkf_step`, `stepQueue`. 7. **Whether it rejects stale writes.** Grep for `PreconditionFailedError`, `preconditionGuard`, `stateUpdatedAt`, `stateEventCount`, `stateCursor`, `412`. 8. **Where its process-wide state lives.** Grep the World's modules for top-level `const`/`let` holding a pool, client, socket, registry, cache, ULID factory, or a log-once boolean. Note every one: these are correct in a `require()`d package and wrong in a bundled one. @@ -284,7 +284,7 @@ These change no signature. A World ported by types alone compiles and then behav None of this is required, and the runtime routes around each absence. Report what the World is missing rather than implementing everything unprompted. -`capabilities` (`hookRetention.active`, `hookResumeDedup`, `hookForceClaim`, `deploymentAffinity`, `maxConcurrency`, `dynamicWorkflowCode`, `inBandFence`), `analytics`, `analytics.events.getMany()`, `runs.experimentalSetAttributes`, `runs.cancelMany`, `runs.waitForTerminalStatus()`, `events.createBatch()`, `streams.createWriteSession()`, `getRuntimeDeadline()`, `getEnvironment()`, `createRunId()`, `describeRun()`, `getEncryptionKeyForRun()`, `resolveLatestDeploymentId()`, `close()`. +`capabilities` (`hookRetention.active`, `hookResumeDedup`, `hookForceClaim`, `deploymentAffinity`, `maxConcurrency`, `dynamicWorkflowCode`, `inBandFence`), `analytics`, `analytics.events.getMany()`, `runs.setAttributes` (rename an existing `runs.experimentalSetAttributes`; the old name is deprecated but still called as a fallback), `runs.cancelMany`, `runs.waitForTerminalStatus()`, `events.createBatch()`, `streams.createWriteSession()`, `getRuntimeDeadline()`, `getEnvironment()`, `createRunId()`, `describeRun()`, `getEncryptionKeyForRun()`, `resolveLatestDeploymentId()`, `close()`. Four are worth raising unprompted because their absence is felt rather than reported: From 611553b90552d5977607d4b1fbb1dd43d1374b91 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 12 Nov 2026 20:05:07 -0700 Subject: [PATCH 2/2] [world] Deprecate runs.experimentalSetAttributes instead of renaming it The current SDK writes attributes as attr_set events and never calls the method. Revert the rename, mark the method and ExperimentalSetAttributesResult deprecated, and remove the unreachable __builtin_set_attributes bridge. First-party World implementations stay for workflow 5.0.0-beta.8 to beta.14. Signed-off-by: Pranay Prakash Co-authored-by: vercel-v[bot] <353008837+vercel-v[bot]@users.noreply.github.com> --- .../deprecate-experimental-set-attributes.md | 5 + .changeset/remove-set-attributes-bridge.md | 5 + .changeset/runs-set-attributes-callers.md | 8 - .changeset/world-runs-set-attributes.md | 5 - docs/content/worlds/v5/building-a-world.mdx | 6 +- docs/content/worlds/v5/upgrading-to-v5.mdx | 1 - packages/core/src/attributes-mvp.md | 6 +- packages/core/src/set-attributes.test.ts | 16 ++ .../workflow/src/internal/builtins.test.ts | 170 ------------------ packages/workflow/src/internal/builtins.ts | 120 ------------- packages/world-local/src/instrumentObject.ts | 1 - .../src/storage/run-retention.test.ts | 2 +- .../src/storage/runs-storage.test.ts | 65 ++++--- .../world-local/src/storage/runs-storage.ts | 16 +- packages/world-postgres/src/storage.ts | 11 +- packages/world-postgres/test/storage.test.ts | 27 +-- packages/world-vercel/src/instrumentObject.ts | 1 - packages/world-vercel/src/runs.test.ts | 40 ----- packages/world-vercel/src/runs.ts | 16 +- packages/world-vercel/src/storage.ts | 7 +- packages/world/src/attributes.ts | 13 +- packages/world/src/interfaces.ts | 50 ++---- skills/migrating-workflow-v4-to-v5/SKILL.md | 2 +- skills/migrating-world-v4-to-v5/SKILL.md | 4 +- 24 files changed, 118 insertions(+), 479 deletions(-) create mode 100644 .changeset/deprecate-experimental-set-attributes.md create mode 100644 .changeset/remove-set-attributes-bridge.md delete mode 100644 .changeset/runs-set-attributes-callers.md delete mode 100644 .changeset/world-runs-set-attributes.md delete mode 100644 packages/workflow/src/internal/builtins.test.ts diff --git a/.changeset/deprecate-experimental-set-attributes.md b/.changeset/deprecate-experimental-set-attributes.md new file mode 100644 index 0000000000..6c11ab4c9a --- /dev/null +++ b/.changeset/deprecate-experimental-set-attributes.md @@ -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`. diff --git a/.changeset/remove-set-attributes-bridge.md b/.changeset/remove-set-attributes-bridge.md new file mode 100644 index 0000000000..e44827615f --- /dev/null +++ b/.changeset/remove-set-attributes-bridge.md @@ -0,0 +1,5 @@ +--- +"workflow": patch +--- + +Remove the unused `__builtin_set_attributes` step, the attributes MVP bridge that wrote through `runs.experimentalSetAttributes`. diff --git a/.changeset/runs-set-attributes-callers.md b/.changeset/runs-set-attributes-callers.md deleted file mode 100644 index 49fbda29a9..0000000000 --- a/.changeset/runs-set-attributes-callers.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"workflow": patch -"@workflow/world-local": patch -"@workflow/world-postgres": patch -"@workflow/world-vercel": patch ---- - -Implement and call `runs.setAttributes`, falling back to the deprecated `runs.experimentalSetAttributes` for Worlds that only provide the old name. The first-party Worlds keep the old name as an alias. diff --git a/.changeset/world-runs-set-attributes.md b/.changeset/world-runs-set-attributes.md deleted file mode 100644 index 9e2e1dd01f..0000000000 --- a/.changeset/world-runs-set-attributes.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@workflow/world": minor ---- - -Rename the optional World method `runs.experimentalSetAttributes` to `runs.setAttributes` and `ExperimentalSetAttributesResult` to `SetAttributesResult`. The old names remain as deprecated aliases. diff --git a/docs/content/worlds/v5/building-a-world.mdx b/docs/content/worlds/v5/building-a-world.mdx index c06a2b496a..1bd39339ba 100644 --- a/docs/content/worlds/v5/building-a-world.mdx +++ b/docs/content/worlds/v5/building-a-world.mdx @@ -119,10 +119,6 @@ interface Storage { runs: { get(id: string, params?: GetWorkflowRunParams): Promise; list(params?: ListWorkflowRunsParams): Promise>; - // Optional: backs setAttributes(). Omitting it means run attributes are - // unsupported on this World. The SDK helper no-ops with a warning. - setAttributes?(runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean }): Promise; - // Optional: long poll for a terminal status (see below) waitForTerminalStatus?(id: string, params?: WaitForTerminalRunStatusParams): Promise; }; @@ -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. diff --git a/docs/content/worlds/v5/upgrading-to-v5.mdx b/docs/content/worlds/v5/upgrading-to-v5.mdx index 4eeab6f12b..688d655947 100644 --- a/docs/content/worlds/v5/upgrading-to-v5.mdx +++ b/docs/content/worlds/v5/upgrading-to-v5.mdx @@ -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.setAttributes` | 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. | diff --git a/packages/core/src/attributes-mvp.md b/packages/core/src/attributes-mvp.md index 061b7b0ba7..c448d3ae35 100644 --- a/packages/core/src/attributes-mvp.md +++ b/packages/core/src/attributes-mvp.md @@ -14,11 +14,11 @@ 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. -## World method name +## Retiring the MVP write path -The direct-write World method is `runs.setAttributes`, matching the public `setAttributes()` API, and returns `SetAttributesResult`. It was introduced as `runs.experimentalSetAttributes` (returning `ExperimentalSetAttributesResult`); both old names stay as deprecated aliases so community Worlds that implemented them keep working. `__builtin_set_attributes` calls `runs.setAttributes` when present and falls back to `runs.experimentalSetAttributes`, and the first-party Worlds expose both names so older SDKs that only call the old one keep working. The rename does not touch the wire contract: `world-vercel` still posts to `POST /v2/runs/:runId/attributes`, and no storage column or table changed. +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. -Where the rest of this document says `experimentalSetAttributes`, read `setAttributes`. +`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. diff --git a/packages/core/src/set-attributes.test.ts b/packages/core/src/set-attributes.test.ts index cd1e33726a..c0ef823055 100644 --- a/packages/core/src/set-attributes.test.ts +++ b/packages/core/src/set-attributes.test.ts @@ -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] = { diff --git a/packages/workflow/src/internal/builtins.test.ts b/packages/workflow/src/internal/builtins.test.ts deleted file mode 100644 index 41f356f8a9..0000000000 --- a/packages/workflow/src/internal/builtins.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -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 setAttributes = vi.fn().mockRejectedValue(error); - globals[WORLD_CACHE] = { - runs: { setAttributes }, - }; - - for (const attempt of [1, 2]) { - setStepAttempt(attempt); - - await expect( - __builtin_set_attributes([{ key: '$tag.kind', value: 'agent' }], { - allowReservedAttributes: true, - }) - ).rejects.toBe(error); - } - - expect(setAttributes).toHaveBeenCalledTimes(2); - }); - - it('logs and completes after the third failed attempt', async () => { - const setAttributes = vi - .fn() - .mockRejectedValue(new Error('world unavailable')); - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - globals[WORLD_CACHE] = { - runs: { setAttributes }, - }; - setStepAttempt(3); - - await expect( - __builtin_set_attributes([{ key: '$tag.kind', value: 'agent' }], { - allowReservedAttributes: true, - }) - ).resolves.toBeUndefined(); - - expect(setAttributes).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); - }); - it('prefers runs.setAttributes over the deprecated name', async () => { - const setAttributes = vi.fn().mockResolvedValue({ attributes: {} }); - const experimentalSetAttributes = vi.fn(); - globals[WORLD_CACHE] = { - runs: { setAttributes, experimentalSetAttributes }, - }; - setStepAttempt(1); - - await __builtin_set_attributes([{ key: 'phase', value: 'init' }]); - - expect(setAttributes).toHaveBeenCalledWith( - 'run_123', - [{ key: 'phase', value: 'init' }], - undefined - ); - expect(experimentalSetAttributes).not.toHaveBeenCalled(); - }); - - it('falls back to runs.experimentalSetAttributes for older Worlds', async () => { - const runs = { - calls: [] as unknown[][], - // Uses `this` to check the fallback is still invoked as a method. - async experimentalSetAttributes(...args: unknown[]) { - this.calls.push(args); - return { attributes: {} }; - }, - }; - const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - globals[WORLD_CACHE] = { runs }; - setStepAttempt(1); - - await __builtin_set_attributes([{ key: '$tag.kind', value: 'agent' }], { - allowReservedAttributes: true, - }); - - expect(runs.calls).toEqual([ - [ - 'run_123', - [{ key: '$tag.kind', value: 'agent' }], - { allowReservedAttributes: true }, - ], - ]); - expect(consoleWarn).not.toHaveBeenCalled(); - }); - - it('warns once and no-ops when the World implements neither name', async () => { - delete globals[UNSUPPORTED_WORLD_WARNED]; - const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - globals[WORLD_CACHE] = { name: 'community', runs: {} }; - setStepAttempt(1); - - await expect( - __builtin_set_attributes([{ key: 'phase', value: 'init' }]) - ).resolves.toBeUndefined(); - await __builtin_set_attributes([{ key: 'phase', value: 'done' }]); - - expect(consoleWarn).toHaveBeenCalledTimes(1); - expect(consoleWarn.mock.calls[0]?.[0]).toContain( - 'does not implement runs.setAttributes' - ); - }); -}); diff --git a/packages/workflow/src/internal/builtins.ts b/packages/workflow/src/internal/builtins.ts index c358851184..101230ef84 100644 --- a/packages/workflow/src/internal/builtins.ts +++ b/packages/workflow/src/internal/builtins.ts @@ -20,123 +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; - -type SetAttributesMethod = ( - runId: string, - changes: Array<{ key: string; value: string | null }>, - options?: { allowReservedAttributes?: boolean } -) => Promise; - -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; - - 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?: { - setAttributes?: SetAttributesMethod; - /** Deprecated name, still implemented by older World adapters. */ - experimentalSetAttributes?: SetAttributesMethod; - }; - } - | undefined; - const runs = world?.runs; - // Bind to `runs` so implementations that rely on `this` keep working. - const setAttributes = - typeof runs?.setAttributes === 'function' - ? runs.setAttributes.bind(runs) - : typeof runs?.experimentalSetAttributes === 'function' - ? runs.experimentalSetAttributes.bind(runs) - : undefined; - if (!setAttributes) { - // 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 runs.setAttributes; 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 setAttributes(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; diff --git a/packages/world-local/src/instrumentObject.ts b/packages/world-local/src/instrumentObject.ts index aed2611d08..fadfea31be 100644 --- a/packages/world-local/src/instrumentObject.ts +++ b/packages/world-local/src/instrumentObject.ts @@ -22,7 +22,6 @@ const WORLD_LOCAL_SERVICE = { const RUN_ID_ARG_INDEX_BY_METHOD: Record = { 'world.runs.get': 0, - 'world.runs.setAttributes': 0, 'world.runs.experimentalSetAttributes': 0, 'world.steps.get': 0, 'world.events.create': 0, diff --git a/packages/world-local/src/storage/run-retention.test.ts b/packages/world-local/src/storage/run-retention.test.ts index 39300e228d..68645c46c0 100644 --- a/packages/world-local/src/storage/run-retention.test.ts +++ b/packages/world-local/src/storage/run-retention.test.ts @@ -404,7 +404,7 @@ describe('run retention (world-local)', () => { // write. The decision is made inside the run file lock against the // re-read attributes for exactly that reason. const run = await startRun(); - await storage.runs.setAttributes?.( + await storage.runs.experimentalSetAttributes?.( run.runId, [{ key: RETENTION_ATTRIBUTE, value: '0' }], { allowReservedAttributes: true } diff --git a/packages/world-local/src/storage/runs-storage.test.ts b/packages/world-local/src/storage/runs-storage.test.ts index 1098a973a7..bbb70839b5 100644 --- a/packages/world-local/src/storage/runs-storage.test.ts +++ b/packages/world-local/src/storage/runs-storage.test.ts @@ -13,7 +13,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createStorage } from '../storage.js'; import { createRun } from '../test-helpers.js'; -describe('runs.setAttributes (world-local)', () => { +describe('runs.experimentalSetAttributes (world-local)', () => { let testDir: string; let storage: Storage; @@ -37,7 +37,7 @@ describe('runs.setAttributes (world-local)', () => { it('upserts new keys', async () => { const run = await newRun(); - const result = await storage.runs.setAttributes!(run.runId, [ + const result = await storage.runs.experimentalSetAttributes!(run.runId, [ { key: 'phase', value: 'init' }, { key: 'tenant', value: 't1' }, ]); @@ -49,12 +49,12 @@ describe('runs.setAttributes (world-local)', () => { it('updates existing keys (merge semantics)', async () => { const run = await newRun(); - await storage.runs.setAttributes!(run.runId, [ + await storage.runs.experimentalSetAttributes!(run.runId, [ { key: 'phase', value: 'init' }, { key: 'tenant', value: 't1' }, ]); - const result = await storage.runs.setAttributes!(run.runId, [ + const result = await storage.runs.experimentalSetAttributes!(run.runId, [ { key: 'phase', value: 'done' }, ]); @@ -63,12 +63,12 @@ describe('runs.setAttributes (world-local)', () => { it('removes keys when value is null', async () => { const run = await newRun(); - await storage.runs.setAttributes!(run.runId, [ + await storage.runs.experimentalSetAttributes!(run.runId, [ { key: 'phase', value: 'init' }, { key: 'orderId', value: 'ord_123' }, ]); - const result = await storage.runs.setAttributes!(run.runId, [ + const result = await storage.runs.experimentalSetAttributes!(run.runId, [ { key: 'orderId', value: null }, ]); @@ -78,11 +78,11 @@ describe('runs.setAttributes (world-local)', () => { it('applies set and unset in a single call', async () => { const run = await newRun(); - await storage.runs.setAttributes!(run.runId, [ + await storage.runs.experimentalSetAttributes!(run.runId, [ { key: 'stale', value: 'yes' }, ]); - const result = await storage.runs.setAttributes!(run.runId, [ + const result = await storage.runs.experimentalSetAttributes!(run.runId, [ { key: 'stale', value: null }, { key: 'fresh', value: 'yes' }, ]); @@ -92,7 +92,7 @@ describe('runs.setAttributes (world-local)', () => { it('throws WorkflowRunNotFoundError for unknown run', async () => { await expect( - storage.runs.setAttributes!('wrun_doesnotexist', [ + storage.runs.experimentalSetAttributes!('wrun_doesnotexist', [ { key: 'phase', value: 'init' }, ]) ).rejects.toBeInstanceOf(WorkflowRunNotFoundError); @@ -101,7 +101,7 @@ describe('runs.setAttributes (world-local)', () => { it('rejects keys starting with reserved prefix', async () => { const run = await newRun(); await expect( - storage.runs.setAttributes!(run.runId, [ + storage.runs.experimentalSetAttributes!(run.runId, [ { key: `${RESERVED_ATTRIBUTE_KEY_PREFIX}sys`, value: 'x' }, ]) ).rejects.toBeInstanceOf(AttributeValidationError); @@ -109,7 +109,7 @@ describe('runs.setAttributes (world-local)', () => { it('accepts reserved-prefix keys when allowReservedAttributes is set (framework escape hatch)', async () => { const run = await newRun(); - const result = await storage.runs.setAttributes!( + const result = await storage.runs.experimentalSetAttributes!( run.runId, [ { @@ -128,7 +128,7 @@ describe('runs.setAttributes (world-local)', () => { // 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.setAttributes!(run.runId, [ + storage.runs.experimentalSetAttributes!(run.runId, [ { key: `${RESERVED_ATTRIBUTE_KEY_PREFIX}other`, value: 'x' }, ]) ).rejects.toBeInstanceOf(AttributeValidationError); @@ -137,7 +137,7 @@ describe('runs.setAttributes (world-local)', () => { it('rejects keys over the max length', async () => { const run = await newRun(); await expect( - storage.runs.setAttributes!(run.runId, [ + storage.runs.experimentalSetAttributes!(run.runId, [ { key: 'k'.repeat(ATTRIBUTE_KEY_MAX_LENGTH + 1), value: 'x' }, ]) ).rejects.toBeInstanceOf(AttributeValidationError); @@ -146,7 +146,7 @@ describe('runs.setAttributes (world-local)', () => { it('rejects values exceeding the byte cap', async () => { const run = await newRun(); await expect( - storage.runs.setAttributes!(run.runId, [ + storage.runs.experimentalSetAttributes!(run.runId, [ { key: 'big', value: 'a'.repeat(257) }, ]) ).rejects.toBeInstanceOf(AttributeValidationError); @@ -159,10 +159,10 @@ describe('runs.setAttributes (world-local)', () => { key: `k${i}`, value: 'v', })); - await storage.runs.setAttributes!(run.runId, initial); + await storage.runs.experimentalSetAttributes!(run.runId, initial); // Updating an existing key (no growth) must succeed even at the cap. - const result = await storage.runs.setAttributes!(run.runId, [ + const result = await storage.runs.experimentalSetAttributes!(run.runId, [ { key: 'k0', value: 'updated' }, ]); expect(result.attributes.k0).toBe('updated'); @@ -176,9 +176,18 @@ describe('runs.setAttributes (world-local)', () => { { key: 'tenant', value: 't1' as string | null }, ]; - const first = await storage.runs.setAttributes!(run.runId, changes); - const second = await storage.runs.setAttributes!(run.runId, changes); - const third = await storage.runs.setAttributes!(run.runId, changes); + 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. @@ -194,7 +203,7 @@ describe('runs.setAttributes (world-local)', () => { key: `k${i}`, value: 'v', })); - await storage.runs.setAttributes!(run.runId, initial); + await storage.runs.experimentalSetAttributes!(run.runId, initial); // Adding 5 new keys would push us over. const overflow = Array.from({ length: 5 }, (_, i) => ({ @@ -202,7 +211,7 @@ describe('runs.setAttributes (world-local)', () => { value: 'v', })); await expect( - storage.runs.setAttributes!(run.runId, overflow) + storage.runs.experimentalSetAttributes!(run.runId, overflow) ).rejects.toBeInstanceOf(AttributeValidationError); }); @@ -214,7 +223,7 @@ describe('runs.setAttributes (world-local)', () => { // race loses some. await Promise.all( Array.from({ length: 20 }, (_, i) => - storage.runs.setAttributes!(run.runId, [ + storage.runs.experimentalSetAttributes!(run.runId, [ { key: `k${i}`, value: `v${i}` }, ]) ) @@ -223,18 +232,6 @@ describe('runs.setAttributes (world-local)', () => { const refreshed = await storage.runs.get(run.runId); expect(Object.keys(refreshed.attributes ?? {})).toHaveLength(20); }); - - it('keeps the deprecated experimentalSetAttributes alias for older SDKs', async () => { - const run = await newRun(); - - const result = await storage.runs.experimentalSetAttributes!(run.runId, [ - { key: 'phase', value: 'init' }, - ]); - - expect(result.attributes).toEqual({ phase: 'init' }); - const refreshed = await storage.runs.get(run.runId); - expect(refreshed.attributes).toEqual({ phase: 'init' }); - }); }); describe("runs resolveData: 'none' (world-local)", () => { diff --git a/packages/world-local/src/storage/runs-storage.ts b/packages/world-local/src/storage/runs-storage.ts index d7fa8ec24e..c22e185629 100644 --- a/packages/world-local/src/storage/runs-storage.ts +++ b/packages/world-local/src/storage/runs-storage.ts @@ -1,9 +1,9 @@ import { WorkflowRunNotFoundError } from '@workflow/errors'; import type { AttributeChange, + ExperimentalSetAttributesResult, ListWorkflowRunsParams, PaginatedResponse, - SetAttributesResult, Storage, WorkflowRun, WorkflowRunWithoutData, @@ -55,13 +55,11 @@ export interface LocalRunsStorage { params?: LocalListWorkflowRunsParams ): Promise>; }; - setAttributes( + experimentalSetAttributes( runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean } - ): Promise; - /** @deprecated Alias of `setAttributes` for SDK versions that only call the old name. */ - experimentalSetAttributes: LocalRunsStorage['setAttributes']; + ): Promise; } export function createRunsStorage( @@ -80,7 +78,7 @@ export function createRunsStorage( ); }) as Storage['runs']['get']; - const runsStorage: Omit = { + return { get, /** @@ -169,7 +167,7 @@ export function createRunsStorage( }; }) as LocalRunsStorage['list'], - setAttributes: async (runId, changes, options) => { + experimentalSetAttributes: async (runId, changes, options) => { assertSafeEntityId('runId', runId); return db.transactAsync(() => { const row = store.getRun(runId); @@ -186,10 +184,6 @@ export function createRunsStorage( }); }, }; - return { - ...runsStorage, - experimentalSetAttributes: runsStorage.setAttributes, - }; } export { tagClause, tagParams }; diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index a7771f0cfd..43c4b05b80 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -22,6 +22,7 @@ import type { EventResult, EventsResolveData, EventsSubscribeOptions, + ExperimentalSetAttributesResult, GetEventParams, Hook, ListEventsParams, @@ -29,7 +30,6 @@ import type { PaginatedResponse, ResolveData, SerializedData, - SetAttributesResult, StartHook, Step, StepWithoutData, @@ -899,7 +899,7 @@ export function createRunsStorage( return filterRunData(parsed, resolveData); }) as Storage['runs']['get']; - const runsStorage: Storage['runs'] = { + return { get: getRun, /** @@ -956,11 +956,11 @@ export function createRunsStorage( }) as NonNullable, list: createRunsList(drizzle), - setAttributes: async ( + experimentalSetAttributes: async ( runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean } - ): Promise => { + ): Promise => { // 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 @@ -1038,9 +1038,6 @@ export function createRunsStorage( return { attributes: updated.attributes ?? {} }; }, }; - // Deprecated alias for SDK versions that only call the old name. - runsStorage.experimentalSetAttributes = runsStorage.setAttributes; - return runsStorage; } function map(obj: T | null | undefined, fn: (v: T) => R): undefined | R { diff --git a/packages/world-postgres/test/storage.test.ts b/packages/world-postgres/test/storage.test.ts index f164386031..0d361866e6 100644 --- a/packages/world-postgres/test/storage.test.ts +++ b/packages/world-postgres/test/storage.test.ts @@ -665,7 +665,7 @@ describe('Storage (Postgres integration)', () => { }); }); - describe('setAttributes', () => { + describe('experimentalSetAttributes', () => { it('upserts new keys', async () => { const run = await createRun(events, { deploymentId: 'd', @@ -673,7 +673,7 @@ describe('Storage (Postgres integration)', () => { input: new Uint8Array(), }); - const result = await runs.setAttributes!(run.runId, [ + const result = await runs.experimentalSetAttributes!(run.runId, [ { key: 'phase', value: 'init' }, { key: 'tenant', value: 't1' }, ]); @@ -690,8 +690,10 @@ describe('Storage (Postgres integration)', () => { input: new Uint8Array(), }); - await runs.setAttributes!(run.runId, [{ key: 'a', value: '1' }]); - const result = await runs.setAttributes!(run.runId, [ + 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' }); @@ -703,28 +705,15 @@ describe('Storage (Postgres integration)', () => { workflowName: 'w', input: new Uint8Array(), }); - await runs.setAttributes!(run.runId, [ + await runs.experimentalSetAttributes!(run.runId, [ { key: 'a', value: '1' }, { key: 'b', value: '2' }, ]); - const result = await runs.setAttributes!(run.runId, [ + const result = await runs.experimentalSetAttributes!(run.runId, [ { key: 'a', value: null }, ]); expect(result.attributes).toEqual({ b: '2' }); }); - - it('keeps the deprecated experimentalSetAttributes alias for older SDKs', async () => { - const run = await createRun(events, { - deploymentId: 'd', - workflowName: 'w', - input: new Uint8Array(), - }); - - const result = await runs.experimentalSetAttributes!(run.runId, [ - { key: 'phase', value: 'init' }, - ]); - expect(result.attributes).toEqual({ phase: 'init' }); - }); }); describe('native attr_set events', () => { diff --git a/packages/world-vercel/src/instrumentObject.ts b/packages/world-vercel/src/instrumentObject.ts index 85ae71c0c3..0162d9071c 100644 --- a/packages/world-vercel/src/instrumentObject.ts +++ b/packages/world-vercel/src/instrumentObject.ts @@ -22,7 +22,6 @@ const WORKFLOW_SERVER_SERVICE = { const RUN_ID_ARG_INDEX_BY_METHOD: Record = { 'world.runs.get': 0, - 'world.runs.setAttributes': 0, 'world.runs.experimentalSetAttributes': 0, 'world.steps.get': 0, 'world.events.create': 0, diff --git a/packages/world-vercel/src/runs.test.ts b/packages/world-vercel/src/runs.test.ts index 10a9b19659..3988fc002f 100644 --- a/packages/world-vercel/src/runs.test.ts +++ b/packages/world-vercel/src/runs.test.ts @@ -7,7 +7,6 @@ import { getWorkflowRuns, listWorkflowRuns, } from './runs.js'; -import { createStorage } from './storage.js'; import { WORKFLOW_SERVER_URL_OVERRIDE } from './utils.js'; const ORIGIN = WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; @@ -327,42 +326,3 @@ describe('cancelWorkflowRuns', () => { agent.assertNoPendingInterceptors(); }); }); - -describe('runs.setAttributes', () => { - it('posts to the attributes endpoint under both the new and deprecated names', async () => { - const agent = new MockAgent(); - agent.disableNetConnect(); - - const bodies: unknown[] = []; - agent - .get(ORIGIN) - .intercept({ path: '/api/v2/runs/wrun_a/attributes', method: 'POST' }) - .reply( - 200, - (opts: { body?: unknown }) => { - bodies.push(decode(new Uint8Array(opts.body as ArrayBufferLike))); - return encode({ attributes: { phase: 'init' } }); - }, - { headers: { 'content-type': 'application/cbor' } } - ) - .times(2); - - const storage = createStorage({ token: 'test-token', dispatcher: agent }); - const changes = [{ key: 'phase', value: 'init' }]; - - await expect( - storage.runs.setAttributes!('wrun_a', changes) - ).resolves.toEqual({ attributes: { phase: 'init' } }); - await expect( - storage.runs.experimentalSetAttributes!('wrun_a', changes, { - allowReservedAttributes: true, - }) - ).resolves.toEqual({ attributes: { phase: 'init' } }); - - expect(bodies).toEqual([ - { changes }, - { changes, allowReservedAttributes: true }, - ]); - agent.assertNoPendingInterceptors(); - }); -}); diff --git a/packages/world-vercel/src/runs.ts b/packages/world-vercel/src/runs.ts index 1dddb2bb9a..1cac734282 100644 --- a/packages/world-vercel/src/runs.ts +++ b/packages/world-vercel/src/runs.ts @@ -8,7 +8,7 @@ import { BulkCancelWorkflowRunsResultSchema, type CancelWorkflowRunParams, type CreateWorkflowRunRequest, - type SetAttributesResult, + type ExperimentalSetAttributesResult, type GetWorkflowRunParams, type ListWorkflowRunsParams, type PaginatedResponse, @@ -586,11 +586,11 @@ export async function cancelWorkflowRuns( } /** - * Wire response schema for `setAttributes`. The backend + * 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 SetAttributesResponseSchema = z.object({ +const ExperimentalSetAttributesResponseSchema = z.object({ attributes: z.record(z.string(), z.string()), }); @@ -604,15 +604,15 @@ const SetAttributesResponseSchema = z.object({ * `$`-prefixed keys (framework-only; see the SDK helper for details). * The flag is forwarded to the server via the request body. * - * The endpoint path is the wire contract with the backend and is not - * tied to this function's name. See `packages/core/src/attributes-mvp.md`. + * EXPERIMENTAL: tied to the MVP write-only attributes API. See + * `docs/content/docs/v5/changelog/attributes-mvp.mdx`. */ -export async function setAttributes( +export async function experimentalSetAttributes( runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean }, config?: APIConfig -): Promise { +): Promise { try { const response = await makeRequest({ endpoint: `/v2/runs/${encodeURIComponent(runId)}/attributes`, @@ -621,7 +621,7 @@ export async function setAttributes( ? { changes, allowReservedAttributes: true } : { changes }, config, - schema: SetAttributesResponseSchema, + schema: ExperimentalSetAttributesResponseSchema, }); return { attributes: response.attributes }; } catch (error) { diff --git a/packages/world-vercel/src/storage.ts b/packages/world-vercel/src/storage.ts index 448cb9bf5c..91caa3017c 100644 --- a/packages/world-vercel/src/storage.ts +++ b/packages/world-vercel/src/storage.ts @@ -19,10 +19,10 @@ import { getHook, getHookByToken, listHooks } from './hooks.js'; import { instrumentObject } from './instrumentObject.js'; import { cancelWorkflowRuns, + experimentalSetAttributes, getWorkflowRun, getWorkflowRuns, listWorkflowRuns, - setAttributes, waitForWorkflowRunTerminalStatus, } from './runs.js'; import { createSnapshotsStorage } from './snapshots.js'; @@ -46,11 +46,8 @@ export function createStorage(config?: APIConfig): Storage { >, list: ((params?: any) => listWorkflowRuns(params, config)) as Storage['runs']['list'], - setAttributes: (runId, changes, options) => - setAttributes(runId, changes, options, config), - // Deprecated alias for SDK versions that only know the old name. experimentalSetAttributes: (runId, changes, options) => - setAttributes(runId, changes, options, config), + experimentalSetAttributes(runId, changes, options, config), cancelMany: (request) => cancelWorkflowRuns(request, config), }, steps: { diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts index 518c5d4fc3..a592a544a8 100644 --- a/packages/world/src/attributes.ts +++ b/packages/world/src/attributes.ts @@ -53,10 +53,13 @@ export const AttributeChangesSchema = z } }); -/** The post-merge attribute snapshot returned by a World. */ -export interface SetAttributesResult { +/** + * 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; } - -/** @deprecated Use {@link SetAttributesResult}. */ -export type ExperimentalSetAttributesResult = SetAttributesResult; diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index 95e40b755a..fe9ebadfad 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -1,5 +1,8 @@ import type { Analytics } from './analytics.js'; -import type { AttributeChange, SetAttributesResult } from './attributes.js'; +import type { + AttributeChange, + ExperimentalSetAttributesResult, +} from './attributes.js'; import type { BatchEventRequest, CreateEventBatchParams, @@ -290,45 +293,26 @@ export interface Storage { ): 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. - * - * 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. + * Apply a batch of attribute changes directly to a run's materialized + * `attributes`, outside the event log, and return the post-merge + * snapshot. * - * OPTIONAL. World implementations may omit this method; the SDK - * feature-detects its absence and no-ops with a one-time warning so - * third-party / community worlds keep working without adopting it. - * Callers prefer this method and fall back to the deprecated - * {@link experimentalSetAttributes} when only that one exists. + * @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. * - * This direct entity write predates the `attr_set` event type, which - * is how `setAttributes()` records changes on current spec versions. - * See `packages/core/src/attributes-mvp.md`. - */ - setAttributes?( - runId: string, - changes: AttributeChange[], - options?: { allowReservedAttributes?: boolean } - ): Promise; - - /** - * @deprecated Renamed to {@link setAttributes}. Implement - * `setAttributes` instead; callers still fall back to this method - * when a World provides only the old name. + * 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, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean } - ): Promise; + ): Promise; /** * Cancel many runs in a single operation, returning a per-run outcome diff --git a/skills/migrating-workflow-v4-to-v5/SKILL.md b/skills/migrating-workflow-v4-to-v5/SKILL.md index cf53355923..bdd37cb052 100644 --- a/skills/migrating-workflow-v4-to-v5/SKILL.md +++ b/skills/migrating-workflow-v4-to-v5/SKILL.md @@ -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 `runs.setAttributes` (the deprecated `experimentalSetAttributes` name is still called as a fallback). +- 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. diff --git a/skills/migrating-world-v4-to-v5/SKILL.md b/skills/migrating-world-v4-to-v5/SKILL.md index d28ae0e811..c4c7cc1853 100644 --- a/skills/migrating-world-v4-to-v5/SKILL.md +++ b/skills/migrating-world-v4-to-v5/SKILL.md @@ -40,7 +40,7 @@ Before editing, establish and report each of these: 2. **How event IDs are minted today.** Grep for `eventId`, `ulid`, `uuid`, `nanoid`, `nextval`, `AUTO_INCREMENT`, `IDENTITY`. Find the exact line that produces the ID written to storage. Note any place an ID is chosen before the write that stores it, for example an ID planned in advance and saved in a claim record so redeliveries reuse it. That design hands out a position before the commit and needs reworking, not porting. 3. **What settles a write race.** Read the `events.create` implementation. Note whether the ID or ordering is decided in process (read-then-write, an in-memory counter, a `Math.max` over loaded events) or in the store (unique constraint, conditional write, `INSERT ... ON CONFLICT`, a transaction). 4. **Which `specVersion` it declares.** Grep for `specVersion`. Note whether it is a literal or an imported constant. -5. **Which optional members exist.** Grep for `capabilities`, `analytics`, `getRuntimeDeadline`, `getEnvironment`, `createRunId`, `describeRun`, `getEncryptionKeyForRun`, `resolveLatestDeploymentId`, `cancelMany`, `setAttributes`, `experimentalSetAttributes`. +5. **Which optional members exist.** Grep for `capabilities`, `analytics`, `getRuntimeDeadline`, `getEnvironment`, `createRunId`, `describeRun`, `getEncryptionKeyForRun`, `resolveLatestDeploymentId`, `cancelMany`. Also note any `experimentalSetAttributes`: it is deprecated and the SDK no longer calls it (attributes arrive as `attr_set` events), so do not add one; an existing one can go unless the World must still serve `workflow` 5.0.0-beta.8 through beta.14. 6. **Whether it provisions step topics.** Grep for `'step'`, `__wkf_step`, `stepQueue`. 7. **Whether it rejects stale writes.** Grep for `PreconditionFailedError`, `preconditionGuard`, `stateUpdatedAt`, `stateEventCount`, `stateCursor`, `412`. 8. **Where its process-wide state lives.** Grep the World's modules for top-level `const`/`let` holding a pool, client, socket, registry, cache, ULID factory, or a log-once boolean. Note every one: these are correct in a `require()`d package and wrong in a bundled one. @@ -284,7 +284,7 @@ These change no signature. A World ported by types alone compiles and then behav None of this is required, and the runtime routes around each absence. Report what the World is missing rather than implementing everything unprompted. -`capabilities` (`hookRetention.active`, `hookResumeDedup`, `hookForceClaim`, `deploymentAffinity`, `maxConcurrency`, `dynamicWorkflowCode`, `inBandFence`), `analytics`, `analytics.events.getMany()`, `runs.setAttributes` (rename an existing `runs.experimentalSetAttributes`; the old name is deprecated but still called as a fallback), `runs.cancelMany`, `runs.waitForTerminalStatus()`, `events.createBatch()`, `streams.createWriteSession()`, `getRuntimeDeadline()`, `getEnvironment()`, `createRunId()`, `describeRun()`, `getEncryptionKeyForRun()`, `resolveLatestDeploymentId()`, `close()`. +`capabilities` (`hookRetention.active`, `hookResumeDedup`, `hookForceClaim`, `deploymentAffinity`, `maxConcurrency`, `dynamicWorkflowCode`, `inBandFence`), `analytics`, `analytics.events.getMany()`, `runs.cancelMany`, `runs.waitForTerminalStatus()`, `events.createBatch()`, `streams.createWriteSession()`, `getRuntimeDeadline()`, `getEnvironment()`, `createRunId()`, `describeRun()`, `getEncryptionKeyForRun()`, `resolveLatestDeploymentId()`, `close()`. Four are worth raising unprompted because their absence is felt rather than reported: