diff --git a/.changeset/zod-45-compiled-schemas.md b/.changeset/zod-45-compiled-schemas.md new file mode 100644 index 0000000000..adc73218f2 --- /dev/null +++ b/.changeset/zod-45-compiled-schemas.md @@ -0,0 +1,13 @@ +--- +'@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 +'workflow': patch +--- + +Upgrade runtime validation to Zod 4.5 and enable compilation on SDK-owned Zod schemas. diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index be460bd7b1..a2abd12e35 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -63,6 +63,7 @@ const MAX_SAFE_TIMEOUT_MS = 2147483647; // The local workers share the same Node.js process and event loop, // so we need to limit concurrency to avoid overwhelming the system. const DEFAULT_CONCURRENCY_LIMIT = 1000; + const WORKFLOW_LOCAL_QUEUE_CONCURRENCY = parseInt(process.env.WORKFLOW_LOCAL_QUEUE_CONCURRENCY ?? '0', 10) || DEFAULT_CONCURRENCY_LIMIT; @@ -397,11 +398,13 @@ export function createQueue(config: Partial): LocalQueue { return { messageId }; }; - const HeaderParser = z.object({ - 'x-vqs-queue-name': ValidQueueName, - 'x-vqs-message-id': MessageId, - 'x-vqs-message-attempt': z.coerce.number(), - }); + const HeaderParser = z.compile( + z.object({ + 'x-vqs-queue-name': ValidQueueName, + 'x-vqs-message-id': MessageId, + 'x-vqs-message-attempt': z.coerce.number(), + }) + ); const createQueueHandler: Queue['createQueueHandler'] = (prefix, handler) => { return async (req) => { diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index cc47a2493a..5b2a4e6c05 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -29,7 +29,6 @@ import type { } from '@workflow/world'; import { applyAttributeChanges, - EventSchema, eventIdToSlot, FIRST_EVENT_SLOT, getMaxEventsPerRun, @@ -107,6 +106,7 @@ import { handleLegacyEvent } from './legacy.js'; import { purgeRunEntityData, purgesUserDataOnFinish, + ReadEventSchema, withRunPayloadsPurged, } from './run-retention.js'; import { signalRunTerminal } from './run-status-signal.js'; @@ -169,9 +169,11 @@ function getHookRetentionLimitMs(): number { * lifetimes can never share one marker (see * `hookRecoveryMarkerPath`). */ -const HookRecoveryMarkerSchema = z.object({ - eventId: z.string(), -}); +const HookRecoveryMarkerSchema = z.compile( + z.object({ + eventId: z.string(), + }) +); /** * Durable `(runId, resumeId)` claim for a lazy hook resume. Written via @@ -182,13 +184,15 @@ const HookRecoveryMarkerSchema = z.object({ * records the content hash so a reused `resumeId` carrying a different payload * can be rejected as a conflict, matching the server's constraint. */ -const HookResumeClaimSchema = z.object({ - runId: z.string(), - resumeId: z.string(), - hookId: z.string(), - eventId: z.string(), - payloadDigest: z.string().optional(), -}); +const HookResumeClaimSchema = z.compile( + z.object({ + runId: z.string(), + resumeId: z.string(), + hookId: z.string(), + eventId: z.string(), + payloadDigest: z.string().optional(), + }) +); /** * Whether `event` is the `hook_received` a resume claim stands for. @@ -237,7 +241,7 @@ async function findCommittedResumeEvent( basedir, 'events', `${runId}-${eventId}`, - EventSchema, + ReadEventSchema, tag ); if ( @@ -361,7 +365,7 @@ async function findExistingHookCreatedEventId( ): Promise { const result = await paginatedFileSystemQuery({ directory: path.join(basedir, 'events'), - schema: EventSchema, + schema: ReadEventSchema, filePrefix: `${runId}-`, filter: (event) => event.eventType === 'hook_created' && @@ -405,7 +409,7 @@ async function repairHookEntityFromPersistedEvent( basedir, 'events', compositeKey, - EventSchema, + ReadEventSchema, tag ); if ( @@ -851,7 +855,7 @@ export function createEventsStorage( return; } - const cachedEvent = EventSchema.safeParse( + const cachedEvent = ReadEventSchema.safeParse( JSON.parse(serializedEvent, jsonReviver) ); if (cachedEvent.success) { @@ -905,7 +909,7 @@ export function createEventsStorage( const queryRunEvents = (runId: string, pagination: PaginationOptions) => paginatedFileSystemQuery({ directory: path.join(basedir, 'events'), - schema: EventSchema, + schema: ReadEventSchema, cachedItems: eventCache, filePrefix: `${runId}-`, sortOrder: pagination.sortOrder ?? 'asc', @@ -1389,7 +1393,7 @@ export function createEventsStorage( basedir, 'events', `${effectiveRunId}-${committedClaim.eventId}`, - EventSchema, + ReadEventSchema, tag ); const committedEvent = @@ -1482,7 +1486,7 @@ export function createEventsStorage( basedir, 'events', `${effectiveRunId}-${claim.eventId}`, - EventSchema, + ReadEventSchema, tag ); if (atClaimedId && isResumeEvent(atClaimedId, claim)) { @@ -2895,7 +2899,7 @@ export function createEventsStorage( basedir, 'events', `${effectiveRunId}-${eventId}`, - EventSchema, + ReadEventSchema, tag ); if ( @@ -3108,7 +3112,7 @@ export function createEventsStorage( basedir, 'events', compositeKey, - EventSchema, + ReadEventSchema, tag ); if (!event) { @@ -3147,7 +3151,7 @@ export function createEventsStorage( const resolveData = params.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION; const result = await paginatedFileSystemQuery({ directory: path.join(basedir, 'events'), - schema: EventSchema, + schema: ReadEventSchema, cachedItems: eventCache, // Scoped to the run's own event files, since a correlation id // identifies a step or wait only within its run: a slot-numbered diff --git a/packages/world-local/src/storage/helpers.ts b/packages/world-local/src/storage/helpers.ts index a4fb238636..9584fec930 100644 --- a/packages/world-local/src/storage/helpers.ts +++ b/packages/world-local/src/storage/helpers.ts @@ -348,16 +348,18 @@ export function hookTokenClaimPath(basedir: string, token: string): string { return path.join(basedir, 'hooks', 'tokens', `${hashToken(token)}.json`); } -export const HookTokenClaimSchema = z.object({ - // Legacy claims omitted hookId. Keeping it optional preserves their - // existing cross-hook conflict behavior (see #2283). - hookId: z.string().optional(), - runId: z.string(), - // Legacy claims also omitted eventId. Their recovery marker pins the - // canonical hook_created event before concurrent retries publish it. - eventId: z.string().optional(), - tokenRetentionUntil: z.coerce.date().optional(), -}); +export const HookTokenClaimSchema = z.compile( + z.object({ + // Legacy claims omitted hookId. Keeping it optional preserves their + // existing cross-hook conflict behavior (see #2283). + hookId: z.string().optional(), + runId: z.string(), + // Legacy claims also omitted eventId. Their recovery marker pins the + // canonical hook_created event before concurrent retries publish it. + eventId: z.string().optional(), + tokenRetentionUntil: z.coerce.date().optional(), + }) +); export type HookTokenClaim = z.infer; diff --git a/packages/world-local/src/storage/hook-index.ts b/packages/world-local/src/storage/hook-index.ts index b22f23d120..421e0e2fb3 100644 --- a/packages/world-local/src/storage/hook-index.ts +++ b/packages/world-local/src/storage/hook-index.ts @@ -39,14 +39,18 @@ import { hashToken } from './helpers.js'; * (`ensureHookIndexes`) guarded by a completion marker. */ -const IndexEntrySchema = z.object({ - runId: z.string(), -}); +const IndexEntrySchema = z.compile( + z.object({ + runId: z.string(), + }) +); -const ByRunMarkerSchema = z.object({ - hookId: z.string(), - tag: z.string().optional(), -}); +const ByRunMarkerSchema = z.compile( + z.object({ + hookId: z.string(), + tag: z.string().optional(), + }) +); // No `.json` extension so entity listings never pick it up. const INDEX_COMPLETE_MARKER = '.hook-index-complete'; diff --git a/packages/world-local/src/storage/run-retention.ts b/packages/world-local/src/storage/run-retention.ts index 8a5a14e1f0..ac77bbd0c8 100644 --- a/packages/world-local/src/storage/run-retention.ts +++ b/packages/world-local/src/storage/run-retention.ts @@ -1,6 +1,7 @@ import path from 'node:path'; -import type { WorkflowRun } from '@workflow/world'; +import type { Event, WorkflowRun } from '@workflow/world'; import { + EventSchema, getEventDataRefFields, RETENTION_ATTRIBUTE, readRunRetention, @@ -110,6 +111,44 @@ export async function purgeRunEntityData( */ const RawEntitySchema = z.record(z.string(), z.any()); +/** + * `EventSchema`, tolerant of a zero-retention purge having deleted an + * event's ref field (`eventData.input`/`result`/`error`/`payload`, see + * {@link getEventDataRefFields}) outright. + * + * `scrubEntityFiles` above deletes the key rather than writing it back as an + * explicit `undefined`, because JSON has no way to represent "present but + * undefined" and a schema round-trip would drop it either way. A *missing* + * key and a key *present with value `undefined`* are different things to + * Zod, though: a required field whose own schema tolerates `undefined` + * (`SerializedDataSchema`'s trailing `z.any()`) only accepts the latter. + * Every read of a stored event goes through this schema instead of the bare + * `EventSchema` so a purged run's log stays parseable, while `EventSchema` + * itself stays strict for the create-time contract (`CreateEventSchema` + * shares the same per-type schemas, and a run genuinely being created must + * still supply `input`). + */ +export const ReadEventSchema: z.ZodType = z.preprocess((raw) => { + if ( + raw && + typeof raw === 'object' && + 'eventType' in raw && + 'eventData' in raw + ) { + const eventData = (raw as { eventData: unknown }).eventData; + if (eventData && typeof eventData === 'object') { + for (const field of getEventDataRefFields( + String((raw as { eventType: unknown }).eventType) + )) { + if (!(field in eventData)) { + (eventData as Record)[field] = undefined; + } + } + } + } + return raw; +}, EventSchema); + /** Rewrite every file in `directory` belonging to `runId` with `scrub` applied. */ async function scrubEntityFiles( directory: string, diff --git a/packages/world-local/src/streamer.ts b/packages/world-local/src/streamer.ts index a2b6755ea3..02fc684da4 100644 --- a/packages/world-local/src/streamer.ts +++ b/packages/world-local/src/streamer.ts @@ -33,9 +33,11 @@ const chunkIds = globalSingleton( const monotonicUlid = (seedTime?: number): string => chunkIds.next(seedTime); // Schema for the run-to-streams mapping file -const RunStreamsSchema = z.object({ - streams: z.array(z.string()), -}); +const RunStreamsSchema = z.compile( + z.object({ + streams: z.array(z.string()), + }) +); /** * A chunk consists of a boolean `eof` indicating if it's the last chunk, diff --git a/packages/world-postgres/src/message.ts b/packages/world-postgres/src/message.ts index c6d5729e19..52f40aa7fe 100644 --- a/packages/world-postgres/src/message.ts +++ b/packages/world-postgres/src/message.ts @@ -7,16 +7,18 @@ import { Base64Buffer } from './zod.js'; * the body to ensure binary safety * maybe later we can have a `blobs` table for larger payloads */ -export const MessageData = z.object({ - attempt: z.number().describe('The attempt number of the message'), - messageId: MessageId.describe('The unique ID of the message'), - idempotencyKey: z.string().optional(), - headers: z.record(z.string(), z.string()).optional(), - id: z - .string() - .describe( - "The ID of the sub-queue. For workflows, it's the workflow name. For steps, it's the step name." - ), - data: Base64Buffer.describe('The message that was sent'), -}); +export const MessageData = z.compile( + z.object({ + attempt: z.number().describe('The attempt number of the message'), + messageId: MessageId.describe('The unique ID of the message'), + idempotencyKey: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + id: z + .string() + .describe( + "The ID of the sub-queue. For workflows, it's the workflow name. For steps, it's the step name." + ), + data: Base64Buffer.describe('The message that was sent'), + }) +); export type MessageData = z.infer; diff --git a/packages/world-postgres/src/queue.ts b/packages/world-postgres/src/queue.ts index 38655120e4..898960fa7c 100644 --- a/packages/world-postgres/src/queue.ts +++ b/packages/world-postgres/src/queue.ts @@ -55,12 +55,14 @@ const graphileLogger = createGraphileLogger(); const COMPLETED_IDEMPOTENCY_CACHE_LIMIT = 10_000; // Core records MAX_DELIVERIES_EXCEEDED on delivery 49. const MAX_GRAPHILE_JOB_ATTEMPTS = 49; -const GraphileHelpers = z.object({ - abortSignal: z.instanceof(AbortSignal).optional(), - job: z.object({ - attempts: z.number().int().positive(), - }), -}); +const GraphileHelpers = z.compile( + z.object({ + abortSignal: z.instanceof(AbortSignal).optional(), + job: z.object({ + attempts: z.number().int().positive(), + }), + }) +); type HttpExecutionResult = | { type: 'completed' } diff --git a/packages/world-postgres/src/streamer.ts b/packages/world-postgres/src/streamer.ts index a79c3ebd75..faadb72b65 100644 --- a/packages/world-postgres/src/streamer.ts +++ b/packages/world-postgres/src/streamer.ts @@ -12,10 +12,12 @@ import * as z from 'zod'; import { type Drizzle, Schema } from './drizzle/index.js'; import { Mutex } from './util.js'; -const StreamPublishMessage = z.object({ - streamId: z.string(), - chunkId: z.templateLiteral(['chnk_', z.string()]), -}); +const StreamPublishMessage = z.compile( + z.object({ + streamId: z.string(), + chunkId: z.templateLiteral(['chnk_', z.string()]), + }) +); interface StreamChunkEvent { id: `chnk_${string}`; diff --git a/packages/world-postgres/src/zod.ts b/packages/world-postgres/src/zod.ts index e420eafcce..448ded4227 100644 --- a/packages/world-postgres/src/zod.ts +++ b/packages/world-postgres/src/zod.ts @@ -1,10 +1,12 @@ import { z } from 'zod/v4'; -export const Base64Buffer = z.codec(z.base64(), z.instanceof(Buffer), { - decode(b64) { - return Buffer.from(b64, 'base64'); - }, - encode(buf) { - return buf.toString('base64'); - }, -}); +export const Base64Buffer = z.compile( + z.codec(z.base64(), z.instanceof(Buffer), { + decode(b64) { + return Buffer.from(b64, 'base64'); + }, + encode(buf) { + return buf.toString('base64'); + }, + }) +); diff --git a/packages/world-postgres/test/retention.test.ts b/packages/world-postgres/test/retention.test.ts index c7471d5817..8616e52adb 100644 --- a/packages/world-postgres/test/retention.test.ts +++ b/packages/world-postgres/test/retention.test.ts @@ -117,7 +117,7 @@ describe('Retention ($retention: 0)', () => { await events.create(runId, { eventType: 'step_completed', correlationId: stepId, - eventData: { output: new Uint8Array([6, 7]) }, + eventData: { result: new Uint8Array([6, 7]) }, }); const hookId = `hook_${ulid()}`; diff --git a/packages/world-testing/src/server.mts b/packages/world-testing/src/server.mts index 1fa236331e..756cead22b 100644 --- a/packages/world-testing/src/server.mts +++ b/packages/world-testing/src/server.mts @@ -20,26 +20,30 @@ type Files = keyof typeof manifest.workflows; type Workflows = keyof (typeof manifest.workflows)[F]; type NonEmptyArray = [T, ...T[]]; -const Invoke = z - .object({ - file: z.enum(Object.keys(manifest.workflows) as NonEmptyArray), - workflow: z.string(), - args: z.unknown().array().default([]), - }) - .transform((obj) => { - const file = obj.file as keyof typeof manifest.workflows; - const workflow = z - .enum( - Object.keys(manifest.workflows[file]) as NonEmptyArray< - Workflows - > - ) - .parse(obj.workflow); - return { - args: obj.args, - workflow: manifest.workflows[file][workflow], - }; - }); +const Invoke = z.compile( + z + .object({ + file: z.enum(Object.keys(manifest.workflows) as NonEmptyArray), + workflow: z.string(), + args: z.unknown().array().default([]), + }) + .transform((obj) => { + const file = obj.file as keyof typeof manifest.workflows; + const workflow = z + .compile( + z.enum( + Object.keys(manifest.workflows[file]) as NonEmptyArray< + Workflows + > + ) + ) + .parse(obj.workflow); + return { + args: obj.args, + workflow: manifest.workflows[file][workflow], + }; + }) +); // Track flow handler invocations per run for testing inline execution // per-copy-ok: this file is a standalone test server entry (it calls `serve()` diff --git a/packages/world-testing/src/util.mts b/packages/world-testing/src/util.mts index 17e1ca45ca..54001ea365 100644 --- a/packages/world-testing/src/util.mts +++ b/packages/world-testing/src/util.mts @@ -14,12 +14,14 @@ import type { TypedHook } from 'workflow'; import * as z from 'zod'; import type manifest from '../.well-known/workflow/v1/manifest.json'; -export const Control = z.object({ - state: z.literal('listening'), - info: z.object({ - port: z.number(), - }), -}); +export const Control = z.compile( + z.object({ + state: z.literal('listening'), + info: z.object({ + port: z.number(), + }), + }) +); type Control = z.infer; type Files = keyof typeof manifest.workflows; @@ -121,7 +123,7 @@ export async function startServer(opts: { throw new Error('Server did not start correctly'); } -const Invoke = z.object({ runId: z.coerce.string() }); +const Invoke = z.compile(z.object({ runId: z.coerce.string() })); export function createFetcher(control: Control) { return { diff --git a/packages/world-testing/workflows/hooks.ts b/packages/world-testing/workflows/hooks.ts index f3ab531cf4..090c4239b6 100644 --- a/packages/world-testing/workflows/hooks.ts +++ b/packages/world-testing/workflows/hooks.ts @@ -2,11 +2,13 @@ import { defineHook, getWritable } from '@workflow/core'; import * as z from 'zod'; export const Hook = defineHook({ - schema: z.object({ - data: z.string(), - done: z.boolean().optional(), - metadata: z.unknown(), - }), + schema: z.compile( + z.object({ + data: z.string(), + done: z.boolean().optional(), + metadata: z.unknown(), + }) + ), }); export async function collectWithHook(token: string, customData: string) { diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index 63ececad91..7c05c1625c 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -150,7 +150,9 @@ export async function fetchRunKey( }); const data = await response.json(); - const result = z.object({ key: z.string().nullable() }).safeParse(data); + const result = z + .compile(z.object({ key: z.string().nullable() })) + .safeParse(data); if (!result.success) { throw new Error( `Invalid response from Vercel API: expected { key: string | null }. Zod error: ${result.error.message}` diff --git a/packages/world-vercel/src/events-batch.test.ts b/packages/world-vercel/src/events-batch.test.ts index 6dcfa27b28..d13813721c 100644 --- a/packages/world-vercel/src/events-batch.test.ts +++ b/packages/world-vercel/src/events-batch.test.ts @@ -156,7 +156,14 @@ const stepB = { const fullSuccessBody = () => encode({ results: [ - { status: 200, event: completedEvent, step: stepA }, + { + status: 200, + event: { + ...completedEvent, + eventData: { ...completedEvent.eventData, result: undefined }, + }, + step: stepA, + }, { status: 200, event: createdEvent, step: { ...stepB } }, { status: 200, event: startedEvent, step: { ...stepB } }, ], @@ -188,6 +195,15 @@ describe('createWorkflowRunEventBatch', () => { ); expect(result.results).toHaveLength(3); + expect(result.results[0]?.event).toStrictEqual({ + ...completedEvent, + createdAt: new Date(CREATED_AT), + eventData: { ...completedEvent.eventData, result: undefined }, + }); + expect(result.results[1]?.event).toStrictEqual({ + ...createdEvent, + createdAt: new Date(CREATED_AT), + }); expect(requestBody).toBeDefined(); // biome-ignore lint/style/noNonNullAssertion: asserted above const frames = decodeBatchFrames(requestBody!); diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 9bea49d3c5..5c111caedc 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -356,50 +356,129 @@ export interface PreconditionFailureDetails { cursor?: string; } -const CreateEventV4BodyBaseSchema = z.object({ - event: EventSchema, - run: WorkflowRunSchema.optional(), - step: StepWireSchema.transform(deserializeStep).optional(), - hook: HookSchema.optional(), - wait: WaitSchema.optional(), - stepCreated: z.literal(true).optional(), - maxEvents: z.number().int().positive().optional(), -}); - -const CreateEventV4PageSchema = z.union([ - z.object({ - events: z.array(EventSchema), - cursor: z.string().nullable(), - hasMore: z.boolean(), - }), +/** + * Event responses may omit an unresolved payload field entirely. Zod <=4.3 + * treated an object property backed by `z.any()` as optional, so EventSchema + * historically accepted that wire shape even though the property was not + * explicitly optional. Zod 4.5 correctly distinguishes a missing property + * from a present `undefined` value. + * + * Keep CreateEventSchema strict while preserving the Vercel response contract: + * temporarily materialize an omitted payload with a private sentinel for + * EventSchema, then remove only that synthesized value from the parsed response. + * + * Exported so the legacy `/v1/runs/:id/events` path (see `events.ts` + * `createWorkflowRunEventInner` v1Compat catch-all) can parse its event + * responses with the same omitted-payload tolerance the v4 sites use. + */ +const OMITTED_EVENT_PAYLOAD = Symbol('omitted event payload'); +export const VercelEventWireSchema = z.compile( + z + .preprocess((value) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return value; + } + + const event = value as Record; + const payloadField = + typeof event.eventType === 'string' + ? getEventDataPayloadField(event.eventType) + : undefined; + const eventData = event.eventData as Record | undefined; + if ( + !payloadField || + typeof eventData !== 'object' || + eventData === null || + Array.isArray(eventData) || + Object.hasOwn(eventData, payloadField) + ) { + return value; + } + + return { + ...event, + eventData: { + ...eventData, + [payloadField]: OMITTED_EVENT_PAYLOAD, + }, + }; + }, EventSchema) + .transform((event) => { + const payloadField = getEventDataPayloadField(event.eventType); + if (!payloadField || !('eventData' in event)) return event; + + const eventData = event.eventData as Record | undefined; + if ( + typeof eventData !== 'object' || + eventData === null || + Array.isArray(eventData) || + eventData[payloadField] !== OMITTED_EVENT_PAYLOAD + ) { + return event; + } + + const parsedEventData = { ...eventData }; + delete parsedEventData[payloadField]; + return { ...event, eventData: parsedEventData } as Event; + }) +); + +const CreateEventV4BodyBaseSchema = z.compile( z.object({ - events: z.undefined(), - cursor: z.undefined(), - hasMore: z.undefined(), - }), -]); + event: VercelEventWireSchema, + run: WorkflowRunSchema.optional(), + step: StepWireSchema.transform(deserializeStep).optional(), + hook: HookSchema.optional(), + wait: WaitSchema.optional(), + stepCreated: z.literal(true).optional(), + maxEvents: z.number().int().positive().optional(), + }) +); + +const CreateEventV4PageSchema = z.compile( + z.union([ + z.object({ + events: z.array(VercelEventWireSchema), + cursor: z.string().nullable(), + hasMore: z.boolean(), + }), + // This schema is always intersected with CreateEventV4BodyBaseSchema. + // Keep it non-strict so the base response fields remain valid here. + z.object({ + events: z.undefined().optional(), + cursor: z.undefined().optional(), + hasMore: z.undefined().optional(), + }), + ]) +); -const CreateEventV4BodySchema = CreateEventV4BodyBaseSchema.and( - CreateEventV4PageSchema +const CreateEventV4BodySchema = z.compile( + CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema) ); const CreateEventV4BodySchemas: { [T in EventType]: z.ZodType & { event: Event }>; } = { - run_created: CreateEventV4BodyBaseSchema.extend({ - run: WorkflowRunSchema, - }).and(CreateEventV4PageSchema), - run_started: CreateEventV4BodyBaseSchema.extend({ - run: WorkflowRunSchema.and(z.object({ startedAt: z.coerce.date() })), - }).and(CreateEventV4PageSchema), - step_started: CreateEventV4BodyBaseSchema.extend({ - step: StepWireSchema.extend({ - startedAt: z.coerce.date(), - }).transform((step) => ({ - ...deserializeStep(step), - startedAt: step.startedAt, - })), - }).and(CreateEventV4PageSchema), + run_created: z.compile( + CreateEventV4BodyBaseSchema.extend({ + run: WorkflowRunSchema, + }).and(CreateEventV4PageSchema) + ), + run_started: z.compile( + CreateEventV4BodyBaseSchema.extend({ + run: WorkflowRunSchema.and(z.object({ startedAt: z.coerce.date() })), + }).and(CreateEventV4PageSchema) + ), + step_started: z.compile( + CreateEventV4BodyBaseSchema.extend({ + step: StepWireSchema.extend({ + startedAt: z.coerce.date(), + }).transform((step) => ({ + ...deserializeStep(step), + startedAt: step.startedAt, + })), + }).and(CreateEventV4PageSchema) + ), run_completed: CreateEventV4BodySchema, run_failed: CreateEventV4BodySchema, run_cancelled: CreateEventV4BodySchema, @@ -419,23 +498,27 @@ const CreateEventV4BodySchemas: { noop: CreateEventV4BodySchema, }; -const MaxEventsHeaderSchema = z.coerce.number().int().positive(); -const EventStreamEndSchema = z.object({ - _end: z.literal(1), - next: z.string().optional(), - hasMore: z.boolean(), -}); +const MaxEventsHeaderSchema = z.compile(z.coerce.number().int().positive()); +const EventStreamEndSchema = z.compile( + z.object({ + _end: z.literal(1), + next: z.string().optional(), + hasMore: z.boolean(), + }) +); /** * Terminal error frame. The backend sends this when it cannot finish a frame * stream and retrying will not help — the response already committed to `200` * with its first byte, so there is no status code left to carry the failure. */ -const EventStreamErrorSchema = z.object({ - _error: z.literal(1), - code: z.string(), - message: z.string().optional(), -}); +const EventStreamErrorSchema = z.compile( + z.object({ + _error: z.literal(1), + code: z.string(), + message: z.string().optional(), + }) +); /** * An event's payload object is gone from the backend's blob storage. The @@ -465,13 +548,13 @@ function decodeLegacyStructuredError(payload: Uint8Array): unknown { function decodeEventFrame({ meta, body }: DecodedFrame): Event { const eventType = EventTypeSchema.parse(meta.eventType); - if (body.byteLength === 0) return EventSchema.parse(meta); + if (body.byteLength === 0) return VercelEventWireSchema.parse(meta); const payloadField = getEventDataPayloadField(eventType); assert(payloadField, `Event type ${eventType} cannot carry a payload body`); assert(meta.eventData && typeof meta.eventData === 'object'); - return EventSchema.parse({ + return VercelEventWireSchema.parse({ ...meta, eventData: { ...meta.eventData, @@ -681,7 +764,7 @@ function decodePreconditionDetails( const candidate = raw as Record; if (typeof candidate.eventId !== 'string') return undefined; if (hasUnusablePayload(candidate)) return undefined; - const event = EventSchema.safeParse(candidate); + const event = VercelEventWireSchema.safeParse(candidate); if (!event.success) return undefined; events.push(event.data); } @@ -903,13 +986,14 @@ async function decodeCreateEventResponse( code: 'PARSE_ERROR', }); } - const schema: z.ZodType & { event: Event }> = + const schema: z.ZodType & { event: Event }> = z.compile( CreateEventV4BodySchemas[eventType].refine( ({ event }) => event.eventType === eventType || (eventType === 'hook_created' && event.eventType === 'hook_conflict'), { path: ['event', 'eventType'] } - ); + ) + ); let decoded: unknown; try { decoded = decode(bodyBytes); @@ -990,11 +1074,13 @@ export interface CreateEventBatchV4Result { results: CreateEventBatchV4ItemResult[]; } -const BatchItemFailureSchema = z.object({ - status: z.number().int(), - error: z.string(), - message: z.string(), -}); +const BatchItemFailureSchema = z.compile( + z.object({ + status: z.number().int(), + error: z.string(), + message: z.string(), + }) +); /** * POST /api/v4/runs/:runId/events/batch diff --git a/packages/world-vercel/src/events.test.ts b/packages/world-vercel/src/events.test.ts index 8689afff7f..1ec1ccce24 100644 --- a/packages/world-vercel/src/events.test.ts +++ b/packages/world-vercel/src/events.test.ts @@ -162,6 +162,49 @@ describe('createWorkflowRunEvent with v1Compat', () => { agent.assertNoPendingInterceptors(); }); + // A `hook_received` resumed with a payload the legacy v1 server does not + // echo back (e.g. an `undefined` resume payload) comes back with an + // `eventData` that omits the required `payload` key. Under Zod 4.5 the bare + // `EventSchema` rejects a missing property, so this path must parse with the + // omitted-payload-tolerant wire schema (the same fix the v4 sites use), or + // hook resume breaks for legacy spec-1 runs. + it('parses a legacy hook_received response that omits payload', async () => { + const agent = mockAgent(); + agent + .get(ORIGIN) + .intercept({ path: '/api/v1/runs/wrun_legacy/events', method: 'POST' }) + .reply( + 200, + { + eventId: 'evnt_legacy', + runId: 'wrun_legacy', + eventType: 'hook_received', + correlationId: 'hook_1', + createdAt: '2026-06-10T00:00:00.000Z', + specVersion: 1, + // payload key intentionally absent + eventData: {}, + }, + { headers: { 'content-type': 'application/json' } } + ); + + const result = await createWorkflowRunEvent( + 'wrun_legacy', + { + eventType: 'hook_received', + correlationId: 'hook_1', + specVersion: 1, + eventData: { payload: undefined }, + } as AnyEventRequest, + { v1Compat: true }, + { token: 'test-token', dispatcher: agent } + ); + + expect(result.event?.eventId).toBe('evnt_legacy'); + expect(result.event?.eventType).toBe('hook_received'); + agent.assertNoPendingInterceptors(); + }); + it('rejects v1Compat without a runId for non-lifecycle events', async () => { await expect( createWorkflowRunEvent( diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index 7d373a42d2..73bd19b37f 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -43,7 +43,6 @@ import { type EventBatchResult, type EventDataPayloadField, type EventResult, - EventSchema, type GetEventParams, getEventDataPayloadField, isHookEventRequiringExistence, @@ -63,6 +62,7 @@ import { getEventV4, getWorkflowRunEventsV4, type ListEventsV4Params, + VercelEventWireSchema, } from './events-v4.js'; import { decode as decodeRunId } from './run-id/index.js'; import { cancelWorkflowRunV1, createWorkflowRunV1 } from './runs.js'; @@ -684,7 +684,12 @@ async function createWorkflowRunEventInner( options: { method: 'POST' }, data, config, - schema: EventSchema, + // Match the v4 sites: parse legacy event responses with the + // omitted-payload-tolerant wire schema. A `hook_received` response can + // omit the required `payload` key (e.g. a resume with an `undefined` + // payload the server never echoes back), which bare `EventSchema.parse` + // now rejects under Zod 4.5. + schema: VercelEventWireSchema, }); return { event: wireResult }; } diff --git a/packages/world-vercel/src/frames.ts b/packages/world-vercel/src/frames.ts index 2e839dd6e7..e5564785ac 100644 --- a/packages/world-vercel/src/frames.ts +++ b/packages/world-vercel/src/frames.ts @@ -23,7 +23,7 @@ export class IncompleteFrameError extends Error {} // The protocol consumer validates the event or control-frame shape after the // body is available. The byte codec only requires a CBOR object here. -const CborObjectSchema = z.record(z.string(), z.unknown()); +const CborObjectSchema = z.compile(z.record(z.string(), z.unknown())); /** Test/utility: encode a complete frame. Production server uses prefix * + streaming body. */ diff --git a/packages/world-vercel/src/hooks.ts b/packages/world-vercel/src/hooks.ts index e3add16a2d..37cad7fa88 100644 --- a/packages/world-vercel/src/hooks.ts +++ b/packages/world-vercel/src/hooks.ts @@ -20,11 +20,13 @@ function filterHookData(hook: any, resolveData: 'none' | 'all'): Hook { } return normalizeHookData(hook) as Hook; } -const HookWithRefsSchema = HookSchema.omit({ - metadata: true, -}).extend({ - metadataRef: z.any().optional(), -}); +const HookWithRefsSchema = z.compile( + HookSchema.omit({ + metadata: true, + }).extend({ + metadataRef: z.any().optional(), + }) +); export async function listHooks( params: ListHooksParams, diff --git a/packages/world-vercel/src/queue.ts b/packages/world-vercel/src/queue.ts index 0f1eec291e..0541c16de7 100644 --- a/packages/world-vercel/src/queue.ts +++ b/packages/world-vercel/src/queue.ts @@ -105,15 +105,17 @@ class DualTransport implements Transport { // same module copy, so the context never has to cross a copy boundary. const requestIdStorage = new AsyncLocalStorage(); -const MessageWrapper = z.object({ - payload: QueuePayloadSchema, - queueName: ValidQueueName, - /** - * The deployment ID to use when re-enqueueing the message. - * This ensures the message is processed by the same deployment. - */ - deploymentId: z.string().optional(), -}); +const MessageWrapper = z.compile( + z.object({ + payload: QueuePayloadSchema, + queueName: ValidQueueName, + /** + * The deployment ID to use when re-enqueueing the message. + * This ensures the message is processed by the same deployment. + */ + deploymentId: z.string().optional(), + }) +); /** * Sleep Implementation via Message Delays diff --git a/packages/world-vercel/src/resolve-latest-deployment.ts b/packages/world-vercel/src/resolve-latest-deployment.ts index 0d32e99e09..d68b899266 100644 --- a/packages/world-vercel/src/resolve-latest-deployment.ts +++ b/packages/world-vercel/src/resolve-latest-deployment.ts @@ -13,9 +13,11 @@ import { getDispatcher } from './http-client.js'; import { instrumentedFetch, resolveVercelApiToken } from './http-core.js'; import type { APIConfig } from './utils.js'; -const ResolveLatestDeploymentResponseSchema = z.object({ - id: z.string(), -}); +const ResolveLatestDeploymentResponseSchema = z.compile( + z.object({ + id: z.string(), + }) +); /** * Resolve the credential this call should authenticate with. diff --git a/packages/world-vercel/src/runs.ts b/packages/world-vercel/src/runs.ts index 28df1297cf..4193d82e22 100644 --- a/packages/world-vercel/src/runs.ts +++ b/packages/world-vercel/src/runs.ts @@ -41,33 +41,37 @@ import { * `errorCode` is a separate plaintext metadata field used for routing * and classification. */ -export const WorkflowRunWireBaseSchema = WorkflowRunBaseSchema.omit({ - error: true, - errorCode: true, -}).extend({ - error: z.union([SerializedDataSchema, z.any()]).optional(), - errorCode: z.string().optional(), - // Not part of the World interface, but passed through for direct consumers and debugging - blobStorageBytes: z.number().optional(), - streamStorageBytes: z.number().optional(), -}); +export const WorkflowRunWireBaseSchema = z.compile( + WorkflowRunBaseSchema.omit({ + error: true, + errorCode: true, + }).extend({ + error: z.union([SerializedDataSchema, z.any()]).optional(), + errorCode: z.string().optional(), + // Not part of the World interface, but passed through for direct consumers and debugging + blobStorageBytes: z.number().optional(), + streamStorageBytes: z.number().optional(), + }) +); // Wire schema for resolved data (full input/output) -const WorkflowRunWireSchema = WorkflowRunWireBaseSchema; +const WorkflowRunWireSchema = z.compile(WorkflowRunWireBaseSchema); // Wire schema for lazy mode with refs instead of data // input/output can be Uint8Array (v2) or any JSON (legacy v1) -const WorkflowRunWireWithRefsSchema = WorkflowRunWireBaseSchema.omit({ - input: true, - output: true, -}).extend({ - // We discard the results of the refs, so we don't care about the type here - inputRef: z.any().optional(), - outputRef: z.any().optional(), - // Accept both Uint8Array (v2 format) and any (legacy v1 JSON format) - input: z.union([z.instanceof(Uint8Array), z.any()]).optional(), - output: z.union([z.instanceof(Uint8Array), z.any()]).optional(), -}); +const WorkflowRunWireWithRefsSchema = z.compile( + WorkflowRunWireBaseSchema.omit({ + input: true, + output: true, + }).extend({ + // We discard the results of the refs, so we don't care about the type here + inputRef: z.any().optional(), + outputRef: z.any().optional(), + // Accept both Uint8Array (v2 format) and any (legacy v1 JSON format) + input: z.union([z.instanceof(Uint8Array), z.any()]).optional(), + output: z.union([z.instanceof(Uint8Array), z.any()]).optional(), + }) +); // Overloaded function signatures for filterRunData function filterRunData(run: any, resolveData: 'none'): WorkflowRunWithoutData; @@ -566,9 +570,11 @@ export async function cancelWorkflowRuns( * returns the post-merge attribute snapshot so callers don't need to * issue a follow-up read. */ -const ExperimentalSetAttributesResponseSchema = z.object({ - attributes: z.record(z.string(), z.string()), -}); +const ExperimentalSetAttributesResponseSchema = z.compile( + z.object({ + attributes: z.record(z.string(), z.string()), + }) +); /** * Apply attribute changes to a workflow run. The body shape mirrors the diff --git a/packages/world-vercel/src/steps.ts b/packages/world-vercel/src/steps.ts index 09a3c2dd6a..cd395ab64e 100644 --- a/packages/world-vercel/src/steps.ts +++ b/packages/world-vercel/src/steps.ts @@ -26,24 +26,28 @@ import { * For backward compatibility with legacy wire formats, we also accept * any other shape and let the resolved `errorRef` supersede it when present. */ -export const StepWireSchema = StepSchema.omit({ - error: true, -}).extend({ - error: z.union([SerializedDataSchema, z.any()]).optional(), - errorRef: z.any().optional(), -}); +export const StepWireSchema = z.compile( + StepSchema.omit({ + error: true, + }).extend({ + error: z.union([SerializedDataSchema, z.any()]).optional(), + errorRef: z.any().optional(), + }) +); // Wire schema for lazy mode with refs instead of data -const StepWireWithRefsSchema = StepWireSchema.omit({ - input: true, - output: true, -}).extend({ - // We discard the results of the refs, so we don't care about the type here - inputRef: z.any().optional(), - outputRef: z.any().optional(), - input: z.instanceof(Uint8Array).optional(), - output: z.instanceof(Uint8Array).optional(), -}); +const StepWireWithRefsSchema = z.compile( + StepWireSchema.omit({ + input: true, + output: true, + }).extend({ + // We discard the results of the refs, so we don't care about the type here + inputRef: z.any().optional(), + outputRef: z.any().optional(), + input: z.instanceof(Uint8Array).optional(), + output: z.instanceof(Uint8Array).optional(), + }) +); /** * Transform step from wire format to Step interface format. diff --git a/packages/world-vercel/src/streamer.ts b/packages/world-vercel/src/streamer.ts index 7699507e76..290b8874b4 100644 --- a/packages/world-vercel/src/streamer.ts +++ b/packages/world-vercel/src/streamer.ts @@ -179,27 +179,31 @@ function createStreamRequestError( export { encodeMultiChunks } from './stream-chunks.js'; -const StreamInfoResponseSchema = z.object({ - tailIndex: z.number(), - done: z.boolean(), -}); +const StreamInfoResponseSchema = z.compile( + z.object({ + tailIndex: z.number(), + done: z.boolean(), + }) +); /** * Zod schema for the paginated stream chunks response from the server. * When using CBOR (the default for makeRequest), chunk data arrives as * native Uint8Array byte strings, so no base64 decoding is required. */ -const StreamChunksResponseSchema = z.object({ - data: z.array( - z.object({ - index: z.number(), - data: z.instanceof(Uint8Array), - }) - ), - cursor: z.string().nullable(), - hasMore: z.boolean(), - done: z.boolean(), -}); +const StreamChunksResponseSchema = z.compile( + z.object({ + data: z.array( + z.object({ + index: z.number(), + data: z.instanceof(Uint8Array), + }) + ), + cursor: z.string().nullable(), + hasMore: z.boolean(), + done: z.boolean(), + }) +); export async function writeStreamSessionOverHttp( runId: string, diff --git a/packages/world/src/analytics.ts b/packages/world/src/analytics.ts index 422dbde276..a1d6c5a9b3 100644 --- a/packages/world/src/analytics.ts +++ b/packages/world/src/analytics.ts @@ -25,112 +25,126 @@ const NAIVE_DATETIME = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?$/; * offset), and non-string inputs (Date, epoch number), are forwarded * without modification before coercion. */ -const UTCDateSchema = z.preprocess((value) => { - if (typeof value === 'string' && NAIVE_DATETIME.test(value)) { - return `${value.replace(' ', 'T')}Z`; - } - return value; -}, z.coerce.date()); +const UTCDateSchema = z.compile( + z.preprocess((value) => { + if (typeof value === 'string' && NAIVE_DATETIME.test(value)) { + return `${value.replace(' ', 'T')}Z`; + } + return value; + }, z.coerce.date()) +); -const NullableDateSchema = UTCDateSchema.nullable().optional(); -const NullableStringSchema = z.string().nullable().optional(); -const NullableBooleanSchema = z.boolean().nullable().optional(); +const NullableDateSchema = z.compile(UTCDateSchema.nullable().optional()); +const NullableStringSchema = z.compile(z.string().nullable().optional()); +const NullableBooleanSchema = z.compile(z.boolean().nullable().optional()); // Keep analytics object schemas standalone even when they mirror storage // metadata fields. This namespace is an explicit metadata-only read contract; // payload and secret fields should only appear here through deliberate opt-in. -export const AnalyticsRunSchema = z.object({ - runId: z.string(), - status: WorkflowRunStatusSchema, - deploymentId: z.string(), - workflowName: z.string(), - specVersion: z.coerce.number().optional(), - attributes: z.record(z.string(), z.string()).default({}), - createdAt: UTCDateSchema, - updatedAt: UTCDateSchema, - startedAt: NullableDateSchema, - completedAt: NullableDateSchema, - errorCode: NullableStringSchema, - workflowCoreVersion: NullableStringSchema, - workflowEncryptionEnabled: NullableBooleanSchema, -}); +export const AnalyticsRunSchema = z.compile( + z.object({ + runId: z.string(), + status: WorkflowRunStatusSchema, + deploymentId: z.string(), + workflowName: z.string(), + specVersion: z.coerce.number().optional(), + attributes: z.record(z.string(), z.string()).default({}), + createdAt: UTCDateSchema, + updatedAt: UTCDateSchema, + startedAt: NullableDateSchema, + completedAt: NullableDateSchema, + errorCode: NullableStringSchema, + workflowCoreVersion: NullableStringSchema, + workflowEncryptionEnabled: NullableBooleanSchema, + }) +); -export const AnalyticsStepSchema = z.object({ - runId: z.string(), - stepId: z.string(), - stepName: NullableStringSchema, - status: StepStatusSchema, - attempt: z.number().optional(), - createdAt: UTCDateSchema, - updatedAt: UTCDateSchema, - startedAt: NullableDateSchema, - completedAt: NullableDateSchema, - retryAfter: NullableDateSchema, - errorCode: NullableStringSchema, - workflowCoreVersion: NullableStringSchema, - workflowEncryptionEnabled: NullableBooleanSchema, - /** Compute instance of the latest attempt's `step_started`. */ - computeInstanceId: NullableStringSchema, -}); +export const AnalyticsStepSchema = z.compile( + z.object({ + runId: z.string(), + stepId: z.string(), + stepName: NullableStringSchema, + status: StepStatusSchema, + attempt: z.number().optional(), + createdAt: UTCDateSchema, + updatedAt: UTCDateSchema, + startedAt: NullableDateSchema, + completedAt: NullableDateSchema, + retryAfter: NullableDateSchema, + errorCode: NullableStringSchema, + workflowCoreVersion: NullableStringSchema, + workflowEncryptionEnabled: NullableBooleanSchema, + /** Compute instance of the latest attempt's `step_started`. */ + computeInstanceId: NullableStringSchema, + }) +); -export const AnalyticsEventSchema = z.object({ - runId: z.string(), - eventId: z.string(), - eventType: EventTypeSchema, - correlationId: NullableStringSchema, - entityId: NullableStringSchema, - stepName: NullableStringSchema, - workflowName: z.string(), - deploymentId: z.string(), - specVersion: z.coerce.number().optional(), - runCreatedAt: UTCDateSchema, - createdAt: UTCDateSchema, - region: NullableStringSchema, - vercelId: NullableStringSchema, - requestId: NullableStringSchema, - /** Compute instance that wrote the event. See CreateEventParams. */ - computeInstanceId: NullableStringSchema, - resumeAt: NullableDateSchema, - retryAfter: NullableDateSchema, - errorCode: NullableStringSchema, - workflowCoreVersion: NullableStringSchema, - isWebhook: NullableBooleanSchema, - isSystem: NullableBooleanSchema, - workflowEncryptionEnabled: NullableBooleanSchema, -}); +export const AnalyticsEventSchema = z.compile( + z.object({ + runId: z.string(), + eventId: z.string(), + eventType: EventTypeSchema, + correlationId: NullableStringSchema, + entityId: NullableStringSchema, + stepName: NullableStringSchema, + workflowName: z.string(), + deploymentId: z.string(), + specVersion: z.coerce.number().optional(), + runCreatedAt: UTCDateSchema, + createdAt: UTCDateSchema, + region: NullableStringSchema, + vercelId: NullableStringSchema, + requestId: NullableStringSchema, + /** Compute instance that wrote the event. See CreateEventParams. */ + computeInstanceId: NullableStringSchema, + resumeAt: NullableDateSchema, + retryAfter: NullableDateSchema, + errorCode: NullableStringSchema, + workflowCoreVersion: NullableStringSchema, + isWebhook: NullableBooleanSchema, + isSystem: NullableBooleanSchema, + workflowEncryptionEnabled: NullableBooleanSchema, + }) +); -export const AnalyticsHookSchema = z.object({ - runId: z.string(), - hookId: z.string(), - status: z.enum(['created', 'received', 'disposed', 'conflict']), - createdAt: UTCDateSchema, - updatedAt: UTCDateSchema, - receivedAt: NullableDateSchema, - disposedAt: NullableDateSchema, - isWebhook: NullableBooleanSchema, - isSystem: NullableBooleanSchema, - workflowCoreVersion: NullableStringSchema, - workflowEncryptionEnabled: NullableBooleanSchema, -}); +export const AnalyticsHookSchema = z.compile( + z.object({ + runId: z.string(), + hookId: z.string(), + status: z.enum(['created', 'received', 'disposed', 'conflict']), + createdAt: UTCDateSchema, + updatedAt: UTCDateSchema, + receivedAt: NullableDateSchema, + disposedAt: NullableDateSchema, + isWebhook: NullableBooleanSchema, + isSystem: NullableBooleanSchema, + workflowCoreVersion: NullableStringSchema, + workflowEncryptionEnabled: NullableBooleanSchema, + }) +); -export const AnalyticsWaitSchema = z.object({ - runId: z.string(), - waitId: z.string(), - status: WaitStatusSchema, - resumeAt: NullableDateSchema, - createdAt: UTCDateSchema, - updatedAt: UTCDateSchema, - completedAt: NullableDateSchema, - workflowCoreVersion: NullableStringSchema, - workflowEncryptionEnabled: NullableBooleanSchema, -}); +export const AnalyticsWaitSchema = z.compile( + z.object({ + runId: z.string(), + waitId: z.string(), + status: WaitStatusSchema, + resumeAt: NullableDateSchema, + createdAt: UTCDateSchema, + updatedAt: UTCDateSchema, + completedAt: NullableDateSchema, + workflowCoreVersion: NullableStringSchema, + workflowEncryptionEnabled: NullableBooleanSchema, + }) +); -export const AnalyticsAttributeKeySchema = z.object({ - key: z.string(), - runCount: z.coerce.number(), - firstSeenAt: UTCDateSchema, - lastSeenAt: UTCDateSchema, -}); +export const AnalyticsAttributeKeySchema = z.compile( + z.object({ + key: z.string(), + runCount: z.coerce.number(), + firstSeenAt: UTCDateSchema, + lastSeenAt: UTCDateSchema, + }) +); export type AnalyticsRun = z.infer; export type AnalyticsStep = z.infer; diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts index 177e870492..ad0de23a45 100644 --- a/packages/world/src/attributes.ts +++ b/packages/world/src/attributes.ts @@ -12,32 +12,37 @@ 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(); +export const AttributeKeySchema = z.compile( + 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.compile( + 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: AttributeKeySchema, - value: AttributeValueSchema, -}) satisfies z.ZodType; - -export const AttributeChangesSchema = z - .array(AttributeChangeSchema) - .superRefine((changes, context) => { +export const AttributeChangeSchema = z.compile( + z.object({ + key: AttributeKeySchema, + value: AttributeValueSchema, + }) +) satisfies z.ZodType; + +export const AttributeChangesSchema = z.compile( + 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 @@ -51,7 +56,8 @@ export const AttributeChangesSchema = z input: changes, }); } - }); + }) +); /** The post-merge attribute snapshot returned by a World. */ export interface ExperimentalSetAttributesResult { diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index 5d453e8d02..24ceced9e5 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -11,43 +11,47 @@ import type { Wait } from './waits.js'; export * from './event-metadata.js'; // Event type enum -export const EventTypeSchema = z.enum([ - // Run lifecycle events - 'run_created', - 'run_started', - 'run_completed', - 'run_failed', - 'run_cancelled', - // Run attribute events - 'attr_set', - // Step lifecycle events - 'step_created', - 'step_completed', - 'step_failed', - 'step_retrying', - 'step_started', - // Hook lifecycle events - 'hook_created', - 'hook_received', - 'hook_disposed', - 'hook_conflict', // Created by world when hook token already exists - // Wait lifecycle events - 'wait_created', - 'wait_completed', - // Sealed-log filler (specVersion >= 7): written ONLY by the World's backend - // to occupy a slot whose writer allocated it and died. Carries no workflow - // meaning; replay skips it (see EventsConsumer). Never user-creatable. - 'noop', -]); +export const EventTypeSchema = z.compile( + z.enum([ + // Run lifecycle events + 'run_created', + 'run_started', + 'run_completed', + 'run_failed', + 'run_cancelled', + // Run attribute events + 'attr_set', + // Step lifecycle events + 'step_created', + 'step_completed', + 'step_failed', + 'step_retrying', + 'step_started', + // Hook lifecycle events + 'hook_created', + 'hook_received', + 'hook_disposed', + 'hook_conflict', // Created by world when hook token already exists + // Wait lifecycle events + 'wait_created', + 'wait_completed', + // Sealed-log filler (specVersion >= 7): written ONLY by the World's backend + // to occupy a slot whose writer allocated it and died. Carries no workflow + // meaning; replay skips it (see EventsConsumer). Never user-creatable. + 'noop', + ]) +); export type EventType = z.infer; -const RunEventTypeSchema = EventTypeSchema.extract([ - 'run_created', - 'run_started', - 'run_completed', - 'run_failed', - 'run_cancelled', -] as const); +const RunEventTypeSchema = z.compile( + EventTypeSchema.extract([ + 'run_created', + 'run_started', + 'run_completed', + 'run_failed', + 'run_cancelled', + ] as const) +); export type RunEventType = z.infer; export const RUN_EVENT_TYPES = RunEventTypeSchema.options; @@ -55,11 +59,13 @@ export function isRunEventType(eventType: string): eventType is RunEventType { return RUN_EVENT_TYPES.includes(eventType as RunEventType); } -export const TerminalRunEventTypeSchema = EventTypeSchema.extract([ - 'run_completed', - 'run_failed', - 'run_cancelled', -] as const); +export const TerminalRunEventTypeSchema = z.compile( + EventTypeSchema.extract([ + 'run_completed', + 'run_failed', + 'run_cancelled', + ] as const) +); export type TerminalRunEventType = z.infer; export const TERMINAL_RUN_EVENT_TYPES = TerminalRunEventTypeSchema.options; @@ -69,13 +75,15 @@ export function isTerminalRunEventType( return TERMINAL_RUN_EVENT_TYPES.includes(eventType as TerminalRunEventType); } -const StepEventTypeSchema = EventTypeSchema.extract([ - 'step_created', - 'step_completed', - 'step_failed', - 'step_retrying', - 'step_started', -] as const); +const StepEventTypeSchema = z.compile( + EventTypeSchema.extract([ + 'step_created', + 'step_completed', + 'step_failed', + 'step_retrying', + 'step_started', + ] as const) +); export type StepEventType = z.infer; export const STEP_EVENT_TYPES = StepEventTypeSchema.options; @@ -83,10 +91,9 @@ export function isStepEventType(eventType: string): eventType is StepEventType { return STEP_EVENT_TYPES.includes(eventType as StepEventType); } -const TerminalStepEventTypeSchema = EventTypeSchema.extract([ - 'step_completed', - 'step_failed', -] as const); +const TerminalStepEventTypeSchema = z.compile( + EventTypeSchema.extract(['step_completed', 'step_failed'] as const) +); export type TerminalStepEventType = z.infer; export const TERMINAL_STEP_EVENT_TYPES = TerminalStepEventTypeSchema.options; @@ -96,11 +103,13 @@ export function isTerminalStepEventType( return TERMINAL_STEP_EVENT_TYPES.includes(eventType as TerminalStepEventType); } -const HookLifecycleEventTypeSchema = EventTypeSchema.extract([ - 'hook_created', - 'hook_received', - 'hook_disposed', -] as const); +const HookLifecycleEventTypeSchema = z.compile( + EventTypeSchema.extract([ + 'hook_created', + 'hook_received', + 'hook_disposed', + ] as const) +); export type HookLifecycleEventType = z.infer< typeof HookLifecycleEventTypeSchema >; @@ -114,10 +123,9 @@ export function isHookLifecycleEventType( ); } -const HookEventRequiringExistenceTypeSchema = EventTypeSchema.extract([ - 'hook_disposed', - 'hook_received', -] as const); +const HookEventRequiringExistenceTypeSchema = z.compile( + EventTypeSchema.extract(['hook_disposed', 'hook_received'] as const) +); export type HookEventRequiringExistenceType = z.infer< typeof HookEventRequiringExistenceTypeSchema >; @@ -132,10 +140,9 @@ export function isHookEventRequiringExistence( ); } -const WaitEventTypeSchema = EventTypeSchema.extract([ - 'wait_created', - 'wait_completed', -] as const); +const WaitEventTypeSchema = z.compile( + EventTypeSchema.extract(['wait_created', 'wait_completed'] as const) +); export type WaitEventType = z.infer; export const WAIT_EVENT_TYPES = WaitEventTypeSchema.options; @@ -143,11 +150,13 @@ export function isWaitEventType(eventType: string): eventType is WaitEventType { return WAIT_EVENT_TYPES.includes(eventType as WaitEventType); } -const ChildEntityCreationEventTypeSchema = EventTypeSchema.extract([ - 'step_created', - 'hook_created', - 'wait_created', -] as const); +const ChildEntityCreationEventTypeSchema = z.compile( + EventTypeSchema.extract([ + 'step_created', + 'hook_created', + 'wait_created', + ] as const) +); export type ChildEntityCreationEventType = z.infer< typeof ChildEntityCreationEventTypeSchema >; @@ -201,11 +210,13 @@ export function stripEventDataRefs( // Changing the type here will mainly improve type safety for o11y consumers. // Note: specVersion is optional for backward compatibility with legacy data in storage, // but is always sent by the runtime on new events. -export const BaseEventSchema = z.object({ - eventType: EventTypeSchema, - correlationId: z.string().optional(), - specVersion: z.number().optional(), -}); +export const BaseEventSchema = z.compile( + z.object({ + eventType: EventTypeSchema, + correlationId: z.string().optional(), + specVersion: z.number().optional(), + }) +); // Event schemas (shared between creation requests and server responses) // Note: Serialized data fields use SerializedDataSchema to support both: @@ -249,48 +260,54 @@ const stepLatencyTelemetryFields = { optimizations: z.array(z.string()).optional(), }; -const StepCompletedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('step_completed'), - correlationId: z.string(), - eventData: z.object({ - stepName: z.string().optional(), - // Carried so a backend that keys payload refs by workflow name can build - // the key without an extra run lookup on this hot per-step write. - // Optional: older runtimes omit it and the backend falls back to a read. - workflowName: z.string().optional(), - result: SerializedDataSchema, - ...stepLatencyTelemetryFields, - }), -}); +const StepCompletedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('step_completed'), + correlationId: z.string(), + eventData: z.object({ + stepName: z.string().optional(), + // Carried so a backend that keys payload refs by workflow name can build + // the key without an extra run lookup on this hot per-step write. + // Optional: older runtimes omit it and the backend falls back to a read. + workflowName: z.string().optional(), + result: SerializedDataSchema, + ...stepLatencyTelemetryFields, + }), + }) +); -const StepFailedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('step_failed'), - correlationId: z.string(), - eventData: z.object({ - stepName: z.string().optional(), - // The thrown value, serialized via the workflow serialization pipeline. - // Can be any JavaScript value (string, number, object, Error, etc.) - error: SerializedDataSchema, - ...stepLatencyTelemetryFields, - }), -}); +const StepFailedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('step_failed'), + correlationId: z.string(), + eventData: z.object({ + stepName: z.string().optional(), + // The thrown value, serialized via the workflow serialization pipeline. + // Can be any JavaScript value (string, number, object, Error, etc.) + error: SerializedDataSchema, + ...stepLatencyTelemetryFields, + }), + }) +); /** * Event created when a step fails and will be retried. * Sets the step status back to 'pending' and records the error. * The error is stored in step.error for debugging. */ -const StepRetryingEventSchema = BaseEventSchema.extend({ - eventType: z.literal('step_retrying'), - correlationId: z.string(), - eventData: z.object({ - stepName: z.string().optional(), - // The thrown value, serialized via the workflow serialization pipeline. - // Can be any JavaScript value (string, number, object, Error, etc.) - error: SerializedDataSchema, - retryAfter: z.coerce.date().optional(), - }), -}); +const StepRetryingEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('step_retrying'), + correlationId: z.string(), + eventData: z.object({ + stepName: z.string().optional(), + // The thrown value, serialized via the workflow serialization pipeline. + // Can be any JavaScript value (string, number, object, Error, etc.) + error: SerializedDataSchema, + retryAfter: z.coerce.date().optional(), + }), + }) +); /** * Event created when a step begins executing. @@ -305,84 +322,94 @@ const StepRetryingEventSchema = BaseEventSchema.extend({ * it. This mirrors the resilient `run_started` start path above. When `input` * is absent the World requires a prior `step_created` (the legacy contract). */ -const StepStartedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('step_started'), - correlationId: z.string(), - eventData: z - .object({ - stepName: z.string().optional(), - attempt: z.number().optional(), - // Carried on the lazy-start path (where `input` is present) so the - // backend can build the payload ref key without re-reading the run. - workflowName: z.string().optional(), - // Lazy-start: the dehydrated step input, present only when this - // step_started is also responsible for creating the step. - input: SerializedDataSchema.optional(), - // Inline step ownership: the queue message ID of the invocation whose - // handler is executing this step's body inline. Stamped on the lazy - // step_started (and re-stamped on an owner-recovery bare start) so - // that a wake replay can tell "this attempt is in flight in a live - // invocation" apart from "this attempt died with its process": the - // owner's queue message doubles as the liveness lease (a crash means - // the queue redelivers that same messageId, which is allowed to - // re-execute). Ownership derives from the step's LATEST step_started: - // an unstamped bare start (a retry attempt driven by a queued step - // message) clears it. Absent on eager steps and from older runtimes. - // Requires the queue's messageId to be stable across redeliveries of - // one message (see the Queue.createQueueHandler meta contract). - ownerMessageId: z.string().optional(), - }) - .optional(), -}); +const StepStartedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('step_started'), + correlationId: z.string(), + eventData: z + .object({ + stepName: z.string().optional(), + attempt: z.number().optional(), + // Carried on the lazy-start path (where `input` is present) so the + // backend can build the payload ref key without re-reading the run. + workflowName: z.string().optional(), + // Lazy-start: the dehydrated step input, present only when this + // step_started is also responsible for creating the step. + input: SerializedDataSchema.optional(), + // Inline step ownership: the queue message ID of the invocation whose + // handler is executing this step's body inline. Stamped on the lazy + // step_started (and re-stamped on an owner-recovery bare start) so + // that a wake replay can tell "this attempt is in flight in a live + // invocation" apart from "this attempt died with its process": the + // owner's queue message doubles as the liveness lease (a crash means + // the queue redelivers that same messageId, which is allowed to + // re-execute). Ownership derives from the step's LATEST step_started: + // an unstamped bare start (a retry attempt driven by a queued step + // message) clears it. Absent on eager steps and from older runtimes. + // Requires the queue's messageId to be stable across redeliveries of + // one message (see the Queue.createQueueHandler meta contract). + ownerMessageId: z.string().optional(), + }) + .optional(), + }) +); /** * Event created when a step is first invoked. The World implementation * atomically creates both the event and the step entity. */ -const StepCreatedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('step_created'), - correlationId: z.string(), - eventData: z.object({ - stepName: z.string(), - workflowName: z.string().optional(), - input: SerializedDataSchema, - }), -}); +const StepCreatedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('step_created'), + correlationId: z.string(), + eventData: z.object({ + stepName: z.string(), + workflowName: z.string().optional(), + input: SerializedDataSchema, + }), + }) +); /** * Event created when a hook is first invoked. The World implementation * atomically creates both the event and the hook entity. */ -export const HookCreatedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('hook_created'), - correlationId: z.string(), - eventData: z.object({ - token: z.string(), - tokenRetentionUntil: z.coerce.date().optional(), - metadata: SerializedDataSchema.optional(), - isWebhook: z.boolean().optional(), - isSystem: z.boolean().optional(), - }), -}); - -const HookReceivedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('hook_received'), - correlationId: z.string(), - eventData: z.object({ - token: z.string().optional(), - payload: SerializedDataSchema, - }), -}); +export const HookCreatedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('hook_created'), + correlationId: z.string(), + eventData: z.object({ + token: z.string(), + tokenRetentionUntil: z.coerce.date().optional(), + metadata: SerializedDataSchema.optional(), + isWebhook: z.boolean().optional(), + isSystem: z.boolean().optional(), + }), + }) +); -const HookDisposedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('hook_disposed'), - correlationId: z.string(), - eventData: z - .object({ +const HookReceivedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('hook_received'), + correlationId: z.string(), + eventData: z.object({ token: z.string().optional(), - }) - .optional(), -}); + payload: SerializedDataSchema, + }), + }) +); + +const HookDisposedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('hook_disposed'), + correlationId: z.string(), + eventData: z + .object({ + token: z.string().optional(), + }) + .optional(), + }) +); /** * Event created by World implementations when a hook_created request @@ -392,16 +419,18 @@ const HookDisposedEventSchema = BaseEventSchema.extend({ * When the hook consumer sees this event, it should reject any awaited * promises with a HookTokenConflictError. */ -const HookConflictEventSchema = BaseEventSchema.extend({ - eventType: z.literal('hook_conflict'), - correlationId: z.string(), - eventData: z.object({ - token: z.string(), - // TODO: Make this required once all persisted hook_conflict events and - // remote World implementations always include the active hook owner's run ID. - conflictingRunId: z.string().optional(), - }), -}); +const HookConflictEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('hook_conflict'), + correlationId: z.string(), + eventData: z.object({ + token: z.string(), + // TODO: Make this required once all persisted hook_conflict events and + // remote World implementations always include the active hook owner's run ID. + conflictingRunId: z.string().optional(), + }), + }) +); /** * Sealed-log filler event (specVersion >= 7). Written ONLY by the World's @@ -412,58 +441,68 @@ const HookConflictEventSchema = BaseEventSchema.extend({ * without advancing the deterministic clock. NOT user-creatable, and absent * from `CreateEventSchema` for that reason. */ -const NoopEventSchema = BaseEventSchema.extend({ - eventType: z.literal('noop'), - eventData: z - .object({ - sealed: z.boolean().optional(), - }) - .passthrough() - .optional(), -}); +const NoopEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('noop'), + eventData: z + .object({ + sealed: z.boolean().optional(), + }) + .passthrough() + .optional(), + }) +); -const WaitCreatedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('wait_created'), - correlationId: z.string(), - eventData: z.object({ - resumeAt: z.coerce.date(), - }), -}); +const WaitCreatedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('wait_created'), + correlationId: z.string(), + eventData: z.object({ + resumeAt: z.coerce.date(), + }), + }) +); -const WaitCompletedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('wait_completed'), - correlationId: z.string(), - eventData: z - .object({ - resumeAt: z.coerce.date().optional(), - }) - .optional(), -}); +const WaitCompletedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('wait_completed'), + correlationId: z.string(), + eventData: z + .object({ + resumeAt: z.coerce.date().optional(), + }) + .optional(), + }) +); -const AttributeWriterSchema = z.discriminatedUnion('type', [ - z.object({ - type: z.literal('workflow'), - }), - z.object({ - type: z.literal('step'), - stepId: z.string(), - attempt: z.number(), - }), -]); +const AttributeWriterSchema = z.compile( + z.discriminatedUnion('type', [ + z.object({ + type: z.literal('workflow'), + }), + z.object({ + type: z.literal('step'), + stepId: z.string(), + attempt: z.number(), + }), + ]) +); /** * Event created when workflow or step code changes the run's plaintext * attributes. The World materializes changes into `run.attributes`. */ -const AttrSetEventSchema = BaseEventSchema.extend({ - eventType: z.literal('attr_set'), - correlationId: z.string().optional(), - eventData: z.object({ - changes: AttributeChangesSchema, - writer: AttributeWriterSchema, - allowReservedAttributes: z.literal(true).optional(), - }), -}); +const AttrSetEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('attr_set'), + correlationId: z.string().optional(), + eventData: z.object({ + changes: AttributeChangesSchema, + writer: AttributeWriterSchema, + allowReservedAttributes: z.literal(true).optional(), + }), + }) +); // ============================================================================= // Run lifecycle events @@ -473,24 +512,26 @@ const AttrSetEventSchema = BaseEventSchema.extend({ * Event created when a workflow run is first created. The World implementation * atomically creates both the event and the run entity with status 'pending'. */ -const RunCreatedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('run_created'), - eventData: z.object({ - deploymentId: z.string(), - workflowName: z.string(), - input: SerializedDataSchema, - executionContext: z.record(z.string(), z.any()).optional(), - attributes: z.record(z.string(), z.string()).optional(), - allowReservedAttributes: z.literal(true).optional(), - /** - * The run's X25519 public key (base64), stamped by SDKs that support - * sealed (`encp`) envelopes. Persisted onto the run entity so that - * cross-run writers can seal payloads to this run without holding its - * symmetric key. Not secret. See `WorkflowRunBaseSchema`. - */ - encryptionPublicKey: z.string().optional(), - }), -}); +const RunCreatedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('run_created'), + eventData: z.object({ + deploymentId: z.string(), + workflowName: z.string(), + input: SerializedDataSchema, + executionContext: z.record(z.string(), z.any()).optional(), + attributes: z.record(z.string(), z.string()).optional(), + allowReservedAttributes: z.literal(true).optional(), + /** + * The run's X25519 public key (base64), stamped by SDKs that support + * sealed (`encp`) envelopes. Persisted onto the run entity so that + * cross-run writers can seal payloads to this run without holding its + * symmetric key. Not secret. See `WorkflowRunBaseSchema`. + */ + encryptionPublicKey: z.string().optional(), + }), + }) +); /** * Event created when a workflow run starts executing. @@ -501,142 +542,156 @@ const RunCreatedEventSchema = BaseEventSchema.extend({ * runtime passes the run input through the queue so the server can create the run * on the run_started call if it doesn't exist yet. */ -const RunStartedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('run_started'), - eventData: z - .object({ - input: SerializedDataSchema.optional(), - deploymentId: z.string().optional(), - workflowName: z.string().optional(), - executionContext: z.record(z.string(), z.any()).optional(), - attributes: z.record(z.string(), z.string()).optional(), - allowReservedAttributes: z.literal(true).optional(), - /** - * Mirrors `run_created.eventData.encryptionPublicKey`. Carried here for - * the resilient-start path: when the `run_created` write failed, the - * server creates the run from this event instead, and without the key - * the run would silently lose its ability to receive sealed writes. - */ - encryptionPublicKey: z.string().optional(), - }) - .optional(), -}); +const RunStartedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('run_started'), + eventData: z + .object({ + input: SerializedDataSchema.optional(), + deploymentId: z.string().optional(), + workflowName: z.string().optional(), + executionContext: z.record(z.string(), z.any()).optional(), + attributes: z.record(z.string(), z.string()).optional(), + allowReservedAttributes: z.literal(true).optional(), + /** + * Mirrors `run_created.eventData.encryptionPublicKey`. Carried here for + * the resilient-start path: when the `run_created` write failed, the + * server creates the run from this event instead, and without the key + * the run would silently lose its ability to receive sealed writes. + */ + encryptionPublicKey: z.string().optional(), + }) + .optional(), + }) +); /** * Event created when a workflow run completes successfully. * Updates the run entity to status 'completed' with output. */ -const RunCompletedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('run_completed'), - eventData: z.object({ - output: SerializedDataSchema.optional(), - }), -}); +const RunCompletedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('run_completed'), + eventData: z.object({ + output: SerializedDataSchema.optional(), + }), + }) +); /** * Event created when a workflow run fails. * Updates the run entity to status 'failed' with error. */ -const RunFailedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('run_failed'), - eventData: z.object({ - // The thrown value, serialized via the workflow serialization pipeline. - // Can be any JavaScript value (string, number, object, Error, etc.) - error: SerializedDataSchema, - // The high-level error category (USER_ERROR, RUNTIME_ERROR, etc.) used - // for routing and classification. Kept as plaintext metadata so - // observability tools can filter/categorize without needing to decrypt - // the full error payload. - errorCode: z.string().optional(), - }), -}); +const RunFailedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('run_failed'), + eventData: z.object({ + // The thrown value, serialized via the workflow serialization pipeline. + // Can be any JavaScript value (string, number, object, Error, etc.) + error: SerializedDataSchema, + // The high-level error category (USER_ERROR, RUNTIME_ERROR, etc.) used + // for routing and classification. Kept as plaintext metadata so + // observability tools can filter/categorize without needing to decrypt + // the full error payload. + errorCode: z.string().optional(), + }), + }) +); /** * Event created when a workflow run is cancelled. * Updates the run entity to status 'cancelled'. */ -const RunCancelledEventSchema = BaseEventSchema.extend({ - eventType: z.literal('run_cancelled'), - eventData: z - .object({ - // Optional free-text reason for the cancellation. Kept as small - // plaintext metadata (like run_failed's errorCode) so it survives - // resolveData: 'none' and can be displayed without decryption. - cancelReason: z.string().max(512).optional(), - }) - .optional(), -}); +const RunCancelledEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('run_cancelled'), + eventData: z + .object({ + // Optional free-text reason for the cancellation. Kept as small + // plaintext metadata (like run_failed's errorCode) so it survives + // resolveData: 'none' and can be displayed without decryption. + cancelReason: z.string().max(512).optional(), + }) + .optional(), + }) +); // Discriminated union for user-creatable events (requests to world.events.create) // Note: hook_conflict is NOT included here - it can only be created by World implementations -export const CreateEventSchema = z.discriminatedUnion('eventType', [ - // Run lifecycle events - RunCreatedEventSchema, - RunStartedEventSchema, - RunCompletedEventSchema, - RunFailedEventSchema, - RunCancelledEventSchema, - AttrSetEventSchema, - // Step lifecycle events - StepCreatedEventSchema, - StepCompletedEventSchema, - StepFailedEventSchema, - StepRetryingEventSchema, - StepStartedEventSchema, - // Hook lifecycle events - HookCreatedEventSchema, - HookReceivedEventSchema, - HookDisposedEventSchema, - // Wait lifecycle events - WaitCreatedEventSchema, - WaitCompletedEventSchema, -]); +export const CreateEventSchema = z.compile( + z.discriminatedUnion('eventType', [ + // Run lifecycle events + RunCreatedEventSchema, + RunStartedEventSchema, + RunCompletedEventSchema, + RunFailedEventSchema, + RunCancelledEventSchema, + AttrSetEventSchema, + // Step lifecycle events + StepCreatedEventSchema, + StepCompletedEventSchema, + StepFailedEventSchema, + StepRetryingEventSchema, + StepStartedEventSchema, + // Hook lifecycle events + HookCreatedEventSchema, + HookReceivedEventSchema, + HookDisposedEventSchema, + // Wait lifecycle events + WaitCreatedEventSchema, + WaitCompletedEventSchema, + ]) +); // Discriminated union for ALL events (includes World-only events like hook_conflict) // This is used for reading events from the event log -const AllEventsSchema = z.discriminatedUnion('eventType', [ - // Run lifecycle events - RunCreatedEventSchema, - RunStartedEventSchema, - RunCompletedEventSchema, - RunFailedEventSchema, - RunCancelledEventSchema, - AttrSetEventSchema, - // Step lifecycle events - StepCreatedEventSchema, - StepCompletedEventSchema, - StepFailedEventSchema, - StepRetryingEventSchema, - StepStartedEventSchema, - // Hook lifecycle events - HookCreatedEventSchema, - HookReceivedEventSchema, - HookDisposedEventSchema, - HookConflictEventSchema, // World-only: created when hook token conflicts - // Wait lifecycle events - WaitCreatedEventSchema, - WaitCompletedEventSchema, - NoopEventSchema, // World-only: sealed-log filler for an abandoned slot -]); +const AllEventsSchema = z.compile( + z.discriminatedUnion('eventType', [ + // Run lifecycle events + RunCreatedEventSchema, + RunStartedEventSchema, + RunCompletedEventSchema, + RunFailedEventSchema, + RunCancelledEventSchema, + AttrSetEventSchema, + // Step lifecycle events + StepCreatedEventSchema, + StepCompletedEventSchema, + StepFailedEventSchema, + StepRetryingEventSchema, + StepStartedEventSchema, + // Hook lifecycle events + HookCreatedEventSchema, + HookReceivedEventSchema, + HookDisposedEventSchema, + HookConflictEventSchema, // World-only: created when hook token conflicts + // Wait lifecycle events + WaitCreatedEventSchema, + WaitCompletedEventSchema, + NoopEventSchema, // World-only: sealed-log filler for an abandoned slot + ]) +); // Server response includes runId, eventId, and createdAt // specVersion is optional in database for backward 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.compile( + 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/hooks.ts b/packages/world/src/hooks.ts index e518cb4a41..2bc73c24a4 100644 --- a/packages/world/src/hooks.ts +++ b/packages/world/src/hooks.ts @@ -14,31 +14,33 @@ import type { PaginationOptions, ResolveData } from './shared.js'; * excludes the run's mutable state (e.g. status), inputs/outputs, attributes, * and any secret: only fields that are fixed at hook-creation time. */ -export const HookResumeContextSchema = z.object({ - deploymentId: z.string(), - workflowName: z.string(), - // Named `runSpecVersion` to distinguish it from the hook's own `specVersion`. - runSpecVersion: z.number().optional(), - workflowCoreVersion: z.string().optional(), - traceCarrier: TraceCarrierSchema.optional(), - // The run's published X25519 public key (base64), mirrored from the run - // entity. Lets a resume seal (`encp`) its payload to the run without reading - // the run or fetching its symmetric key. Absent on runs created before - // sealed envelopes and on projects with encryption disabled, where the - // resume falls back to the symmetric per-run key. - encryptionPublicKey: z.string().optional(), - // Feature marker: the version of the lazy-hook-resume consumer protocol the - // run's creating deployment supports. Present (>= 1) means that deployment's - // `@workflow/core` re-ensures the `hook_received` event from a queue - // message's `hookInput` on replay. Current producers no longer send - // `hookInput` (the durable write happens before the wake is published), so - // they never read this marker; it remains stamped so OLDER producers, which - // still gate their lazy path on it, keep working against new runs. Because a - // run is pinned to its creating deployment, this marker is a reliable - // per-run attestation, unlike inferring support from a version compare - // against a predicted release cutoff. - hookResumeInputVersion: z.number().optional(), -}); +export const HookResumeContextSchema = z.compile( + z.object({ + deploymentId: z.string(), + workflowName: z.string(), + // Named `runSpecVersion` to distinguish it from the hook's own `specVersion`. + runSpecVersion: z.number().optional(), + workflowCoreVersion: z.string().optional(), + traceCarrier: TraceCarrierSchema.optional(), + // The run's published X25519 public key (base64), mirrored from the run + // entity. Lets a resume seal (`encp`) its payload to the run without reading + // the run or fetching its symmetric key. Absent on runs created before + // sealed envelopes and on projects with encryption disabled, where the + // resume falls back to the symmetric per-run key. + encryptionPublicKey: z.string().optional(), + // Feature marker: the version of the lazy-hook-resume consumer protocol the + // run's creating deployment supports. Present (>= 1) means that deployment's + // `@workflow/core` re-ensures the `hook_received` event from a queue + // message's `hookInput` on replay. Current producers no longer send + // `hookInput` (the durable write happens before the wake is published), so + // they never read this marker; it remains stamped so OLDER producers, which + // still gate their lazy path on it, keep working against new runs. Because a + // run is pinned to its creating deployment, this marker is a reliable + // per-run attestation, unlike inferring support from a version compare + // against a predicted release cutoff. + hookResumeInputVersion: z.number().optional(), + }) +); export type HookResumeContext = z.infer; @@ -75,13 +77,15 @@ export const HOOK_RESUME_DEDUP_VERSION = 1; * `hookResumeInputVersion`, which attests the *consumer* and is fixed at run * creation.) */ -export const HookResumeCapabilitiesSchema = z.object({ - // Present (>= HOOK_RESUME_DEDUP_VERSION) when the live backend enforces the - // `(runId, resumeId)` dedup constraint AND no server-side kill switch is - // active. Absent against an older/rolled-back server or when the kill switch - // is engaged. - hookResumeDedupVersion: z.number(), -}); +export const HookResumeCapabilitiesSchema = z.compile( + z.object({ + // Present (>= HOOK_RESUME_DEDUP_VERSION) when the live backend enforces the + // `(runId, resumeId)` dedup constraint AND no server-side kill switch is + // active. Absent against an older/rolled-back server or when the kill switch + // is engaged. + hookResumeDedupVersion: z.number(), + }) +); export type HookResumeCapabilities = z.infer< typeof HookResumeCapabilitiesSchema @@ -95,35 +99,37 @@ export type HookResumeCapabilities = z.infer< * - specVersion 1: any (legacy JSON format) */ // Hook schemas -export const HookSchema = z.object({ - runId: z.string(), - hookId: z.string(), - token: z.string(), - ownerId: z.string(), - projectId: z.string(), - environment: z.string(), - metadata: SerializedDataSchema.optional(), - createdAt: z.coerce.date(), - // Optional in database for backward compatibility, defaults to 1 (legacy) when reading - specVersion: z.number().optional(), - isWebhook: z.boolean().optional(), - isSystem: z.boolean().optional(), - // Earliest time the token can become available after the owning run ends. - // An active run keeps the token beyond this deadline. - tokenRetentionUntil: z.coerce.date().optional(), - // Present when the server stored it (new hooks) or synthesized it from the - // run (old hooks). Absent only against an old server, where the resume path - // falls back to `runs.get`. - resumeContext: HookResumeContextSchema.optional(), - // Backend dedup capability, computed FRESH by the server on every by-token - // lookup: RESPONSE-ONLY and TRANSIENT. Never persisted on the hook entity - // and never part of `resumeContext`, so a server rollback or kill switch - // takes effect on the next lookup (the field stops appearing). - // `resumeHook()` gates its lazy path on this being present and - // current. Absent against an older/rolled-back server or when the kill switch - // is active. - resumeCapabilities: HookResumeCapabilitiesSchema.optional(), -}); +export const HookSchema = z.compile( + z.object({ + runId: z.string(), + hookId: z.string(), + token: z.string(), + ownerId: z.string(), + projectId: z.string(), + environment: z.string(), + metadata: SerializedDataSchema.optional(), + createdAt: z.coerce.date(), + // Optional in database for backward compatibility, defaults to 1 (legacy) when reading + specVersion: z.number().optional(), + isWebhook: z.boolean().optional(), + isSystem: z.boolean().optional(), + // Earliest time the token can become available after the owning run ends. + // An active run keeps the token beyond this deadline. + tokenRetentionUntil: z.coerce.date().optional(), + // Present when the server stored it (new hooks) or synthesized it from the + // run (old hooks). Absent only against an old server, where the resume path + // falls back to `runs.get`. + resumeContext: HookResumeContextSchema.optional(), + // Backend dedup capability, computed FRESH by the server on every by-token + // lookup: RESPONSE-ONLY and TRANSIENT. Never persisted on the hook entity + // and never part of `resumeContext`, so a server rollback or kill switch + // takes effect on the next lookup (the field stops appearing). + // `resumeHook()` gates its lazy path on this being present and + // current. Absent against an older/rolled-back server or when the kill switch + // is active. + resumeCapabilities: HookResumeCapabilitiesSchema.optional(), + }) +); /** * Represents a Hook. Hooks kept by minimum retention remain readable after diff --git a/packages/world/src/queue.ts b/packages/world/src/queue.ts index d89bde853a..af1261b2ec 100644 --- a/packages/world/src/queue.ts +++ b/packages/world/src/queue.ts @@ -9,28 +9,34 @@ export type QueueKind = 'workflow'; * * Namespace must be lowercase alphanumeric starting with a letter. */ -export const QueuePrefix = z - .string() - .regex( - /^__(?:[a-z][a-z0-9]*_)?wkf_workflow_$/, - 'Must match __wkf_workflow_ or __{namespace}_wkf_workflow_' - ); +export const QueuePrefix = z.compile( + z + .string() + .regex( + /^__(?:[a-z][a-z0-9]*_)?wkf_workflow_$/, + 'Must match __wkf_workflow_ or __{namespace}_wkf_workflow_' + ) +); export type QueuePrefix = z.infer; -export const ValidQueueName = z - .string() - .regex( - /^__(?:[a-z][a-z0-9]*_)?wkf_workflow_.+$/, - 'Must be a valid queue name with a recognized prefix' - ); +export const ValidQueueName = z.compile( + z + .string() + .regex( + /^__(?:[a-z][a-z0-9]*_)?wkf_workflow_.+$/, + 'Must be a valid queue name with a recognized prefix' + ) +); export type ValidQueueName = z.infer; -const QueueNamespace = z - .string() - .regex( - /^[a-z][a-z0-9]*$/, - 'Must be lowercase alphanumeric, starting with a letter' - ); +const QueueNamespace = z.compile( + z + .string() + .regex( + /^[a-z][a-z0-9]*$/, + 'Must be lowercase alphanumeric, starting with a letter' + ) +); /** * Resolves the active queue namespace from an explicit argument or the @@ -79,16 +85,15 @@ export function parseQueueName(name: ValidQueueName): { }; } -export const MessageId = z - .string() - .brand<'MessageId'>() - .describe('A stored queue message ID'); +export const MessageId = z.compile( + z.string().brand<'MessageId'>().describe('A stored queue message ID') +); export type MessageId = z.infer; /** * OpenTelemetry trace context for distributed tracing */ -export const TraceCarrierSchema = z.record(z.string(), z.string()); +export const TraceCarrierSchema = z.compile(z.record(z.string(), z.string())); export type TraceCarrier = z.infer; /** @@ -97,41 +102,43 @@ export type TraceCarrier = z.infer; * When the runtime processes the message, it passes this data to the * run_started event so the server can create the run if it doesn't exist yet. */ -export const RunInputSchema = z.object({ - input: z.unknown(), - deploymentId: z.string(), - workflowName: z.string(), - specVersion: z.number(), - executionContext: z.record(z.string(), z.any()).optional(), - /** Initial plaintext run attributes, for resilient run creation. */ - attributes: z.record(z.string(), z.string()).optional(), - /** - * Permits reserved `$`-prefixed keys in `attributes`, mirrored from the - * `start()` option so resilient run creation validates the same way as - * the original `run_created` attempt. - */ - allowReservedAttributes: z.literal(true).optional(), - /** - * The environment the creating client's writes are attributed to, as - * reported by {@link World.getEnvironment} at `start()` time (on Vercel: - * `'production' | 'preview' | 'development'`). - * - * This exists so the resilient-start path can be checked for a tenant - * mismatch. `start()` writes `run_created` under the caller's own - * credentials while pinning the queue message to a deployment, and those - * two can disagree: if the message is consumed by a deployment in a - * DIFFERENT environment, that consumer's `run_started` re-creates the run - * under ITS tenant, so the same client-minted `wrun_` id ends up existing - * in two environments: one stuck pending forever, the other executing. - * Carrying the creator's environment lets the consumer compare it against - * its own and refuse the delivery instead of forking the run. - * - * Absent for worlds with no environment dimension (local, Postgres), and - * for older SDKs. Consumers must treat it as advisory and skip the check - * when it is missing. - */ - environment: z.string().optional(), -}); +export const RunInputSchema = z.compile( + z.object({ + input: z.unknown(), + deploymentId: z.string(), + workflowName: z.string(), + specVersion: z.number(), + executionContext: z.record(z.string(), z.any()).optional(), + /** Initial plaintext run attributes, for resilient run creation. */ + attributes: z.record(z.string(), z.string()).optional(), + /** + * Permits reserved `$`-prefixed keys in `attributes`, mirrored from the + * `start()` option so resilient run creation validates the same way as + * the original `run_created` attempt. + */ + allowReservedAttributes: z.literal(true).optional(), + /** + * The environment the creating client's writes are attributed to, as + * reported by {@link World.getEnvironment} at `start()` time (on Vercel: + * `'production' | 'preview' | 'development'`). + * + * This exists so the resilient-start path can be checked for a tenant + * mismatch. `start()` writes `run_created` under the caller's own + * credentials while pinning the queue message to a deployment, and those + * two can disagree: if the message is consumed by a deployment in a + * DIFFERENT environment, that consumer's `run_started` re-creates the run + * under ITS tenant, so the same client-minted `wrun_` id ends up existing + * in two environments: one stuck pending forever, the other executing. + * Carrying the creator's environment lets the consumer compare it against + * its own and refuse the delivery instead of forking the run. + * + * Absent for worlds with no environment dimension (local, Postgres), and + * for older SDKs. Consumers must treat it as advisory and skip the check + * when it is missing. + */ + environment: z.string().optional(), + }) +); export type RunInput = z.infer; /** @@ -165,54 +172,58 @@ export type RunInput = z.infer; * The `input` is the already-serialized (and possibly encrypted) step input: * the identical bytes the producer also sent on the direct `events.create`. */ -export const StepDispatchInputSchema = z.object({ - /** - * The serialized step input, reused verbatim from the direct write. Always - * binary: producers only attach `stepInput` when the dehydrated input is a - * `Uint8Array` and the run's queue transport preserves bytes (CBOR). - * Validated here so a malformed or transport-mangled payload fails the - * message parse instead of being silently written into a `step_created` as - * non-binary data. `Buffer` is a `Uint8Array` subclass and passes. - */ - input: z.custom((value) => value instanceof Uint8Array, { - message: 'stepInput.input must be a Uint8Array', - }), -}); +export const StepDispatchInputSchema = z.compile( + z.object({ + /** + * The serialized step input, reused verbatim from the direct write. Always + * binary: producers only attach `stepInput` when the dehydrated input is a + * `Uint8Array` and the run's queue transport preserves bytes (CBOR). + * Validated here so a malformed or transport-mangled payload fails the + * message parse instead of being silently written into a `step_created` as + * non-binary data. `Buffer` is a `Uint8Array` subclass and passes. + */ + input: z.custom((value) => value instanceof Uint8Array, { + message: 'stepInput.input must be a Uint8Array', + }), + }) +); export type StepDispatchInput = z.infer; -export const HookResumeInputSchema = z.object({ - /** Stable idempotency key minted once per `resumeHook()` call. */ - resumeId: z.string(), - /** The hook being resumed. */ - hookId: z.string(), - /** - * The hook's token, written into the `hook_received` event's `eventData` so - * the consumer's re-ensured event carries the same token the producer - * would. Replay validates `eventData.token` against the `createHook` token. - */ - token: z.string(), - /** The serialized resume payload, reused verbatim from the direct write. */ - payload: z.unknown(), - /** - * Content digest of `payload`, computed once by the producer over the - * serialized bytes and forwarded verbatim on both the direct `events.create` - * and this queue message. The consumer forwards it back to the server so both - * writers of the same `resumeId` record an identical digest on the - * `(runId, resumeId)` constraint, required because the v4 payload ref is not - * content-stable server-side. - */ - payloadDigest: z.string(), - /** - * The deployment the run is pinned to, from the producer's resume context. - * Lets the consumer detect a misrouted delivery with a cheap ambient - * deployment-id comparison BEFORE its hoisted `hook_received` replay-preload - * write: only a detected mismatch pays for the authoritative run fetch and - * the deployment-affinity guard. Optional for queued-message compatibility: - * messages from older producers omit it and skip the pre-write - * check (the authoritative guard before replay still protects them). - */ - deploymentId: z.string().optional(), -}); +export const HookResumeInputSchema = z.compile( + z.object({ + /** Stable idempotency key minted once per `resumeHook()` call. */ + resumeId: z.string(), + /** The hook being resumed. */ + hookId: z.string(), + /** + * The hook's token, written into the `hook_received` event's `eventData` so + * the consumer's re-ensured event carries the same token the producer + * would. Replay validates `eventData.token` against the `createHook` token. + */ + token: z.string(), + /** The serialized resume payload, reused verbatim from the direct write. */ + payload: z.unknown(), + /** + * Content digest of `payload`, computed once by the producer over the + * serialized bytes and forwarded verbatim on both the direct `events.create` + * and this queue message. The consumer forwards it back to the server so both + * writers of the same `resumeId` record an identical digest on the + * `(runId, resumeId)` constraint, required because the v4 payload ref is not + * content-stable server-side. + */ + payloadDigest: z.string(), + /** + * The deployment the run is pinned to, from the producer's resume context. + * Lets the consumer detect a misrouted delivery with a cheap ambient + * deployment-id comparison BEFORE its hoisted `hook_received` replay-preload + * write: only a detected mismatch pays for the authoritative run fetch and + * the deployment-affinity guard. Optional for queued-message compatibility: + * messages from older producers omit it and skip the pre-write + * check (the authoritative guard before replay still protects them). + */ + deploymentId: z.string().optional(), + }) +); export type HookResumeInput = z.infer; /** @@ -244,132 +255,136 @@ export type HookResumeInput = z.infer; * parse of the whole invocation payload (which would wedge the run), since it * is only ever forwarded to a span attribute. */ -export const HookResumeTimingSchema = z.object({ - /** Epoch ms at entry into `resumeHook()`: the start of the TTR window. */ - resumeRequestedAtMs: z.number(), - /** Epoch ms immediately before the queue publish was requested. */ - queuePublishRequestedAtMs: z.number(), - /** - * Which `resumeHook()` dispatch path ran. Current producers always report - * `sequential` (durable write, then wake); older producers may report - * `lazy` or `parallel`. - */ - strategy: z.string().optional(), - /** Epoch ms the final consumer's queue handler was entered. */ - consumerStartedAtMs: z.number().optional(), - /** Epoch ms this invocation's first replay pass began. */ - replayStartedAtMs: z.number().optional(), - /** Epoch ms replay first encountered a durable step after the resume. */ - nextStepEncounteredAtMs: z.number().optional(), - /** How the consumer initialized replay state: see `ResumeSetupSource`. */ - setupSource: z.string().optional(), -}); +export const HookResumeTimingSchema = z.compile( + z.object({ + /** Epoch ms at entry into `resumeHook()`: the start of the TTR window. */ + resumeRequestedAtMs: z.number(), + /** Epoch ms immediately before the queue publish was requested. */ + queuePublishRequestedAtMs: z.number(), + /** + * Which `resumeHook()` dispatch path ran. Current producers always report + * `sequential` (durable write, then wake); older producers may report + * `lazy` or `parallel`. + */ + strategy: z.string().optional(), + /** Epoch ms the final consumer's queue handler was entered. */ + consumerStartedAtMs: z.number().optional(), + /** Epoch ms this invocation's first replay pass began. */ + replayStartedAtMs: z.number().optional(), + /** Epoch ms replay first encountered a durable step after the resume. */ + nextStepEncounteredAtMs: z.number().optional(), + /** How the consumer initialized replay state: see `ResumeSetupSource`. */ + setupSource: z.string().optional(), + }) +); export type HookResumeTiming = z.infer; -export const WorkflowInvokePayloadSchema = z.object({ - runId: z.string(), - traceCarrier: TraceCarrierSchema.optional(), - requestedAt: z.coerce.date().optional(), - /** - * Consecutive replay divergences in this recovery chain and latest position. - * - * `eventIds` is every divergent event id in the chain, oldest first, so the - * terminal failure can say whether each recovery replay diverged at the same - * event or wandered. The producer bounds it to the recovery budget plus one. - * `.catch(undefined)` on the field for the reason `hookResumeTiming` has it: - * a malformed history must degrade to "no history" rather than fail the - * parse of every delivery of this message. A consumer that predates the - * field ignores it; a producer that predates it sends none, and the consumer - * restarts the history from `eventId`. - */ - replayDivergence: z - .object({ - eventId: z.string(), - count: z.number().int().positive(), - eventIds: z.array(z.string()).optional().catch(undefined), - }) - .optional(), - /** - * Re-invocations so far in this chain of stale-snapshot (precondition) - * rejections. Counted on the message because the in-process restart budget - * lives in the invocation closure and the queue's delivery count resets on - * every fresh enqueue, so without this a permanently fenced run would cycle - * forever instead of failing. - */ - preconditionReinvocations: z.number().int().positive().optional(), - /** Number of times this message has been re-enqueued due to server errors (5xx) */ - serverErrorRetryCount: z.number().int().optional(), - /** Number of times this message has been re-routed after a deployment mismatch */ - deploymentMismatchRetryCount: z.number().int().nonnegative().optional(), - /** - * The wait this message is the delayed continuation for, and which attempt - * in that wait's chain it is. - * - * Present only on wait-continuation messages. It exists so the invocation a - * continuation wakes can recognize itself as that continuation: if the wait - * is STILL pending when it replays — the continuation arrived before its - * deadline — then its own idempotency key is already spent, and re-enqueueing - * under the same key is silently dropped by the world's dedupe window. The - * attempt number is what makes the next key fresh, so an early delivery - * costs one extra hop instead of losing the wait's timer permanently. - * - * Counted on the message for the same reason as - * {@link WorkflowInvokePayloadSchema.shape.preconditionReinvocations}: the - * budget has to survive across invocations, and a fresh enqueue resets - * anything the queue tracks itself. Absent on the first continuation, so a - * producer that predates this field is indistinguishable from attempt 0 and - * a consumer that predates it simply ignores the field. - */ - /** - * `.catch(undefined)` for the reason `hookResumeTiming` has it: this field - * must never be able to fail the parse of the invocation payload. A - * malformed value would otherwise throw on every delivery of the message - * and burn the run's delivery budget. Degrading to `undefined` reads as - * "not a continuation", which costs at worst the pre-attempt behavior for - * that one wait rather than killing the run. - */ - waitContinuation: z - .object({ - correlationId: z.string(), - attempt: z.number().int().nonnegative(), - }) - .optional() - .catch(undefined), - /** Step ID for inline step execution in combined handler. If provided, the flow execution - * will jump directly to execute the step with the given ID before doing an event replay. */ - stepId: z.string().optional(), - /** Step name, sent alongside stepId to avoid loading the event log to resolve the name. */ - stepName: z.string().optional(), - /** Run creation data, only present on the first queue delivery from start() */ - runInput: RunInputSchema.optional(), - /** - * Legacy lazy hook resume data. A consumer that understands this field - * idempotently ensures the `hook_received` event exists (keyed by `resumeId`) - * before replaying. - */ - hookInput: HookResumeInputSchema.optional(), - /** - * Resilient step dispatch data, only present alongside `stepId` when the - * producer parallelized the `step_created` write with this queue publish. A - * consumer that understands this field idempotently ensures the - * `step_created` event exists (keyed by `stepId`) before executing the step. - */ - stepInput: StepDispatchInputSchema.optional(), - /** - * Hook-resume TTR timing. Present on both `resumeHook()` dispatch paths - * (unlike legacy `hookInput`), and - * forwarded onto a dispatched step message when the resuming invocation - * hands the next durable step to another invocation. Purely observational. - * See {@link HookResumeTimingSchema}. - * - * `.catch(undefined)` because this field must never be able to fail the - * parse of the invocation payload: a malformed value (a NaN boundary, a - * shape change from a future producer) would otherwise throw on every - * delivery of that message and burn the run's delivery budget over a - * telemetry field. Anything unparseable degrades to "no measurement". - */ - hookResumeTiming: HookResumeTimingSchema.optional().catch(undefined), -}); +export const WorkflowInvokePayloadSchema = z.compile( + z.object({ + runId: z.string(), + traceCarrier: TraceCarrierSchema.optional(), + requestedAt: z.coerce.date().optional(), + /** + * Consecutive replay divergences in this recovery chain and latest position. + * + * `eventIds` is every divergent event id in the chain, oldest first, so the + * terminal failure can say whether each recovery replay diverged at the same + * event or wandered. The producer bounds it to the recovery budget plus one. + * `.catch(undefined)` on the field for the reason `hookResumeTiming` has it: + * a malformed history must degrade to "no history" rather than fail the + * parse of every delivery of this message. A consumer that predates the + * field ignores it; a producer that predates it sends none, and the consumer + * restarts the history from `eventId`. + */ + replayDivergence: z + .object({ + eventId: z.string(), + count: z.number().int().positive(), + eventIds: z.array(z.string()).optional().catch(undefined), + }) + .optional(), + /** + * Re-invocations so far in this chain of stale-snapshot (precondition) + * rejections. Counted on the message because the in-process restart budget + * lives in the invocation closure and the queue's delivery count resets on + * every fresh enqueue, so without this a permanently fenced run would cycle + * forever instead of failing. + */ + preconditionReinvocations: z.number().int().positive().optional(), + /** Number of times this message has been re-enqueued due to server errors (5xx) */ + serverErrorRetryCount: z.number().int().optional(), + /** Number of times this message has been re-routed after a deployment mismatch */ + deploymentMismatchRetryCount: z.number().int().nonnegative().optional(), + /** + * The wait this message is the delayed continuation for, and which attempt + * in that wait's chain it is. + * + * Present only on wait-continuation messages. It exists so the invocation a + * continuation wakes can recognize itself as that continuation: if the wait + * is STILL pending when it replays — the continuation arrived before its + * deadline — then its own idempotency key is already spent, and re-enqueueing + * under the same key is silently dropped by the world's dedupe window. The + * attempt number is what makes the next key fresh, so an early delivery + * costs one extra hop instead of losing the wait's timer permanently. + * + * Counted on the message for the same reason as + * {@link WorkflowInvokePayloadSchema.shape.preconditionReinvocations}: the + * budget has to survive across invocations, and a fresh enqueue resets + * anything the queue tracks itself. Absent on the first continuation, so a + * producer that predates this field is indistinguishable from attempt 0 and + * a consumer that predates it simply ignores the field. + */ + /** + * `.catch(undefined)` for the reason `hookResumeTiming` has it: this field + * must never be able to fail the parse of the invocation payload. A + * malformed value would otherwise throw on every delivery of the message + * and burn the run's delivery budget. Degrading to `undefined` reads as + * "not a continuation", which costs at worst the pre-attempt behavior for + * that one wait rather than killing the run. + */ + waitContinuation: z + .object({ + correlationId: z.string(), + attempt: z.number().int().nonnegative(), + }) + .optional() + .catch(undefined), + /** Step ID for inline step execution in combined handler. If provided, the flow execution + * will jump directly to execute the step with the given ID before doing an event replay. */ + stepId: z.string().optional(), + /** Step name, sent alongside stepId to avoid loading the event log to resolve the name. */ + stepName: z.string().optional(), + /** Run creation data, only present on the first queue delivery from start() */ + runInput: RunInputSchema.optional(), + /** + * Legacy lazy hook resume data. A consumer that understands this field + * idempotently ensures the `hook_received` event exists (keyed by `resumeId`) + * before replaying. + */ + hookInput: HookResumeInputSchema.optional(), + /** + * Resilient step dispatch data, only present alongside `stepId` when the + * producer parallelized the `step_created` write with this queue publish. A + * consumer that understands this field idempotently ensures the + * `step_created` event exists (keyed by `stepId`) before executing the step. + */ + stepInput: StepDispatchInputSchema.optional(), + /** + * Hook-resume TTR timing. Present on both `resumeHook()` dispatch paths + * (unlike legacy `hookInput`), and + * forwarded onto a dispatched step message when the resuming invocation + * hands the next durable step to another invocation. Purely observational. + * See {@link HookResumeTimingSchema}. + * + * `.catch(undefined)` because this field must never be able to fail the + * parse of the invocation payload: a malformed value (a NaN boundary, a + * shape change from a future producer) would otherwise throw on every + * delivery of that message and burn the run's delivery budget over a + * telemetry field. Anything unparseable degrades to "no measurement". + */ + hookResumeTiming: HookResumeTimingSchema.optional().catch(undefined), + }) +); export type WorkflowInvokePayload = z.infer; export type HealthCheckPayload = z.infer; @@ -378,21 +393,23 @@ export type HealthCheckPayload = z.infer; * Health check payload - used to verify that the queue pipeline * can deliver messages to the combined workflow endpoint. */ -export const HealthCheckPayloadSchema = z.object({ - __healthCheck: z.literal(true), - correlationId: z.string(), - /** - * The run id the caller is about to create, when the probe is being used to - * prepare a cross-deployment `start()`. - * - * The responder runs inside the target deployment, so it can derive that - * run's public key locally from its own key material and return it in the - * probe response. That lets the caller seal the workflow arguments without - * a separate key lookup. Absent for probes issued for other reasons (CLI - * health command, dashboard), in which case no key is returned. - */ - runId: z.string().optional(), -}); +export const HealthCheckPayloadSchema = z.compile( + z.object({ + __healthCheck: z.literal(true), + correlationId: z.string(), + /** + * The run id the caller is about to create, when the probe is being used to + * prepare a cross-deployment `start()`. + * + * The responder runs inside the target deployment, so it can derive that + * run's public key locally from its own key material and return it in the + * probe response. That lets the caller seal the workflow arguments without + * a separate key lookup. Absent for probes issued for other reasons (CLI + * health command, dashboard), in which case no key is returned. + */ + runId: z.string().optional(), + }) +); /** * Health check MUST come first. @@ -411,10 +428,9 @@ export const HealthCheckPayloadSchema = z.object({ * Ordering health check first is safe in the other direction: it requires * `__healthCheck: true`, which an invoke payload never carries. */ -export const QueuePayloadSchema = z.union([ - HealthCheckPayloadSchema, - WorkflowInvokePayloadSchema, -]); +export const QueuePayloadSchema = z.compile( + z.union([HealthCheckPayloadSchema, WorkflowInvokePayloadSchema]) +); export type QueuePayload = z.infer; export interface QueueOptions { diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index 73e79fd83c..c5b2629fd0 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -3,19 +3,13 @@ import { type SerializedData, SerializedDataSchema } from './serialization.js'; import type { PaginationOptions, ResolveData } from './shared.js'; // Workflow run schemas -export const WorkflowRunStatusSchema = z.enum([ - 'pending', - 'running', - 'completed', - 'failed', - 'cancelled', -]); +export const WorkflowRunStatusSchema = z.compile( + z.enum(['pending', 'running', 'completed', 'failed', 'cancelled']) +); export type WorkflowRunStatus = z.infer; -export const TerminalWorkflowRunStatusSchema = WorkflowRunStatusSchema.extract([ - 'completed', - 'failed', - 'cancelled', -] as const); +export const TerminalWorkflowRunStatusSchema = z.compile( + WorkflowRunStatusSchema.extract(['completed', 'failed', 'cancelled'] as const) +); export type TerminalWorkflowRunStatus = z.infer< typeof TerminalWorkflowRunStatusSchema >; @@ -38,126 +32,130 @@ export function isTerminalWorkflowRunStatus( * - specVersion >= 2: Uint8Array (binary devalue format) * - specVersion 1: any (legacy JSON format) */ -export const WorkflowRunBaseSchema = z.object({ - runId: z.string(), - status: WorkflowRunStatusSchema, - deploymentId: z.string(), - /** - * The machine-readable name of the workflow function. - * - * This field contains a structured identifier like `workflow//./src/workflows/order//processOrder` - * that encodes the workflow's module specifier and function name. - * - * Use `parseWorkflowName()` from `@workflow/utils/parse-name` to extract: - * - `shortName`: User-friendly display name (e.g., `"processOrder"`) - * - `moduleSpecifier`: The module path or package (e.g., `"./src/workflows/order"`) - * - `functionName`: The full function path (e.g., `"processOrder"`) - * - * @example - * ```ts - * import { parseWorkflowName } from "@workflow/utils/parse-name"; - * - * const parsed = parseWorkflowName(run.workflowName); - * // parsed.shortName → "processOrder" - * // parsed.moduleSpecifier → "./src/workflows/order" - * ``` - */ - workflowName: z.string(), - // Optional in database for backward compatibility, defaults to 1 (legacy) when reading - specVersion: z.number().optional(), - executionContext: z.record(z.string(), z.any()).optional(), - input: SerializedDataSchema.optional(), - output: SerializedDataSchema.optional(), - /** - * The thrown value from a run_failed event, serialized via the workflow - * serialization pipeline. To display the error to a user, hydrate it via - * `hydrateRunError` (with the encryption key if encryption is enabled). - * Observability tools cannot view the error without going through the - * decryption + hydration pipeline. - */ - error: SerializedDataSchema.optional(), - /** - * The high-level error category (USER_ERROR, RUNTIME_ERROR, etc.) from a - * run_failed event. Kept as plaintext metadata for routing and filtering - * without needing to decrypt the full error payload. - */ - errorCode: z.string().optional(), - /** - * Plaintext string-string metadata attached to the run via - * `setAttributes()` (or, in the future, materialized - * from `attr_set` events). Stored unencrypted alongside other - * plaintext fields so observability surfaces can read it without - * going through the decryption pipeline. - * - * Defaults to `{}` after schema parsing so consumers always receive - * a record regardless of world. World adapters need not initialize - * the field on disk: `world-local` JSON files written before this - * field existed, and rows from any other adapter that omits the - * column, both read as `{}` after Zod parses them. - * - * EXPERIMENTAL (MVP): the full Workflow Attributes feature replaces - * the direct-mutation MVP path with an event-sourced model. See - * the attributes-mvp changelog entry. - */ - attributes: z.record(z.string(), z.string()).default({}), - /** - * The run's X25519 public key, base64-encoded (~44 chars). - * - * Lets any party that can read this run seal a payload *to* it without - * being able to read the run's data. This is used for cross-run writes - * such as a hook resumption from another deployment, or a child workflow - * writing into a forwarded stream. The matching private scalar is never - * stored: it is re-derived on demand from the deployment's own key - * material, so this field is not secret and its presence does not weaken - * the run's confidentiality. - * - * Stamped at run creation by SDKs that support sealed (`encp`) envelopes. - * **Presence is the writer-side gate**: a run only carries a public key if - * the runtime that created it could also open sealed payloads, and runs are - * pinned to their creating deployment. Writers therefore seal iff this - * field is set, and otherwise fall back to fetching the symmetric per-run - * key. Absent on runs created by older SDKs, and on worlds that do not - * implement `getEncryptionKeyForRun` (encryption disabled). - */ - encryptionPublicKey: z.string().optional(), - expiredAt: z.coerce.date().optional(), - startedAt: z.coerce.date().optional(), - completedAt: z.coerce.date().optional(), - createdAt: z.coerce.date(), - updatedAt: z.coerce.date(), -}); - -// Discriminated union based on status -export const WorkflowRunSchema = z.discriminatedUnion('status', [ - // Non-final states - WorkflowRunBaseSchema.extend({ - status: z.enum(['pending', 'running']), - output: z.undefined().optional(), - error: z.undefined().optional(), - completedAt: z.undefined().optional(), - }), - // Cancelled state - WorkflowRunBaseSchema.extend({ - status: z.literal('cancelled'), - output: z.undefined().optional(), - error: z.undefined().optional(), - completedAt: z.coerce.date(), - }), - // Completed state - output can be v1 or v2 format - WorkflowRunBaseSchema.extend({ - status: z.literal('completed'), +export const WorkflowRunBaseSchema = z.compile( + z.object({ + runId: z.string(), + status: WorkflowRunStatusSchema, + deploymentId: z.string(), + /** + * The machine-readable name of the workflow function. + * + * This field contains a structured identifier like `workflow//./src/workflows/order//processOrder` + * that encodes the workflow's module specifier and function name. + * + * Use `parseWorkflowName()` from `@workflow/utils/parse-name` to extract: + * - `shortName`: User-friendly display name (e.g., `"processOrder"`) + * - `moduleSpecifier`: The module path or package (e.g., `"./src/workflows/order"`) + * - `functionName`: The full function path (e.g., `"processOrder"`) + * + * @example + * ```ts + * import { parseWorkflowName } from "@workflow/utils/parse-name"; + * + * const parsed = parseWorkflowName(run.workflowName); + * // parsed.shortName → "processOrder" + * // parsed.moduleSpecifier → "./src/workflows/order" + * ``` + */ + workflowName: z.string(), + // Optional in database for backward compatibility, defaults to 1 (legacy) when reading + specVersion: z.number().optional(), + executionContext: z.record(z.string(), z.any()).optional(), + input: SerializedDataSchema.optional(), output: SerializedDataSchema.optional(), - error: z.undefined().optional(), - completedAt: z.coerce.date(), - }), - // Failed state - WorkflowRunBaseSchema.extend({ - status: z.literal('failed'), - output: z.undefined().optional(), + /** + * The thrown value from a run_failed event, serialized via the workflow + * serialization pipeline. To display the error to a user, hydrate it via + * `hydrateRunError` (with the encryption key if encryption is enabled). + * Observability tools cannot view the error without going through the + * decryption + hydration pipeline. + */ error: SerializedDataSchema.optional(), - completedAt: z.coerce.date(), - }), -]); + /** + * The high-level error category (USER_ERROR, RUNTIME_ERROR, etc.) from a + * run_failed event. Kept as plaintext metadata for routing and filtering + * without needing to decrypt the full error payload. + */ + errorCode: z.string().optional(), + /** + * Plaintext string-string metadata attached to the run via + * `setAttributes()` (or, in the future, materialized + * from `attr_set` events). Stored unencrypted alongside other + * plaintext fields so observability surfaces can read it without + * going through the decryption pipeline. + * + * Defaults to `{}` after schema parsing so consumers always receive + * a record regardless of world. World adapters need not initialize + * the field on disk: `world-local` JSON files written before this + * field existed, and rows from any other adapter that omits the + * column, both read as `{}` after Zod parses them. + * + * EXPERIMENTAL (MVP): the full Workflow Attributes feature replaces + * the direct-mutation MVP path with an event-sourced model. See + * the attributes-mvp changelog entry. + */ + attributes: z.record(z.string(), z.string()).default({}), + /** + * The run's X25519 public key, base64-encoded (~44 chars). + * + * Lets any party that can read this run seal a payload *to* it without + * being able to read the run's data. This is used for cross-run writes + * such as a hook resumption from another deployment, or a child workflow + * writing into a forwarded stream. The matching private scalar is never + * stored: it is re-derived on demand from the deployment's own key + * material, so this field is not secret and its presence does not weaken + * the run's confidentiality. + * + * Stamped at run creation by SDKs that support sealed (`encp`) envelopes. + * **Presence is the writer-side gate**: a run only carries a public key if + * the runtime that created it could also open sealed payloads, and runs are + * pinned to their creating deployment. Writers therefore seal iff this + * field is set, and otherwise fall back to fetching the symmetric per-run + * key. Absent on runs created by older SDKs, and on worlds that do not + * implement `getEncryptionKeyForRun` (encryption disabled). + */ + encryptionPublicKey: z.string().optional(), + expiredAt: z.coerce.date().optional(), + startedAt: z.coerce.date().optional(), + completedAt: z.coerce.date().optional(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + }) +); + +// Discriminated union based on status +export const WorkflowRunSchema = z.compile( + z.discriminatedUnion('status', [ + // Non-final states + WorkflowRunBaseSchema.extend({ + status: z.enum(['pending', 'running']), + output: z.undefined().optional(), + error: z.undefined().optional(), + completedAt: z.undefined().optional(), + }), + // Cancelled state + WorkflowRunBaseSchema.extend({ + status: z.literal('cancelled'), + output: z.undefined().optional(), + error: z.undefined().optional(), + completedAt: z.coerce.date(), + }), + // Completed state - output can be v1 or v2 format + WorkflowRunBaseSchema.extend({ + status: z.literal('completed'), + output: SerializedDataSchema.optional(), + error: z.undefined().optional(), + completedAt: z.coerce.date(), + }), + // Failed state + WorkflowRunBaseSchema.extend({ + status: z.literal('failed'), + output: z.undefined().optional(), + error: SerializedDataSchema.optional(), + completedAt: z.coerce.date(), + }), + ]) +); // Inferred types export type WorkflowRun = z.infer; @@ -235,16 +233,18 @@ export const BULK_CANCEL_MAX_RUN_IDS = 500; * `cancelReason` (max 512 chars) is recorded on each run_cancelled event, * mirroring the single-run {@link CancelRunOptions.cancelReason}. */ -export const BulkCancelWorkflowRunsRequestSchema = z.object({ - runIds: z - .array(z.string()) - .min(1) - .max(BULK_CANCEL_MAX_RUN_IDS) - .refine((ids) => new Set(ids).size === ids.length, { - message: 'runIds must not contain duplicates', - }), - cancelReason: z.string().max(512).optional(), -}); +export const BulkCancelWorkflowRunsRequestSchema = z.compile( + z.object({ + runIds: z + .array(z.string()) + .min(1) + .max(BULK_CANCEL_MAX_RUN_IDS) + .refine((ids) => new Set(ids).size === ids.length, { + message: 'runIds must not contain duplicates', + }), + cancelReason: z.string().max(512).optional(), + }) +); export type BulkCancelWorkflowRunsRequest = z.infer< typeof BulkCancelWorkflowRunsRequestSchema >; @@ -260,9 +260,8 @@ export type BulkCancelWorkflowRunsRequest = z.infer< * - `failed`: cancellation failed; `code` is a machine-readable error code * and `retryable` indicates whether retrying may succeed. */ -export const BulkCancelWorkflowRunResultSchema = z.discriminatedUnion( - 'outcome', - [ +export const BulkCancelWorkflowRunResultSchema = z.compile( + z.discriminatedUnion('outcome', [ z.object({ runId: z.string(), outcome: z.literal('cancelled') }), z.object({ runId: z.string(), outcome: z.literal('already_cancelled') }), z.object({ @@ -277,7 +276,7 @@ export const BulkCancelWorkflowRunResultSchema = z.discriminatedUnion( code: z.string(), retryable: z.boolean(), }), - ] + ]) ); export type BulkCancelWorkflowRunResult = z.infer< typeof BulkCancelWorkflowRunResultSchema @@ -287,17 +286,19 @@ export type BulkCancelWorkflowRunResult = z.infer< * Aggregate result of a bulk cancellation. `results` preserves the order of * the requested `runIds`; `summary` counts each outcome. */ -export const BulkCancelWorkflowRunsResultSchema = z.object({ - summary: z.object({ - requested: z.number(), - cancelled: z.number(), - alreadyCancelled: z.number(), - notCancellable: z.number(), - notFound: z.number(), - failed: z.number(), - }), - results: z.array(BulkCancelWorkflowRunResultSchema), -}); +export const BulkCancelWorkflowRunsResultSchema = z.compile( + z.object({ + summary: z.object({ + requested: z.number(), + cancelled: z.number(), + alreadyCancelled: z.number(), + notCancellable: z.number(), + notFound: z.number(), + failed: z.number(), + }), + results: z.array(BulkCancelWorkflowRunResultSchema), + }) +); export type BulkCancelWorkflowRunsResult = z.infer< typeof BulkCancelWorkflowRunsResultSchema >; diff --git a/packages/world/src/serialization.ts b/packages/world/src/serialization.ts index bbb1f014d0..e2218eb0b0 100644 --- a/packages/world/src/serialization.ts +++ b/packages/world/src/serialization.ts @@ -13,20 +13,22 @@ export type SerializedData = Uint8Array | unknown; * Zod schema for validating SerializedData (Uint8Array). * Used for specVersion >= 2. */ -export const BinarySerializedDataSchema: z.ZodType = - z.instanceof(Uint8Array) as z.ZodType; +export const BinarySerializedDataSchema: z.ZodType = z.compile( + z.instanceof(Uint8Array) +) as z.ZodType; /** * Legacy schema for serialized data (specVersion 1). * Legacy data was stored as JSON, so it can be any value. */ -export const LegacySerializedDataSchemaV1: z.ZodType = z.any(); +export const LegacySerializedDataSchemaV1: z.ZodType = z.compile( + z.any() +); /** * Union schema that accepts both v2+ (Uint8Array) and legacy (any) serialized data. * Use this for validation when data may come from either specVersion. */ -export const SerializedDataSchema = z.union([ - BinarySerializedDataSchema, - LegacySerializedDataSchemaV1, -]); +export const SerializedDataSchema = z.compile( + z.union([BinarySerializedDataSchema, LegacySerializedDataSchemaV1]) +); diff --git a/packages/world/src/shared.ts b/packages/world/src/shared.ts index 9c3d6d0e7f..e83c5d1aba 100644 --- a/packages/world/src/shared.ts +++ b/packages/world/src/shared.ts @@ -25,13 +25,15 @@ export interface PaginationOptions { sortOrder?: 'asc' | 'desc'; } -export const PageInfoSchema = z.object({ - currentLookbackDays: z.number(), - maxLookbackDays: z.number(), - currentWindowStart: z.coerce.date(), - maxWindowStart: z.coerce.date(), - upgradeAvailable: z.boolean(), -}); +export const PageInfoSchema = z.compile( + z.object({ + currentLookbackDays: z.number(), + maxLookbackDays: z.number(), + currentWindowStart: z.coerce.date(), + maxWindowStart: z.coerce.date(), + upgradeAvailable: z.boolean(), + }) +); export type PageInfo = z.infer; @@ -39,12 +41,14 @@ export type PageInfo = z.infer; export const PaginatedResponseSchema = ( dataSchema: T ) => - z.object({ - data: z.array(dataSchema), - cursor: z.string().nullable(), - hasMore: z.boolean(), - pageInfo: PageInfoSchema.optional(), - }); + z.compile( + z.object({ + data: z.array(dataSchema), + cursor: z.string().nullable(), + hasMore: z.boolean(), + pageInfo: PageInfoSchema.optional(), + }) + ); // Inferred type from schema export type PaginatedResponse = z.infer< @@ -61,11 +65,13 @@ export type ResolveData = 'none' | 'all'; /** * A standard error schema shape for propogating errors from runs and steps */ -export const StructuredErrorSchema = z.object({ - message: z.string(), - stack: z.string().optional(), - code: z.string().optional(), // Populated with RunErrorCode values (USER_ERROR, RUNTIME_ERROR, etc.) for run_failed events -}); +export const StructuredErrorSchema = z.compile( + z.object({ + message: z.string(), + stack: z.string().optional(), + code: z.string().optional(), // Populated with RunErrorCode values (USER_ERROR, RUNTIME_ERROR, etc.) for run_failed events + }) +); export type StructuredError = z.infer; diff --git a/packages/world/src/steps.ts b/packages/world/src/steps.ts index 53e3d1b77a..d1f08f2dcc 100644 --- a/packages/world/src/steps.ts +++ b/packages/world/src/steps.ts @@ -3,13 +3,9 @@ import { type SerializedData, SerializedDataSchema } from './serialization.js'; import type { PaginationOptions, ResolveData } from './shared.js'; // Step schemas -export const StepStatusSchema = z.enum([ - 'pending', - 'running', - 'completed', - 'failed', - 'cancelled', -]); +export const StepStatusSchema = z.compile( + z.enum(['pending', 'running', 'completed', 'failed', 'cancelled']) +); /** * Schema for workflow steps. @@ -19,65 +15,65 @@ export const StepStatusSchema = z.enum([ * - specVersion 1: any (legacy JSON format) */ // TODO: implement a discriminated union here to match the run schema -export const StepSchema = z.object({ - runId: z.string(), - stepId: z.string(), - /** - * The machine-readable name of the step function. - * - * This field contains a structured identifier like `step//./src/workflows/order//processPayment` - * that encodes the step's module specifier and function name. - * - * Use `parseStepName()` from `@workflow/utils/parse-name` to extract: - * - `shortName`: User-friendly display name (e.g., `"processPayment"`) - * - `moduleSpecifier`: The module path or package (e.g., `"./src/workflows/order"`) - * - `functionName`: The full function path (e.g., `"processPayment"` or `"outer/nested"`) - * - * @example - * ```ts - * import { parseStepName } from "@workflow/utils/parse-name"; - * - * const parsed = parseStepName(step.stepName); - * // parsed.shortName → "processPayment" - * // parsed.moduleSpecifier → "./src/workflows/order" - * ``` - */ - stepName: z.string(), - status: StepStatusSchema, - input: SerializedDataSchema.optional(), - output: SerializedDataSchema.optional(), - /** - * The thrown value from a step_retrying or step_failed event, serialized - * via the workflow serialization pipeline. Tracks the most recent error - * the step encountered, which may be from a retry attempt (step_retrying) - * or the final failure (step_failed). - * - * To display the error to a user, hydrate it via `hydrateStepError` (with - * the encryption key if encryption is enabled). Observability tools cannot - * view the error without going through the decryption + hydration pipeline. - */ - error: SerializedDataSchema.optional(), - attempt: z.number(), - /** - * When the step first started executing. Set by the first step_started event - * and not updated on subsequent retries. - */ - startedAt: z.coerce.date().optional(), - completedAt: z.coerce.date().optional(), - createdAt: z.coerce.date(), - updatedAt: z.coerce.date(), - retryAfter: z.coerce.date().optional(), - // Optional in database for backwards compatibility, defaults to 1 (legacy) when reading - specVersion: z.number().optional(), -}); +export const StepSchema = z.compile( + z.object({ + runId: z.string(), + stepId: z.string(), + /** + * The machine-readable name of the step function. + * + * This field contains a structured identifier like `step//./src/workflows/order//processPayment` + * that encodes the step's module specifier and function name. + * + * Use `parseStepName()` from `@workflow/utils/parse-name` to extract: + * - `shortName`: User-friendly display name (e.g., `"processPayment"`) + * - `moduleSpecifier`: The module path or package (e.g., `"./src/workflows/order"`) + * - `functionName`: The full function path (e.g., `"processPayment"` or `"outer/nested"`) + * + * @example + * ```ts + * import { parseStepName } from "@workflow/utils/parse-name"; + * + * const parsed = parseStepName(step.stepName); + * // parsed.shortName → "processPayment" + * // parsed.moduleSpecifier → "./src/workflows/order" + * ``` + */ + stepName: z.string(), + status: StepStatusSchema, + input: SerializedDataSchema.optional(), + output: SerializedDataSchema.optional(), + /** + * The thrown value from a step_retrying or step_failed event, serialized + * via the workflow serialization pipeline. Tracks the most recent error + * the step encountered, which may be from a retry attempt (step_retrying) + * or the final failure (step_failed). + * + * To display the error to a user, hydrate it via `hydrateStepError` (with + * the encryption key if encryption is enabled). Observability tools cannot + * view the error without going through the decryption + hydration pipeline. + */ + error: SerializedDataSchema.optional(), + attempt: z.number(), + /** + * When the step first started executing. Set by the first step_started event + * and not updated on subsequent retries. + */ + startedAt: z.coerce.date().optional(), + completedAt: z.coerce.date().optional(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + retryAfter: z.coerce.date().optional(), + // Optional in database for backwards compatibility, defaults to 1 (legacy) when reading + specVersion: z.number().optional(), + }) +); // Inferred types export type StepStatus = z.infer; -export const TerminalStepStatusSchema = StepStatusSchema.extract([ - 'completed', - 'failed', - 'cancelled', -] as const); +export const TerminalStepStatusSchema = z.compile( + StepStatusSchema.extract(['completed', 'failed', 'cancelled'] as const) +); export type TerminalStepStatus = z.infer; export const TERMINAL_STEP_STATUSES = TerminalStepStatusSchema.options; diff --git a/packages/world/src/ulid.ts b/packages/world/src/ulid.ts index 9bd09c1cfe..bd4d27eac0 100644 --- a/packages/world/src/ulid.ts +++ b/packages/world/src/ulid.ts @@ -2,7 +2,7 @@ import { decodeTime } from 'ulid'; import { z } from 'zod'; import { isSlotBody } from './slot-identity.js'; -const UlidSchema = z.string().ulid(); +const UlidSchema = z.compile(z.string().ulid()); /** * A workflow run ID: the `wrun_` prefix followed by a 26-char ULID (minted @@ -10,7 +10,9 @@ const UlidSchema = z.string().ulid(); * well-formed ULID) rather than a loose length bound, so callers can't smuggle * arbitrary strings through APIs that persist a run ID verbatim. */ -export const workflowRunIdSchema = z.templateLiteral(['wrun_', z.ulid()]); +export const workflowRunIdSchema = z.compile( + z.templateLiteral(['wrun_', z.ulid()]) +); export type WorkflowRunId = z.infer; diff --git a/packages/world/src/waits.ts b/packages/world/src/waits.ts index 547c9268d2..ae0da6399f 100644 --- a/packages/world/src/waits.ts +++ b/packages/world/src/waits.ts @@ -1,17 +1,19 @@ import { z } from 'zod'; -export const WaitStatusSchema = z.enum(['waiting', 'completed']); +export const WaitStatusSchema = z.compile(z.enum(['waiting', 'completed'])); -export const WaitSchema = z.object({ - waitId: z.string(), - runId: z.string(), - status: WaitStatusSchema, - resumeAt: z.coerce.date().optional(), - completedAt: z.coerce.date().optional(), - createdAt: z.coerce.date(), - updatedAt: z.coerce.date(), - specVersion: z.number().optional(), -}); +export const WaitSchema = z.compile( + z.object({ + waitId: z.string(), + runId: z.string(), + status: WaitStatusSchema, + resumeAt: z.coerce.date().optional(), + completedAt: z.coerce.date().optional(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + specVersion: z.number().optional(), + }) +); export type WaitStatus = z.infer; export type Wait = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f587d65b2..6fe4e3b9ca 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.5.4 + version: 4.5.4 overrides: '@vercel/queue>@vercel/oidc': 3.2.0 @@ -276,7 +276,7 @@ importers: version: link:../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: bun: specifier: ^1.3.0 @@ -313,14 +313,14 @@ importers: version: link:../utils zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 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.5.4) typescript: specifier: 'catalog:' version: 6.0.3 @@ -330,19 +330,19 @@ importers: optionalDependencies: '@ai-sdk/anthropic': specifier: ^3.0.0 - version: 3.0.58(zod@4.3.6) + version: 3.0.58(zod@4.5.4) '@ai-sdk/gateway': specifier: ^3.0.0 - version: 3.0.66(zod@4.3.6) + version: 3.0.66(zod@4.5.4) '@ai-sdk/google': specifier: ^3.0.0 - version: 3.0.43(zod@4.3.6) + version: 3.0.43(zod@4.5.4) '@ai-sdk/openai': specifier: ^3.0.0 - version: 3.0.41(zod@4.3.6) + version: 3.0.41(zod@4.5.4) '@ai-sdk/xai': specifier: ^3.0.0 - version: 3.0.67(zod@4.3.6) + version: 3.0.67(zod@4.5.4) packages/astro: dependencies: @@ -521,7 +521,7 @@ importers: version: 5.1.0 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@types/node': specifier: 'catalog:' @@ -597,7 +597,7 @@ importers: version: 3.0.1 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@opentelemetry/api': specifier: 1.9.1 @@ -652,13 +652,13 @@ importers: version: link:../next ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) workflow: specifier: workspace:* version: link:../workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@types/node': specifier: 'catalog:' @@ -1312,7 +1312,7 @@ importers: version: link:../tsconfig ai: specifier: 'catalog:' - version: 6.0.116(zod@4.4.3) + version: 6.0.116(zod@4.5.4) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1385,7 +1385,7 @@ importers: version: 3.0.1 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@types/node': specifier: 'catalog:' @@ -1428,7 +1428,7 @@ importers: version: 7.29.0 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@opentelemetry/api': specifier: 1.9.1 @@ -1492,7 +1492,7 @@ importers: version: 3.0.1 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@testcontainers/postgresql': specifier: 11.12.0 @@ -1578,7 +1578,7 @@ importers: version: link:../workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@types/jsonlines': specifier: 0.1.5 @@ -1630,7 +1630,7 @@ importers: version: 8.20.0 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@opentelemetry/api': specifier: 1.9.1 @@ -1776,7 +1776,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) astro: specifier: ^7.0.6 version: 7.0.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@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(supports-color@10.2.2))(jiti@2.7.0)(rollup@4.62.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) @@ -1785,13 +1785,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.5.4) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/example: dependencies: @@ -1809,7 +1809,7 @@ importers: version: link:../../packages/ai ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 @@ -1818,13 +1818,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.5.4) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@types/lodash.chunk': specifier: ^4.2.9 @@ -1862,19 +1862,19 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) 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.5.4) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/fastify: dependencies: @@ -1896,13 +1896,13 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) 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.5.4) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1911,7 +1911,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/hono: devDependencies: @@ -1923,7 +1923,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) hono: specifier: ^4.12.27 version: 4.12.28 @@ -1935,13 +1935,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(supports-color@10.2.2))(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.5.4) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/nest: dependencies: @@ -1965,7 +1965,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) express: specifier: ^5.2.1 version: 5.2.1(supports-color@10.2.2) @@ -1974,7 +1974,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.5.4) reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -2005,13 +2005,13 @@ importers: version: 6.0.3 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 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.5.4) '@node-rs/xxhash': specifier: 1.7.6 version: 1.7.6 @@ -2044,7 +2044,7 @@ importers: version: link:../../packages/ai ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -2074,7 +2074,7 @@ importers: version: 16.3.4(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(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.5.4) 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) @@ -2101,7 +2101,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -2135,7 +2135,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.5.4) '@node-rs/xxhash': specifier: 1.7.6 version: 1.7.6 @@ -2168,7 +2168,7 @@ importers: version: link:../../packages/ai ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -2198,7 +2198,7 @@ importers: version: 16.3.4(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(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.5.4) 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) @@ -2225,7 +2225,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -2265,7 +2265,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) h3: specifier: ^1.15.5 version: 1.15.11 @@ -2277,13 +2277,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)(supports-color@10.2.2) 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.5.4) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/nitro-v3: dependencies: @@ -2299,7 +2299,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 @@ -2308,7 +2308,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(supports-color@10.2.2))(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.5.4) rollup: specifier: ^4.62.2 version: 4.62.2 @@ -2317,7 +2317,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/nuxt: dependencies: @@ -2336,7 +2336,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) h3: specifier: ^1.15.5 version: 1.15.11 @@ -2348,7 +2348,7 @@ importers: version: 4.4.8(847e7fff0111f9c0edd64ee9a61b9e3a) 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.5.4) 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) @@ -2357,7 +2357,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/python: devDependencies: @@ -2387,7 +2387,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.5.4) '@node-rs/xxhash': specifier: 1.7.6 version: 1.7.6 @@ -2411,7 +2411,7 @@ importers: version: link:../../packages/ai ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) exsolve: specifier: ^1.0.7 version: 1.0.7 @@ -2423,7 +2423,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@sveltejs/kit': specifier: ^2.69.1 @@ -2573,7 +2573,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 @@ -2582,7 +2582,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(supports-color@10.2.2))(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.5.4) 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) @@ -2591,7 +2591,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/vite: dependencies: @@ -2607,7 +2607,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 @@ -2616,7 +2616,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(supports-color@10.2.2))(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.5.4) 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) @@ -2625,7 +2625,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/vitest: devDependencies: @@ -2646,7 +2646,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 packages: @@ -17908,12 +17908,12 @@ 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==} + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} @@ -17934,89 +17934,75 @@ packages: snapshots: - '@ai-sdk/anthropic@3.0.58(zod@4.3.6)': + '@ai-sdk/anthropic@3.0.58(zod@4.5.4)': 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.5.4) + zod: 4.5.4 optional: true - '@ai-sdk/gateway@2.0.0(zod@4.3.6)': + '@ai-sdk/gateway@2.0.0(zod@4.5.4)': 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.5.4) '@vercel/oidc': 3.0.3 - zod: 4.3.6 + zod: 4.5.4 - '@ai-sdk/gateway@3.0.143(zod@4.4.3)': + '@ai-sdk/gateway@3.0.143(zod@4.5.4)': dependencies: '@ai-sdk/provider': 3.0.13 - '@ai-sdk/provider-utils': 4.0.35(zod@4.4.3) + '@ai-sdk/provider-utils': 4.0.35(zod@4.5.4) '@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 + zod: 4.5.4 - '@ai-sdk/gateway@3.0.66(zod@4.4.3)': + '@ai-sdk/gateway@3.0.66(zod@4.5.4)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.4.3) + '@ai-sdk/provider-utils': 4.0.19(zod@4.5.4) '@vercel/oidc': 3.1.0 - zod: 4.4.3 + zod: 4.5.4 - '@ai-sdk/google@3.0.43(zod@4.3.6)': + '@ai-sdk/google@3.0.43(zod@4.5.4)': 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.5.4) + zod: 4.5.4 optional: true - '@ai-sdk/openai-compatible@2.0.35(zod@4.3.6)': + '@ai-sdk/openai-compatible@2.0.35(zod@4.5.4)': 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.5.4) + zod: 4.5.4 optional: true - '@ai-sdk/openai@3.0.41(zod@4.3.6)': + '@ai-sdk/openai@3.0.41(zod@4.5.4)': 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.5.4) + zod: 4.5.4 optional: true - '@ai-sdk/provider-utils@3.0.12(zod@4.3.6)': + '@ai-sdk/provider-utils@3.0.12(zod@4.5.4)': dependencies: '@ai-sdk/provider': 2.0.0 '@standard-schema/spec': 1.0.0 eventsource-parser: 3.0.6 - zod: 4.3.6 + zod: 4.5.4 - '@ai-sdk/provider-utils@4.0.19(zod@4.3.6)': + '@ai-sdk/provider-utils@4.0.19(zod@4.5.4)': dependencies: '@ai-sdk/provider': 3.0.8 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 - zod: 4.3.6 + zod: 4.5.4 - '@ai-sdk/provider-utils@4.0.19(zod@4.4.3)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 4.4.3 - - '@ai-sdk/provider-utils@4.0.35(zod@4.4.3)': + '@ai-sdk/provider-utils@4.0.35(zod@4.5.4)': dependencies: '@ai-sdk/provider': 3.0.13 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.1.0 - zod: 4.4.3 + zod: 4.5.4 '@ai-sdk/provider@2.0.0': dependencies: @@ -18030,32 +18016,32 @@ 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.5.4)': 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.5.4) + ai: 5.0.76(zod@4.5.4) react: 19.2.7 swr: 2.3.6(react@19.2.7) throttleit: 2.1.0 optionalDependencies: - zod: 4.3.6 + zod: 4.5.4 - '@ai-sdk/react@3.0.221(react@19.2.4)(zod@4.4.3)': + '@ai-sdk/react@3.0.221(react@19.2.4)(zod@4.5.4)': dependencies: - '@ai-sdk/provider-utils': 4.0.35(zod@4.4.3) - ai: 6.0.219(zod@4.4.3) + '@ai-sdk/provider-utils': 4.0.35(zod@4.5.4) + ai: 6.0.219(zod@4.5.4) react: 19.2.4 swr: 2.3.6(react@19.2.4) throttleit: 2.1.0 transitivePeerDependencies: - zod - '@ai-sdk/xai@3.0.67(zod@4.3.6)': + '@ai-sdk/xai@3.0.67(zod@4.5.4)': dependencies: - '@ai-sdk/openai-compatible': 2.0.35(zod@4.3.6) + '@ai-sdk/openai-compatible': 2.0.35(zod@4.5.4) '@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.5.4) + zod: 4.5.4 optional: true '@alloc/quick-lru@5.2.0': {} @@ -26314,7 +26300,7 @@ snapshots: jiti: 2.7.0 magic-string: 0.30.21 prettier: 3.8.1 - zod: 4.4.3 + zod: 4.5.4 transitivePeerDependencies: - supports-color @@ -26328,7 +26314,7 @@ snapshots: '@tanstack/router-utils': 1.162.2(supports-color@10.2.2) chokidar: 5.0.0 unplugin: 3.0.0 - zod: 4.4.3 + zod: 4.5.4 optionalDependencies: '@tanstack/react-router': 1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 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) @@ -26379,7 +26365,7 @@ snapshots: ufo: 1.6.4 vitefu: 1.1.3(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)) xmlbuilder2: 4.0.3 - zod: 4.4.3 + zod: 4.5.4 optionalDependencies: 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) transitivePeerDependencies: @@ -26956,7 +26942,7 @@ snapshots: '@vercel/geistdocs@1.23.1(40d2a3683a2d7e627776171e9299d6a1)': dependencies: - '@ai-sdk/react': 3.0.221(react@19.2.4)(zod@4.4.3) + '@ai-sdk/react': 3.0.221(react@19.2.4)(zod@4.5.4) '@clack/prompts': 0.11.0 '@icons-pack/react-simple-icons': 13.8.0(react@19.2.4) '@orama/tokenizers': 3.1.18 @@ -26964,7 +26950,7 @@ snapshots: '@streamdown/code': 1.0.2(react@19.2.4) '@vercel/agent-readability': 0.6.0(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.46.4))(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)))(svelte@5.56.4(@typescript-eslint/types@8.46.4))(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)))(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(h3@1.15.11)(next@16.3.3(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@vercel/oidc': 3.8.2 - ai: 6.0.219(zod@4.4.3) + ai: 6.0.219(zod@4.5.4) class-variance-authority: 0.7.1 clsx: 2.1.1 commander: 14.0.3 @@ -26997,7 +26983,7 @@ snapshots: unist-util-visit: 5.1.0 use-stick-to-bottom: 1.1.1(react@19.2.4) vaul: 1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - zod: 4.4.3 + zod: 4.5.4 transitivePeerDependencies: - '@emotion/is-prop-valid' - '@fumadocs/mdx-remote' @@ -27792,37 +27778,29 @@ snapshots: agent-base@7.1.4: {} - ai@5.0.76(zod@4.3.6): + ai@5.0.76(zod@4.5.4): dependencies: - '@ai-sdk/gateway': 2.0.0(zod@4.3.6) + '@ai-sdk/gateway': 2.0.0(zod@4.5.4) '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@4.3.6) - '@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) + '@ai-sdk/provider-utils': 3.0.12(zod@4.5.4) '@opentelemetry/api': 1.9.1 - zod: 4.3.6 + zod: 4.5.4 - ai@6.0.116(zod@4.4.3): + ai@6.0.116(zod@4.5.4): dependencies: - '@ai-sdk/gateway': 3.0.66(zod@4.4.3) + '@ai-sdk/gateway': 3.0.66(zod@4.5.4) '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.4.3) + '@ai-sdk/provider-utils': 4.0.19(zod@4.5.4) '@opentelemetry/api': 1.9.1 - zod: 4.4.3 + zod: 4.5.4 - ai@6.0.219(zod@4.4.3): + ai@6.0.219(zod@4.5.4): dependencies: - '@ai-sdk/gateway': 3.0.143(zod@4.4.3) + '@ai-sdk/gateway': 3.0.143(zod@4.5.4) '@ai-sdk/provider': 3.0.13 - '@ai-sdk/provider-utils': 4.0.35(zod@4.4.3) + '@ai-sdk/provider-utils': 4.0.35(zod@4.5.4) '@opentelemetry/api': 1.9.1 - zod: 4.4.3 + zod: 4.5.4 ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: @@ -28031,7 +28009,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: @@ -28123,7 +28101,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: @@ -30205,7 +30183,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.5.4 optionalDependencies: next: 16.3.3(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 @@ -33308,25 +33286,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.5.4): optionalDependencies: ws: 8.20.0 - zod: 4.3.6 + zod: 4.5.4 - openai@6.6.0(ws@8.20.0)(zod@4.3.6): + openai@6.6.0(ws@8.20.0)(zod@4.5.4): optionalDependencies: ws: 8.20.0 - zod: 4.3.6 + zod: 4.5.4 - openai@6.9.0(ws@8.20.0)(zod@4.3.6): + openai@6.9.0(ws@8.20.0)(zod@4.5.4): optionalDependencies: ws: 8.20.0 - zod: 4.3.6 + zod: 4.5.4 - openai@6.9.1(ws@8.20.0)(zod@4.3.6): + openai@6.9.1(ws@8.20.0)(zod@4.5.4): optionalDependencies: ws: 8.20.0 - zod: 4.3.6 + zod: 4.5.4 optionator@0.9.4: dependencies: @@ -37664,10 +37642,10 @@ snapshots: zod@4.1.11: {} - zod@4.3.6: {} - zod@4.4.3: {} + zod@4.5.4: {} + zustand@4.5.7(@types/react@19.1.13)(react@19.1.0): dependencies: use-sync-external-store: 1.6.0(react@19.1.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1ff94d75eb..66e0613d2f 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.5.4 overrides: # `@vercel/queue` accepts any `@vercel/oidc` in the 3.x line, and 3.3+ pulls in