From 4a812c740badf39860246f0393c2105d9829cffc Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:08:20 -0700 Subject: [PATCH 1/4] [core] Prune schema modules from workflow bundles --- .changeset/prune-workflow-sandbox.md | 6 + .../src/workflow-dependency-pruning.test.ts | 69 ++++++ packages/core/src/attribute-changes.ts | 2 +- packages/core/src/events-consumer.ts | 4 +- packages/core/src/flushable-stream.ts | 2 +- packages/core/src/runtime/run.ts | 9 +- packages/core/src/serialization-format.ts | 2 +- packages/core/src/serialization.ts | 2 +- packages/core/src/workflow.ts | 2 +- packages/world/package.json | 16 ++ packages/world/src/attributes-validation.ts | 145 +++++++++++ packages/world/src/attributes.ts | 227 +----------------- packages/world/src/event-metadata.ts | 65 +++++ packages/world/src/events.ts | 103 +------- 14 files changed, 320 insertions(+), 334 deletions(-) create mode 100644 .changeset/prune-workflow-sandbox.md create mode 100644 packages/builders/src/workflow-dependency-pruning.test.ts create mode 100644 packages/world/src/attributes-validation.ts create mode 100644 packages/world/src/event-metadata.ts diff --git a/.changeset/prune-workflow-sandbox.md b/.changeset/prune-workflow-sandbox.md new file mode 100644 index 0000000000..cac9860382 --- /dev/null +++ b/.changeset/prune-workflow-sandbox.md @@ -0,0 +1,6 @@ +--- +'@workflow/core': patch +'@workflow/world': patch +--- + +Keep schema-only World modules out of workflow VM bundles. diff --git a/packages/builders/src/workflow-dependency-pruning.test.ts b/packages/builders/src/workflow-dependency-pruning.test.ts new file mode 100644 index 0000000000..93f48ab9a5 --- /dev/null +++ b/packages/builders/src/workflow-dependency-pruning.test.ts @@ -0,0 +1,69 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BaseBuilder, type DiscoveredEntries } from './base-builder.js'; +import type { StandaloneConfig } from './types.js'; + +class TestBuilder extends BaseBuilder { + async build(): Promise {} + + createWorkflowBundle( + inputFile: string, + outfile: string, + discoveredEntries: DiscoveredEntries + ) { + return this.createWorkflowsBundle({ + inputFiles: [inputFile], + outfile, + bundleFinalOutput: false, + discoveredEntries, + }); + } +} + +describe('workflow dependency pruning', () => { + const outputDirs: string[] = []; + + afterEach(() => { + for (const outputDir of outputDirs) { + rmSync(outputDir, { recursive: true, force: true }); + } + }); + + it('does not bundle world schemas into a workflow without schemas', async () => { + const repoRoot = resolve(import.meta.dirname, '../../..'); + const workingDir = join(repoRoot, 'workbench/nextjs-turbopack'); + const inputFile = join(workingDir, 'workflows/97_bench.ts'); + const outputDir = mkdtempSync(join(tmpdir(), 'workflow-pruning-')); + outputDirs.push(outputDir); + + const config: StandaloneConfig = { + buildTarget: 'standalone', + workingDir, + projectRoot: repoRoot, + moduleSpecifierRoot: repoRoot, + dirs: ['.'], + stepsBundlePath: join(outputDir, 'steps.js'), + workflowsBundlePath: join(outputDir, 'workflow.js'), + webhookBundlePath: join(outputDir, 'webhook.js'), + sourcemap: false, + }; + const discoveredEntries: DiscoveredEntries = { + discoveredSteps: new Set(), + discoveredWorkflows: new Set([inputFile]), + discoveredSerdeFiles: new Set(), + }; + + const { interimBundleText } = await new TestBuilder( + config + ).createWorkflowBundle( + inputFile, + config.workflowsBundlePath, + discoveredEntries + ); + + expect(interimBundleText).toBeDefined(); + expect(interimBundleText).not.toContain('/node_modules/zod'); + }); +}); diff --git a/packages/core/src/attribute-changes.ts b/packages/core/src/attribute-changes.ts index 5f53d39308..a4f579a59e 100644 --- a/packages/core/src/attribute-changes.ts +++ b/packages/core/src/attribute-changes.ts @@ -3,7 +3,7 @@ import { type AttributeChange, AttributeValidationError, validateAttributeChanges, -} from '@workflow/world'; +} from '@workflow/world/attributes-validation'; interface AttributeChangeOptions { allowReservedAttributes?: boolean; diff --git a/packages/core/src/events-consumer.ts b/packages/core/src/events-consumer.ts index a6686006b9..07f5144805 100644 --- a/packages/core/src/events-consumer.ts +++ b/packages/core/src/events-consumer.ts @@ -1,4 +1,6 @@ -import { type Event, entityEventClass, envNumber } from '@workflow/world'; +import type { Event } from '@workflow/world'; +import { envNumber } from '@workflow/world/env-config'; +import { entityEventClass } from '@workflow/world/event-metadata'; import { eventsLogger } from './logger.js'; /** diff --git a/packages/core/src/flushable-stream.ts b/packages/core/src/flushable-stream.ts index 3722e8d449..74e83f9091 100644 --- a/packages/core/src/flushable-stream.ts +++ b/packages/core/src/flushable-stream.ts @@ -1,6 +1,6 @@ import { WorkflowRuntimeError } from '@workflow/errors'; import { type PromiseWithResolvers, withResolvers } from '@workflow/utils'; -import { envNumber } from '@workflow/world'; +import { envNumber } from '@workflow/world/env-config'; import { STREAM_DRAIN_SYMBOL } from './symbols.js'; /** diff --git a/packages/core/src/runtime/run.ts b/packages/core/src/runtime/run.ts index 201eb5a4f8..f0d3e37ab3 100644 --- a/packages/core/src/runtime/run.ts +++ b/packages/core/src/runtime/run.ts @@ -5,12 +5,9 @@ import { WorkflowRunNotFoundError, } from '@workflow/errors'; import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from '@workflow/serde'; -import { - envNumber, - SPEC_VERSION_CURRENT, - type WorkflowRunStatus, - type World, -} from '@workflow/world'; +import type { WorkflowRunStatus, World } from '@workflow/world'; +import { envNumber } from '@workflow/world/env-config'; +import { SPEC_VERSION_CURRENT } from '@workflow/world/spec-version'; import { type DecryptionKey, deriveRunPayloadKeys, diff --git a/packages/core/src/serialization-format.ts b/packages/core/src/serialization-format.ts index 9799d1ee64..0a6f910568 100644 --- a/packages/core/src/serialization-format.ts +++ b/packages/core/src/serialization-format.ts @@ -7,7 +7,7 @@ * Node imports, so this module remains safe to bundle for browsers. */ -import { getEventDataRefFields } from '@workflow/world'; +import { getEventDataRefFields } from '@workflow/world/event-metadata'; import { parse, unflatten } from 'devalue'; import { decompress, diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts index c38fb98d8f..c11e9a11a8 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization.ts @@ -5,7 +5,7 @@ import { WorkflowRuntimeError, } from '@workflow/errors'; import { once } from '@workflow/utils'; -import { envNumber } from '@workflow/world'; +import { envNumber } from '@workflow/world/env-config'; import { parse, stringify, unflatten } from 'devalue'; import { monotonicFactory } from 'ulid'; import { importKey } from './encryption.js'; diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 9fb43bd2dd..d8f474d0c4 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -12,7 +12,7 @@ import { } from '@workflow/utils'; import { parseWorkflowName } from '@workflow/utils/parse-name'; import type { Event, WorkflowRun, WorldCapabilities } from '@workflow/world'; -import { SPEC_VERSION_SUPPORTS_COMPRESSION } from '@workflow/world'; +import { SPEC_VERSION_SUPPORTS_COMPRESSION } from '@workflow/world/spec-version'; import * as nanoid from 'nanoid'; import { monotonicFactory } from 'ulid'; import { EventConsumerResult, EventsConsumer } from './events-consumer.js'; diff --git a/packages/world/package.json b/packages/world/package.json index 78469e8b62..3edaf01bfd 100644 --- a/packages/world/package.json +++ b/packages/world/package.json @@ -6,6 +6,22 @@ "main": "dist/index.js", "exports": { ".": "./dist/index.js", + "./attributes-validation": { + "types": "./dist/attributes-validation.d.ts", + "default": "./dist/attributes-validation.js" + }, + "./env-config": { + "types": "./dist/env-config.d.ts", + "default": "./dist/env-config.js" + }, + "./event-metadata": { + "types": "./dist/event-metadata.d.ts", + "default": "./dist/event-metadata.js" + }, + "./spec-version": { + "types": "./dist/spec-version.d.ts", + "default": "./dist/spec-version.js" + }, "./*": "./dist/*" }, "publishConfig": { diff --git a/packages/world/src/attributes-validation.ts b/packages/world/src/attributes-validation.ts new file mode 100644 index 0000000000..d757c3c407 --- /dev/null +++ b/packages/world/src/attributes-validation.ts @@ -0,0 +1,145 @@ +/** A single run-attribute change. `null` removes the key. */ +export interface AttributeChange { + key: string; + value: string | null; +} + +export interface AttributeValidationContext { + /** Existing keys make the post-merge count exact. */ + existingKeys?: Iterable; + /** Reserved `$` keys are only available to framework code. */ + allowReservedAttributes?: boolean; +} + +export interface AttributeKeyValidationOptions { + allowReservedAttributes?: boolean; +} + +export const RESERVED_ATTRIBUTE_KEY_PREFIX = '$'; +export const ROOT_RUN_ID_ATTRIBUTE = `${RESERVED_ATTRIBUTE_KEY_PREFIX}rootRunId`; +export const PARENT_RUN_ID_ATTRIBUTE = `${RESERVED_ATTRIBUTE_KEY_PREFIX}parentRunId`; +export const ATTRIBUTE_KEY_MAX_LENGTH = 256; +export const ATTRIBUTE_VALUE_MAX_BYTES = 256; +export const ATTRIBUTE_MAX_PER_RUN = 64; + +/** A validation failure that callers can translate at their API boundary. */ +export class AttributeValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AttributeValidationError'; + } +} + +export function validateAttributeKey( + key: string, + options: AttributeKeyValidationOptions = {} +): AttributeValidationError | null { + if (typeof key !== 'string') { + return new AttributeValidationError( + `Attribute key must be a string, got ${typeof key}` + ); + } + if (key.length === 0) { + return new AttributeValidationError('Attribute key must not be empty'); + } + if (key.length > ATTRIBUTE_KEY_MAX_LENGTH) { + return new AttributeValidationError( + `Attribute key length ${key.length} exceeds limit ${ATTRIBUTE_KEY_MAX_LENGTH}: ${JSON.stringify(key.slice(0, 32))}…` + ); + } + if ( + !options.allowReservedAttributes && + key.startsWith(RESERVED_ATTRIBUTE_KEY_PREFIX) + ) { + return new AttributeValidationError( + `Attribute key ${JSON.stringify(key)} starts with reserved prefix "${RESERVED_ATTRIBUTE_KEY_PREFIX}" — that namespace is reserved for framework/library code. Set { allowReservedAttributes: true } only if your caller is framework-level.` + ); + } + return null; +} + +export function validateAttributeValue( + value: string | null +): AttributeValidationError | null { + if (value === null) return null; + if (typeof value !== 'string') { + return new AttributeValidationError( + `Attribute value must be a string or null, got ${typeof value}` + ); + } + const bytes = new TextEncoder().encode(value).length; + if (bytes > ATTRIBUTE_VALUE_MAX_BYTES) { + return new AttributeValidationError( + `Attribute value byte length ${bytes} exceeds limit ${ATTRIBUTE_VALUE_MAX_BYTES}` + ); + } + return null; +} + +function validateChange( + change: AttributeChange, + seenKeys: Set, + allowReservedAttributes: boolean +): void { + const keyError = validateAttributeKey(change.key, { + allowReservedAttributes, + }); + if (keyError) throw keyError; + const valueError = validateAttributeValue(change.value); + if (valueError) throw valueError; + if (seenKeys.has(change.key)) { + throw new AttributeValidationError( + `Attribute key ${JSON.stringify(change.key)} appears more than once in the same batch` + ); + } + seenKeys.add(change.key); +} + +function attributeCountDelta( + change: AttributeChange, + existingKeys: ReadonlySet | undefined +): number { + if (existingKeys === undefined) return change.value === null ? -1 : 1; + const exists = existingKeys.has(change.key); + if (change.value === null) return exists ? -1 : 0; + return exists ? 0 : 1; +} + +export function validateAttributeChanges( + changes: AttributeChange[], + context: AttributeValidationContext = {} +): void { + const seenKeys = new Set(); + const existingKeys = + context.existingKeys === undefined + ? undefined + : context.existingKeys instanceof Set + ? context.existingKeys + : new Set(context.existingKeys); + let netChange = 0; + for (const change of changes) { + validateChange(change, seenKeys, context.allowReservedAttributes === true); + netChange += attributeCountDelta(change, existingKeys); + } + const postMerge = (existingKeys?.size ?? 0) + netChange; + if (postMerge > ATTRIBUTE_MAX_PER_RUN) { + throw new AttributeValidationError( + `Run attribute count would exceed limit ${ATTRIBUTE_MAX_PER_RUN} (post-merge ${postMerge})` + ); + } +} + +export function applyAttributeChanges( + existing: Record | undefined, + changes: AttributeChange[] +): Record { + const next = { ...(existing ?? {}) }; + for (const { key, value } of changes) { + if (value === null) { + delete next[key]; + } else { + next[key] = value; + } + } + return next; +} diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts index af9aef4496..5c58947f53 100644 --- a/packages/world/src/attributes.ts +++ b/packages/world/src/attributes.ts @@ -1,235 +1,18 @@ import { z } from 'zod'; -/** - * Reserved key prefix for system-managed attributes. User code may not set - * keys starting with `$` — those are blocked at validation time so the - * namespace remains available for future system use. - */ -export const RESERVED_ATTRIBUTE_KEY_PREFIX = '$'; +import type { AttributeChange } from './attributes-validation.js'; -/** - * Reserved attribute keys recording cross-run lineage. `start()` sets these on - * a run created from inside another run: `$rootRunId` is the root of the chain, - * `$parentRunId` the direct parent edge. - */ -export const ROOT_RUN_ID_ATTRIBUTE = `${RESERVED_ATTRIBUTE_KEY_PREFIX}rootRunId`; -export const PARENT_RUN_ID_ATTRIBUTE = `${RESERVED_ATTRIBUTE_KEY_PREFIX}parentRunId`; +export * from './attributes-validation.js'; -/** Max length of an attribute key, in characters. */ -export const ATTRIBUTE_KEY_MAX_LENGTH = 256; - -/** Max length of an attribute value, in bytes (UTF-8). */ -export const ATTRIBUTE_VALUE_MAX_BYTES = 256; - -/** Max number of attributes on a single run (post-merge). */ -export const ATTRIBUTE_MAX_PER_RUN = 64; - -/** - * A single change in an `experimentalSetAttributes` call. `value: null` - * means "remove this key from the run's attributes". - * - * The shape is deliberately the same as the future `attr_set` event's - * `eventData.changes` entries so the SDK and wire format do not change - * when the full attributes feature lands. - */ +/** Runtime schema for a single run-attribute change. */ export const AttributeChangeSchema = z.object({ key: z.string(), value: z.union([z.string(), z.null()]), -}); - -export type AttributeChange = z.infer; +}) satisfies z.ZodType; export const AttributeChangesSchema = z.array(AttributeChangeSchema); -/** - * Result returned by `runs.experimentalSetAttributes` — the post-merge - * snapshot of all attributes on the run. Provided so callers (notably - * `setAttributes` and observability emitters) do not need a follow-up read. - */ +/** The post-merge attribute snapshot returned by a World. */ export interface ExperimentalSetAttributesResult { attributes: Record; } - -export interface AttributeValidationContext { - /** - * Existing attribute keys on the run, used to enforce the per-run - * cap accurately against the post-merge total — an incoming change - * that updates an already-present key contributes zero net adds. - * - * If omitted, the cap check assumes every non-null change is a fresh - * add, which is conservative but still safe (the only false-positive - * shape rejects updates to existing keys at the cap boundary; the - * authoritative server-side check uses the real post-merge size). - */ - existingKeys?: Iterable; - /** - * Permit keys that start with the reserved `$` prefix. Default `false`. - * - * The `$` namespace is reserved for framework / library code built on - * top of the workflow SDK (telemetry, agent metadata, etc.). User code - * MUST NOT set it; if a user tries, validation rejects the call so - * accidental conflicts with tooling-owned keys can't slip through. - * - * Set this to `true` only from framework-level code that is aware of - * the namespace conventions in use. Misuse can collide with tooling - * keys and break observability surfaces. - */ - allowReservedAttributes?: boolean; -} - -export interface AttributeKeyValidationOptions { - /** - * Permit keys that start with the reserved `$` prefix. See the - * `allowReservedAttributes` note on `AttributeValidationContext`. - */ - allowReservedAttributes?: boolean; -} - -/** - * Thrown when an attribute key or value violates one of the validation - * rules. Use a plain `Error` here so the world layer can decide whether - * to wrap as `FatalError` (SDK) or return a 400 (server endpoint). - */ -export class AttributeValidationError extends Error { - constructor(message: string) { - super(message); - this.name = 'AttributeValidationError'; - } -} - -const valueByteLength = (value: string): number => - new TextEncoder().encode(value).length; - -/** - * Validate a single attribute key. Returns an `AttributeValidationError` - * on violation, or `null` if the key is valid. Returning instead of - * throwing lets callers aggregate or wrap the failure as needed. - * - * The reserved `$`-prefix rule is enforced by default; framework code - * may pass `allowReservedAttributes: true` to opt out. - */ -export function validateAttributeKey( - key: string, - options: AttributeKeyValidationOptions = {} -): AttributeValidationError | null { - if (typeof key !== 'string') { - return new AttributeValidationError( - `Attribute key must be a string, got ${typeof key}` - ); - } - if (key.length === 0) { - return new AttributeValidationError('Attribute key must not be empty'); - } - if (key.length > ATTRIBUTE_KEY_MAX_LENGTH) { - return new AttributeValidationError( - `Attribute key length ${key.length} exceeds limit ${ATTRIBUTE_KEY_MAX_LENGTH}: ${JSON.stringify(key.slice(0, 32))}…` - ); - } - if ( - !options.allowReservedAttributes && - key.startsWith(RESERVED_ATTRIBUTE_KEY_PREFIX) - ) { - return new AttributeValidationError( - `Attribute key ${JSON.stringify(key)} starts with reserved prefix "${RESERVED_ATTRIBUTE_KEY_PREFIX}" — that namespace is reserved for framework/library code. Set { allowReservedAttributes: true } only if your caller is framework-level.` - ); - } - return null; -} - -/** - * Validate a single attribute value. `null` represents an unset and is - * always valid. Returns an `AttributeValidationError` on violation or - * `null` if the value is valid. - */ -export function validateAttributeValue( - value: string | null -): AttributeValidationError | null { - if (value === null) return null; - if (typeof value !== 'string') { - return new AttributeValidationError( - `Attribute value must be a string or null, got ${typeof value}` - ); - } - const bytes = valueByteLength(value); - if (bytes > ATTRIBUTE_VALUE_MAX_BYTES) { - return new AttributeValidationError( - `Attribute value byte length ${bytes} exceeds limit ${ATTRIBUTE_VALUE_MAX_BYTES}` - ); - } - return null; -} - -/** - * Validate a batch of attribute changes. Throws `AttributeValidationError` - * on the first violation found. Pass `existingKeys` (in `context`) so - * the per-run cap check can use the real post-merge total — without it - * the check is conservative and may reject an update to an - * already-present key when the run is at the cap. - */ -export function validateAttributeChanges( - changes: AttributeChange[], - context: AttributeValidationContext = {} -): void { - const seenKeys = new Set(); - const existingKeys = - context.existingKeys === undefined - ? undefined - : context.existingKeys instanceof Set - ? (context.existingKeys as Set) - : new Set(context.existingKeys); - let netAdds = 0; - let netDeletes = 0; - for (const change of changes) { - const keyError = validateAttributeKey(change.key, { - allowReservedAttributes: context.allowReservedAttributes, - }); - if (keyError) throw keyError; - const valueError = validateAttributeValue(change.value); - if (valueError) throw valueError; - if (seenKeys.has(change.key)) { - throw new AttributeValidationError( - `Attribute key ${JSON.stringify(change.key)} appears more than once in the same batch` - ); - } - seenKeys.add(change.key); - // Per-run cap accounting: an upsert on an already-present key is - // a zero-net change; a delete on an absent key is also zero-net. - // When `existingKeys` is undefined the cap check falls back to the - // conservative "every upsert is +1" shape, documented above. - if (change.value !== null) { - if (existingKeys === undefined || !existingKeys.has(change.key)) { - netAdds += 1; - } - } else if (existingKeys === undefined || existingKeys.has(change.key)) { - netDeletes += 1; - } - } - const existing = existingKeys === undefined ? 0 : existingKeys.size; - const postMerge = existing + netAdds - netDeletes; - if (postMerge > ATTRIBUTE_MAX_PER_RUN) { - throw new AttributeValidationError( - `Run attribute count would exceed limit ${ATTRIBUTE_MAX_PER_RUN} (post-merge ${postMerge})` - ); - } -} - -/** - * Apply a batch of validated changes to an existing attribute map. Returns - * a new map; does not mutate the input. The world layer uses this to - * compute the post-merge snapshot when the underlying store cannot do the - * merge in a single atomic operation. - */ -export function applyAttributeChanges( - existing: Record | undefined, - changes: AttributeChange[] -): Record { - const next: Record = { ...(existing ?? {}) }; - for (const { key, value } of changes) { - if (value === null) { - delete next[key]; - } else { - next[key] = value; - } - } - return next; -} diff --git a/packages/world/src/event-metadata.ts b/packages/world/src/event-metadata.ts new file mode 100644 index 0000000000..8964d70018 --- /dev/null +++ b/packages/world/src/event-metadata.ts @@ -0,0 +1,65 @@ +import type { EventType } from './events.js'; + +/** Groups events that are mutually exclusive outcomes for one entity. */ +const ENTITY_EVENT_CLASS_BY_TYPE = { + step_created: 'step_created', + step_started: 'step_started', + step_retrying: 'step_retrying', + step_completed: 'step_terminal', + step_failed: 'step_terminal', + wait_created: 'wait_created', + wait_completed: 'wait_completed', + hook_created: 'hook_created', + hook_disposed: 'hook_disposed', + run_started: 'run_started', +} as const satisfies Partial>; + +export type EntityEventClass = + (typeof ENTITY_EVENT_CLASS_BY_TYPE)[keyof typeof ENTITY_EVENT_CLASS_BY_TYPE]; + +export function entityEventClass( + eventType: string +): EntityEventClass | undefined { + return ( + ENTITY_EVENT_CLASS_BY_TYPE as Record + )[eventType]; +} + +/** The opaque payload field carried by each event type. */ +export const EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE = { + run_created: 'input', + run_started: 'input', + run_completed: 'output', + run_failed: 'error', + step_created: 'input', + step_started: 'input', + step_completed: 'result', + step_failed: 'error', + step_retrying: 'error', + hook_created: 'metadata', + hook_received: 'payload', +} as const satisfies Partial>; + +export type EventDataPayloadField = + (typeof EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE)[keyof typeof EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE]; + +/** Payload fields removed when events are loaded without referenced data. */ +export const EVENT_DATA_REF_FIELDS = Object.fromEntries( + Object.entries(EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE).map( + ([eventType, field]) => [eventType, [field]] + ) +) as Record; + +export function getEventDataRefFields(eventType: string): readonly string[] { + return EVENT_DATA_REF_FIELDS[eventType] ?? []; +} + +export function getEventDataPayloadField( + eventType: string +): EventDataPayloadField | undefined { + return ( + EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE as Partial< + Record + > + )[eventType]; +} diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index 9ac9226fe0..c53351f360 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { AttributeChangesSchema } from './attributes.js'; +import { getEventDataRefFields } from './event-metadata.js'; import type { Hook } from './hooks.js'; import type { StartedWorkflowRun, WorkflowRun } from './runs.js'; import { SerializedDataSchema } from './serialization.js'; @@ -7,6 +8,8 @@ import type { PaginationOptions, ResolveData } from './shared.js'; import type { StartedStep, Step } from './steps.js'; import type { Wait } from './waits.js'; +export * from './event-metadata.js'; + // Event type enum export const EventTypeSchema = z.enum([ // Run lifecycle events @@ -89,59 +92,6 @@ export function isTerminalStepEventType( return TERMINAL_STEP_EVENT_TYPES.includes(eventType as TerminalStepEventType); } -/** - * Groups event types into the classes a replay tracks per entity: the entity - * named by the event's `correlationId`, or the run itself for run events, - * which carry none. - * - * Types that share a class are the mutually exclusive outcomes of one - * decision, so the log records the class once and the first event of it is the - * one that counts: a step either completes or fails. - * - * Classes are independent of each other. A step whose result is in the log has - * still recorded exactly one `step_created`, and can still record another - * `step_started` if an attempt is running somewhere. What a class bounds is - * which events can be *ignored*: a replay may pass over an event whose class - * it already recorded for that entity and which no consumer wants (see - * `EventsConsumer`), and only then. - * - * Note the omissions, all of them types a mapping would be dead weight for. - * `hook_received` and `hook_conflict` are deliveries whose consumer subscribes - * lazily, `attr_set` is written on every attribute write, and `run_created` - * precedes every replay. The terminal run types are absent for a different - * reason: recording a class requires a consumer to take an event of it, and no - * consumer takes `run_completed` / `run_failed` / `run_cancelled` — the runtime - * exits before replaying the body once the log holds one, so they never reach a - * consumer at all. An entry for them could never match. - */ -const ENTITY_EVENT_CLASS_BY_TYPE = { - step_created: 'step_created', - step_started: 'step_started', - step_retrying: 'step_retrying', - step_completed: 'step_terminal', - step_failed: 'step_terminal', - wait_created: 'wait_created', - wait_completed: 'wait_completed', - hook_created: 'hook_created', - hook_disposed: 'hook_disposed', - run_started: 'run_started', -} as const satisfies Partial>; - -export type EntityEventClass = - (typeof ENTITY_EVENT_CLASS_BY_TYPE)[keyof typeof ENTITY_EVENT_CLASS_BY_TYPE]; - -/** - * The per-entity class `eventType` belongs to, or `undefined` when it belongs - * to none. See {@link ENTITY_EVENT_CLASS_BY_TYPE}. - */ -export function entityEventClass( - eventType: string -): EntityEventClass | undefined { - return ( - ENTITY_EVENT_CLASS_BY_TYPE as Record - )[eventType]; -} - const HookLifecycleEventTypeSchema = EventTypeSchema.extract([ 'hook_created', 'hook_received', @@ -208,53 +158,6 @@ export function isChildEntityCreationEventType( ); } -/** - * Field within eventData that carries the opaque user payload for event types - * that have one. V4 worlds split this field into the wire body while keeping - * the remaining eventData fields in metadata. - */ -export const EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE = { - run_created: 'input', - run_started: 'input', - run_completed: 'output', - run_failed: 'error', - step_created: 'input', - step_started: 'input', - step_completed: 'result', - step_failed: 'error', - step_retrying: 'error', - hook_created: 'metadata', - hook_received: 'payload', -} as const satisfies Partial>; - -export type EventDataPayloadField = - (typeof EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE)[keyof typeof EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE]; - -/** - * Fields within eventData that hold ref/payload data per event type. - * When resolveData is 'none', only these fields are stripped — all other - * metadata (stepName, workflowName, etc.) is preserved. - */ -export const EVENT_DATA_REF_FIELDS = Object.fromEntries( - Object.entries(EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE).map( - ([eventType, field]) => [eventType, [field]] - ) -) as Record; - -export function getEventDataRefFields(eventType: string): readonly string[] { - return EVENT_DATA_REF_FIELDS[eventType] ?? []; -} - -export function getEventDataPayloadField( - eventType: string -): EventDataPayloadField | undefined { - return ( - EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE as Partial< - Record - > - )[eventType]; -} - /** * Strip ref/payload fields from eventData based on resolveData setting. * When resolveData is 'none', removes only large data fields (refs) from From 97641e27035db5e4fea3da1c50a3837148165ae9 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:44:35 -0700 Subject: [PATCH 2/4] [world] Inline one-off validation options --- packages/core/src/attribute-changes.ts | 6 +----- packages/world/src/attributes-validation.ts | 20 +++++++------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/packages/core/src/attribute-changes.ts b/packages/core/src/attribute-changes.ts index a4f579a59e..3f53ee2ce6 100644 --- a/packages/core/src/attribute-changes.ts +++ b/packages/core/src/attribute-changes.ts @@ -5,13 +5,9 @@ import { validateAttributeChanges, } from '@workflow/world/attributes-validation'; -interface AttributeChangeOptions { - allowReservedAttributes?: boolean; -} - export function normalizeAttributeChanges( attrs: Record, - options: AttributeChangeOptions = {} + options: { allowReservedAttributes?: boolean } = {} ): AttributeChange[] { if (attrs === null || typeof attrs !== 'object' || Array.isArray(attrs)) { throw new FatalError( diff --git a/packages/world/src/attributes-validation.ts b/packages/world/src/attributes-validation.ts index d757c3c407..281b9f10ed 100644 --- a/packages/world/src/attributes-validation.ts +++ b/packages/world/src/attributes-validation.ts @@ -4,17 +4,6 @@ export interface AttributeChange { value: string | null; } -export interface AttributeValidationContext { - /** Existing keys make the post-merge count exact. */ - existingKeys?: Iterable; - /** Reserved `$` keys are only available to framework code. */ - allowReservedAttributes?: boolean; -} - -export interface AttributeKeyValidationOptions { - allowReservedAttributes?: boolean; -} - export const RESERVED_ATTRIBUTE_KEY_PREFIX = '$'; export const ROOT_RUN_ID_ATTRIBUTE = `${RESERVED_ATTRIBUTE_KEY_PREFIX}rootRunId`; export const PARENT_RUN_ID_ATTRIBUTE = `${RESERVED_ATTRIBUTE_KEY_PREFIX}parentRunId`; @@ -32,7 +21,7 @@ export class AttributeValidationError extends Error { export function validateAttributeKey( key: string, - options: AttributeKeyValidationOptions = {} + options: { allowReservedAttributes?: boolean } = {} ): AttributeValidationError | null { if (typeof key !== 'string') { return new AttributeValidationError( @@ -107,7 +96,12 @@ function attributeCountDelta( export function validateAttributeChanges( changes: AttributeChange[], - context: AttributeValidationContext = {} + context: { + /** Existing keys make the post-merge count exact. */ + existingKeys?: Iterable; + /** Reserved `$` keys are only available to framework code. */ + allowReservedAttributes?: boolean; + } = {} ): void { const seenKeys = new Set(); const existingKeys = From 88b44acc977249b225bb9cefb06c8f7bbefbd72e Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:03:15 -0700 Subject: [PATCH 3/4] refactor(world): simplify event schema boundaries --- ...ependency-pruning.test.ts => workflow-bundle-boundary.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/builders/src/{workflow-dependency-pruning.test.ts => workflow-bundle-boundary.test.ts} (100%) diff --git a/packages/builders/src/workflow-dependency-pruning.test.ts b/packages/builders/src/workflow-bundle-boundary.test.ts similarity index 100% rename from packages/builders/src/workflow-dependency-pruning.test.ts rename to packages/builders/src/workflow-bundle-boundary.test.ts From 7bfd5a4d3e60102b3941e351e22c76fced04207a Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:03:30 -0700 Subject: [PATCH 4/4] refactor(world): simplify event schema boundaries --- .changeset/prune-workflow-sandbox.md | 8 +- .../docs/v5/changelog/attributes-mvp.mdx | 4 +- .../src/workflow-bundle-boundary.test.ts | 13 +- packages/world-vercel/src/events-v4.ts | 9 +- packages/world/src/attributes-validation.ts | 86 +++---- packages/world/src/attributes.test.ts | 91 ++++--- packages/world/src/attributes.ts | 51 +++- packages/world/src/event-metadata.test.ts | 28 +++ packages/world/src/event-metadata.ts | 63 ++--- packages/world/src/events.test.ts | 25 ++ packages/world/src/events.ts | 70 ++++-- packages/world/src/index.ts | 6 +- pnpm-lock.yaml | 235 ++++++++---------- pnpm-workspace.yaml | 2 +- 14 files changed, 404 insertions(+), 287 deletions(-) create mode 100644 packages/world/src/event-metadata.test.ts diff --git a/.changeset/prune-workflow-sandbox.md b/.changeset/prune-workflow-sandbox.md index cac9860382..195f774a06 100644 --- a/.changeset/prune-workflow-sandbox.md +++ b/.changeset/prune-workflow-sandbox.md @@ -1,6 +1,12 @@ --- +'@workflow/ai': patch +'@workflow/cli': patch '@workflow/core': patch '@workflow/world': patch +'@workflow/world-local': patch +'@workflow/world-postgres': patch +'@workflow/world-testing': patch +'@workflow/world-vercel': patch --- -Keep schema-only World modules out of workflow VM bundles. +Keep schema-only World modules out of workflow VM bundles, upgrade Zod to 4.4, and make event response optionality explicit. diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx index 06ce42c6ba..5634556ecb 100644 --- a/docs/content/docs/v5/changelog/attributes-mvp.mdx +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -273,7 +273,7 @@ If you need behavior the MVP does not provide (read, list, filter, initial attri Unit tests in `@workflow/world` (validation surface) and `@workflow/core` (VM-side dispatch + host-side stub): - 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) -- Reserved `$` namespace — rejected by default, accepted when `allowReservedAttributes: true` is passed (both for `validateAttributeKey` and at the batch level via `validateAttributeChanges`) +- Reserved `$` namespace — rejected by default and accepted by the contextual `validateAttributeChanges` check when `allowReservedAttributes: true` is passed - `experimental_setAttributes({})` is a no-op (no dispatch, no events) - `undefined` value normalizes to a `null`-valued change on the wire - The `{ allowReservedAttributes: true }` opt-in is forwarded through the step bridge so the world receives the flag @@ -367,7 +367,7 @@ For the MVP the endpoint reuses the existing `WORKFLOW_EVENT` fact with `eventTy ### Validation rules are shared between SDK and world -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. +Context-free validation lives in the exported Zod schemas (`AttributeKeySchema`, `AttributeValueSchema`, `AttributeChangeSchema`, and `AttributeChangesSchema`). The schema-free `validateAttributeChanges` helper adds rules that depend on caller context, including the post-merge count and reserved `$` namespace. 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 remains the authoritative spec for the limits (256-char keys, 256-byte values, max 64 attributes per run, `$`-prefixed keys reserved). ### Run row reconstruction had to thread `attributes` through diff --git a/packages/builders/src/workflow-bundle-boundary.test.ts b/packages/builders/src/workflow-bundle-boundary.test.ts index 93f48ab9a5..f8795dbb68 100644 --- a/packages/builders/src/workflow-bundle-boundary.test.ts +++ b/packages/builders/src/workflow-bundle-boundary.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -22,7 +22,7 @@ class TestBuilder extends BaseBuilder { } } -describe('workflow dependency pruning', () => { +describe('workflow bundle boundary', () => { const outputDirs: string[] = []; afterEach(() => { @@ -33,14 +33,17 @@ describe('workflow dependency pruning', () => { it('does not bundle world schemas into a workflow without schemas', async () => { const repoRoot = resolve(import.meta.dirname, '../../..'); - const workingDir = join(repoRoot, 'workbench/nextjs-turbopack'); - const inputFile = join(workingDir, 'workflows/97_bench.ts'); const outputDir = mkdtempSync(join(tmpdir(), 'workflow-pruning-')); outputDirs.push(outputDir); + const inputFile = join(outputDir, 'minimal.ts'); + writeFileSync( + inputFile, + `export async function minimal() { "use workflow"; return 1; }` + ); const config: StandaloneConfig = { buildTarget: 'standalone', - workingDir, + workingDir: outputDir, projectRoot: repoRoot, moduleSpecifierRoot: repoRoot, dirs: ['.'], diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 734e68dc51..4ebfa5c9a8 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -331,9 +331,12 @@ const CreateEventV4PageSchema = z.union([ hasMore: z.boolean(), }), z.object({ - events: z.undefined(), - cursor: z.undefined(), - hasMore: z.undefined(), + // Materialized writes omit page metadata entirely. Zod 4.4 correctly + // treats bare z.undefined() properties as required, so optionality must be + // explicit when the keys themselves may be absent. + events: z.undefined().optional(), + cursor: z.undefined().optional(), + hasMore: z.undefined().optional(), }), ]); diff --git a/packages/world/src/attributes-validation.ts b/packages/world/src/attributes-validation.ts index 281b9f10ed..e134b1fd9d 100644 --- a/packages/world/src/attributes-validation.ts +++ b/packages/world/src/attributes-validation.ts @@ -11,6 +11,8 @@ export const ATTRIBUTE_KEY_MAX_LENGTH = 256; export const ATTRIBUTE_VALUE_MAX_BYTES = 256; export const ATTRIBUTE_MAX_PER_RUN = 64; +const textEncoder = new TextEncoder(); + /** A validation failure that callers can translate at their API boundary. */ export class AttributeValidationError extends Error { constructor(message: string) { @@ -19,79 +21,58 @@ export class AttributeValidationError extends Error { } } -export function validateAttributeKey( - key: string, - options: { allowReservedAttributes?: boolean } = {} -): AttributeValidationError | null { +function assertValidAttributeKey( + key: unknown, + allowReservedAttributes: boolean +): asserts key is string { if (typeof key !== 'string') { - return new AttributeValidationError( + throw new AttributeValidationError( `Attribute key must be a string, got ${typeof key}` ); } if (key.length === 0) { - return new AttributeValidationError('Attribute key must not be empty'); + throw new AttributeValidationError('Attribute key must not be empty'); } if (key.length > ATTRIBUTE_KEY_MAX_LENGTH) { - return new AttributeValidationError( + throw new AttributeValidationError( `Attribute key length ${key.length} exceeds limit ${ATTRIBUTE_KEY_MAX_LENGTH}: ${JSON.stringify(key.slice(0, 32))}…` ); } if ( - !options.allowReservedAttributes && + !allowReservedAttributes && key.startsWith(RESERVED_ATTRIBUTE_KEY_PREFIX) ) { - return new AttributeValidationError( + throw new AttributeValidationError( `Attribute key ${JSON.stringify(key)} starts with reserved prefix "${RESERVED_ATTRIBUTE_KEY_PREFIX}" — that namespace is reserved for framework/library code. Set { allowReservedAttributes: true } only if your caller is framework-level.` ); } - return null; } -export function validateAttributeValue( - value: string | null -): AttributeValidationError | null { - if (value === null) return null; - if (typeof value !== 'string') { - return new AttributeValidationError( +function assertValidAttributeValue( + value: unknown +): asserts value is string | null { + if (value !== null && typeof value !== 'string') { + throw new AttributeValidationError( `Attribute value must be a string or null, got ${typeof value}` ); } - const bytes = new TextEncoder().encode(value).length; - if (bytes > ATTRIBUTE_VALUE_MAX_BYTES) { - return new AttributeValidationError( - `Attribute value byte length ${bytes} exceeds limit ${ATTRIBUTE_VALUE_MAX_BYTES}` - ); - } - return null; -} + if (value === null) return; -function validateChange( - change: AttributeChange, - seenKeys: Set, - allowReservedAttributes: boolean -): void { - const keyError = validateAttributeKey(change.key, { - allowReservedAttributes, - }); - if (keyError) throw keyError; - const valueError = validateAttributeValue(change.value); - if (valueError) throw valueError; - if (seenKeys.has(change.key)) { + const bytes = textEncoder.encode(value).length; + if (bytes > ATTRIBUTE_VALUE_MAX_BYTES) { throw new AttributeValidationError( - `Attribute key ${JSON.stringify(change.key)} appears more than once in the same batch` + `Attribute value byte length ${bytes} exceeds limit ${ATTRIBUTE_VALUE_MAX_BYTES}` ); } - seenKeys.add(change.key); } function attributeCountDelta( - change: AttributeChange, + key: string, + value: string | null, existingKeys: ReadonlySet | undefined ): number { - if (existingKeys === undefined) return change.value === null ? -1 : 1; - const exists = existingKeys.has(change.key); - if (change.value === null) return exists ? -1 : 0; - return exists ? 0 : 1; + if (value === null) return existingKeys?.has(key) ? -1 : 0; + return existingKeys === undefined || !existingKeys.has(key) ? 1 : 0; } export function validateAttributeChanges( @@ -110,15 +91,22 @@ export function validateAttributeChanges( : context.existingKeys instanceof Set ? context.existingKeys : new Set(context.existingKeys); - let netChange = 0; + let postMergeCount = existingKeys?.size ?? 0; for (const change of changes) { - validateChange(change, seenKeys, context.allowReservedAttributes === true); - netChange += attributeCountDelta(change, existingKeys); + const { key, value } = change; + assertValidAttributeKey(key, context.allowReservedAttributes === true); + assertValidAttributeValue(value); + if (seenKeys.has(key)) { + throw new AttributeValidationError( + `Attribute key ${JSON.stringify(key)} appears more than once in the same batch` + ); + } + seenKeys.add(key); + postMergeCount += attributeCountDelta(key, value, existingKeys); } - const postMerge = (existingKeys?.size ?? 0) + netChange; - if (postMerge > ATTRIBUTE_MAX_PER_RUN) { + if (postMergeCount > ATTRIBUTE_MAX_PER_RUN) { throw new AttributeValidationError( - `Run attribute count would exceed limit ${ATTRIBUTE_MAX_PER_RUN} (post-merge ${postMerge})` + `Run attribute count would exceed limit ${ATTRIBUTE_MAX_PER_RUN} (post-merge ${postMergeCount})` ); } } diff --git a/packages/world/src/attributes.test.ts b/packages/world/src/attributes.test.ts index 1faade3b5c..e065216f11 100644 --- a/packages/world/src/attributes.test.ts +++ b/packages/world/src/attributes.test.ts @@ -2,76 +2,79 @@ import { describe, expect, it } from 'vitest'; import { ATTRIBUTE_KEY_MAX_LENGTH, ATTRIBUTE_MAX_PER_RUN, + AttributeChangeSchema, + AttributeChangesSchema, + AttributeKeySchema, AttributeValidationError, + AttributeValueSchema, applyAttributeChanges, validateAttributeChanges, - validateAttributeKey, - validateAttributeValue, } from './attributes.js'; -describe('validateAttributeKey', () => { +describe('attribute schemas', () => { it('accepts a normal key', () => { - expect(validateAttributeKey('phase')).toBeNull(); + expect(AttributeKeySchema.safeParse('phase').success).toBe(true); }); it('rejects empty keys', () => { - expect(validateAttributeKey('')).toBeInstanceOf(AttributeValidationError); + expect(AttributeKeySchema.safeParse('').success).toBe(false); }); it('rejects keys over the length cap', () => { expect( - validateAttributeKey('k'.repeat(ATTRIBUTE_KEY_MAX_LENGTH + 1)) - ).toBeInstanceOf(AttributeValidationError); + AttributeKeySchema.safeParse('k'.repeat(ATTRIBUTE_KEY_MAX_LENGTH + 1)) + .success + ).toBe(false); }); it('accepts keys exactly at the length cap', () => { expect( - validateAttributeKey('k'.repeat(ATTRIBUTE_KEY_MAX_LENGTH)) - ).toBeNull(); + AttributeKeySchema.safeParse('k'.repeat(ATTRIBUTE_KEY_MAX_LENGTH)).success + ).toBe(true); }); - it('rejects keys starting with the reserved prefix by default', () => { - expect(validateAttributeKey('$internal')).toBeInstanceOf( - AttributeValidationError - ); - }); - - it('accepts reserved-prefix keys when allowReservedAttributes is set', () => { - expect( - validateAttributeKey('$internal', { allowReservedAttributes: true }) - ).toBeNull(); - }); - - it('still rejects reserved-prefix keys when allowReservedAttributes is explicitly false', () => { - expect( - validateAttributeKey('$internal', { allowReservedAttributes: false }) - ).toBeInstanceOf(AttributeValidationError); - }); -}); - -describe('validateAttributeValue', () => { it('accepts null (unset)', () => { - expect(validateAttributeValue(null)).toBeNull(); + expect(AttributeValueSchema.safeParse(null).success).toBe(true); }); it('accepts a normal string', () => { - expect(validateAttributeValue('hello')).toBeNull(); + expect(AttributeValueSchema.safeParse('hello').success).toBe(true); }); it('rejects values over the byte cap', () => { - expect(validateAttributeValue('a'.repeat(257))).toBeInstanceOf( - AttributeValidationError - ); + expect(AttributeValueSchema.safeParse('a'.repeat(257)).success).toBe(false); }); it('counts UTF-8 bytes, not characters', () => { // 4-byte UTF-8 emoji; 64 of them = 256 bytes exactly (at the cap) const at = '💥'.repeat(64); - expect(validateAttributeValue(at)).toBeNull(); + expect(AttributeValueSchema.safeParse(at).success).toBe(true); const over = '💥'.repeat(65); // 260 bytes, over - expect(validateAttributeValue(over)).toBeInstanceOf( - AttributeValidationError - ); + expect(AttributeValueSchema.safeParse(over).success).toBe(false); + }); + + it('validates complete changes and batches', () => { + expect( + AttributeChangeSchema.safeParse({ key: 'phase', value: 'running' }) + .success + ).toBe(true); + expect( + AttributeChangeSchema.safeParse({ key: '', value: 'running' }).success + ).toBe(false); + expect( + AttributeChangesSchema.safeParse([ + { key: 'phase', value: 'running' }, + { key: 'phase', value: 'done' }, + ]).success + ).toBe(false); + }); + + it('leaves reserved-key policy to the contextual validator', () => { + expect( + AttributeChangesSchema.safeParse([ + { key: '$framework.kind', value: 'agent' }, + ]).success + ).toBe(true); }); }); @@ -104,6 +107,18 @@ describe('validateAttributeChanges', () => { ).toThrow(AttributeValidationError); }); + it('does not let an unknown deletion offset a new attribute', () => { + const changes: Array<{ key: string; value: string | null }> = Array.from( + { length: ATTRIBUTE_MAX_PER_RUN + 1 }, + (_, i) => ({ key: `k${i}`, value: 'v' }) + ); + changes.push({ key: 'not-known-to-exist', value: null }); + + expect(() => validateAttributeChanges(changes)).toThrow( + AttributeValidationError + ); + }); + it('does not count upserts on already-present keys against the cap', () => { // 64 keys already exist; the call updates one of them. Post-merge // size is still 64 so the cap must accept it. diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts index 5c58947f53..a4e9b70f91 100644 --- a/packages/world/src/attributes.ts +++ b/packages/world/src/attributes.ts @@ -1,16 +1,57 @@ -import { z } from 'zod'; +import * as z from 'zod'; -import type { AttributeChange } from './attributes-validation.js'; +import { + ATTRIBUTE_KEY_MAX_LENGTH, + ATTRIBUTE_VALUE_MAX_BYTES, + type AttributeChange, + AttributeValidationError, + validateAttributeChanges, +} from './attributes-validation.js'; export * from './attributes-validation.js'; +const textEncoder = new TextEncoder(); + +export const AttributeKeySchema = z + .string() + .min(1, { error: 'Attribute key must not be empty' }) + .max(ATTRIBUTE_KEY_MAX_LENGTH, { + error: `Attribute key exceeds limit ${ATTRIBUTE_KEY_MAX_LENGTH}`, + }); + +export const AttributeValueSchema = z + .string() + .refine( + (value) => textEncoder.encode(value).length <= ATTRIBUTE_VALUE_MAX_BYTES, + { + error: `Attribute value exceeds limit ${ATTRIBUTE_VALUE_MAX_BYTES} UTF-8 bytes`, + } + ) + .nullable(); + /** Runtime schema for a single run-attribute change. */ export const AttributeChangeSchema = z.object({ - key: z.string(), - value: z.union([z.string(), z.null()]), + key: AttributeKeySchema, + value: AttributeValueSchema, }) satisfies z.ZodType; -export const AttributeChangesSchema = z.array(AttributeChangeSchema); +export const AttributeChangesSchema = z + .array(AttributeChangeSchema) + .superRefine((changes, context) => { + try { + // Reserved keys are contextual: attr_set events may carry them when the + // sibling allowReservedAttributes flag is set. Callers that prohibit the + // reserved namespace enforce that through validateAttributeChanges. + validateAttributeChanges(changes, { allowReservedAttributes: true }); + } catch (error) { + if (!(error instanceof AttributeValidationError)) throw error; + context.addIssue({ + code: 'custom', + message: error.message, + input: changes, + }); + } + }); /** The post-merge attribute snapshot returned by a World. */ export interface ExperimentalSetAttributesResult { diff --git a/packages/world/src/event-metadata.test.ts b/packages/world/src/event-metadata.test.ts new file mode 100644 index 0000000000..98a7bed8de --- /dev/null +++ b/packages/world/src/event-metadata.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { + entityEventClass, + getEventDataPayloadField, + getEventDataRefFields, +} from './event-metadata.js'; + +describe('event metadata', () => { + it('classifies mutually exclusive entity events', () => { + expect(entityEventClass('step_completed')).toBe('step_terminal'); + expect(entityEventClass('step_failed')).toBe('step_terminal'); + expect(entityEventClass('run_completed')).toBeUndefined(); + }); + + it('uses one payload-field mapping for singular and plural lookups', () => { + expect(getEventDataRefFields('run_created')).toEqual(['input']); + expect(getEventDataPayloadField('run_created')).toBe('input'); + expect(getEventDataRefFields('step_completed')).toEqual(['result']); + expect(getEventDataPayloadField('step_completed')).toBe('result'); + }); + + it('returns no metadata for unknown and inherited property names', () => { + expect(getEventDataRefFields('unknown')).toEqual([]); + expect(getEventDataPayloadField('unknown')).toBeUndefined(); + expect(getEventDataRefFields('constructor')).toEqual([]); + expect(entityEventClass('toString')).toBeUndefined(); + }); +}); diff --git a/packages/world/src/event-metadata.ts b/packages/world/src/event-metadata.ts index 8964d70018..dcacc34c93 100644 --- a/packages/world/src/event-metadata.ts +++ b/packages/world/src/event-metadata.ts @@ -1,5 +1,12 @@ import type { EventType } from './events.js'; +function getOwnProperty( + object: T, + key: string +): T[keyof T] | undefined { + return Object.hasOwn(object, key) ? object[key as keyof T] : undefined; +} + /** Groups events that are mutually exclusive outcomes for one entity. */ const ENTITY_EVENT_CLASS_BY_TYPE = { step_created: 'step_created', @@ -20,46 +27,40 @@ export type EntityEventClass = export function entityEventClass( eventType: string ): EntityEventClass | undefined { - return ( - ENTITY_EVENT_CLASS_BY_TYPE as Record - )[eventType]; + return getOwnProperty(ENTITY_EVENT_CLASS_BY_TYPE, eventType); } -/** The opaque payload field carried by each event type. */ -export const EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE = { - run_created: 'input', - run_started: 'input', - run_completed: 'output', - run_failed: 'error', - step_created: 'input', - step_started: 'input', - step_completed: 'result', - step_failed: 'error', - step_retrying: 'error', - hook_created: 'metadata', - hook_received: 'payload', -} as const satisfies Partial>; +/** Opaque payload fields removed when events load without referenced data. */ +const EVENT_DATA_REF_FIELDS_BY_EVENT_TYPE = { + run_created: ['input'], + run_started: ['input'], + run_completed: ['output'], + run_failed: ['error'], + step_created: ['input'], + step_started: ['input'], + step_completed: ['result'], + step_failed: ['error'], + step_retrying: ['error'], + hook_created: ['metadata'], + hook_received: ['payload'], +} as const satisfies Partial>; export type EventDataPayloadField = - (typeof EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE)[keyof typeof EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE]; + (typeof EVENT_DATA_REF_FIELDS_BY_EVENT_TYPE)[keyof typeof EVENT_DATA_REF_FIELDS_BY_EVENT_TYPE][number]; -/** Payload fields removed when events are loaded without referenced data. */ -export const EVENT_DATA_REF_FIELDS = Object.fromEntries( - Object.entries(EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE).map( - ([eventType, field]) => [eventType, [field]] - ) -) as Record; +const NO_EVENT_DATA_REF_FIELDS: readonly EventDataPayloadField[] = []; -export function getEventDataRefFields(eventType: string): readonly string[] { - return EVENT_DATA_REF_FIELDS[eventType] ?? []; +export function getEventDataRefFields( + eventType: string +): readonly EventDataPayloadField[] { + return ( + getOwnProperty(EVENT_DATA_REF_FIELDS_BY_EVENT_TYPE, eventType) ?? + NO_EVENT_DATA_REF_FIELDS + ); } export function getEventDataPayloadField( eventType: string ): EventDataPayloadField | undefined { - return ( - EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE as Partial< - Record - > - )[eventType]; + return getEventDataRefFields(eventType)[0]; } diff --git a/packages/world/src/events.test.ts b/packages/world/src/events.test.ts index 587d48535b..98eb1b2550 100644 --- a/packages/world/src/events.test.ts +++ b/packages/world/src/events.test.ts @@ -109,3 +109,28 @@ describe('run_cancelled cancelReason', () => { ).toBe('operator cancelled'); }); }); + +describe('omitted event payloads', () => { + const runCreated = { + eventType: 'run_created', + runId: 'wrun_00000000000000000000000000', + eventId: 'evnt_00000000000000000000000000', + createdAt: new Date().toISOString(), + eventData: { + deploymentId: 'dpl_123', + workflowName: 'workflows/example', + }, + }; + + it('restores an omitted payload as undefined on stored events', () => { + const parsed = EventSchema.parse(runCreated); + expect(parsed.eventType).toBe('run_created'); + if (parsed.eventType === 'run_created') { + expect(parsed.eventData).toHaveProperty('input', undefined); + } + }); + + it('still requires the payload key on create requests', () => { + expect(CreateEventSchema.safeParse(runCreated).success).toBe(false); + }); +}); diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index c53351f360..6cd636c51c 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -1,6 +1,9 @@ import { z } from 'zod'; import { AttributeChangesSchema } from './attributes.js'; -import { getEventDataRefFields } from './event-metadata.js'; +import { + getEventDataPayloadField, + getEventDataRefFields, +} from './event-metadata.js'; import type { Hook } from './hooks.js'; import type { StartedWorkflowRun, WorkflowRun } from './runs.js'; import { SerializedDataSchema } from './serialization.js'; @@ -595,24 +598,57 @@ const AllEventsSchema = z.discriminatedUnion('eventType', [ WaitCompletedEventSchema, ]); +function restoreOmittedEventPayload(value: unknown): unknown { + if (typeof value !== 'object' || value === null) return value; + const event = value as Record; + if (typeof event.eventType !== 'string') return value; + const payloadField = getEventDataPayloadField(event.eventType); + if (payloadField === undefined) return value; + + const eventData = event.eventData; + if ( + eventData !== undefined && + (typeof eventData !== 'object' || eventData === null) + ) { + return value; + } + if ( + eventData !== undefined && + Object.hasOwn(eventData as object, payloadField) + ) { + return value; + } + + return { + ...event, + eventData: { + ...((eventData as Record | undefined) ?? {}), + [payloadField]: undefined, + }, + }; +} + // Server response includes runId, eventId, and createdAt // specVersion is optional in database for backwards compatibility -export const EventSchema = AllEventsSchema.and( - z.object({ - runId: z.string(), - eventId: z.string(), - createdAt: z.coerce.date(), - occurredAt: z.coerce.date().optional(), - specVersion: z.number().optional(), - /** - * Lazy hook resume idempotency key, persisted on `hook_received` events so - * the queue consumer can detect that the producer's concurrent direct write - * already landed in the run_started preload and skip its own re-ensure. - * Mirrors {@link CreateEventParams.resumeId}; absent on all other events and - * on legacy (non-lazy) resumes. - */ - resumeId: z.string().optional(), - }) +export const EventSchema = z.preprocess( + restoreOmittedEventPayload, + AllEventsSchema.and( + z.object({ + runId: z.string(), + eventId: z.string(), + createdAt: z.coerce.date(), + occurredAt: z.coerce.date().optional(), + specVersion: z.number().optional(), + /** + * Lazy hook resume idempotency key, persisted on `hook_received` events so + * the queue consumer can detect that the producer's concurrent direct write + * already landed in the run_started preload and skip its own re-ensure. + * Mirrors {@link CreateEventParams.resumeId}; absent on all other events and + * on legacy (non-lazy) resumes. + */ + resumeId: z.string().optional(), + }) + ) ); // Inferred types diff --git a/packages/world/src/index.ts b/packages/world/src/index.ts index 6068b365b1..0634822657 100644 --- a/packages/world/src/index.ts +++ b/packages/world/src/index.ts @@ -15,14 +15,14 @@ export { ATTRIBUTE_VALUE_MAX_BYTES, AttributeChangeSchema, AttributeChangesSchema, + AttributeKeySchema, AttributeValidationError, + AttributeValueSchema, applyAttributeChanges, PARENT_RUN_ID_ATTRIBUTE, RESERVED_ATTRIBUTE_KEY_PREFIX, ROOT_RUN_ID_ATTRIBUTE, validateAttributeChanges, - validateAttributeKey, - validateAttributeValue, } from './attributes.js'; export { _resetEnvWarnCacheForTests, @@ -36,8 +36,6 @@ export { BaseEventSchema, CHILD_ENTITY_CREATION_EVENT_TYPES, CreateEventSchema, - EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE, - EVENT_DATA_REF_FIELDS, EventSchema, EventTypeSchema, entityEventClass, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 462c3c4b08..9ea5538a90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,8 +58,8 @@ catalogs: specifier: ^4.1.10 version: 4.1.10 zod: - specifier: ~4.3.6 - version: 4.3.6 + specifier: ~4.4.3 + version: 4.4.3 overrides: '@opentelemetry/api': 1.9.1 @@ -260,7 +260,7 @@ importers: version: link:../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: bun: specifier: ^1.3.0 @@ -294,14 +294,14 @@ importers: version: link:../serde zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: '@workflow/tsconfig': specifier: workspace:* version: link:../tsconfig ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) typescript: specifier: 'catalog:' version: 6.0.3 @@ -311,19 +311,19 @@ importers: optionalDependencies: '@ai-sdk/anthropic': specifier: ^3.0.0 - version: 3.0.58(zod@4.3.6) + version: 3.0.58(zod@4.4.3) '@ai-sdk/gateway': specifier: ^3.0.0 - version: 3.0.66(zod@4.3.6) + version: 3.0.66(zod@4.4.3) '@ai-sdk/google': specifier: ^3.0.0 - version: 3.0.43(zod@4.3.6) + version: 3.0.43(zod@4.4.3) '@ai-sdk/openai': specifier: ^3.0.0 - version: 3.0.41(zod@4.3.6) + version: 3.0.41(zod@4.4.3) '@ai-sdk/xai': specifier: ^3.0.0 - version: 3.0.67(zod@4.3.6) + version: 3.0.67(zod@4.4.3) packages/astro: dependencies: @@ -502,7 +502,7 @@ importers: version: 5.1.0 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: '@types/node': specifier: 'catalog:' @@ -575,7 +575,7 @@ importers: version: 3.0.1 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: '@opentelemetry/api': specifier: 1.9.1 @@ -630,13 +630,13 @@ importers: version: link:../next ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) workflow: specifier: workspace:* version: link:../workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: '@types/node': specifier: 'catalog:' @@ -1351,7 +1351,7 @@ importers: version: 3.0.1 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: '@types/node': specifier: 'catalog:' @@ -1391,7 +1391,7 @@ importers: version: 7.29.0 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: '@opentelemetry/api': specifier: 1.9.1 @@ -1455,7 +1455,7 @@ importers: version: 3.0.1 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: '@testcontainers/postgresql': specifier: 11.12.0 @@ -1541,7 +1541,7 @@ importers: version: link:../workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: '@types/jsonlines': specifier: 0.1.5 @@ -1590,7 +1590,7 @@ importers: version: 8.20.0 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: '@opentelemetry/api': specifier: 1.9.1 @@ -1736,7 +1736,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) astro: specifier: ^7.0.6 version: 7.0.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@netlify/blobs@9.1.2)(@types/node@24.6.2)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8)))(ioredis@5.10.1)(jiti@2.7.0)(rollup@4.62.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) @@ -1745,13 +1745,13 @@ importers: version: 4.2.0 openai: specifier: 6.9.0 - version: 6.9.0(ws@8.20.0)(zod@4.3.6) + version: 6.9.0(ws@8.20.0)(zod@4.4.3) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 workbench/example: dependencies: @@ -1769,7 +1769,7 @@ importers: version: link:../../packages/ai ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 @@ -1778,13 +1778,13 @@ importers: version: 0.0.4 openai: specifier: ^6 - version: 6.1.0(ws@8.20.0)(zod@4.3.6) + version: 6.1.0(ws@8.20.0)(zod@4.4.3) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: '@types/lodash.chunk': specifier: ^4.2.9 @@ -1822,19 +1822,19 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 openai: specifier: ^6.1.0 - version: 6.6.0(ws@8.20.0)(zod@4.3.6) + version: 6.6.0(ws@8.20.0)(zod@4.4.3) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 workbench/fastify: dependencies: @@ -1856,13 +1856,13 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 openai: specifier: ^6.6.0 - version: 6.9.1(ws@8.20.0)(zod@4.3.6) + version: 6.9.1(ws@8.20.0)(zod@4.4.3) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1871,7 +1871,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 workbench/hono: devDependencies: @@ -1883,7 +1883,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) hono: specifier: ^4.12.27 version: 4.12.28 @@ -1895,13 +1895,13 @@ importers: version: 3.0.260610-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(@vercel/queue@0.3.1)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.1.3(@types/node@24.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) openai: specifier: ^6.6.0 - version: 6.6.0(ws@8.20.0)(zod@4.3.6) + version: 6.6.0(ws@8.20.0)(zod@4.4.3) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 workbench/nest: dependencies: @@ -1925,7 +1925,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) express: specifier: ^5.2.1 version: 5.2.1 @@ -1934,7 +1934,7 @@ importers: version: 4.2.0 openai: specifier: ^6.1.0 - version: 6.9.1(ws@8.20.0)(zod@4.3.6) + version: 6.9.1(ws@8.20.0)(zod@4.4.3) reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -1965,13 +1965,13 @@ importers: version: 6.0.3 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 workbench/nextjs-turbopack: dependencies: '@ai-sdk/react': specifier: 2.0.76 - version: 2.0.76(react@19.2.7)(zod@4.3.6) + version: 2.0.76(react@19.2.7)(zod@4.4.3) '@node-rs/xxhash': specifier: 1.7.6 version: 1.7.6 @@ -2004,7 +2004,7 @@ importers: version: link:../../packages/ai ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -2034,7 +2034,7 @@ importers: version: 16.2.11(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) openai: specifier: 6.9.1 - version: 6.9.1(ws@8.20.0)(zod@4.3.6) + version: 6.9.1(ws@8.20.0)(zod@4.4.3) radix-ui: specifier: 1.4.3 version: 1.4.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2061,7 +2061,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -2095,7 +2095,7 @@ importers: dependencies: '@ai-sdk/react': specifier: 2.0.76 - version: 2.0.76(react@19.2.7)(zod@4.3.6) + version: 2.0.76(react@19.2.7)(zod@4.4.3) '@node-rs/xxhash': specifier: 1.7.6 version: 1.7.6 @@ -2128,7 +2128,7 @@ importers: version: link:../../packages/ai ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -2158,7 +2158,7 @@ importers: version: 16.2.11(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) openai: specifier: 6.9.1 - version: 6.9.1(ws@8.20.0)(zod@4.3.6) + version: 6.9.1(ws@8.20.0)(zod@4.4.3) radix-ui: specifier: 1.4.3 version: 1.4.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2185,7 +2185,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -2225,7 +2225,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) h3: specifier: ^1.15.5 version: 1.15.11 @@ -2237,13 +2237,13 @@ importers: version: 2.13.4(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(better-sqlite3@11.10.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(oxc-parser@0.133.0)(rolldown@1.1.4)(srvx@0.11.21) openai: specifier: ^6.6.0 - version: 6.6.0(ws@8.20.0)(zod@4.3.6) + version: 6.6.0(ws@8.20.0)(zod@4.4.3) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 workbench/nitro-v3: dependencies: @@ -2259,7 +2259,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 @@ -2268,7 +2268,7 @@ importers: version: 3.0.260610-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(@vercel/queue@0.3.1)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.1.3(@types/node@24.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) openai: specifier: ^6.1.0 - version: 6.6.0(ws@8.20.0)(zod@4.3.6) + version: 6.6.0(ws@8.20.0)(zod@4.4.3) rollup: specifier: ^4.62.2 version: 4.62.2 @@ -2277,7 +2277,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 workbench/nuxt: dependencies: @@ -2296,7 +2296,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) h3: specifier: ^1.15.5 version: 1.15.11 @@ -2308,7 +2308,7 @@ importers: version: 4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@biomejs/biome@2.4.4)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.0)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(@vue/compiler-sfc@3.5.35)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8)))(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.1.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.1.4)(rollup@4.62.2))(rollup@4.62.2)(terser@5.44.0)(tsx@4.20.6)(typescript@6.0.3)(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))(yaml@2.9.0) openai: specifier: ^6.6.0 - version: 6.6.0(ws@8.20.0)(zod@4.3.6) + version: 6.6.0(ws@8.20.0)(zod@4.4.3) vite: specifier: 7.3.6 version: 7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) @@ -2317,7 +2317,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 workbench/python: devDependencies: @@ -2347,7 +2347,7 @@ importers: dependencies: '@ai-sdk/react': specifier: 2.0.76 - version: 2.0.76(react@19.2.7)(zod@4.3.6) + version: 2.0.76(react@19.2.7)(zod@4.4.3) '@node-rs/xxhash': specifier: 1.7.6 version: 1.7.6 @@ -2371,7 +2371,7 @@ importers: version: link:../../packages/ai ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) exsolve: specifier: ^1.0.7 version: 1.0.7 @@ -2383,7 +2383,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 devDependencies: '@sveltejs/kit': specifier: ^2.69.1 @@ -2533,7 +2533,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 @@ -2542,7 +2542,7 @@ importers: version: 3.0.260610-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(@vercel/queue@0.3.1)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@7.3.6(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) openai: specifier: ^6.1.0 - version: 6.9.1(ws@8.20.0)(zod@4.3.6) + version: 6.9.1(ws@8.20.0)(zod@4.4.3) vite: specifier: ^7.3.6 version: 7.3.6(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) @@ -2551,7 +2551,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 workbench/vite: dependencies: @@ -2567,7 +2567,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.4.3) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 @@ -2576,7 +2576,7 @@ importers: version: 3.0.260610-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(@vercel/queue@0.3.1)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@7.3.6(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) openai: specifier: ^6.6.0 - version: 6.6.0(ws@8.20.0)(zod@4.3.6) + version: 6.6.0(ws@8.20.0)(zod@4.4.3) vite: specifier: ^7.3.6 version: 7.3.6(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) @@ -2585,7 +2585,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 workbench/vitest: devDependencies: @@ -2606,7 +2606,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.4.3 packages: @@ -17563,9 +17563,6 @@ packages: zod@4.1.11: resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -17589,19 +17586,19 @@ packages: snapshots: - '@ai-sdk/anthropic@3.0.58(zod@4.3.6)': + '@ai-sdk/anthropic@3.0.58(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.19(zod@4.4.3) + zod: 4.4.3 optional: true - '@ai-sdk/gateway@2.0.0(zod@4.3.6)': + '@ai-sdk/gateway@2.0.0(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@4.3.6) + '@ai-sdk/provider-utils': 3.0.12(zod@4.4.3) '@vercel/oidc': 3.0.3 - zod: 4.3.6 + zod: 4.4.3 '@ai-sdk/gateway@3.0.143(zod@4.4.3)': dependencies: @@ -17610,13 +17607,6 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 4.4.3 - '@ai-sdk/gateway@3.0.66(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) - '@vercel/oidc': 3.1.0 - zod: 4.3.6 - '@ai-sdk/gateway@3.0.66(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -17624,40 +17614,33 @@ snapshots: '@vercel/oidc': 3.1.0 zod: 4.4.3 - '@ai-sdk/google@3.0.43(zod@4.3.6)': + '@ai-sdk/google@3.0.43(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.19(zod@4.4.3) + zod: 4.4.3 optional: true - '@ai-sdk/openai-compatible@2.0.35(zod@4.3.6)': + '@ai-sdk/openai-compatible@2.0.35(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.19(zod@4.4.3) + zod: 4.4.3 optional: true - '@ai-sdk/openai@3.0.41(zod@4.3.6)': + '@ai-sdk/openai@3.0.41(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.19(zod@4.4.3) + zod: 4.4.3 optional: true - '@ai-sdk/provider-utils@3.0.12(zod@4.3.6)': + '@ai-sdk/provider-utils@3.0.12(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.0 '@standard-schema/spec': 1.0.0 eventsource-parser: 3.0.6 - zod: 4.3.6 - - '@ai-sdk/provider-utils@4.0.19(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 4.3.6 + zod: 4.4.3 '@ai-sdk/provider-utils@4.0.19(zod@4.4.3)': dependencies: @@ -17685,15 +17668,15 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/react@2.0.76(react@19.2.7)(zod@4.3.6)': + '@ai-sdk/react@2.0.76(react@19.2.7)(zod@4.4.3)': dependencies: - '@ai-sdk/provider-utils': 3.0.12(zod@4.3.6) - ai: 5.0.76(zod@4.3.6) + '@ai-sdk/provider-utils': 3.0.12(zod@4.4.3) + ai: 5.0.76(zod@4.4.3) react: 19.2.7 swr: 2.3.6(react@19.2.7) throttleit: 2.1.0 optionalDependencies: - zod: 4.3.6 + zod: 4.4.3 '@ai-sdk/react@3.0.221(react@19.2.4)(zod@4.4.3)': dependencies: @@ -17705,12 +17688,12 @@ snapshots: transitivePeerDependencies: - zod - '@ai-sdk/xai@3.0.67(zod@4.3.6)': + '@ai-sdk/xai@3.0.67(zod@4.4.3)': dependencies: - '@ai-sdk/openai-compatible': 2.0.35(zod@4.3.6) + '@ai-sdk/openai-compatible': 2.0.35(zod@4.4.3) '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.19(zod@4.4.3) + zod: 4.4.3 optional: true '@alloc/quick-lru@5.2.0': {} @@ -27025,21 +27008,13 @@ snapshots: agent-base@7.1.4: {} - ai@5.0.76(zod@4.3.6): + ai@5.0.76(zod@4.4.3): dependencies: - '@ai-sdk/gateway': 2.0.0(zod@4.3.6) + '@ai-sdk/gateway': 2.0.0(zod@4.4.3) '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@4.3.6) + '@ai-sdk/provider-utils': 3.0.12(zod@4.4.3) '@opentelemetry/api': 1.9.1 - zod: 4.3.6 - - ai@6.0.116(zod@4.3.6): - dependencies: - '@ai-sdk/gateway': 3.0.66(zod@4.3.6) - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) - '@opentelemetry/api': 1.9.1 - zod: 4.3.6 + zod: 4.4.3 ai@6.0.116(zod@4.4.3): dependencies: @@ -27266,7 +27241,7 @@ snapshots: vitefu: 1.1.3(vite@8.1.3(@types/node@22.19.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 - zod: 4.3.6 + zod: 4.4.3 optionalDependencies: sharp: 0.34.5 transitivePeerDependencies: @@ -27358,7 +27333,7 @@ snapshots: vitefu: 1.1.3(vite@8.1.3(@types/node@24.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 - zod: 4.3.6 + zod: 4.4.3 optionalDependencies: sharp: 0.34.5 transitivePeerDependencies: @@ -29426,7 +29401,7 @@ snapshots: unist-util-remove-position: 5.0.0 unist-util-visit: 5.0.0 vfile: 6.0.3 - zod: 4.3.6 + zod: 4.4.3 optionalDependencies: next: 16.2.11(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 @@ -32494,25 +32469,25 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@6.1.0(ws@8.20.0)(zod@4.3.6): + openai@6.1.0(ws@8.20.0)(zod@4.4.3): optionalDependencies: ws: 8.20.0 - zod: 4.3.6 + zod: 4.4.3 - openai@6.6.0(ws@8.20.0)(zod@4.3.6): + openai@6.6.0(ws@8.20.0)(zod@4.4.3): optionalDependencies: ws: 8.20.0 - zod: 4.3.6 + zod: 4.4.3 - openai@6.9.0(ws@8.20.0)(zod@4.3.6): + openai@6.9.0(ws@8.20.0)(zod@4.4.3): optionalDependencies: ws: 8.20.0 - zod: 4.3.6 + zod: 4.4.3 - openai@6.9.1(ws@8.20.0)(zod@4.3.6): + openai@6.9.1(ws@8.20.0)(zod@4.4.3): optionalDependencies: ws: 8.20.0 - zod: 4.3.6 + zod: 4.4.3 optionator@0.9.4: dependencies: @@ -36596,8 +36571,6 @@ snapshots: zod@4.1.11: {} - zod@4.3.6: {} - zod@4.4.3: {} zustand@4.5.7(@types/react@19.1.13)(react@19.1.0): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ba8c3d839c..db8444d902 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,7 +24,7 @@ catalog: ulid: ~3.0.1 undici: 7.29.0 vitest: ^4.1.10 - zod: ~4.3.6 + zod: ~4.4.3 onlyBuiltDependencies: - esbuild