workflow 5.0.0-beta.7 → 5.0.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,4 +6,21 @@
6
6
  export declare function __builtin_response_array_buffer(this: Request | Response): Promise<ArrayBuffer>;
7
7
  export declare function __builtin_response_json(this: Request | Response): Promise<unknown>;
8
8
  export declare function __builtin_response_text(this: Request | Response): Promise<string>;
9
+ /**
10
+ * Step bridge for workflow-body `setAttributes` calls. The VM-side
11
+ * helper validates input and dispatches here via `useStep`. This step
12
+ * runs in normal Node context with full world access.
13
+ *
14
+ * The dispatch reads the world and current run id directly from
15
+ * `globalThis` symbols populated by the workflow/step runtime — this
16
+ * intentionally avoids importing `@workflow/core` so the Next.js
17
+ * deferred-entries discoverer can't walk a chain into world adapters
18
+ * and `@vercel/queue` from this step file.
19
+ */
20
+ export declare function __builtin_set_attributes(changes: Array<{
21
+ key: string;
22
+ value: string | null;
23
+ }>, options?: {
24
+ allowReservedAttributes?: boolean;
25
+ }): Promise<void>;
9
26
  //# sourceMappingURL=builtins.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../../src/internal/builtins.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,wBAAsB,+BAA+B,CACnD,IAAI,EAAE,OAAO,GAAG,QAAQ,wBAIzB;AAED,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,oBAGrE;AAED,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,mBAGrE"}
1
+ {"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../../src/internal/builtins.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,wBAAsB,+BAA+B,CACnD,IAAI,EAAE,OAAO,GAAG,QAAQ,wBAIzB;AAED,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,oBAGrE;AAED,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,mBAGrE;AAmBD;;;;;;;;;;GAUG;AACH,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,EACrD,OAAO,CAAC,EAAE;IAAE,uBAAuB,CAAC,EAAE,OAAO,CAAA;CAAE,iBAsEhD"}
@@ -15,4 +15,68 @@ export async function __builtin_response_text() {
15
15
  'use step';
16
16
  return this.text();
17
17
  }
