-
Notifications
You must be signed in to change notification settings - Fork 269
Attributes MVP (experimental and write-only) #2088
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
76902cf
6caf143
c912dd7
bff53a6
e258aa4
0babcb5
e64d634
4a8d5ba
13d698b
83f4ad3
5083bb1
41ed14a
5972c4f
e25730f
860140b
fb35e10
7cc364a
85ecadc
2d5099b
284e90d
6ab1a11
6a5ef86
9be88c3
f66576b
4edc59c
45db5ae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| --- | ||
| '@workflow/core': patch | ||
| '@workflow/world': patch | ||
| '@workflow/world-local': patch | ||
| '@workflow/world-postgres': patch | ||
| '@workflow/world-vercel': patch | ||
| 'workflow': patch | ||
| --- | ||
|
|
||
| Add `experimental_setAttributes()` workflow-level helper for attaching string key/value metadata to a workflow run, surfaced as `run.attributes` |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { FatalError } from '@workflow/errors'; | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { experimental_setAttributes } from './set-attributes.js'; | ||
|
|
||
| describe('experimental_setAttributes (host-side stub)', () => { | ||
| // The host-side `experimental_setAttributes` is the fallback resolved when callers | ||
| // are NOT in the workflow VM. The real implementation lives in | ||
| // `workflow/set-attributes.ts` and is selected via the `workflow` | ||
| // package-exports condition. Reaching this file from a step body or | ||
| // plain host code is unsupported and must surface a clear error. | ||
| it('throws FatalError telling the user experimental_setAttributes is workflow-body only', async () => { | ||
| await expect(experimental_setAttributes({ phase: 'init' })).rejects.toBeInstanceOf( | ||
| FatalError | ||
| ); | ||
| await expect(experimental_setAttributes({ phase: 'init' })).rejects.toThrow( | ||
| /workflow.*function/i | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { FatalError } from '@workflow/errors'; | ||
| import type { ExperimentalSetAttributesOptions } from './workflow/set-attributes.js'; | ||
|
|
||
| export type { ExperimentalSetAttributesOptions }; | ||
|
|
||
| /** | ||
| * Host-side stub for `experimental_setAttributes`. The real | ||
| * implementation lives in `./workflow/set-attributes.ts` and is | ||
| * selected by the `workflow` package-exports condition when the | ||
| * workflow VM bundle is resolved. | ||
| * | ||
| * Reaching this stub means the function was called outside a workflow | ||
| * body — most likely from a `'use step'` function or plain host code. | ||
| * That isn't supported in the MVP: attribute mutations must be | ||
| * event-sourced through the workflow runtime so they survive replay. | ||
| */ | ||
| export async function experimental_setAttributes( | ||
| _attrs: Record<string, string | undefined>, | ||
| _options?: ExperimentalSetAttributesOptions | ||
| ): Promise<void> { | ||
| throw new FatalError( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The PR is also internally inconsistent on this exact question. The world-adapter-doesn't-support case in A few alternatives worth considering:
Note also that empty objects are already a silent no-op in the sibling workflow-side implementation ( At minimum, the docstring should justify why this is
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FatalError is correct here for now. we can make tagging also work inside steps easily, but expanding this contract slowly and intentionally since the uderlying implementation might change. for now, trying to set a tag inside a step should fail the step loudly so you don't accidentally think it's ok |
||
| "experimental_setAttributes() must be called from a 'use workflow' function. " + | ||
| 'Calling it from a step body or plain host code is not supported.' | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import { FatalError } from '@workflow/errors'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { WORKFLOW_USE_STEP } from '../symbols.js'; | ||
| import { experimental_setAttributes } from './set-attributes.js'; | ||
|
|
||
| describe('workflow.experimental_setAttributes', () => { | ||
| const dispatchCalls: Array<{ | ||
| stepName: string; | ||
| changes: Array<{ key: string; value: string | null }>; | ||
| options: { allowReservedAttributes?: boolean } | undefined; | ||
| }> = []; | ||
|
|
||
| beforeEach(() => { | ||
| dispatchCalls.length = 0; | ||
| (globalThis as Record<symbol, unknown>)[WORKFLOW_USE_STEP] = vi.fn( | ||
| (stepName: string) => | ||
| async ( | ||
| changes: Array<{ key: string; value: string | null }>, | ||
| options?: { allowReservedAttributes?: boolean } | ||
| ) => { | ||
| dispatchCalls.push({ stepName, changes, options }); | ||
| } | ||
| ); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| delete (globalThis as Record<symbol, unknown>)[WORKFLOW_USE_STEP]; | ||
| }); | ||
|
|
||
| it('dispatches normalized changes through __builtin_set_attributes', async () => { | ||
| await experimental_setAttributes({ phase: 'init', orderId: 'ord_1' }); | ||
| expect(dispatchCalls).toEqual([ | ||
| { | ||
| stepName: '__builtin_set_attributes', | ||
| changes: [ | ||
| { key: 'phase', value: 'init' }, | ||
| { key: 'orderId', value: 'ord_1' }, | ||
| ], | ||
| options: {}, | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| it('translates undefined values into null (unset semantics)', async () => { | ||
| await experimental_setAttributes({ phase: 'done', stale: undefined }); | ||
| expect(dispatchCalls).toEqual([ | ||
| { | ||
| stepName: '__builtin_set_attributes', | ||
| changes: [ | ||
| { key: 'phase', value: 'done' }, | ||
| { key: 'stale', value: null }, | ||
| ], | ||
| options: {}, | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| it('is a no-op for an empty record (no dispatch)', async () => { | ||
| await experimental_setAttributes({}); | ||
| expect(dispatchCalls).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('throws FatalError when the workflow runtime has not initialized useStep', async () => { | ||
| delete (globalThis as Record<symbol, unknown>)[WORKFLOW_USE_STEP]; | ||
| await expect( | ||
| experimental_setAttributes({ phase: 'init' }) | ||
| ).rejects.toBeInstanceOf(FatalError); | ||
| }); | ||
|
|
||
| it('throws FatalError for reserved-prefix keys before any dispatch', async () => { | ||
| await expect( | ||
| experimental_setAttributes({ $sys: 'x' }) | ||
| ).rejects.toBeInstanceOf(FatalError); | ||
| expect(dispatchCalls).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('dispatches reserved-prefix keys when allowReservedAttributes opt-in is set, and forwards the flag to the step', async () => { | ||
| await experimental_setAttributes( | ||
| { '$framework.kind': 'agent' }, | ||
| { allowReservedAttributes: true } | ||
| ); | ||
| expect(dispatchCalls).toEqual([ | ||
| { | ||
| stepName: '__builtin_set_attributes', | ||
| changes: [{ key: '$framework.kind', value: 'agent' }], | ||
| options: { allowReservedAttributes: true }, | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| it('still rejects reserved-prefix keys when allowReservedAttributes is explicitly false', async () => { | ||
| await expect( | ||
| experimental_setAttributes( | ||
| { '$framework.kind': 'agent' }, | ||
| { allowReservedAttributes: false } | ||
| ) | ||
| ).rejects.toBeInstanceOf(FatalError); | ||
| expect(dispatchCalls).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('throws FatalError when called with a non-object', async () => { | ||
| await expect( | ||
| experimental_setAttributes(null as unknown as Record<string, string>) | ||
| ).rejects.toBeInstanceOf(FatalError); | ||
| await expect( | ||
| experimental_setAttributes([] as unknown as Record<string, string>) | ||
| ).rejects.toBeInstanceOf(FatalError); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the workflow-body-only restriction load-bearing, or scope?
Step-body support looks like ~10 lines: the host stub would do exactly what
__builtin_set_attributesalready does — read the runId fromSymbol.for('WORKFLOW_STEP_CONTEXT_STORAGE'), read the world fromSymbol.for('@workflow/world//cache'), validate, dispatch directly. Both symbols are already populated when a step body runs (the bridge step proves it).Plain host code outside any run genuinely can't be supported with this signature (no runId to infer) — that part isn't arbitrary, you'd need a separate
experimental_setRunAttributes(runId, {...}). But step body is just a scope decision.Trade-offs of allowing step body:
What you'd gain:
setAttributesworks "anywhere inside a run."What you'd lose:
step_created/step_completedpair via the__builtin_set_attributesbridge; step-body calls would write directly with no extra events. Replay determinism is unaffected (step bodies aren't re-executed during replay anyway).Forward-compat with #1933 is fine either way — the planned
attr_setevent'swriter: { type: 'step', stepId, attempt }discriminator already accounts for step writers.Not asking you to add it in this PR — but if the restriction is just scope-cut rather than something the architecture is leaning on, please mention that in the changelog so it doesn't read as a hard architectural constraint that has to stay until V1.