diff --git a/.changeset/centralize-event-semantics.md b/.changeset/centralize-event-semantics.md new file mode 100644 index 0000000000..e346b67043 --- /dev/null +++ b/.changeset/centralize-event-semantics.md @@ -0,0 +1,12 @@ +--- +'@workflow/world': minor +'@workflow/core': patch +'@workflow/world-local': patch +'@workflow/world-postgres': patch +'@workflow/world-vercel': patch +'@workflow/web-shared': patch +'@workflow/web': patch +'@workflow/cli': patch +--- + +Centralize workflow event type classifiers and event-data payload field helpers. diff --git a/packages/cli/src/commands/cancel.ts b/packages/cli/src/commands/cancel.ts index f2a02462a2..a01515a3c4 100644 --- a/packages/cli/src/commands/cancel.ts +++ b/packages/cli/src/commands/cancel.ts @@ -2,7 +2,7 @@ import readline from 'node:readline'; import { Args, Flags } from '@oclif/core'; import { cancelRun } from '@workflow/core/runtime'; import { parseWorkflowName } from '@workflow/utils/parse-name'; -import type { WorkflowRun } from '@workflow/world'; +import { WorkflowRunStatusSchema } from '@workflow/world'; import chalk from 'chalk'; import Table from 'easy-table'; import { BaseCommand } from '../base.js'; @@ -50,7 +50,7 @@ export default class Cancel extends BaseCommand { status: Flags.string({ description: 'Filter runs by status for bulk cancel', required: false, - options: ['running', 'completed', 'failed', 'cancelled', 'pending'], + options: [...WorkflowRunStatusSchema.options], helpGroup: 'Bulk Cancel', helpLabel: '--status', }), @@ -110,7 +110,9 @@ export default class Cancel extends BaseCommand { // Fetch matching runs. Only metadata is needed to display and cancel, so // prefer the analytics read path when the backend provides one. - const status = flags.status as WorkflowRun['status'] | undefined; + const status = flags.status + ? WorkflowRunStatusSchema.parse(flags.status) + : undefined; const limit = flags.limit || 50; const analytics = world.analytics; const fetchMatches = async () => { diff --git a/packages/cli/src/commands/inspect.ts b/packages/cli/src/commands/inspect.ts index 6a64698a46..23b052f5bb 100644 --- a/packages/cli/src/commands/inspect.ts +++ b/packages/cli/src/commands/inspect.ts @@ -1,5 +1,6 @@ import { Args, Flags } from '@oclif/core'; import { VERCEL_403_ERROR_MESSAGE } from '@workflow/errors'; +import { WorkflowRunStatusSchema } from '@workflow/world'; import { BaseCommand } from '../base.js'; import { LOGGING_CONFIG, logger } from '../lib/config/log.js'; import type { InspectCLIOptions } from '../lib/config/types.js'; @@ -125,7 +126,7 @@ export default class Inspect extends BaseCommand { status: Flags.string({ description: 'filter runs by status (only for runs)', required: false, - options: ['running', 'completed', 'failed', 'cancelled', 'pending'], + options: [...WorkflowRunStatusSchema.options], helpGroup: 'Filtering', helpLabel: '--status', }), @@ -297,7 +298,9 @@ function toInspectOptions(flags: any): InspectCLIOptions { sort: flags.sort as 'asc' | 'desc' | undefined, limit: flags.limit, workflowName: flags.workflowName, - status: flags.status, + status: flags.status + ? WorkflowRunStatusSchema.parse(flags.status) + : undefined, since: flags.since, until: flags.until, withData: flags.withData, diff --git a/packages/cli/src/lib/config/types.ts b/packages/cli/src/lib/config/types.ts index a9d4461cf7..749c4289bc 100644 --- a/packages/cli/src/lib/config/types.ts +++ b/packages/cli/src/lib/config/types.ts @@ -1,4 +1,6 @@ // Re-export builder types for backwards compatibility +import type { WorkflowRunStatus } from '@workflow/world'; + export type { BuildTarget, WorkflowConfig, @@ -18,7 +20,7 @@ export type InspectCLIOptions = { sort?: 'asc' | 'desc'; limit?: number; workflowName?: string; - status?: string; + status?: WorkflowRunStatus; /** Listing window start: relative duration (12h, 7d) or timestamp (runs only) */ since?: string; /** Listing window end: relative duration or timestamp; requires --since */ diff --git a/packages/cli/src/lib/inspect/hydration.test.ts b/packages/cli/src/lib/inspect/hydration.test.ts new file mode 100644 index 0000000000..499f7bec06 --- /dev/null +++ b/packages/cli/src/lib/inspect/hydration.test.ts @@ -0,0 +1,32 @@ +import { importKey } from '@workflow/core/encryption'; +import { dehydrateStepError } from '@workflow/core/serialization'; +import { describe, expect, it } from 'vitest'; +import { hydrateResourceIO, isEncryptedRef } from './hydration.js'; + +describe('hydrateResourceIO', () => { + it('displays and decrypts encrypted event errors', async () => { + const rawKey = new Uint8Array(32).fill(7); + const error = new Error('step failed'); + const encryptedError = await dehydrateStepError( + error, + 'run_1', + await importKey(rawKey), + [], + globalThis, + true + ); + const event = { + runId: 'run_1', + eventId: 'event_1', + eventType: 'step_failed', + eventData: { error: encryptedError }, + }; + + const encrypted = await hydrateResourceIO(event); + expect(isEncryptedRef(encrypted.eventData.error)).toBe(true); + + const decrypted = await hydrateResourceIO(event, async () => rawKey); + expect(decrypted.eventData.error).toBeInstanceOf(Error); + expect(decrypted.eventData.error).toHaveProperty('message', error.message); + }); +}); diff --git a/packages/cli/src/lib/inspect/hydration.ts b/packages/cli/src/lib/inspect/hydration.ts index 499edaaac7..6f1817bbc4 100644 --- a/packages/cli/src/lib/inspect/hydration.ts +++ b/packages/cli/src/lib/inspect/hydration.ts @@ -19,6 +19,7 @@ import { serializedInstanceToRef, } from '@workflow/core/serialization-format'; import { parseClassName } from '@workflow/utils/parse-name'; +import { getEventDataRefFields } from '@workflow/world'; import chalk from 'chalk'; /** @@ -324,6 +325,7 @@ async function maybeDecryptFields< input?: any; output?: any; metadata?: any; + eventType?: string; eventData?: any; }, >(resource: T, resolver: EncryptionKeyResolver): Promise { @@ -350,13 +352,7 @@ async function maybeDecryptFields< // Decrypt eventData fields (Event) if (result.eventData && typeof result.eventData === 'object') { const eventData = { ...result.eventData }; - for (const field of [ - 'result', - 'input', - 'output', - 'metadata', - 'payload', - ]) { + for (const field of getEventDataRefFields(result.eventType ?? '')) { eventData[field] = await maybeDecrypt(eventData[field], k); } result.eventData = eventData; @@ -406,7 +402,9 @@ function replaceEncryptedAndExpiredWithRef(resource: T): T { if (result.eventData && typeof result.eventData === 'object') { const ed = { ...(result.eventData as Record) }; - for (const key of ['result', 'input', 'output', 'metadata', 'payload']) { + const eventType = + typeof result.eventType === 'string' ? result.eventType : ''; + for (const key of getEventDataRefFields(eventType)) { ed[key] = toDisplayRef(ed[key]); } result.eventData = ed; diff --git a/packages/cli/src/lib/inspect/output.ts b/packages/cli/src/lib/inspect/output.ts index 5e88f1ecdf..8934cb9b89 100644 --- a/packages/cli/src/lib/inspect/output.ts +++ b/packages/cli/src/lib/inspect/output.ts @@ -6,7 +6,14 @@ import { } from '@workflow/core/serialization'; import { VERCEL_403_ERROR_MESSAGE } from '@workflow/errors'; import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name'; -import type { Event, Hook, Step, WorkflowRun, World } from '@workflow/world'; +import { + type Event, + type Hook, + isWaitEventType, + type Step, + type WorkflowRun, + type World, +} from '@workflow/world'; import chalk from 'chalk'; import { formatDistance } from 'date-fns'; @@ -619,7 +626,7 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => { const useAnalytics = !opts.withData && Boolean(world.analytics); const resolveData = opts.withData ? 'all' : 'none'; - const status = opts.status as WorkflowRun['status'] | undefined; + const status = opts.status; // Resolve --since/--until into an explicit listing window. Only the // analytics read path supports one; the runtime storage APIs have no time @@ -1371,7 +1378,7 @@ export const listSleeps = async ( // Group wait events by correlationId const waitEventsByCorrelation = new Map(); for (const event of events.data) { - if (event.correlationId?.startsWith('wait_')) { + if (isWaitEventType(event.eventType) && event.correlationId) { const existing = waitEventsByCorrelation.get(event.correlationId) ?? []; existing.push(event); waitEventsByCorrelation.set(event.correlationId, existing); diff --git a/packages/core/src/serialization-format.test.ts b/packages/core/src/serialization-format.test.ts index efca288de6..e1204a68c4 100644 --- a/packages/core/src/serialization-format.test.ts +++ b/packages/core/src/serialization-format.test.ts @@ -204,8 +204,8 @@ describe('hydrateResourceIO', () => { const event = { eventId: 'evt_123', + eventType: 'step_completed', eventData: { - type: 'step_completed', result: resultPayload, }, }; @@ -213,7 +213,6 @@ describe('hydrateResourceIO', () => { const hydrated = hydrateResourceIO(event, testRevivers); expect(hydrated.eventId).toBe('evt_123'); expect(hydrated.eventData.result).toEqual({ key: 'value' }); - expect(hydrated.eventData.type).toBe('step_completed'); }); it('should hydrate event eventData.output', () => { @@ -221,8 +220,8 @@ describe('hydrateResourceIO', () => { const event = { eventId: 'evt_456', + eventType: 'run_completed', eventData: { - type: 'run_completed', output: outputPayload, }, }; @@ -230,7 +229,20 @@ describe('hydrateResourceIO', () => { const hydrated = hydrateResourceIO(event, testRevivers); expect(hydrated.eventId).toBe('evt_456'); expect(hydrated.eventData.output).toEqual({ message: 'done' }); - expect(hydrated.eventData.type).toBe('run_completed'); + }); + + it.each([ + 'run_started', + 'step_started', + ] as const)('should hydrate eventData.input for %s events', (eventType) => { + const event = { + eventId: `evt_${eventType}`, + eventType, + eventData: { input: makeDevlPayload({ value: eventType }) }, + }; + + const hydrated = hydrateResourceIO(event, testRevivers); + expect(hydrated.eventData.input).toEqual({ value: eventType }); }); it('should hydrate event eventData.metadata for hook_created events', () => { @@ -238,8 +250,8 @@ describe('hydrateResourceIO', () => { const event = { eventId: 'evt_hook_created', + eventType: 'hook_created', eventData: { - type: 'hook_created', token: 'hook_tok_123', metadata: metadataPayload, }, @@ -259,8 +271,8 @@ describe('hydrateResourceIO', () => { const event = { eventId: 'evt_hook_received', + eventType: 'hook_received', eventData: { - type: 'hook_received', payload, }, }; @@ -278,8 +290,8 @@ describe('hydrateResourceIO', () => { const event = { eventId: 'evt_step_failed', + eventType: 'step_failed', eventData: { - type: 'step_failed', error: errorPayload, }, }; @@ -290,7 +302,6 @@ describe('hydrateResourceIO', () => { message: 'something blew up', stack: 'Error: something blew up\n at foo:1:1', }); - expect(hydrated.eventData.type).toBe('step_failed'); }); it('should hydrate hook metadata', () => { diff --git a/packages/core/src/serialization-format.ts b/packages/core/src/serialization-format.ts index 1fbbd6bfdb..87947ef7cc 100644 --- a/packages/core/src/serialization-format.ts +++ b/packages/core/src/serialization-format.ts @@ -6,6 +6,7 @@ * o11y, CLI o11y). It has NO Node.js dependencies. */ +import { getEventDataRefFields } from '@workflow/world'; import { parse, unflatten } from 'devalue'; // --------------------------------------------------------------------------- @@ -690,66 +691,17 @@ function hydrateWorkflowIO< /** * Hydrate the eventData fields of an event resource. */ -function hydrateEventData( - resource: T, - revivers: Revivers -): T { +function hydrateEventData< + T extends { eventId?: string; eventType?: string; eventData?: any }, +>(resource: T, revivers: Revivers): T { if (!resource.eventData) return resource; const eventData = { ...resource.eventData }; - // step_completed events have eventData.result (serialized return value) - if ('result' in eventData && eventData.result != null) { - try { - eventData.result = hydrateData(eventData.result, revivers); - } catch { - // Leave un-hydrated - } - } - - // step_created events have eventData.input (serialized step arguments) - if ('input' in eventData && eventData.input != null) { - try { - eventData.input = hydrateData(eventData.input, revivers); - } catch { - // Leave un-hydrated - } - } - - // run_completed events have eventData.output (serialized return value) - if ('output' in eventData && eventData.output != null) { - try { - eventData.output = hydrateData(eventData.output, revivers); - } catch { - // Leave un-hydrated - } - } - - // hook_created events may have serialized metadata - if ('metadata' in eventData && eventData.metadata != null) { - try { - eventData.metadata = hydrateData(eventData.metadata, revivers); - } catch { - // Leave un-hydrated - } - } - - // hook_received events have eventData.payload (serialized hook payload) - if ('payload' in eventData && eventData.payload != null) { - try { - eventData.payload = hydrateData(eventData.payload, revivers); - } catch { - // Leave un-hydrated - } - } - - // step_failed / step_retrying / run_failed events have eventData.error - // (the thrown value, serialized via the error pipeline). Without this, - // event listings in o11y tooling would surface the raw `Uint8Array` - // payload instead of a hydrated `{ name, message, stack, … }` object. - if ('error' in eventData && eventData.error != null) { + for (const field of getEventDataRefFields(resource.eventType ?? '')) { + if (eventData[field] == null) continue; try { - eventData.error = hydrateData(eventData.error, revivers); + eventData[field] = hydrateData(eventData[field], revivers); } catch { // Leave un-hydrated } @@ -791,6 +743,7 @@ export function hydrateResourceIO< stepId?: string; hookId?: string; eventId?: string; + eventType?: string; input?: any; output?: any; metadata?: any; diff --git a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx index fe4ea839ed..812f4b9412 100644 --- a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx +++ b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx @@ -1,7 +1,12 @@ 'use client'; import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name'; -import type { Event, Hook, WorkflowRun } from '@workflow/world'; +import { + type Event, + type Hook, + isTerminalWorkflowRunStatus, + type WorkflowRun, +} from '@workflow/world'; import clsx from 'clsx'; import { Send, Zap } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -77,10 +82,7 @@ export function EntityDetailPanel({ correlationId: string ) => Promise<{ stoppedCount: number }>; /** Callback to load event data for a specific event (lazy loading) */ - onLoadEventData?: ( - correlationId: string, - eventId: string - ) => Promise; + onLoadEventData?: (event: Event) => Promise; /** Callback to resolve a hook with a payload. */ onResolveHook?: ( hookToken: string, @@ -133,8 +135,7 @@ export function EntityDetailPanel({ const canWakeUp = useMemo(() => { void rawEventsLength; if (resource !== 'sleep' || !rawEvents) return false; - const terminalStates = ['completed', 'failed', 'cancelled']; - if (terminalStates.includes(run.status)) return false; + if (isTerminalWorkflowRunStatus(run.status)) return false; const hasWaitCreated = rawEvents.some( (e) => e.eventType === 'wait_created' ); @@ -153,8 +154,7 @@ export function EntityDetailPanel({ // Check if we already resolved this hook in this session if (resolvedHookIds.has(resourceId)) return false; - const terminalStates = ['completed', 'failed', 'cancelled']; - if (terminalStates.includes(run.status)) return false; + if (isTerminalWorkflowRunStatus(run.status)) return false; const hasHookDisposed = rawEvents.some( (e) => e.eventType === 'hook_disposed' ); diff --git a/packages/web-shared/src/components/sidebar/events-list.tsx b/packages/web-shared/src/components/sidebar/events-list.tsx index 5996049228..6a06e69ab7 100644 --- a/packages/web-shared/src/components/sidebar/events-list.tsx +++ b/packages/web-shared/src/components/sidebar/events-list.tsx @@ -1,12 +1,8 @@ 'use client'; -import type { Event } from '@workflow/world'; +import { type Event, getEventDataRefFields } from '@workflow/world'; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { - getEventDataRefFields, - hasEncryptedFields, - isExpiredMarker, -} from '../../lib/hydration'; +import { hasEncryptedFields, isExpiredMarker } from '../../lib/hydration'; import { Collapsible, CollapsibleContent, @@ -21,29 +17,6 @@ import { TimestampTooltip } from '../ui/timestamp-tooltip'; import { AttrSetEventBlock } from './attributes-block'; import { CopyableDataBlock, EncryptedDataBlock } from './copyable-data-block'; -/** - * Event types whose eventData contains an error field with a StructuredError. - */ -const ERROR_EVENT_TYPES = new Set(['step_failed', 'step_retrying']); - -/** - * Event types that carry user-serialized data in their eventData field. - */ -const DATA_EVENT_TYPES = new Set([ - 'step_created', - 'step_completed', - 'step_failed', - 'step_retrying', - 'hook_created', - 'hook_received', - 'run_created', - 'run_completed', - 'run_failed', - 'wait_created', - 'wait_completed', - 'attr_set', -]); - const parseDateValue = (value: unknown): Date | null => { if (value == null) return null; @@ -76,10 +49,7 @@ function EventItem({ showSeparateEventOccurrenceTimestamps = false, }: { event: Event; - onLoadEventData?: ( - correlationId: string, - eventId: string - ) => Promise; + onLoadEventData?: (event: Event) => Promise; /** When this changes (e.g., Decrypt was clicked), invalidate cached data */ encryptionKey?: Uint8Array; /** Show occurredAt separately instead of folding it into the Created timestamp. */ @@ -96,19 +66,20 @@ function EventItem({ const existingData = 'eventData' in event && event.eventData != null ? event.eventData : null; const mergedDisplay = loadedData ?? existingData; - const canHaveData = DATA_EVENT_TYPES.has(event.eventType); + const canHaveData = + existingData !== null || getEventDataRefFields(event.eventType).length > 0; const loadEventData = useCallback( - async (options?: { force?: boolean }) => { - if (!onLoadEventData || !event.correlationId || !event.eventId) return; - if (!options?.force && loadedDataRef.current !== null) { + async (force: boolean) => { + if (!onLoadEventData) return; + if (!force && loadedDataRef.current !== null) { return; } try { setIsLoading(true); setLoadError(null); - const data = await onLoadEventData(event.correlationId, event.eventId); + const data = await onLoadEventData(event); loadedDataRef.current = data; setLoadedData(data); } catch (err) { @@ -117,13 +88,13 @@ function EventItem({ setIsLoading(false); } }, - [onLoadEventData, event.correlationId, event.eventId] + [onLoadEventData, event] ); - const handleExpand = useCallback(async () => { + const handleExpand = useCallback(() => { if (isLoading) return; wasExpandedRef.current = true; - await loadEventData(); + void loadEventData(false); }, [isLoading, loadEventData]); // When the encryption key changes and this event was previously expanded, @@ -132,7 +103,7 @@ function EventItem({ if (!encryptionKey || !wasExpandedRef.current) return; loadedDataRef.current = null; setLoadedData(null); - void loadEventData({ force: true }); + void loadEventData(true); }, [encryptionKey, loadEventData]); const createdAt = new Date(event.createdAt); @@ -289,11 +260,10 @@ function EventDataBlock({ return ; } - // For error events (step_failed, step_retrying), the eventData has the shape - // { error: StructuredError, stack?: string, ... }. Check both the top-level - // value and the nested `error` field for a stack trace. + // Error events carry a StructuredError in eventData.error. Check both the + // nested field and the top-level value for legacy payloads. if ( - ERROR_EVENT_TYPES.has(eventType) && + getEventDataRefFields(eventType).includes('error') && data != null && typeof data === 'object' ) { @@ -328,10 +298,7 @@ export function EventsList({ events: Event[]; isLoading?: boolean; error?: Error | null; - onLoadEventData?: ( - correlationId: string, - eventId: string - ) => Promise; + onLoadEventData?: (event: Event) => Promise; onStreamClick?: (streamId: string) => void; onRunClick?: (runId: string) => void; /** When provided, signals that decryption is active (triggers re-load of expanded events) */ diff --git a/packages/web-shared/src/components/sidebar/sidebar-data-context.tsx b/packages/web-shared/src/components/sidebar/sidebar-data-context.tsx index 02a7a85943..dd26e768d8 100644 --- a/packages/web-shared/src/components/sidebar/sidebar-data-context.tsx +++ b/packages/web-shared/src/components/sidebar/sidebar-data-context.tsx @@ -14,10 +14,7 @@ export interface SidebarDataContextValue { runId: string, correlationId: string ) => Promise<{ stoppedCount: number }>; - onLoadEventData?: ( - correlationId: string, - eventId: string - ) => Promise; + onLoadEventData?: (event: Event) => Promise; onResolveHook?: ( hookToken: string, payload: unknown, diff --git a/packages/web-shared/src/components/workflow-trace-view.tsx b/packages/web-shared/src/components/workflow-trace-view.tsx index 2d6e0913ab..7c5c0991f5 100644 --- a/packages/web-shared/src/components/workflow-trace-view.tsx +++ b/packages/web-shared/src/components/workflow-trace-view.tsx @@ -801,10 +801,7 @@ export const WorkflowTraceViewer = ({ /** Callback when a run reference is clicked in the detail panel */ onRunClick?: (runId: string) => void; /** Callback to load event data for a specific event (lazy loading in sidebar) */ - onLoadEventData?: ( - correlationId: string, - eventId: string - ) => Promise; + onLoadEventData?: (event: Event) => Promise; /** Load next trace page when vertical scroll reaches bottom. */ onLoadMoreSpans?: () => void | Promise; /** Whether trace pagination has more data to load. */ diff --git a/packages/web-shared/src/components/workflow-traces/event-colors.ts b/packages/web-shared/src/components/workflow-traces/event-colors.ts index 28dd224acc..3dd5243e3c 100644 --- a/packages/web-shared/src/components/workflow-traces/event-colors.ts +++ b/packages/web-shared/src/components/workflow-traces/event-colors.ts @@ -2,7 +2,7 @@ * Color utilities for workflow event markers */ -import type { Event } from '@workflow/world'; +import { type Event, isHookLifecycleEventType } from '@workflow/world'; export interface EventColorPalette { /** Color of the diamond/marker itself */ @@ -51,11 +51,7 @@ export function getEventColor( } // Webhook-related - Purple - if ( - eventType === 'hook_created' || - eventType === 'hook_received' || - eventType === 'hook_disposed' - ) { + if (isHookLifecycleEventType(eventType)) { return { color: 'var(--ds-purple-600)', background: 'var(--ds-purple-100)', @@ -93,11 +89,7 @@ export function getEventColor( */ export function shouldShowVerticalLine(eventType: Event['eventType']): boolean { // Show vertical lines for hook-related events - if ( - eventType === 'hook_created' || - eventType === 'hook_received' || - eventType === 'hook_disposed' - ) { + if (isHookLifecycleEventType(eventType)) { return true; } diff --git a/packages/web-shared/src/components/workflow-traces/trace-colors.ts b/packages/web-shared/src/components/workflow-traces/trace-colors.ts index c56ea5856e..da658ebf05 100644 --- a/packages/web-shared/src/components/workflow-traces/trace-colors.ts +++ b/packages/web-shared/src/components/workflow-traces/trace-colors.ts @@ -2,7 +2,11 @@ * Color utilities for workflow traces */ -import type { Step, WorkflowRun } from '@workflow/world'; +import { + isHookLifecycleEventType, + type Step, + type WorkflowRun, +} from '@workflow/world'; import styles from '../trace-viewer/trace-viewer.module.css'; import type { SpanNode, SpanNodeEvent } from '../trace-viewer/types'; @@ -99,11 +103,7 @@ export const getCustomSpanEventClassName = ( } // Webhook-related events - Purple - if ( - eventName === 'hook_created' || - eventName === 'hook_received' || - eventName === 'hook_disposed' - ) { + if (isHookLifecycleEventType(eventName)) { return styles.eventHook; } diff --git a/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts b/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts index 6972bdf1e0..bdd4a9d17b 100644 --- a/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts +++ b/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts @@ -3,7 +3,11 @@ */ import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name'; -import type { Event, WorkflowRun } from '@workflow/world'; +import { + type Event, + isTerminalStepEventType, + type WorkflowRun, +} from '@workflow/world'; import type { Span, SpanEvent } from '../trace-viewer/types'; import { shouldShowVerticalLine } from './event-colors'; import { calculateDuration, dateToOtelTime } from './trace-time-utils'; @@ -269,11 +273,7 @@ export function stepToSpan(stepEvents: Event[], maxEndTime: Date): Span | null { const completedEvent = stepEvents .slice() .reverse() - .find( - (event) => - event.eventType === 'step_completed' || - event.eventType === 'step_failed' - ); + .find((event) => isTerminalStepEventType(event.eventType)); endTime = getEventTimestamp(completedEvent) ?? new Date(step.completedAt); } diff --git a/packages/web-shared/src/lib/event-analysis.ts b/packages/web-shared/src/lib/event-analysis.ts index f64e02963b..7fc5a69424 100644 --- a/packages/web-shared/src/lib/event-analysis.ts +++ b/packages/web-shared/src/lib/event-analysis.ts @@ -3,7 +3,11 @@ * Used by run-actions and trace viewer components. */ -import type { Event, WorkflowRunStatus } from '@workflow/world'; +import { + type Event, + isTerminalWorkflowRunStatus, + type WorkflowRunStatus, +} from '@workflow/world'; // Time thresholds for Re-enqueue button visibility const STEP_ACTIVITY_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes @@ -140,9 +144,7 @@ export function analyzeEvents(events: Event[] | undefined): EventAnalysis { export function isTerminalStatus( status: WorkflowRunStatus | undefined ): boolean { - return ( - status === 'completed' || status === 'failed' || status === 'cancelled' - ); + return status ? isTerminalWorkflowRunStatus(status) : false; } /** diff --git a/packages/web-shared/src/lib/event-materialization.ts b/packages/web-shared/src/lib/event-materialization.ts index dfda3af9b4..a605e9c624 100644 --- a/packages/web-shared/src/lib/event-materialization.ts +++ b/packages/web-shared/src/lib/event-materialization.ts @@ -10,7 +10,13 @@ * of making separate API calls for each entity type. */ -import type { Event, StepStatus } from '@workflow/world'; +import { + type Event, + isHookLifecycleEventType, + isStepEventType, + isWaitEventType, + type StepStatus, +} from '@workflow/world'; // --------------------------------------------------------------------------- // Materialized entity types @@ -60,18 +66,18 @@ export interface MaterializedEntities { } // --------------------------------------------------------------------------- -// Helper: group events by correlationId prefix +// Helper: group events by correlationId // --------------------------------------------------------------------------- function groupByCorrelationId( events: Event[], - prefixes: string[] + matchesEventType: (eventType: string) => boolean ): Map { const groups = new Map(); for (const event of events) { const cid = event.correlationId; if (!cid) continue; - if (!prefixes.some((p) => cid.startsWith(p))) continue; + if (!matchesEventType(event.eventType)) continue; const existing = groups.get(cid); if (existing) { existing.push(event); @@ -101,7 +107,7 @@ function getEventTimestamp(event: Event | undefined): Date | undefined { * step_created event with no completion yet. */ export function materializeSteps(events: Event[]): MaterializedStep[] { - const groups = groupByCorrelationId(events, ['step_']); + const groups = groupByCorrelationId(events, isStepEventType); const steps: MaterializedStep[] = []; for (const [correlationId, stepEvents] of groups) { @@ -169,7 +175,7 @@ export function materializeSteps(events: Event[]): MaterializedStep[] { * Group hook_* events by correlationId and build Hook-like entities. */ export function materializeHooks(events: Event[]): MaterializedHook[] { - const groups = groupByCorrelationId(events, ['hook_']); + const groups = groupByCorrelationId(events, isHookLifecycleEventType); const hooks: MaterializedHook[] = []; for (const [correlationId, hookEvents] of groups) { @@ -208,7 +214,7 @@ export function materializeHooks(events: Event[]): MaterializedHook[] { * Group wait_* events by correlationId and build Wait-like entities. */ export function materializeWaits(events: Event[]): MaterializedWait[] { - const groups = groupByCorrelationId(events, ['wait_']); + const groups = groupByCorrelationId(events, isWaitEventType); const waits: MaterializedWait[] = []; for (const [correlationId, waitEvents] of groups) { diff --git a/packages/web-shared/src/lib/hydration.ts b/packages/web-shared/src/lib/hydration.ts index edefa0caea..e15e4fb950 100644 --- a/packages/web-shared/src/lib/hydration.ts +++ b/packages/web-shared/src/lib/hydration.ts @@ -16,21 +16,7 @@ import { type Revivers, serializedInstanceToRef, } from '@workflow/core/serialization-format'; -import { EVENT_DATA_REF_FIELDS } from '@workflow/world'; - -const V4_EXTRA_EVENT_DATA_REF_FIELDS: Record = { - run_started: ['input'], - step_started: ['input'], -}; - -export function getEventDataRefFields(eventType: string): string[] { - return [ - ...new Set([ - ...(EVENT_DATA_REF_FIELDS[eventType] ?? []), - ...(V4_EXTRA_EVENT_DATA_REF_FIELDS[eventType] ?? []), - ]), - ]; -} +import { getEventDataRefFields } from '@workflow/world'; // Re-export types and utilities that consumers need export { diff --git a/packages/web-shared/src/lib/trace-builder.ts b/packages/web-shared/src/lib/trace-builder.ts index cb7bf7e535..0de9bda878 100644 --- a/packages/web-shared/src/lib/trace-builder.ts +++ b/packages/web-shared/src/lib/trace-builder.ts @@ -6,7 +6,13 @@ * a fully-formed Trace ready for the trace viewer. */ -import type { Event, WorkflowRun } from '@workflow/world'; +import { + type Event, + isHookLifecycleEventType, + isStepEventType, + isWaitEventType, + type WorkflowRun, +} from '@workflow/world'; import type { Span } from '../components/trace-viewer/types'; import { getEventTimestamp, @@ -18,20 +24,6 @@ import { } from '../components/workflow-traces/trace-span-construction'; import { otelTimeToMs } from '../components/workflow-traces/trace-time-utils'; -// --------------------------------------------------------------------------- -// Event type classifiers -// --------------------------------------------------------------------------- - -export const isStepEvent = (eventType: string) => eventType.startsWith('step_'); - -export const isTimerEvent = (eventType: string) => - eventType === 'wait_created' || eventType === 'wait_completed'; - -export const isHookLifecycleEvent = (eventType: string) => - eventType === 'hook_received' || - eventType === 'hook_created' || - eventType === 'hook_disposed'; - /** * Events that belong to the run root span rather than a child entity span. * Mirrors the fallthrough logic in {@link groupEventsByCorrelation}: events @@ -41,9 +33,9 @@ export const isHookLifecycleEvent = (eventType: string) => */ export const isRunLevelEvent = (event: Event): boolean => !event.correlationId || - (!isTimerEvent(event.eventType) && - !isHookLifecycleEvent(event.eventType) && - !isStepEvent(event.eventType)); + (!isWaitEventType(event.eventType) && + !isHookLifecycleEventType(event.eventType) && + !isStepEventType(event.eventType)); /** * Filter the raw event list down to the events for a selected span. @@ -100,17 +92,17 @@ export function groupEventsByCorrelation(events: Event[]): GroupedEvents { continue; } - if (isTimerEvent(event.eventType)) { + if (isWaitEventType(event.eventType)) { pushEvent(timerEvents, correlationId, event); continue; } - if (isHookLifecycleEvent(event.eventType)) { + if (isHookLifecycleEventType(event.eventType)) { pushEvent(hookEvents, correlationId, event); continue; } - if (isStepEvent(event.eventType)) { + if (isStepEventType(event.eventType)) { pushEvent(eventsByStepId, correlationId, event); continue; } diff --git a/packages/web/app/components/run-detail-view.tsx b/packages/web/app/components/run-detail-view.tsx index 06f603f9e8..d44d17c137 100644 --- a/packages/web/app/components/run-detail-view.tsx +++ b/packages/web/app/components/run-detail-view.tsx @@ -10,7 +10,7 @@ import { StreamViewer, stepEventsToStepEntity, } from '@workflow/web-shared'; -import type { Event, WorkflowRun } from '@workflow/world'; +import { type Event, isStepEventType, type WorkflowRun } from '@workflow/world'; import { AlertCircle, GitBranch, @@ -123,7 +123,7 @@ function GraphTabContent({ if (!allEvents) return []; const stepEventsMap = new Map(); for (const event of allEvents) { - if (event.eventType.startsWith('step_') && event.correlationId) { + if (isStepEventType(event.eventType) && event.correlationId) { const existing = stepEventsMap.get(event.correlationId); if (existing) { existing.push(event); @@ -313,27 +313,6 @@ export function RunDetailView({ [env] ); - // Callback for sidebar EventsList — takes (correlationId, eventId) - const handleLoadSidebarEventData = useCallback( - async (_correlationId: string, eventId: string) => { - const { error, result } = await unwrapServerActionResult( - fetchEvent(env, runId, eventId, 'all') - ); - if (error) { - throw error; - } - const fullEvent = await hydrateResourceIOAsync( - result, - encryptionKeyRef.current ?? undefined - ); - if ('eventData' in fullEvent) { - return fullEvent.eventData; - } - return null; - }, - [env, runId] - ); - // Only show graph tab for local backend const isLocalBackend = serverConfig.backendId === 'local' || @@ -415,7 +394,7 @@ export function RunDetailView({ onStreamClick: handleStreamClick, onRunClick: handleRunRefClick, onWakeUpSleep: handleWakeUpSleep, - onLoadEventData: handleLoadSidebarEventData, + onLoadEventData: handleLoadEventData, onResolveHook: handleResolveHook, encryptionKey: encryptionKey ?? undefined, onDecrypt: handleDecrypt, @@ -429,7 +408,7 @@ export function RunDetailView({ handleStreamClick, handleRunRefClick, handleWakeUpSleep, - handleLoadSidebarEventData, + handleLoadEventData, handleResolveHook, encryptionKey, handleDecrypt, diff --git a/packages/web/app/lib/flow-graph/workflow-graph-types.ts b/packages/web/app/lib/flow-graph/workflow-graph-types.ts index 429e7bb42e..2053d0aa73 100644 --- a/packages/web/app/lib/flow-graph/workflow-graph-types.ts +++ b/packages/web/app/lib/flow-graph/workflow-graph-types.ts @@ -4,6 +4,8 @@ * The manifest adapter transforms the raw SWC plugin output into this format. */ +import type { WorkflowRunStatus } from '@workflow/world'; + export interface Position { x: number; y: number; @@ -124,7 +126,7 @@ export interface EdgeTraversal { export interface WorkflowRunExecution { runId: string; - status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + status: WorkflowRunStatus; nodeExecutions: Map; // nodeId -> array of executions (for retries) edgeTraversals: Map; // edgeId -> traversal info currentNode?: string; // for running workflows diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index cc0456cdb6..7b3a8ddcc2 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -23,8 +23,13 @@ import { applyAttributeChanges, EventSchema, HookSchema, + isChildEntityCreationEvent, + isHookEventRequiringExistence, + isHookLifecycleEventType, isLegacySpecVersion, + isStepEventType, isTerminalRunEventType, + isTerminalStepEventType, isTerminalStepStatus, isTerminalWorkflowRunStatus, requiresNewerWorld, @@ -625,13 +630,7 @@ export function createEventsStorage( // step_completed / step_failed / step_retrying is atomic. step_created // is also serialized so duplicate-create races don't leave extra // step_created events in the log. - const isStepEvent = - data.eventType === 'step_created' || - data.eventType === 'step_started' || - data.eventType === 'step_completed' || - data.eventType === 'step_failed' || - data.eventType === 'step_retrying'; - if (isStepEvent && runId && data.correlationId) { + if (isStepEventType(data.eventType) && runId && data.correlationId) { const lockKey = tag ? `${runId}-${data.correlationId}.${tag}` : `${runId}-${data.correlationId}`; @@ -656,11 +655,11 @@ export function createEventsStorage( // ordering that is journaled durably and makes every subsequent // replay of the owning run diverge at that event // (https://github.com/vercel/workflow/issues/2781). - const isHookLifecycleEvent = - data.eventType === 'hook_created' || - data.eventType === 'hook_received' || - data.eventType === 'hook_disposed'; - if (isHookLifecycleEvent && runId && data.correlationId) { + if ( + isHookLifecycleEventType(data.eventType) && + runId && + data.correlationId + ) { const lockKey = tag ? `${runId}-${data.correlationId}.hook.${tag}` : `${runId}-${data.correlationId}.hook`; @@ -859,22 +858,12 @@ export function createEventsStorage( // below). This mirrors the resilient run_started path. Detect it here // so the entity-creation terminal-run guard treats it like a creation // and the "step must exist" ordering guard doesn't reject it. + const createsChildEntity = isChildEntityCreationEvent(data); const lazyStepStart = - data.eventType === 'step_started' && - 'eventData' in data && - !!data.eventData && - typeof (data.eventData as { stepName?: unknown }).stepName === - 'string' && - (data.eventData as { input?: unknown }).input !== undefined; + createsChildEntity && data.eventType === 'step_started'; // Run terminal state validation if (currentRun && isTerminalWorkflowRunStatus(currentRun.status)) { - const runTerminalEvents = [ - 'run_started', - 'run_completed', - 'run_failed', - ]; - // Idempotent operation: run_cancelled on already cancelled run is allowed if ( data.eventType === 'run_cancelled' && @@ -906,10 +895,7 @@ export function createEventsStorage( } // Other run state transitions are not allowed on terminal runs - if ( - runTerminalEvents.includes(data.eventType) || - data.eventType === 'run_cancelled' - ) { + if (isTerminalRunEventType(data.eventType)) { throw new EntityConflictError( `Cannot transition run from terminal state "${currentRun.status}"` ); @@ -919,12 +905,7 @@ export function createEventsStorage( // step_started creates a step, so it is rejected here too — a bare // (non-lazy) step_started falls through to the step-validation // block below, which uses RunExpiredError for terminal runs. - if ( - data.eventType === 'step_created' || - data.eventType === 'hook_created' || - data.eventType === 'wait_created' || - lazyStepStart - ) { + if (createsChildEntity) { throw new EntityConflictError( `Cannot create new entities on run in terminal state "${currentRun.status}"` ); @@ -940,13 +921,9 @@ export function createEventsStorage( // Step-related event validation (ordering and terminal state) // Store existingStep so we can reuse it later (avoid double read) let validatedStep: Step | null = null; - const stepEvents = [ - 'step_started', - 'step_completed', - 'step_failed', - 'step_retrying', - ]; - if (stepEvents.includes(data.eventType) && data.correlationId) { + const stepEventRequiresExistingStep = + isStepEventType(data.eventType) && data.eventType !== 'step_created'; + if (stepEventRequiresExistingStep && data.correlationId) { const stepCompositeKey = `${effectiveRunId}-${data.correlationId}`; validatedStep = await readJSONWithFallback( basedir, @@ -1001,9 +978,8 @@ export function createEventsStorage( } // Hook-related event validation (ordering) - const hookEventsRequiringExistence = ['hook_disposed', 'hook_received']; if ( - hookEventsRequiringExistence.includes(data.eventType) && + isHookEventRequiringExistence(data.eventType) && data.correlationId ) { // A resume must never be journaled after the hook's disposal. @@ -1055,13 +1031,11 @@ export function createEventsStorage( // check (packages/core/src/step.ts). if ( lazyStepStart && - 'eventData' in event && - (event as { eventData?: Record }).eventData + event.eventType === 'step_started' && + event.eventData ) { - const { input: _strippedInput, ...rest } = ( - event as { eventData: Record } - ).eventData; - (event as { eventData: Record }).eventData = rest; + const { input: _strippedInput, ...eventData } = event.eventData; + event = { ...event, eventData }; } // Track entity created/updated for EventResult @@ -1394,10 +1368,7 @@ export function createEventsStorage( // synthetic step_created event keeps replay correct (the client step // consumer marks hasCreatedEvent only when it observes that event). if (!validatedStep && lazyStepStart) { - const lazyData = data.eventData as { - stepName: string; - input: any; - }; + const lazyData = data.eventData; const stepCreatedLockName = tag ? `${effectiveRunId}-${data.correlationId}.created.${tag}` : `${effectiveRunId}-${data.correlationId}.created`; @@ -2280,8 +2251,7 @@ export function createEventsStorage( // step_failed), and run-terminal events end the loop. `resolveData` // matches the list path so eventData refs are handled identically. if ( - (data.eventType === 'step_completed' || - data.eventType === 'step_failed') && + isTerminalStepEventType(data.eventType) && typeof params?.sinceCursor === 'string' ) { // Intentionally no `limit`: this returns a single default-size page, diff --git a/packages/world-local/src/storage/hooks-storage.ts b/packages/world-local/src/storage/hooks-storage.ts index 7dcd0c1bf3..ea4a18619d 100644 --- a/packages/world-local/src/storage/hooks-storage.ts +++ b/packages/world-local/src/storage/hooks-storage.ts @@ -50,7 +50,7 @@ function getHookCreatedToken(event: Event): string | undefined { return typeof token === 'string' ? token : undefined; } -function hookFromCreatedEvent(event: Event & HookCreatedEvent): Hook { +function hookFromCreatedEvent(event: HookCreatedEvent): Hook { const { token, metadata, isWebhook, isSystem } = event.eventData; return { runId: event.runId, @@ -70,7 +70,7 @@ function hookFromCreatedEvent(event: Event & HookCreatedEvent): Hook { function isMatchingHookCreatedEvent( event: Event, matches: (event: Event) => boolean -): event is Event & HookCreatedEvent { +): event is HookCreatedEvent { return ( event.eventType === 'hook_created' && typeof event.correlationId === 'string' && @@ -108,7 +108,7 @@ async function findLiveHookCreatedEvent( index: { kind: 'token'; token: string } | { kind: 'id'; hookId: string }, matches: (event: Event) => boolean, tag?: string -): Promise<(Event & HookCreatedEvent) | null> { +): Promise { const newest = await findNewestIndexedHookCreatedEvent( basedir, index, @@ -137,7 +137,7 @@ async function findLiveHookCreatedEvent( async function restoreHookCachesFromEvent( basedir: string, - event: Event & HookCreatedEvent, + event: HookCreatedEvent, tag?: string ): Promise { const hook = hookFromCreatedEvent(event); diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index ef06c06462..d4ff202089 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -31,11 +31,19 @@ import { AttributeValidationError, EventSchema, HookSchema, + isChildEntityCreationEvent, + isChildEntityCreationEventType, + isHookEventRequiringExistence, isLegacySpecVersion, + isTerminalRunEventType, + isTerminalStepStatus, + isTerminalWorkflowRunStatus, requiresNewerWorld, SPEC_VERSION_CURRENT, StepSchema, stripEventDataRefs, + TERMINAL_STEP_STATUSES, + TERMINAL_WORKFLOW_RUN_STATUSES, validateAttributeChanges, validateUlidTimestamp, WorkflowRunSchema, @@ -428,19 +436,11 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { let stepCreatedLazily = false; const now = new Date(); - // Helper to check if run is in terminal state - const isRunTerminal = (status: string) => - ['completed', 'failed', 'cancelled'].includes(status); - - // Helper to check if step is in terminal state - const isStepTerminal = (status: string) => - ['completed', 'failed', 'cancelled'].includes(status); - // Terminal step statuses for use in SQL WHERE clauses (atomic guard). // Must match the Vercel world's conditional expressions: // ne(status, 'completed') AND ne(status, 'failed') AND ne(status, 'cancelled') const terminalStepStatuses: (typeof Schema.steps.status.enumValues)[number][] = - ['completed', 'failed', 'cancelled']; + [...TERMINAL_STEP_STATUSES]; // ============================================================ // VALIDATION: Terminal state and event ordering checks @@ -586,22 +586,12 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // mirrors the resilient run_started path. Detect it here so the // entity-creation terminal-run guard treats it like a creation and the // "step must exist" ordering guard below doesn't reject it. + const createsChildEntity = isChildEntityCreationEvent(data); const lazyStepStart = - data.eventType === 'step_started' && - 'eventData' in data && - !!data.eventData && - typeof (data.eventData as { stepName?: unknown }).stepName === - 'string' && - (data.eventData as { input?: unknown }).input !== undefined; + createsChildEntity && data.eventType === 'step_started'; // Run terminal state validation - if (currentRun && isRunTerminal(currentRun.status)) { - const runTerminalEvents = [ - 'run_started', - 'run_completed', - 'run_failed', - ]; - + if (currentRun && isTerminalWorkflowRunStatus(currentRun.status)) { // Idempotent operation: run_cancelled on already cancelled run is allowed if ( data.eventType === 'run_cancelled' && @@ -650,10 +640,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { } // Other run state transitions are not allowed on terminal runs - if ( - runTerminalEvents.includes(data.eventType) || - data.eventType === 'run_cancelled' - ) { + if (isTerminalRunEventType(data.eventType)) { throw new EntityConflictError( `Cannot transition run from terminal state "${currentRun.status}"` ); @@ -663,12 +650,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // step_started creates a step, so it is rejected here too — a bare // (non-lazy) step_started falls through to the step-validation block // below, which uses RunExpiredError for terminal runs. - if ( - data.eventType === 'step_created' || - data.eventType === 'hook_created' || - data.eventType === 'wait_created' || - lazyStepStart - ) { + if (createsChildEntity) { throw new EntityConflictError( `Cannot create new entities on run in terminal state "${currentRun.status}"` ); @@ -731,14 +713,14 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // where there is nothing terminal to guard against. if (validatedStep) { // Step terminal state validation - if (isStepTerminal(validatedStep.status)) { + if (isTerminalStepStatus(validatedStep.status)) { throw new EntityConflictError( `Cannot modify step in terminal state "${validatedStep.status}"` ); } // On terminal runs: only allow completing/failing in-progress steps - if (currentRun && isRunTerminal(currentRun.status)) { + if (currentRun && isTerminalWorkflowRunStatus(currentRun.status)) { if (validatedStep.status !== 'running') { throw new RunExpiredError( `Cannot modify non-running step on run in terminal state "${currentRun.status}"` @@ -749,11 +731,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { } // Hook-related event validation (ordering) - const hookEventsRequiringExistence = ['hook_disposed', 'hook_received']; - if ( - hookEventsRequiringExistence.includes(data.eventType) && - data.correlationId - ) { + if (isHookEventRequiringExistence(data.eventType) && data.correlationId) { const [existingHook] = await drizzle .select({ hookId: Schema.hooks.hookId }) .from(Schema.hooks) @@ -847,7 +825,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // Must match the Vercel world's conditional expressions: // ne(status, 'completed') AND ne(status, 'failed') AND ne(status, 'cancelled') const terminalRunStatuses: (typeof Schema.runs.status.enumValues)[number][] = - ['completed', 'failed', 'cancelled']; + [...TERMINAL_WORKFLOW_RUN_STATUSES]; // Handle run_completed event: update run status and cleanup hooks // Uses conditional UPDATE to prevent completing an already-terminal run. @@ -877,7 +855,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { if (!existing) { throw new WorkflowRunNotFoundError(effectiveRunId); } - if (isRunTerminal(existing.status)) { + if (isTerminalWorkflowRunStatus(existing.status)) { throw new EntityConflictError( `Cannot transition run from terminal state "${existing.status}"` ); @@ -929,7 +907,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { if (!existing) { throw new WorkflowRunNotFoundError(effectiveRunId); } - if (isRunTerminal(existing.status)) { + if (isTerminalWorkflowRunStatus(existing.status)) { throw new EntityConflictError( `Cannot transition run from terminal state "${existing.status}"` ); @@ -974,7 +952,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { if (!existing) { throw new WorkflowRunNotFoundError(effectiveRunId); } - if (isRunTerminal(existing.status)) { + if (isTerminalWorkflowRunStatus(existing.status)) { throw new EntityConflictError( `Cannot transition run from terminal state "${existing.status}"` ); @@ -1139,15 +1117,12 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // the ownership claim: only the caller that inserts the row gets to // run the step body inline. if (lazyStepStart && !validatedStep) { - const lazyData = (data as any).eventData as { - stepName: string; - input: any; - }; + const lazyData = data.eventData; const [inserted] = await tx .insert(Schema.steps) .values({ runId: effectiveRunId, - stepId: data.correlationId!, + stepId: data.correlationId, stepName: lazyData.stepName, input: lazyData.input as SerializedContent, status: 'pending', @@ -1241,7 +1216,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { `Step "${data.correlationId}" not found` ); } - if (isStepTerminal(existing.status)) { + if (isTerminalStepStatus(existing.status)) { throw new EntityConflictError( `Cannot modify step in terminal state "${existing.status}"` ); @@ -1307,7 +1282,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { `Step "${data.correlationId}" not found` ); } - if (isStepTerminal(existing.status)) { + if (isTerminalStepStatus(existing.status)) { throw new EntityConflictError( `Cannot modify step in terminal state "${existing.status}"` ); @@ -1352,7 +1327,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { `Step "${data.correlationId}" not found` ); } - if (isStepTerminal(existing.status)) { + if (isTerminalStepStatus(existing.status)) { throw new EntityConflictError( `Cannot modify step in terminal state "${existing.status}"` ); @@ -1395,7 +1370,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { `Step "${data.correlationId}" not found` ); } - if (isStepTerminal(existing.status)) { + if (isTerminalStepStatus(existing.status)) { throw new EntityConflictError( `Cannot modify step in terminal state "${existing.status}"` ); @@ -1661,9 +1636,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // unique constraint we might add) don't get misclassified as a // correlationId conflict. const isDeduplicatedCorrelatedEvent = - data.eventType === 'step_created' || - data.eventType === 'hook_created' || - data.eventType === 'wait_created' || + isChildEntityCreationEventType(data.eventType) || (data.eventType === 'attr_set' && data.eventData.writer.type === 'workflow'); const pgErr = (err as { code?: string; constraint?: string }).code diff --git a/packages/world-vercel/src/events.test.ts b/packages/world-vercel/src/events.test.ts index cde58c8044..d282cdda1e 100644 --- a/packages/world-vercel/src/events.test.ts +++ b/packages/world-vercel/src/events.test.ts @@ -162,8 +162,8 @@ describe('splitEventDataForV4 attribute fields', () => { expect(meta.workflowName).toBe('wf'); }); - it('carries attributes on resilient-start run_started', () => { - const { meta } = splitEventDataForV4({ + it('splits resilient-start run_started input into the payload body', () => { + const { payload, meta } = splitEventDataForV4({ eventType: 'run_started', specVersion: 4, eventData: { @@ -174,6 +174,8 @@ describe('splitEventDataForV4 attribute fields', () => { }, } as AnyEventRequest); + expect(payload).toBeInstanceOf(Uint8Array); + expect(meta.input).toBeUndefined(); expect(meta.attributes).toEqual({ sourceAtStart: 'api' }); }); diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index 7021e2f05f..22b247b01a 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -37,10 +37,13 @@ import { type AnyEventRequest, type CreateEventParams, type Event, + type EventDataPayloadField, type EventResult, EventSchema, EventTypeSchema, type GetEventParams, + getEventDataPayloadField, + isHookEventRequiringExistence, type ListEventsByCorrelationIdParams, type ListEventsParams, type PaginatedResponse, @@ -69,71 +72,6 @@ import { makeRequest, } from './utils.js'; -/** - * Per-event-type map of the field within `eventData` that holds the user - * payload. The backend uses the same convention on the v4 read side. - * - * The v4 wire encoding picks this field out of `eventData`, CBOR-encodes - * its value, and ships it as the frame body. Everything else in - * `eventData` rides in the frame's CBOR meta block. - * - * This map's values, together with `MetaSourceField` below, ARE the wire - * contract for `eventData` on v4: every field a @workflow/world event - * schema can put in `eventData` must be routed either to the frame body - * (a payload field here) or the frame meta (a `MetaSourceField`). Unlike - * v3 (which serialized the whole object), a field that is neither does not - * cross the wire. `assertEventDataWireContractExhaustive` turns that into a - * compile error — the silent drop that bit `step_retrying.retryAfter` is - * now a build break that names the unrouted field. (The backend's meta - * parser still has to accept any new meta field independently, so a new - * field is a two-sided change.) - */ -const PAYLOAD_FIELD_BY_EVENT_TYPE = { - run_created: 'input', - // run_started normally has no payload, but on the resilient-start path - // the runtime piggybacks `runInput.input` here so the server can - // synthesize the missing run_created. Without this entry the v4 split - // would silently drop those bytes and the backend's "run_started arrived - // before run_created" fallback would have nothing to backfill from. - run_started: 'input', - run_completed: 'output', - run_failed: 'error', - step_created: 'input', - // step_started normally has no payload, but on the lazy-start path the - // runtime piggybacks the step input here so the server can synthesize the - // missing step_created (mirrors run_started above). Without this entry the - // v4 split would silently drop those bytes and the backend's "step_started - // arrived before step_created" fallback would have nothing to create from. - step_started: 'input', - step_completed: 'result', - step_failed: 'error', - step_retrying: 'error', - hook_created: 'metadata', - hook_received: 'payload', -} as const satisfies Record; - -/** - * The payload field names — the values of the map above. These are the - * fields that become the opaque frame body rather than frame meta. - */ -type PayloadField = - (typeof PAYLOAD_FIELD_BY_EVENT_TYPE)[keyof typeof PAYLOAD_FIELD_BY_EVENT_TYPE]; - -/** - * Look up the payload field for an event type, or undefined for the event - * types that carry no user payload (run_cancelled, attr_set, wait_*, - * hook_disposed). Note `step_started` carries a payload only on the - * lazy-start path; legacy starts send an empty body. The map is `as const` - * so it can drive - * `PayloadField`; the cast keeps the lookup callable with any event-type - * string. - */ -function payloadFieldFor(eventType: string): PayloadField | undefined { - return ( - PAYLOAD_FIELD_BY_EVENT_TYPE as Record - )[eventType]; -} - /** * Union of every field a user-creatable event can carry in `eventData`, * derived from the @workflow/world `CreateEventSchema` discriminated union @@ -154,13 +92,6 @@ const eventsNeedingResolve = new Set([ 'step_started', // runtime reads result.step (checks attempt, state) ]); -// Hook events that 404 when the hook is already disposed or never existed — -// translate to a typed HookNotFoundError so the runtime can branch on it. -const hookEventsRequiringExistence = new Set([ - 'hook_disposed', - 'hook_received', -]); - // ============================================================================= // Helpers // ============================================================================= @@ -204,7 +135,7 @@ interface SplitEventData { * Source field names in `eventData` that `splitEventDataForV4` lifts into * the frame meta (some are renamed on the wire, e.g. `token` → `hookToken`). * This is the metadata half of the v4 `eventData` allowlist; the payload - * half is `PayloadField`. The exhaustiveness guard below keeps this in sync + * half is `EventDataPayloadField`. The exhaustiveness guard below keeps this in sync * with the @workflow/world schema in both directions; the per-field * extraction in `splitEventDataForV4` is bespoke, so it must read each field * listed here. @@ -235,7 +166,7 @@ type MetaSourceField = * against the @workflow/world event schemas. * * - `Unhandled`: schema fields routed to neither the payload body - * (`PayloadField`) nor the frame meta (`MetaSourceField`). + * (`EventDataPayloadField`) nor the frame meta (`MetaSourceField`). * - `Stale`: allowlisted meta fields that no longer exist on any schema. * * Both must be `never`. Add a field to a @workflow/world event schema @@ -244,7 +175,10 @@ type MetaSourceField = * the constraint '[never, never]'` — the historical "silently dropped" * footgun, now a build break that names the field. */ -type Unhandled = Exclude; +type Unhandled = Exclude< + EventDataField, + EventDataPayloadField | MetaSourceField +>; type Stale = Exclude; function assertEventDataWireContractExhaustive< _Check extends [never, never], @@ -259,7 +193,8 @@ assertEventDataWireContractExhaustive<[Unhandled, Stale]>(); * CBOR-encoded meta block of the same frame. * * Exported for unit tests (the meta allowlist is the eventData wire - * contract — see the warning on PAYLOAD_FIELD_BY_EVENT_TYPE). + * contract — see the warning on EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE in + * @workflow/world). */ export function splitEventDataForV4(data: AnyEventRequest): SplitEventData { // Some event types in the AnyEventRequest discriminated union (e.g. @@ -268,7 +203,7 @@ export function splitEventDataForV4(data: AnyEventRequest): SplitEventData { const eventData = (( data as unknown as { eventData?: Record } ).eventData ?? {}) as Record; - const payloadField = payloadFieldFor(data.eventType); + const payloadField = getEventDataPayloadField(data.eventType); const meta: SplitEventData['meta'] = {}; if (typeof eventData.deploymentId === 'string') { @@ -461,7 +396,7 @@ function buildEventFromV4( const eventData = (decoded.eventData ?? {}) as Record; if (payloadBody.byteLength > 0) { - const payloadField = payloadFieldFor(decoded.eventType); + const payloadField = getEventDataPayloadField(decoded.eventType); const normalizedPayload = normalizeSerializedData(payloadBody); if (payloadField && normalizedPayload instanceof Uint8Array) { eventData[payloadField] = normalizedPayload; @@ -580,7 +515,7 @@ export async function createWorkflowRunEvent( } catch (err) { // 404 on hook_disposed / hook_received → already-disposed hook. if ( - hookEventsRequiringExistence.has(data.eventType) && + isHookEventRequiringExistence(data.eventType) && WorkflowWorldError.is(err) && err.status === 404 && data.correlationId diff --git a/packages/world-vercel/src/serialized-data.ts b/packages/world-vercel/src/serialized-data.ts index 5fc4087477..ddd52d7d21 100644 --- a/packages/world-vercel/src/serialized-data.ts +++ b/packages/world-vercel/src/serialized-data.ts @@ -1,14 +1,10 @@ import { WorkflowWorldError } from '@workflow/errors'; -import { EVENT_DATA_REF_FIELDS } from '@workflow/world'; +import { getEventDataRefFields } from '@workflow/world'; const FORMAT_PREFIX_LENGTH = 4; const GZIP_FORMAT_PREFIX = 'gzip'; const ZSTD_FORMAT_PREFIX = 'zstd'; const formatDecoder = new TextDecoder(); -const V4_EXTRA_EVENT_DATA_REF_FIELDS: Record = { - run_started: ['input'], - step_started: ['input'], -}; interface NodeZlibDecode { gunzipSync?: (data: Uint8Array) => Uint8Array; @@ -103,12 +99,7 @@ export function normalizeEventData>( } const eventType = typeof event.eventType === 'string' ? event.eventType : ''; - const refFields = [ - ...new Set([ - ...(EVENT_DATA_REF_FIELDS[eventType] ?? []), - ...(V4_EXTRA_EVENT_DATA_REF_FIELDS[eventType] ?? []), - ]), - ]; + const refFields = getEventDataRefFields(eventType); if (refFields.length === 0) { return event; } diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index 29c82acb6c..935180f77f 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -3,56 +3,6 @@ import { AttributeChangesSchema } from './attributes.js'; import { SerializedDataSchema } from './serialization.js'; import type { PaginationOptions, ResolveData } from './shared.js'; -/** - * Fields within eventData that hold ref/payload data per event type. - * When resolveData is 'none', only these fields are stripped — all other - * metadata (stepName, workflowName, etc.) is preserved. - */ -export const EVENT_DATA_REF_FIELDS: Record = { - run_created: ['input'], - run_completed: ['output'], - run_failed: ['error'], - step_created: ['input'], - step_completed: ['result'], - step_failed: ['error'], - step_retrying: ['error'], - hook_created: ['metadata'], - hook_received: ['payload'], -}; - -/** - * Strip ref/payload fields from eventData based on resolveData setting. - * When resolveData is 'none', removes only large data fields (refs) from - * eventData while preserving metadata like stepName, workflowName, etc. - */ -export function stripEventDataRefs( - event: Event, - resolveData: ResolveData -): Event { - if (resolveData !== 'none') return event; - if (!('eventData' in event)) return event; - - const eventData = (event as any).eventData; - if (!eventData || typeof eventData !== 'object') { - const { eventData: _, ...rest } = event as any; - return rest; - } - - const refFields = EVENT_DATA_REF_FIELDS[event.eventType]; - if (!refFields || refFields.length === 0) return event; - - const stripped = { ...eventData }; - for (const field of refFields) { - delete stripped[field]; - } - - const { eventData: _, ...rest } = event as any; - return { - ...rest, - ...(Object.keys(stripped).length > 0 ? { eventData: stripped } : {}), - }; -} - // Event type enum export const EventTypeSchema = z.enum([ // Run lifecycle events @@ -79,6 +29,21 @@ export const EventTypeSchema = z.enum([ 'wait_completed', ]); export type EventType = z.infer; + +const RunEventTypeSchema = 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; + +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', @@ -93,6 +58,179 @@ 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); +export type StepEventType = z.infer; +export const STEP_EVENT_TYPES = StepEventTypeSchema.options; + +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); +export type TerminalStepEventType = z.infer; +export const TERMINAL_STEP_EVENT_TYPES = TerminalStepEventTypeSchema.options; + +export function isTerminalStepEventType( + eventType: string +): eventType is TerminalStepEventType { + return TERMINAL_STEP_EVENT_TYPES.includes(eventType as TerminalStepEventType); +} + +const HookLifecycleEventTypeSchema = EventTypeSchema.extract([ + 'hook_created', + 'hook_received', + 'hook_disposed', +] as const); +export type HookLifecycleEventType = z.infer< + typeof HookLifecycleEventTypeSchema +>; +export const HOOK_LIFECYCLE_EVENT_TYPES = HookLifecycleEventTypeSchema.options; + +export function isHookLifecycleEventType( + eventType: string +): eventType is HookLifecycleEventType { + return HOOK_LIFECYCLE_EVENT_TYPES.includes( + eventType as HookLifecycleEventType + ); +} + +const HookEventRequiringExistenceTypeSchema = EventTypeSchema.extract([ + 'hook_disposed', + 'hook_received', +] as const); +export type HookEventRequiringExistenceType = z.infer< + typeof HookEventRequiringExistenceTypeSchema +>; +export const HOOK_EVENTS_REQUIRING_EXISTENCE = + HookEventRequiringExistenceTypeSchema.options; + +export function isHookEventRequiringExistence( + eventType: string +): eventType is HookEventRequiringExistenceType { + return HOOK_EVENTS_REQUIRING_EXISTENCE.includes( + eventType as HookEventRequiringExistenceType + ); +} + +const WaitEventTypeSchema = EventTypeSchema.extract([ + 'wait_created', + 'wait_completed', +] as const); +export type WaitEventType = z.infer; +export const WAIT_EVENT_TYPES = WaitEventTypeSchema.options; + +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); +export type ChildEntityCreationEventType = z.infer< + typeof ChildEntityCreationEventTypeSchema +>; +export const CHILD_ENTITY_CREATION_EVENT_TYPES = + ChildEntityCreationEventTypeSchema.options; + +export function isChildEntityCreationEventType( + eventType: string +): eventType is ChildEntityCreationEventType { + return CHILD_ENTITY_CREATION_EVENT_TYPES.includes( + eventType as ChildEntityCreationEventType + ); +} + +/** + * Field within eventData that carries the opaque user payload for event types + * that have one. V4 worlds split this field into the wire body while keeping + * the remaining eventData fields in metadata. + */ +export const EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE = { + run_created: 'input', + run_started: 'input', + run_completed: 'output', + run_failed: 'error', + step_created: 'input', + step_started: 'input', + step_completed: 'result', + step_failed: 'error', + step_retrying: 'error', + hook_created: 'metadata', + hook_received: 'payload', +} as const satisfies Partial>; + +export type EventDataPayloadField = + (typeof EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE)[keyof typeof EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE]; + +/** + * Fields within eventData that hold ref/payload data per event type. + * When resolveData is 'none', only these fields are stripped — all other + * metadata (stepName, workflowName, etc.) is preserved. + */ +export const EVENT_DATA_REF_FIELDS = Object.fromEntries( + Object.entries(EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE).map( + ([eventType, field]) => [eventType, [field]] + ) +) as Record; + +export function getEventDataRefFields(eventType: string): readonly string[] { + return EVENT_DATA_REF_FIELDS[eventType] ?? []; +} + +export function getEventDataPayloadField( + eventType: string +): EventDataPayloadField | undefined { + return ( + EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE as Partial< + Record + > + )[eventType]; +} + +/** + * Strip ref/payload fields from eventData based on resolveData setting. + * When resolveData is 'none', removes only large data fields (refs) from + * eventData while preserving metadata like stepName, workflowName, etc. + */ +export function stripEventDataRefs( + event: Event, + resolveData: ResolveData +): Event { + if (resolveData !== 'none') return event; + if (!('eventData' in event)) return event; + + const eventData = (event as any).eventData; + if (!eventData || typeof eventData !== 'object') { + const { eventData: _, ...rest } = event as any; + return rest; + } + + const refFields = getEventDataRefFields(event.eventType); + if (refFields.length === 0) return event; + + const stripped = { ...eventData }; + for (const field of refFields) { + delete stripped[field]; + } + + const { eventData: _, ...rest } = event as any; + return { + ...rest, + ...(Object.keys(stripped).length > 0 ? { eventData: stripped } : {}), + }; +} + // Base event schema with common properties // TODO: Event data on all specific event schemas can actually be undefined, // as the world may omit eventData when resolveData is set to 'none'. @@ -468,7 +606,13 @@ export const EventSchema = AllEventsSchema.and( // Inferred types export type Event = z.infer; -export type HookCreatedEvent = z.infer; +export type EventOfType = Extract; +export type EventRequestOfType = Extract< + AnyEventRequest, + { eventType: T } +>; +export type HookCreatedEvent = EventOfType<'hook_created'>; +export type HookCreatedEventRequest = EventRequestOfType<'hook_created'>; export type HookReceivedEvent = z.infer; export type HookConflictEvent = z.infer; @@ -478,6 +622,27 @@ export type HookConflictEvent = z.infer; */ export type AnyEventRequest = z.infer; +type ChildEntityCreationEventRequest = + | EventRequestOfType + | (EventRequestOfType<'step_started'> & { + eventData: { + stepName: string; + input: unknown; + }; + }); + +/** Includes lazy step_started requests that create their step on demand. */ +export function isChildEntityCreationEvent( + event: AnyEventRequest +): event is ChildEntityCreationEventRequest { + if (isChildEntityCreationEventType(event.eventType)) return true; + return ( + event.eventType === 'step_started' && + typeof event.eventData?.stepName === 'string' && + event.eventData.input !== undefined + ); +} + /** * Event request for creating a new workflow run. * Can be used with a client-generated runId or null for server-generated. diff --git a/packages/world/src/hooks.ts b/packages/world/src/hooks.ts index 9010ba514d..48319240f1 100644 --- a/packages/world/src/hooks.ts +++ b/packages/world/src/hooks.ts @@ -33,30 +33,7 @@ export const HookSchema = z.object({ * - specVersion >= 2: Uint8Array (binary devalue format) * - specVersion 1: unknown (legacy JSON format) */ -export type Hook = z.infer & { - /** The unique identifier of the workflow run this hook belongs to. */ - runId: string; - /** The unique identifier of this hook within the workflow run. */ - hookId: string; - /** The secret token used to reference this hook. */ - token: string; - /** The owner ID (team or user) that owns this hook. */ - ownerId: string; - /** The project ID this hook belongs to. */ - projectId: string; - /** The environment (e.g., "production", "preview", "development") where this hook was created. */ - environment: string; - /** Optional metadata associated with the hook, set when the hook was created. */ - metadata?: SerializedData; - /** The timestamp when this hook was created. */ - createdAt: Date; - /** The spec version when this hook was created. */ - specVersion?: number; - /** Whether this hook is resumable via the public webhook endpoint. undefined = legacy (treated as true for backwards compat). */ - isWebhook?: boolean; - /** Whether this hook is a system-managed hook (e.g., for abort signals). */ - isSystem?: boolean; -}; +export type Hook = z.infer; // Request types export interface CreateHookRequest { diff --git a/packages/world/src/index.ts b/packages/world/src/index.ts index e71f32b8f5..9f6fe90910 100644 --- a/packages/world/src/index.ts +++ b/packages/world/src/index.ts @@ -28,15 +28,33 @@ export { export type * from './events.js'; export { BaseEventSchema, + CHILD_ENTITY_CREATION_EVENT_TYPES, CreateEventSchema, + EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE, EVENT_DATA_REF_FIELDS, EventSchema, EventTypeSchema, + getEventDataPayloadField, + getEventDataRefFields, + HOOK_EVENTS_REQUIRING_EXISTENCE, + HOOK_LIFECYCLE_EVENT_TYPES, HookCreatedEventSchema, + isChildEntityCreationEvent, + isChildEntityCreationEventType, + isHookEventRequiringExistence, + isHookLifecycleEventType, + isRunEventType, + isStepEventType, isTerminalRunEventType, + isTerminalStepEventType, + isWaitEventType, + RUN_EVENT_TYPES, + STEP_EVENT_TYPES, stripEventDataRefs, TERMINAL_RUN_EVENT_TYPES, + TERMINAL_STEP_EVENT_TYPES, TerminalRunEventTypeSchema, + WAIT_EVENT_TYPES, } from './events.js'; export type * from './hooks.js'; export { HookSchema } from './hooks.js';