18
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYnVpbHRpbnMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zcmMvaW50ZXJuYWwvYnVpbHRpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7Ozs7R0FJRztBQUVILE1BQU0sQ0FBQyxLQUFLLFVBQVUsK0JBQStCO0lBR25ELFVBQVUsQ0FBQztJQUNYLE9BQU8sSUFBSSxDQUFDLFdBQVcsRUFBRSxDQUFDO0FBQzVCLENBQUM7QUFFRCxNQUFNLENBQUMsS0FBSyxVQUFVLHVCQUF1QjtJQUMzQyxVQUFVLENBQUM7SUFDWCxPQUFPLElBQUksQ0FBQyxJQUFJLEVBQUUsQ0FBQztBQUNyQixDQUFDO0FBRUQsTUFBTSxDQUFDLEtBQUssVUFBVSx1QkFBdUI7SUFDM0MsVUFBVSxDQUFDO0lBQ1gsT0FBTyxJQUFJLENBQUMsSUFBSSxFQUFFLENBQUM7QUFDckIsQ0FBQyIsInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogVGhlc2UgYXJlIHRoZSBidWlsdC1pbiBzdGVwcyB0aGF0IGFyZSBcImF1dG9tYXRpY2FsbHkgYXZhaWxhYmxlXCIgaW4gdGhlIHdvcmtmbG93IHNjb3BlLiBUaGV5IGFyZVxuICogc2ltaWxhciB0byBcInN0ZGxpYlwiIGV4Y2VwdCB0aGF0IGFyZSBub3QgbWVhbnQgdG8gYmUgaW1wb3J0ZWQgYnkgdXNlcnMsIGJ1dCBhcmUgaW5zdGVhZCBcImp1c3QgYXZhaWxhYmxlXCJcbiAqIGFsb25nc2lkZSB1c2VyIGRlZmluZWQgc3RlcHMuIFRoZXkgYXJlIHVzZWQgaW50ZXJuYWxseSBieSB0aGUgcnVudGltZVxuICovXG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBfX2J1aWx0aW5fcmVzcG9uc2VfYXJyYXlfYnVmZmVyKFxuICB0aGlzOiBSZXF1ZXN0IHwgUmVzcG9uc2Vcbikge1xuICAndXNlIHN0ZXAnO1xuICByZXR1cm4gdGhpcy5hcnJheUJ1ZmZlcigpO1xufVxuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gX19idWlsdGluX3Jlc3BvbnNlX2pzb24odGhpczogUmVxdWVzdCB8IFJlc3BvbnNlKSB7XG4gICd1c2Ugc3RlcCc7XG4gIHJldHVybiB0aGlzLmpzb24oKTtcbn1cblxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIF9fYnVpbHRpbl9yZXNwb25zZV90ZXh0KHRoaXM6IFJlcXVlc3QgfCBSZXNwb25zZSkge1xuICAndXNlIHN0ZXAnO1xuICByZXR1cm4gdGhpcy50ZXh0KCk7XG59XG4iXX0=
18
+ /**
19
+ * Process-wide dedupe for the unsupported-world warning so high-volume
20
+ * callers don't flood logs.
21
+ */
22
+ const UNSUPPORTED_WORLD_WARNED = Symbol.for('@workflow/setAttributes//unsupportedWorldWarned');
23
+ const INTERNAL_ATTRIBUTES_MAX_ATTEMPTS = 3;
24
+ function formatUnknownError(error) {
25
+ if (error instanceof Error) {
26
+ return error.stack ?? `${error.name}: ${error.message}`;
27
+ }
28
+ return String(error);
29
+ }
30
+ /**
31
+ * Step bridge for workflow-body `setAttributes` calls. The VM-side
32
+ * helper validates input and dispatches here via `useStep`. This step
33
+ * runs in normal Node context with full world access.
34
+ *
35
+ * The dispatch reads the world and current run id directly from
36
+ * `globalThis` symbols populated by the workflow/step runtime — this
37
+ * intentionally avoids importing `@workflow/core` so the Next.js
38
+ * deferred-entries discoverer can't walk a chain into world adapters
39
+ * and `@vercel/queue` from this step file.
40
+ */
41
+ export async function __builtin_set_attributes(changes, options) {
42
+ 'use step';
43
+ if (changes.length === 0)
44
+ return;
45
+ const g = globalThis;
46
+ const contextStorage = g[Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')];
47
+ const store = contextStorage?.getStore?.();
48
+ const attempt = typeof store?.stepMetadata?.attempt === 'number'
49
+ ? store.stepMetadata.attempt
50
+ : INTERNAL_ATTRIBUTES_MAX_ATTEMPTS;
51
+ const world = g[Symbol.for('@workflow/world//cache')];
52
+ if (typeof world?.runs?.experimentalSetAttributes !== 'function') {
53
+ // World adapter doesn't implement attributes yet — no-op the call,
54
+ // but emit one process-wide warning so users know their writes are
55
+ // being dropped. The VM-side validation already ran so the input
56
+ // is well-formed.
57
+ if (!g[UNSUPPORTED_WORLD_WARNED]) {
58
+ g[UNSUPPORTED_WORLD_WARNED] = true;
59
+ const worldName = world?.name ? ` (${world.name})` : '';
60
+ 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.`);
61
+ }
62
+ return;
63
+ }
64
+ try {
65
+ const runId = store?.workflowMetadata?.workflowRunId;
66
+ if (!runId) {
67
+ throw new Error('__builtin_set_attributes: no workflow run id available in step context');
68
+ }
69
+ await world.runs.experimentalSetAttributes(runId, changes, options);
70
+ }
71
+ catch (error) {
72
+ if (attempt < INTERNAL_ATTRIBUTES_MAX_ATTEMPTS) {
73
+ throw error;
74
+ }
75
+ // Failing to post tags should not fail a run during the experimental phase.
76
+ // After three attempts, log and let the internal step complete so the
77
+ // runtime does not convert retry exhaustion into a FatalError.
78
+ console.error(`[workflow] setAttributes: failed to post tags after ${INTERNAL_ATTRIBUTES_MAX_ATTEMPTS} attempts; dropping the internal attribute write. ${formatUnknownError(error)}`);
79
+ }
80
+ }
81
+ __builtin_set_attributes.maxRetries = INTERNAL_ATTRIBUTES_MAX_ATTEMPTS - 1;
82
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYnVpbHRpbnMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zcmMvaW50ZXJuYWwvYnVpbHRpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7Ozs7R0FJRztBQUVILE1BQU0sQ0FBQyxLQUFLLFVBQVUsK0JBQStCO0lBR25ELFVBQVUsQ0FBQztJQUNYLE9BQU8sSUFBSSxDQUFDLFdBQVcsRUFBRSxDQUFDO0FBQzVCLENBQUM7QUFFRCxNQUFNLENBQUMsS0FBSyxVQUFVLHVCQUF1QjtJQUMzQyxVQUFVLENBQUM7SUFDWCxPQUFPLElBQUksQ0FBQyxJQUFJLEVBQUUsQ0FBQztBQUNyQixDQUFDO0FBRUQsTUFBTSxDQUFDLEtBQUssVUFBVSx1QkFBdUI7SUFDM0MsVUFBVSxDQUFDO0lBQ1gsT0FBTyxJQUFJLENBQUMsSUFBSSxFQUFFLENBQUM7QUFDckIsQ0FBQztBQUVEOzs7R0FHRztBQUNILE1BQU0sd0JBQXdCLEdBQUcsTUFBTSxDQUFDLEdBQUcsQ0FDekMsaURBQWlELENBQ2xELENBQUM7QUFFRixNQUFNLGdDQUFnQyxHQUFHLENBQUMsQ0FBQztBQUUzQyxTQUFTLGtCQUFrQixDQUFDLEtBQWM7SUFDeEMsSUFBSSxLQUFLLFlBQVksS0FBSyxFQUFFLENBQUM7UUFDM0IsT0FBTyxLQUFLLENBQUMsS0FBSyxJQUFJLEdBQUcsS0FBSyxDQUFDLElBQUksS0FBSyxLQUFLLENBQUMsT0FBTyxFQUFFLENBQUM7SUFDMUQsQ0FBQztJQUNELE9BQU8sTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDO0FBQ3ZCLENBQUM7QUFFRDs7Ozs7Ozs7OztHQVVHO0FBQ0gsTUFBTSxDQUFDLEtBQUssVUFBVSx3QkFBd0IsQ0FDNUMsT0FBcUQsRUFDckQsT0FBK0M7SUFFL0MsVUFBVSxDQUFDO0lBQ1gsSUFBSSxPQUFPLENBQUMsTUFBTSxLQUFLLENBQUM7UUFBRSxPQUFPO0lBQ2pDLE1BQU0sQ0FBQyxHQUFHLFVBQXFDLENBQUM7SUFFaEQsTUFBTSxjQUFjLEdBQUcsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsK0JBQStCLENBQUMsQ0FTdkQsQ0FBQztJQUNkLE1BQU0sS0FBSyxHQUFHLGNBQWMsRUFBRSxRQUFRLEVBQUUsRUFBRSxDQUFDO0lBQzNDLE1BQU0sT0FBTyxHQUNYLE9BQU8sS0FBSyxFQUFFLFlBQVksRUFBRSxPQUFPLEtBQUssUUFBUTtRQUM5QyxDQUFDLENBQUMsS0FBSyxDQUFDLFlBQVksQ0FBQyxPQUFPO1FBQzVCLENBQUMsQ0FBQyxnQ0FBZ0MsQ0FBQztJQUV2QyxNQUFNLEtBQUssR0FBRyxDQUFDLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBQyx3QkFBd0IsQ0FBQyxDQVd2QyxDQUFDO0lBQ2QsSUFBSSxPQUFPLEtBQUssRUFBRSxJQUFJLEVBQUUseUJBQXlCLEtBQUssVUFBVSxFQUFFLENBQUM7UUFDakUsbUVBQW1FO1FBQ25FLG1FQUFtRTtRQUNuRSxpRUFBaUU7UUFDakUsa0JBQWtCO1FBQ2xCLElBQUksQ0FBQyxDQUFDLENBQUMsd0JBQXdCLENBQUMsRUFBRSxDQUFDO1lBQ2pDLENBQUMsQ0FBQyx3QkFBd0IsQ0FBQyxHQUFHLElBQUksQ0FBQztZQUNuQyxNQUFNLFNBQVMsR0FBRyxLQUFLLEVBQUUsSUFBSSxDQUFDLENBQUMsQ0FBQyxLQUFLLEtBQUssQ0FBQyxJQUFJLEdBQUcsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDO1lBQ3hELE9BQU8sQ0FBQyxJQUFJLENBQ1YsNkRBQTZELFNBQVMscU1BQXFNLENBQzVRLENBQUM7UUFDSixDQUFDO1FBQ0QsT0FBTztJQUNULENBQUM7SUFFRCxJQUFJLENBQUM7UUFDSCxNQUFNLEtBQUssR0FBRyxLQUFLLEVBQUUsZ0JBQWdCLEVBQUUsYUFBYSxDQUFDO1FBQ3JELElBQUksQ0FBQyxLQUFLLEVBQUUsQ0FBQztZQUNYLE1BQU0sSUFBSSxLQUFLLENBQ2Isd0VBQXdFLENBQ3pFLENBQUM7UUFDSixDQUFDO1FBRUQsTUFBTSxLQUFLLENBQUMsSUFBSSxDQUFDLHlCQUF5QixDQUFDLEtBQUssRUFBRSxPQUFPLEVBQUUsT0FBTyxDQUFDLENBQUM7SUFDdEUsQ0FBQztJQUFDLE9BQU8sS0FBSyxFQUFFLENBQUM7UUFDZixJQUFJLE9BQU8sR0FBRyxnQ0FBZ0MsRUFBRSxDQUFDO1lBQy9DLE1BQU0sS0FBSyxDQUFDO1FBQ2QsQ0FBQztRQUVELDRFQUE0RTtRQUM1RSxzRUFBc0U7UUFDdEUsK0RBQStEO1FBQy9ELE9BQU8sQ0FBQyxLQUFLLENBQ1gsdURBQXVELGdDQUFnQyxxREFBcUQsa0JBQWtCLENBQUMsS0FBSyxDQUFDLEVBQUUsQ0FDeEssQ0FBQztJQUNKLENBQUM7QUFDSCxDQUFDO0FBR0Msd0JBR0QsQ0FBQyxVQUFVLEdBQUcsZ0NBQWdDLEdBQUcsQ0FBQyxDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBUaGVzZSBhcmUgdGhlIGJ1aWx0LWluIHN0ZXBzIHRoYXQgYXJlIFwiYXV0b21hdGljYWxseSBhdmFpbGFibGVcIiBpbiB0aGUgd29ya2Zsb3cgc2NvcGUuIFRoZXkgYXJlXG4gKiBzaW1pbGFyIHRvIFwic3RkbGliXCIgZXhjZXB0IHRoYXQgYXJlIG5vdCBtZWFudCB0byBiZSBpbXBvcnRlZCBieSB1c2VycywgYnV0IGFyZSBpbnN0ZWFkIFwianVzdCBhdmFpbGFibGVcIlxuICogYWxvbmdzaWRlIHVzZXIgZGVmaW5lZCBzdGVwcy4gVGhleSBhcmUgdXNlZCBpbnRlcm5hbGx5IGJ5IHRoZSBydW50aW1lXG4gKi9cblxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIF9fYnVpbHRpbl9yZXNwb25zZV9hcnJheV9idWZmZXIoXG4gIHRoaXM6IFJlcXVlc3QgfCBSZXNwb25zZVxuKSB7XG4gICd1c2Ugc3RlcCc7XG4gIHJldHVybiB0aGlzLmFycmF5QnVmZmVyKCk7XG59XG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBfX2J1aWx0aW5fcmVzcG9uc2VfanNvbih0aGlzOiBSZXF1ZXN0IHwgUmVzcG9uc2UpIHtcbiAgJ3VzZSBzdGVwJztcbiAgcmV0dXJuIHRoaXMuanNvbigpO1xufVxuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gX19idWlsdGluX3Jlc3BvbnNlX3RleHQodGhpczogUmVxdWVzdCB8IFJlc3BvbnNlKSB7XG4gICd1c2Ugc3RlcCc7XG4gIHJldHVybiB0aGlzLnRleHQoKTtcbn1cblxuLyoqXG4gKiBQcm9jZXNzLXdpZGUgZGVkdXBlIGZvciB0aGUgdW5zdXBwb3J0ZWQtd29ybGQgd2FybmluZyBzbyBoaWdoLXZvbHVtZVxuICogY2FsbGVycyBkb24ndCBmbG9vZCBsb2dzLlxuICovXG5jb25zdCBVTlNVUFBPUlRFRF9XT1JMRF9XQVJORUQgPSBTeW1ib2wuZm9yKFxuICAnQHdvcmtmbG93L3NldEF0dHJpYnV0ZXMvL3Vuc3VwcG9ydGVkV29ybGRXYXJuZWQnXG4pO1xuXG5jb25zdCBJTlRFUk5BTF9BVFRSSUJVVEVTX01BWF9BVFRFTVBUUyA9IDM7XG5cbmZ1bmN0aW9uIGZvcm1hdFVua25vd25FcnJvcihlcnJvcjogdW5rbm93bikge1xuICBpZiAoZXJyb3IgaW5zdGFuY2VvZiBFcnJvcikge1xuICAgIHJldHVybiBlcnJvci5zdGFjayA/PyBgJHtlcnJvci5uYW1lfTogJHtlcnJvci5tZXNzYWdlfWA7XG4gIH1cbiAgcmV0dXJuIFN0cmluZyhlcnJvcik7XG59XG5cbi8qKlxuICogU3RlcCBicmlkZ2UgZm9yIHdvcmtmbG93LWJvZHkgYHNldEF0dHJpYnV0ZXNgIGNhbGxzLiBUaGUgVk0tc2lkZVxuICogaGVscGVyIHZhbGlkYXRlcyBpbnB1dCBhbmQgZGlzcGF0Y2hlcyBoZXJlIHZpYSBgdXNlU3RlcGAuIFRoaXMgc3RlcFxuICogcnVucyBpbiBub3JtYWwgTm9kZSBjb250ZXh0IHdpdGggZnVsbCB3b3JsZCBhY2Nlc3MuXG4gKlxuICogVGhlIGRpc3BhdGNoIHJlYWRzIHRoZSB3b3JsZCBhbmQgY3VycmVudCBydW4gaWQgZGlyZWN0bHkgZnJvbVxuICogYGdsb2JhbFRoaXNgIHN5bWJvbHMgcG9wdWxhdGVkIGJ5IHRoZSB3b3JrZmxvdy9zdGVwIHJ1bnRpbWUg4oCUIHRoaXNcbiAqIGludGVudGlvbmFsbHkgYXZvaWRzIGltcG9ydGluZyBgQHdvcmtmbG93L2NvcmVgIHNvIHRoZSBOZXh0LmpzXG4gKiBkZWZlcnJlZC1lbnRyaWVzIGRpc2NvdmVyZXIgY2FuJ3Qgd2FsayBhIGNoYWluIGludG8gd29ybGQgYWRhcHRlcnNcbiAqIGFuZCBgQHZlcmNlbC9xdWV1ZWAgZnJvbSB0aGlzIHN0ZXAgZmlsZS5cbiAqL1xuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIF9fYnVpbHRpbl9zZXRfYXR0cmlidXRlcyhcbiAgY2hhbmdlczogQXJyYXk8eyBrZXk6IHN0cmluZzsgdmFsdWU6IHN0cmluZyB8IG51bGwgfT4sXG4gIG9wdGlvbnM/OiB7IGFsbG93UmVzZXJ2ZWRBdHRyaWJ1dGVzPzogYm9vbGVhbiB9XG4pIHtcbiAgJ3VzZSBzdGVwJztcbiAgaWYgKGNoYW5nZXMubGVuZ3RoID09PSAwKSByZXR1cm47XG4gIGNvbnN0IGcgPSBnbG9iYWxUaGlzIGFzIFJlY29yZDxzeW1ib2wsIHVua25vd24+O1xuXG4gIGNvbnN0IGNvbnRleHRTdG9yYWdlID0gZ1tTeW1ib2wuZm9yKCdXT1JLRkxPV19TVEVQX0NPTlRFWFRfU1RPUkFHRScpXSBhc1xuICAgIHwge1xuICAgICAgICBnZXRTdG9yZTogKCkgPT5cbiAgICAgICAgICB8IHtcbiAgICAgICAgICAgICAgc3RlcE1ldGFkYXRhPzogeyBhdHRlbXB0PzogbnVtYmVyIH07XG4gICAgICAgICAgICAgIHdvcmtmbG93TWV0YWRhdGE/OiB7IHdvcmtmbG93UnVuSWQ/OiBzdHJpbmcgfTtcbiAgICAgICAgICAgIH1cbiAgICAgICAgICB8IHVuZGVmaW5lZDtcbiAgICAgIH1cbiAgICB8IHVuZGVmaW5lZDtcbiAgY29uc3Qgc3RvcmUgPSBjb250ZXh0U3RvcmFnZT8uZ2V0U3RvcmU/LigpO1xuICBjb25zdCBhdHRlbXB0ID1cbiAgICB0eXBlb2Ygc3RvcmU/LnN0ZXBNZXRhZGF0YT8uYXR0ZW1wdCA9PT0gJ251bWJlcidcbiAgICAgID8gc3RvcmUuc3RlcE1ldGFkYXRhLmF0dGVtcHRcbiAgICAgIDogSU5URVJOQUxfQVRUUklCVVRFU19NQVhfQVRURU1QVFM7XG5cbiAgY29uc3Qgd29ybGQgPSBnW1N5bWJvbC5mb3IoJ0B3b3JrZmxvdy93b3JsZC8vY2FjaGUnKV0gYXNcbiAgICB8IHtcbiAgICAgICAgbmFtZT86IHN0cmluZztcbiAgICAgICAgcnVucz86IHtcbiAgICAgICAgICBleHBlcmltZW50YWxTZXRBdHRyaWJ1dGVzPzogKFxuICAgICAgICAgICAgcnVuSWQ6IHN0cmluZyxcbiAgICAgICAgICAgIGNoYW5nZXM6IEFycmF5PHsga2V5OiBzdHJpbmc7IHZhbHVlOiBzdHJpbmcgfCBudWxsIH0+LFxuICAgICAgICAgICAgb3B0aW9ucz86IHsgYWxsb3dSZXNlcnZlZEF0dHJpYnV0ZXM/OiBib29sZWFuIH1cbiAgICAgICAgICApID0+IFByb21pc2U8dW5rbm93bj47XG4gICAgICAgIH07XG4gICAgICB9XG4gICAgfCB1bmRlZmluZWQ7XG4gIGlmICh0eXBlb2Ygd29ybGQ/LnJ1bnM/LmV4cGVyaW1lbnRhbFNldEF0dHJpYnV0ZXMgIT09ICdmdW5jdGlvbicpIHtcbiAgICAvLyBXb3JsZCBhZGFwdGVyIGRvZXNuJ3QgaW1wbGVtZW50IGF0dHJpYnV0ZXMgeWV0IOKAlCBuby1vcCB0aGUgY2FsbCxcbiAgICAvLyBidXQgZW1pdCBvbmUgcHJvY2Vzcy13aWRlIHdhcm5pbmcgc28gdXNlcnMga25vdyB0aGVpciB3cml0ZXMgYXJlXG4gICAgLy8gYmVpbmcgZHJvcHBlZC4gVGhlIFZNLXNpZGUgdmFsaWRhdGlvbiBhbHJlYWR5IHJhbiBzbyB0aGUgaW5wdXRcbiAgICAvLyBpcyB3ZWxsLWZvcm1lZC5cbiAgICBpZiAoIWdbVU5TVVBQT1JURURfV09STERfV0FSTkVEXSkge1xuICAgICAgZ1tVTlNVUFBPUlRFRF9XT1JMRF9XQVJORURdID0gdHJ1ZTtcbiAgICAgIGNvbnN0IHdvcmxkTmFtZSA9IHdvcmxkPy5uYW1lID8gYCAoJHt3b3JsZC5uYW1lfSlgIDogJyc7XG4gICAgICBjb25zb2xlLndhcm4oXG4gICAgICAgIGBbd29ya2Zsb3ddIHNldEF0dHJpYnV0ZXM6IHRoZSBjdXJyZW50IHdvcmxkIGltcGxlbWVudGF0aW9uJHt3b3JsZE5hbWV9IGRvZXMgbm90IGltcGxlbWVudCBleHBlcmltZW50YWxTZXRBdHRyaWJ1dGVzOyB0aGlzIGNhbGwgKGFuZCBhbnkgc3Vic2VxdWVudCBzZXRBdHRyaWJ1dGVzIGNhbGxzIGluIHRoaXMgcHJvY2VzcykgaXMgYSBuby1vcC4gQXR0cmlidXRlcyB3aWxsIGJlY29tZSBhdmFpbGFibGUgb25jZSB0aGUgd29ybGQgYWRhcHRlciBhZGRzIHN1cHBvcnQuYFxuICAgICAgKTtcbiAgICB9XG4gICAgcmV0dXJuO1xuICB9XG5cbiAgdHJ5IHtcbiAgICBjb25zdCBydW5JZCA9IHN0b3JlPy53b3JrZmxvd01ldGFkYXRhPy53b3JrZmxvd1J1bklkO1xuICAgIGlmICghcnVuSWQpIHtcbiAgICAgIHRocm93IG5ldyBFcnJvcihcbiAgICAgICAgJ19fYnVpbHRpbl9zZXRfYXR0cmlidXRlczogbm8gd29ya2Zsb3cgcnVuIGlkIGF2YWlsYWJsZSBpbiBzdGVwIGNvbnRleHQnXG4gICAgICApO1xuICAgIH1cblxuICAgIGF3YWl0IHdvcmxkLnJ1bnMuZXhwZXJpbWVudGFsU2V0QXR0cmlidXRlcyhydW5JZCwgY2hhbmdlcywgb3B0aW9ucyk7XG4gIH0gY2F0Y2ggKGVycm9yKSB7XG4gICAgaWYgKGF0dGVtcHQgPCBJTlRFUk5BTF9BVFRSSUJVVEVTX01BWF9BVFRFTVBUUykge1xuICAgICAgdGhyb3cgZXJyb3I7XG4gICAgfVxuXG4gICAgLy8gRmFpbGluZyB0byBwb3N0IHRhZ3Mgc2hvdWxkIG5vdCBmYWlsIGEgcnVuIGR1cmluZyB0aGUgZXhwZXJpbWVudGFsIHBoYXNlLlxuICAgIC8vIEFmdGVyIHRocmVlIGF0dGVtcHRzLCBsb2cgYW5kIGxldCB0aGUgaW50ZXJuYWwgc3RlcCBjb21wbGV0ZSBzbyB0aGVcbiAgICAvLyBydW50aW1lIGRvZXMgbm90IGNvbnZlcnQgcmV0cnkgZXhoYXVzdGlvbiBpbnRvIGEgRmF0YWxFcnJvci5cbiAgICBjb25zb2xlLmVycm9yKFxuICAgICAgYFt3b3JrZmxvd10gc2V0QXR0cmlidXRlczogZmFpbGVkIHRvIHBvc3QgdGFncyBhZnRlciAke0lOVEVSTkFMX0FUVFJJQlVURVNfTUFYX0FUVEVNUFRTfSBhdHRlbXB0czsgZHJvcHBpbmcgdGhlIGludGVybmFsIGF0dHJpYnV0ZSB3cml0ZS4gJHtmb3JtYXRVbmtub3duRXJyb3IoZXJyb3IpfWBcbiAgICApO1xuICB9XG59XG5cbihcbiAgX19idWlsdGluX3NldF9hdHRyaWJ1dGVzIGFzIHR5cGVvZiBfX2J1aWx0aW5fc2V0X2F0dHJpYnV0ZXMgJiB7XG4gICAgbWF4UmV0cmllczogbnVtYmVyO1xuICB9XG4pLm1heFJldHJpZXMgPSBJTlRFUk5BTF9BVFRSSUJVVEVTX01BWF9BVFRFTVBUUyAtIDE7XG4iXX0=
@@ -0,0 +1,63 @@
1
+ ---
2
+ title: experimental_setAttributes
3
+ description: Attach string metadata to workflow run for observability.
4
+ type: reference
5
+ summary: Use experimental_setAttributes inside a workflow or step function to set run attributes.
6
+ prerequisites:
7
+ - /docs/foundations/workflows-and-steps
8
+ related:
9
+ - /docs/observability/attributes
10
+ - /docs/api-reference/workflow/fatal-error
11
+ ---
12
+
13
+ Attaches string metadata to the current workflow run.
14
+
15
+ <Callout>
16
+ This API is experimental and may change before the stable attributes API is released.
17
+ </Callout>
18
+
19
+ ```typescript lineNumbers
20
+ import { experimental_setAttributes } from "workflow"
21
+
22
+ export async function orderWorkflow(orderId: string) {
23
+ "use workflow"
24
+
25
+ await experimental_setAttributes({
26
+ phase: "received",
27
+ orderId,
28
+ })
29
+ }
30
+ ```
31
+
32
+ ## API Signature
33
+
34
+ ### Parameters
35
+
36
+ <TSDoc
37
+ definition={`
38
+ import { experimental_setAttributes } from "workflow";
39
+ export default experimental_setAttributes;`}
40
+ showSections={['parameters']}
41
+ />
42
+
43
+ ## Usage
44
+
45
+ Call `experimental_setAttributes` from a `"use workflow"` function or a `"use step"` function. Calling it from plain application code is not supported because there is no active workflow run.
46
+
47
+ Attribute values must be strings. Pass `undefined` to remove an attribute:
48
+
49
+ ```typescript lineNumbers
50
+ import { experimental_setAttributes } from "workflow"
51
+
52
+ export async function cleanupAttributes() {
53
+ "use workflow"
54
+
55
+ await experimental_setAttributes({ staleKey: undefined })
56
+ }
57
+ ```
58
+
59
+ Attribute keys must be 1-256 characters, values must be strings up to 256 bytes, and each run can have up to 64 attributes. Keys that start with `$` are reserved for framework and library code.
60
+
61
+ Validation errors throw [`FatalError`](/docs/api-reference/workflow/fatal-error) and fail the run before an attribute write is attempted.
62
+
63
+ When called from a workflow body, the write is recorded through an internal step. When called from a step body, the step posts the attributes directly to the World. Storage errors from step-body calls throw from `experimental_setAttributes`, so catch them inside the step if the write should be best-effort.
@@ -47,6 +47,9 @@ Workflow SDK contains the following functions you can use inside your workflow f
47
47
  <Card href="/docs/api-reference/workflow/get-writable" title="getWritable()">
48
48
  Access the current workflow run's default stream.
49
49
  </Card>
50
+ <Card href="/docs/api-reference/workflow/experimental-set-attributes" title="experimental_setAttributes()">
51
+ Attach experimental string metadata to the current workflow run.
52
+ </Card>
50
53
  </Cards>
51
54
 
52
55
  ## Error Classes
@@ -68,7 +68,7 @@ const nextConfig: NextConfig = {};
68
68
 
69
69
  export default withWorkflow(nextConfig, {
70
70
  workflows: {
71
- lazyDiscovery: true,
71
+ lazyDiscovery: false,
72
72
  local: {
73
73
  port: 4000,
74
74
  },
@@ -79,7 +79,7 @@ export default withWorkflow(nextConfig, {
79
79
 
80
80
  | Option | Type | Default | Description |
81
81
  | --- | --- | --- | --- |
82
- | `workflows.lazyDiscovery` | `boolean` | `false` | When `true`, defers workflow discovery until files are requested instead of scanning eagerly at startup. Useful for large projects where startup time matters. |
82
+ | `workflows.lazyDiscovery` | `boolean` | `true` | Defers workflow discovery until files are requested instead of scanning eagerly at startup. Set to `false` to force eager discovery (scanning the project up front). Requires a Next.js version that supports deferred entries; older versions fall back to eager discovery automatically. |
83
83
  | `workflows.local.port` | `number` | — | Overrides the `PORT` environment variable for local development. Has no effect when deployed to Vercel. |
84
84
  | `workflows.sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` | Controls source maps on generated workflow bundles. See [Source maps](#source-maps) below. |
85
85
 
@@ -0,0 +1,368 @@
1
+ ---
2
+ title: Workflow Attributes (MVP, experimental)
3
+ description: A minimal, write-only subset of the planned Workflow Attributes feature, forward-compatible with the full 5.0.0 release. Lets workflow code attach plaintext string key/value metadata to a run.
4
+ ---
5
+
6
+ # Workflow Attributes (MVP)
7
+
8
+ This is a minimal, **experimental** subset of the [planned Workflow Attributes feature for 5.0.0](https://github.com/vercel/workflow/pull/1933). See [discussion #132](https://github.com/vercel/workflow/discussions/132) for broader background on the use cases and the full design space.
9
+
10
+ The MVP lets workflow code attach plaintext `string → string` metadata to a run, viewable in any observability surface that reads the `WorkflowRun` entity. It is deliberately narrow: write-only, no reads from inside a run, no list/filter endpoints, no event-log representation. The wire format and SDK surface are chosen so the full 5.0.0 implementation replaces this without source-level breaking changes for end users.
11
+
12
+ ## What MVP supports
13
+
14
+ - `experimental_setAttributes(record)` callable from a **workflow body** (`"use workflow"` function), dispatched via an internal `__builtin_set_attributes` step bridge so the mutation gets a `step_created → step_completed` event pair
15
+ - Attributes are materialized onto the `WorkflowRun` entity, plaintext, and visible via `world.runs.get()` / `world.runs.list()` and any observability UI built on top of those
16
+ - World implementations emit a side-channel observability record per successful write (in `world-vercel`, this hooks into the same observability/analytics pipeline already used for other run lifecycle events)
17
+
18
+ Calling `experimental_setAttributes` from a step body was intentionally not supported in the MVP, but step-body calls are now supported as a follow-up. Plain host code remains unsupported because there is no active workflow run to attach attributes to.
19
+
20
+ ## What MVP does **not** support (deferred to 5.0.0)
21
+
22
+ - Reading attributes from inside a workflow or step (`getAttribute` / `getAttributes`)
23
+ - `start(workflow, input, { attributes })` (initial attributes at run creation)
24
+ - Filtering runs by attribute value (`runs.list({ attributes: { ... } })`)
25
+ - Enumerating attribute keys or values (`listAttributeKeys`, `listAttributeValues`)
26
+ - Writer attribution / event-log history of attribute changes
27
+ - Any non-string value type
28
+
29
+ See [PR #1933](https://github.com/vercel/workflow/pull/1933) for the design of those features.
30
+
31
+ ## Why the MVP defers an `attr_set` event type
32
+
33
+ The full design represents attribute changes as a new `attr_set` event type in the event log, replayed by the workflow runtime VM to reconstruct the attribute snapshot. That requires bumping `SPEC_VERSION_CURRENT`, because every world implementation (including community worlds) needs to handle the new event during replay, and the runtime's reconstruction logic gains a new case.
34
+
35
+ A spec version bump is expensive: it gates every world adapter and ties the rollout to coordinated upgrades. We do not want to pay that cost twice — once for the MVP, again for the full feature.
36
+
37
+ The MVP instead writes attributes via a direct entity-mutation path (outside the event log) which does not require a spec version bump. The downside is that MVP-era attributes have **no representation in the event log** and will not be visible to event-based reconstruction (e.g. a materialization rebuild). When the full feature ships, new writes use `attr_set` events; old runs created during the MVP window retain whatever attributes were materialized at the time, but their history is not recoverable.
38
+
39
+ ## Implementation plan
40
+
41
+ ### 1. `@workflow/world` — Storage interface addition
42
+
43
+ Add an `experimentalSetAttributes` method to `Storage.runs`:
44
+
45
+ {/*@skip-typecheck - snippet, not runnable code*/}
46
+
47
+ ```ts
48
+ runs: {
49
+ get: /* unchanged */;
50
+ list: /* unchanged */;
51
+
52
+ /**
53
+ * Apply a set of attribute changes to a run. Merge semantics:
54
+ * keys with a string value are upserted, keys with `value: null`
55
+ * are removed. Other keys on the run are untouched.
56
+ *
57
+ * `options.allowReservedAttributes` permits `$`-prefixed keys for
58
+ * framework-level callers that own a reserved sub-namespace.
59
+ *
60
+ * OPTIONAL. World implementations may omit this method; the SDK
61
+ * detects absence and no-ops `experimental_setAttributes` with a warning so that
62
+ * third-party / community worlds continue to function without
63
+ * adopting the experimental API.
64
+ *
65
+ * EXPERIMENTAL: this method exists as a stopgap until the
66
+ * `attr_set` event type lands. See the 5.0.0 attributes design.
67
+ */
68
+ experimentalSetAttributes?(
69
+ runId: string,
70
+ changes: Array<{ key: string; value: string | null }>,
71
+ options?: { allowReservedAttributes?: boolean }
72
+ ): Promise<{ attributes: Record<string, string> }>;
73
+ }
74
+ ```
75
+
76
+ The method is **optional** to avoid forcing every World implementation (especially community-maintained adapters such as Redis, MongoDB, Turso, and similar) to ship support before the API stabilises. World implementations that do support it return the post-merge attribute snapshot so callers — notably the SDK helper and world adapters emitting observability records — have it without a follow-up read.
77
+
78
+ Add `attributes?: Record<string, string>` to `WorkflowRunBaseSchema` in `packages/world/src/runs.ts`. Optional for backward compatibility: runs created before this field landed have no `attributes` and read as `undefined`.
79
+
80
+ ### 2. Wire format (used by `world-vercel`)
81
+
82
+ `world-vercel` calls into a remote endpoint to persist attributes:
83
+
84
+ ```
85
+ POST /v2/runs/:runId/attributes
86
+
87
+ {
88
+ "changes": [
89
+ { "key": "phase", "value": "done" },
90
+ { "key": "stale", "value": null }
91
+ ],
92
+ "allowReservedAttributes": true
93
+ }
94
+ ```
95
+
96
+ Response: `{ "attributes": { "phase": "done", "tenant": "t1" } }`.
97
+
98
+ The `changes` field **deliberately mirrors** the eventual `attr_set` event's `eventData.changes`. `allowReservedAttributes` is optional and framework-only; omit it for user-authored attributes. When the full feature ships, this endpoint goes away — the same `changes` shape is posted to `POST /v2/runs/:id/events` with `eventType: 'attr_set'` (plus a `writer` discriminator). No SDK signature change, no client-side migration.
99
+
100
+ ### 3. `@workflow/core` — SDK surface
101
+
102
+ A new export from `@workflow/core` (re-exported by `workflow`):
103
+
104
+ {/*@skip-typecheck - snippet, not runnable code*/}
105
+
106
+ ```ts
107
+ function experimental_setAttributes(
108
+ attrs: Record<string, string | undefined>,
109
+ options?: { allowReservedAttributes?: boolean }
110
+ ): Promise<void>
111
+ ```
112
+
113
+ `undefined` is normalized to `null` (unset). An empty object is a no-op (no RPC, no events).
114
+
115
+ Validation (shared helper, applied both client-side and server-side):
116
+
117
+ - Key: 1–256 chars, must not start with `$` (reserved — see "Reserved `$` namespace" below)
118
+ - Value: ≤ 256 bytes UTF-8
119
+ - Maximum 64 attributes per run (validated against the post-merge snapshot when the server applies)
120
+ - SDK-side violations throw `FatalError` from `@workflow/errors` before the internal step is dispatched; worlds revalidate as the final authority before mutating storage
121
+
122
+ #### Reserved `$` namespace
123
+
124
+ Keys starting with `$` are reserved for framework and library code built on top of the workflow SDK (telemetry tags, agent metadata, future platform-emitted attributes, etc.). User code calling `experimental_setAttributes({ '$foo': 'bar' })` throws `FatalError` so accidental collisions with tooling-owned keys can't slip through.
125
+
126
+ Framework / library authors that own a `$`-prefixed sub-namespace can opt in per-call:
127
+
128
+ {/*@skip-typecheck - snippet, not runnable code*/}
129
+
130
+ ```ts
131
+ await experimental_setAttributes(
132
+ { '$agent.kind': 'durable-agent' },
133
+ { allowReservedAttributes: true }
134
+ );
135
+ ```
136
+
137
+ The flag is per-call (no run-level "this run accepts reserved keys" mode), so each framework call site explicitly declares intent. Don't enable it from user code — misuse can conflict with observability surfaces, agent dashboards, or future platform features that rely on the reserved namespace.
138
+
139
+ `experimental_setAttributes` is callable from a workflow body:
140
+
141
+ ```ts
142
+ import { experimental_setAttributes } from 'workflow';
143
+
144
+ export async function myWorkflow(orderId: string) {
145
+ 'use workflow';
146
+ await experimental_setAttributes({ phase: 'init', orderId });
147
+ // ...
148
+ await experimental_setAttributes({ phase: 'done' });
149
+ }
150
+ ```
151
+
152
+ The workflow-body path validates input inside the VM and then dispatches the canonical `AttributeChange[]` through an internal `__builtin_set_attributes` step bridge — see "How workflow-body dispatch works" below. The mutation is materialized on the run entity by the step body.
153
+
154
+ Step-body calls resolve to the host-side export. In a step context, that export validates the input and posts the attribute changes directly to `world.runs.experimentalSetAttributes(runId, changes)`. Plain host code still throws `FatalError`.
155
+
156
+ #### Usage patterns
157
+
158
+ Three call patterns are supported. Pick by whether you need ordering and whether you need to know the write has landed before continuing:
159
+
160
+ **Awaited (default).** The workflow blocks on the internal write step before continuing. Use this when later workflow logic should wait until the attribute write has either succeeded or exhausted the MVP's best-effort retry budget. Deterministic SDK validation errors still throw before dispatch; persistence failures from the world are retried by the internal step and then logged/dropped after three attempts so failing to post tags does not fail a run during the experimental phase.
161
+
162
+ {/*@skip-typecheck - snippet, not runnable code*/}
163
+
164
+ ```ts
165
+ 'use workflow';
166
+ await experimental_setAttributes({ phase: 'init' });
167
+ const result = await processOrder();
168
+ await experimental_setAttributes({ phase: 'done', orderId: result.id });
169
+ ```
170
+
171
+ **Fire-and-forget (`void`).** Drop the `await` to let the workflow proceed without blocking. The pending step queues on the workflow's next suspension (any `await` on a runtime primitive — a step, `sleep`, a hook). This is the canonical pattern for observability / tracking metadata where the workflow doesn't depend on the write.
172
+
173
+ {/*@skip-typecheck - snippet, not runnable code*/}
174
+
175
+ ```ts
176
+ 'use workflow';
177
+ void experimental_setAttributes({ phase: 'init', orderId });
178
+ // Workflow doesn't block. The step queues on the next runtime await.
179
+ const result = await processOrder();
180
+ void experimental_setAttributes({ phase: 'done' });
181
+ return result;
182
+ ```
183
+
184
+ Two trade-offs to know about:
185
+
186
+ 1. **Order of arrival at the world is not workflow-source order.** Fire-and-forget steps run out-of-band on the queue worker. A `void` write to one key followed by an `await` write to the same key may race; LWW-by-arrival applies (see "Concurrent writes" below).
187
+
188
+ 2. **The last `void` before `return` may not land.** If you place a `void experimental_setAttributes(...)` immediately before returning, with no intervening `await` on a runtime primitive, drain-on-completion commits the `step_created` event but the step body is not reliably dispatched before the run transitions to its terminal status — see the architectural note in the "Implementation notes" section. In practice workflows almost always have an `await` after the last fire-and-forget call (a step, a sleep, a hook); if you don't, add `await sleep('0s')` before returning, or use the awaited form for that final write.
189
+
190
+ **Parallel (`Promise.all`).** Multiple calls dispatch concurrently. Writes to disjoint keys all land. Writes to the same key resolve last-write-wins by *arrival order at the world* (not the order the workflow body issued the calls) — so don't use `Promise.all` for writes that must observe a specific order to the same key.
191
+
192
+ {/*@skip-typecheck - snippet, not runnable code*/}
193
+
194
+ ```ts
195
+ 'use workflow';
196
+ await Promise.all([
197
+ experimental_setAttributes({ phase: 'init' }), // disjoint keys — all land
198
+ experimental_setAttributes({ orderId: 'ord_123' }),
199
+ experimental_setAttributes({ tenant: 't1' }),
200
+ ]);
201
+ ```
202
+
203
+ #### Optional world support
204
+
205
+ Because `runs.experimentalSetAttributes` is **optional** on the World interface (see §1), the `__builtin_set_attributes` step body checks for its presence before dispatching and no-ops when absent after logging a single process-wide warning. User code does not need to feature-detect; calling `experimental_setAttributes` against an unsupporting world is safe but ineffective.
206
+
207
+ ### 4. World implementations
208
+
209
+ #### `world-local`
210
+
211
+ Implement `experimentalSetAttributes(runId, changes)` by reading the run's JSON file, merging the changes into `run.attributes` (set on string value, delete on null), and writing back atomically using the same per-run write gate that protects entity writes today. Apply validation server-side before merging.
212
+
213
+ #### `world-postgres`
214
+
215
+ Add an `attributes JSONB` column to the runs table (default `'{}'::jsonb`, NOT NULL). Apply the merge in SQL using `jsonb_set` / `jsonb_strip_nulls` so the database does the merge atomically without a read-modify-write cycle, returning the post-merge map via `RETURNING attributes`.
216
+
217
+ #### `world-vercel`
218
+
219
+ Pure HTTP wrapper. Calls the wire endpoint described in §2 and returns the response's `attributes`. The backing service materializes the attribute map onto its run-row storage; where the underlying data store supports atomic per-key map updates, the merge is a single atomic operation rather than a read-modify-write cycle — the same shape the future `attr_set` event handler will use, so the storage layout is forward-compatible.
220
+
221
+ After the persistence ack, the service emits a side-channel observability record carrying the post-merge attribute snapshot, decoupled from the request path so the runtime never waits on analytics emission.
222
+
223
+ ### 5. Observability surfaces
224
+
225
+ Because attributes are stored plaintext on the `WorkflowRun` entity, any UI that already calls `world.runs.get()` / `world.runs.list()` can read them with no additional plumbing. The render details (where attributes appear in the run-detail view, formatting, etc.) are out of scope for this MVP and tracked separately from the SDK work.
226
+
227
+ ## Trade-offs and known limitations
228
+
229
+ ### Concurrent writes to the same key
230
+
231
+ The MVP applies **last-write-wins by arrival order at the world**. Two concurrent `experimental_setAttributes` calls writing the same key produce a final state matching whichever request the world processes second. There is no conditional / `expectedValue` semantic and no `unique: true` mode.
232
+
233
+ Writes from a single `await`-ed call chain are serialized by the workflow VM and land in workflow-source order. The concurrent / racy case applies to:
234
+
235
+ - Multiple `experimental_setAttributes` calls inside one `Promise.all` writing the same key (the workflow VM dispatches them concurrently; the world sees them in scheduler order, not source order).
236
+ - `void experimental_setAttributes(...)` followed by another call to the same key — the fire-and-forget step may still be in flight when the next call lands.
237
+ - Multiple workflows writing the same key on the same run (rare — usually one workflow owns a run).
238
+
239
+ Disjoint-key writes are unaffected: every call lands, regardless of pattern. Applications that need conditional semantics on a shared key should wait for the 5.0.0 release; we will not retrofit conditional writes onto the MVP path.
240
+
241
+ ### No event-log history
242
+
243
+ Attribute changes do not appear in `world.events.list(runId)`. There is no record of *when* a key changed or *which step attempt* set it. The current snapshot on the run entity is authoritative; the history is lost.
244
+
245
+ When the full feature ships, new writes carry writer attribution (`writer: { type: 'workflow' }` or `writer: { type: 'step', stepId, attempt }`) in their `attr_set` events. MVP-era writes will not have this — history starts at the `attr_set` cutover.
246
+
247
+ ### MVP attributes do not survive materialization rebuild
248
+
249
+ Any tooling that reconstructs the run entity from the event log (disaster recovery, debugging, audit) will see no attributes on MVP-era runs, because the writes are not in the event log. This is the chief reason `experimentalSetAttributes` is named "experimental" — it is a known break from the otherwise-strict event-sourced model.
250
+
251
+ The 5.0.0 path closes this gap.
252
+
253
+ ### SDK surface stability
254
+
255
+ `experimental_setAttributes(record)` is intended to be stable across MVP and 5.0.0. User code calling it under the MVP will continue to work after the full feature lands; only the runtime dispatch path changes (`"use step"` indirection → workflow-VM-native intercept, parallel to `sleep`).
256
+
257
+ If you need behavior the MVP does not provide (read, list, filter, initial attributes at `start`, writer attribution), wait for 5.0.0 rather than building around the MVP surface.
258
+
259
+ ## Test coverage
260
+
261
+ Unit tests in `@workflow/world` (validation surface) and `@workflow/core` (VM-side dispatch + host-side stub):
262
+
263
+ - Validation rules — key length, value byte cap, `$` prefix, per-batch duplicates, post-merge count cap (with `existingKeys` so updates of present keys don't falsely trip the cap)
264
+ - Reserved `$` namespace — rejected by default, accepted when `allowReservedAttributes: true` is passed (both for `validateAttributeKey` and at the batch level via `validateAttributeChanges`)
265
+ - `experimental_setAttributes({})` is a no-op (no dispatch, no events)
266
+ - `undefined` value normalizes to a `null`-valued change on the wire
267
+ - The `{ allowReservedAttributes: true }` opt-in is forwarded through the step bridge so the world receives the flag
268
+ - Workflow VM with no `WORKFLOW_USE_STEP` bound throws `FatalError`
269
+ - Host-side implementation posts directly to the world when called from a step context
270
+ - Host-side implementation throws `FatalError` when called from plain host code
271
+
272
+ Unit tests in `workflow` (internal built-in step behavior):
273
+
274
+ - `__builtin_set_attributes` rethrows world write failures on attempts 1 and 2 so normal step retry semantics apply
275
+ - On attempt 3, `__builtin_set_attributes` logs a `console.error` and resolves so retry exhaustion does not turn the internal attribute write into a `FatalError`
276
+ - The internal step is configured for three total attempts (`maxRetries = 2`)
277
+
278
+ Integration tests in `world-local`:
279
+
280
+ - Upsert, merge across calls, unset via `null`, set-and-unset in a single batch
281
+ - Validation rejection (reserved prefix, oversize key, oversize value, post-merge cap)
282
+ - Reserved-prefix escape hatch via `{ allowReservedAttributes: true }` (per-call, not sticky on the run)
283
+ - Cap-boundary updates: a write that only updates existing keys must succeed even when the run is exactly at the cap
284
+ - Idempotency: repeated identical calls converge to the same final snapshot
285
+ - Concurrent writes serialize via the per-run mutex; no lost writes across 20 parallel calls
286
+
287
+ Integration tests in `world-postgres`:
288
+
289
+ - Upsert, merge across calls, unset via `null` (the SQL `jsonb_set` / `-` chain is exercised through these)
290
+ - Atomic cap enforcement inside the `UPDATE`'s `WHERE` clause; concurrent writers cannot collectively push past the per-run cap
291
+
292
+ End-to-end in `workbench/nextjs-turbopack` (exercises the full SWC plugin + workflow VM + step worker + `world-vercel` wire path against the production workflow-server `/v2/runs/:runId/attributes` endpoint):
293
+
294
+ - Awaited workflow-body calls dispatch through the `__builtin_set_attributes` step bridge and merge correctly (the test inspects the run's event log to confirm a `step_created` / `step_completed` pair was emitted)
295
+ - Fire-and-forget (`void experimental_setAttributes`) attributes land before the run terminates
296
+ - `Promise.all` of disjoint-key writes — every key persists
297
+ - Workflow throws after an awaited `experimental_setAttributes` — the attribute persists on the now-`failed` run (the per-run file lock on `run_failed` re-reads inside the critical section so the attribute snapshot survives the lifecycle write)
298
+
299
+ ## Migration to 5.0.0
300
+
301
+ When the full attributes feature ships:
302
+
303
+ - `experimental_setAttributes` (SDK) — unchanged signature, new dispatch path
304
+ - `runs.experimentalSetAttributes` (world interface) — deprecated, then removed; replaced by `events.create(runId, { eventType: 'attr_set', eventData: { changes, writer } })`
305
+ - Wire endpoint — `POST /v2/runs/:runId/attributes` removed; the same `changes` shape posts to `POST /v2/runs/:id/events`
306
+ - Pre-existing attribute values on MVP-era runs remain on the run entity but are not represented in the event log
307
+
308
+ Skew protection means workflows started under the MVP will continue to run with the MVP dispatch path on their original deployment. New deployments use the new path. No in-place data migration is needed.
309
+
310
+ ## Implementation notes (decisions made during the MVP build-out)
311
+
312
+ This section records concrete decisions taken while landing the MVP that weren't in the original plan, so the next iteration has them in one place.
313
+
314
+ ### How workflow-body dispatch works
315
+
316
+ `experimental_setAttributes` from a workflow body is wired through an internal built-in step, `__builtin_set_attributes`, defined in `packages/workflow/src/internal/builtins.ts` alongside the other workflow-side builtins (`__builtin_response_json`, etc.). The mechanism:
317
+
318
+ 1. The workflow VM bundle resolves `experimental_setAttributes` to `packages/core/src/workflow/set-attributes.ts` (via the `workflow` package-exports condition).
319
+ 2. That helper validates the input record inline (no shared helper, no cross-file dependency from a 'use step' file) and produces canonical `AttributeChange[]`.
320
+ 3. It dispatches through the standard workflow-VM step mechanism: `globalThis[WORKFLOW_USE_STEP]('__builtin_set_attributes')(changes)`. The `useStep` dispatcher is the same one used by every other step call from a workflow body, populated by `packages/core/src/workflow.ts` at VM bootstrap.
321
+ 4. The dispatch queues a step (`step_created`), the host runs `__builtin_set_attributes(changes, options)` from `packages/workflow/src/internal/builtins.ts`. The step body reads the active world, current run id, and attempt number directly from `globalThis` symbols (`Symbol.for('@workflow/world//cache')` and `Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')`) — populated by the host runtime — and calls `world.runs.experimentalSetAttributes(runId, changes, options)`.
322
+ 5. The step completes (`step_completed`), the workflow resumes.
323
+
324
+ This puts the mutation on the event log as a normal `step_created → step_completed` pair without inventing a new event type — that stays for the full 5.0.0 cutover.
325
+
326
+ The internal step is best-effort during the experimental phase. It sets `maxRetries = 2`, for three total attempts. If `world.runs.experimentalSetAttributes` fails on attempts 1 or 2, the error is rethrown so the runtime retries the step normally. If it still fails on attempt 3, the step logs `console.error` and returns; the workflow run continues instead of receiving a retry-exhaustion `FatalError` for failed tag posting.
327
+
328
+ Step-body calls do not use the internal built-in step. They run in host context already, so they post directly to the World. Storage errors throw from `experimental_setAttributes` like any other step-side side effect and can be caught by user code inside the step.
329
+
330
+ The step body intentionally does **not** import anything from `@workflow/core`. That keeps the Next.js deferred-entries discoverer from walking a `__builtin_set_attributes` → `@workflow/core/...` → world adapter → `@vercel/queue` chain, which earlier drafts triggered (blowing the call stack of webpack's regex-based extractor with `RangeError: Maximum call stack size exceeded at RegExpStringIterator.next` on tarball-installed `nextjs-webpack` builds).
331
+
332
+ The host-side `experimental_setAttributes` export (`packages/core/src/set-attributes.ts`, resolved by everything that isn't the workflow VM) supports step bodies by reading the current run id from step context and posting directly to the World. It still throws `FatalError` when called from plain host code.
333
+
334
+ When the full 5.0.0 attributes feature lands, `__builtin_set_attributes` is replaced by an `events.create(runId, { eventType: 'attr_set', ... })` dispatch path; SDK signatures don't change.
335
+
336
+ ### Endpoint lives under `v2`, not a fresh namespace
337
+
338
+ The initial draft placed the new endpoint at `POST /v3/runs/:runId/attributes`, on the assumption that introducing a new wire feature warranted a major namespace bump. In practice `world-vercel` mixes `/v1/...` and `/v2/...` endpoints already, and creating a `v3Api` subrouter just for a single endpoint would have required duplicating the auth / flags / rate-limit middleware stack. The MVP endpoint is therefore mounted under the existing `v2Api`. The wire body shape is unchanged, so the migration path described above (rerouting from `/v2/runs/:runId/attributes` to `/v2/runs/:id/events`) still holds — just within the same namespace.
339
+
340
+ ### Concurrent writes: read-modify-write, not per-key atomic
341
+
342
+ The plan called for per-key atomic `UpdateExpression` updates (`SET #attrs.#k = :v` / `REMOVE #attrs.#k`) in the `world-vercel` backing store, on the basis that it eliminates the read-modify-write race. The MVP ships with the simpler read-modify-write path instead:
343
+
344
+ - **In `world-postgres`** the SQL-side `jsonb_set` / `-` chain *is* used and is genuinely atomic on the run row, so the only race is the cap check (a separate `SELECT`). Documented as LWW-by-arrival for the cap; the merge itself is atomic.
345
+ - **In the `world-vercel` backing service** the attributes column is laid out as a native key-addressable map so the atomic `UpdateItem` variant can be enabled later without a data migration. The MVP commits the merged map via the existing entity update path. Two concurrent writers therefore race; whichever lands second wins on shared keys, and any write to a non-overlapping key is preserved.
346
+ - **In `world-local`** an in-process per-run mutex serializes the read-merge-write sequence so parallel `experimental_setAttributes` calls from concurrent steps do not lose writes within a single process. There is a corresponding test that exercises 20 parallel writes to the same run.
347
+
348
+ This is consistent with the original "concurrent writes are LWW by arrival" caveat. Promoting to per-key atomic writes is a no-API-break change once the event-sourced path lands.
349
+
350
+ ### `WORKFLOW_ATTRIBUTES` usage-fact schema: not introduced
351
+
352
+ The plan called for a dedicated `WORKFLOW_ATTRIBUTES` usage fact carrying the post-merge snapshot. Landing that schema would have required a coordinated change to the shared usage-facts package (used by `world-vercel`'s backing service) plus an ingest-side update before the endpoint could ship.
353
+
354
+ For the MVP the endpoint reuses the existing `WORKFLOW_EVENT` fact with `eventType: 'attr_set'`. That mirrors how other run-lifecycle events are reported, and it's enough for "did an attribute mutation happen" debugging without adding an analytics dependency to the critical path. A dedicated fact carrying the full snapshot can land alongside the event-sourced path without touching the SDK wire contract.
355
+
356
+ ### Validation rules are shared between SDK and world
357
+
358
+ Validation lives in a single helper exported from `@workflow/world` (`validateAttributeChanges`, `validateAttributeKey`, `validateAttributeValue`). Both the SDK `experimental_setAttributes` helper and the `world-local` / `world-postgres` implementations call it; the `world-vercel` backing service applies the same rules independently. The shared module is the authoritative spec for the limits (256-char keys, 256-byte values, max 64 attributes per run, `$`-prefixed keys reserved) — any future change goes through one file.
359
+
360
+ ### Run row reconstruction had to thread `attributes` through
361
+
362
+ `world-local`'s events storage rebuilds the run row on every lifecycle event (`run_started`, `run_completed`, `run_failed`, `run_cancelled`) by explicitly listing fields rather than spreading. Without forwarding `attributes` through each branch, any attribute set before the run completed would be silently dropped by the next lifecycle event. Each lifecycle write now also takes the per-run file lock (`withRunFileLock`) and re-reads the on-disk run inside the critical section, so an `experimental_setAttributes` call that lands in the same async window as a `run_completed` event is no longer clobbered.
363
+
364
+ ### Optional world method: feature-detect, warn once
365
+
366
+ `runs.experimentalSetAttributes` is optional on the `World` interface so community worlds (Redis, MongoDB, Turso, etc.) continue to build and run without adopting the experimental API. The SDK helper feature-detects the method's presence on first dispatch; if absent, it logs a single `console.warn` for the lifetime of the process and resolves silently for that call and all subsequent calls. Users do not need to feature-detect in their own code — calling `experimental_setAttributes` against an unsupporting world is safe but ineffective.
367
+
368
+ See "Test coverage" above for the full test surface that ships with this change.