From 2f2e9e97025ed572cb699f0a285cb75af4274f7d Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:56:26 -0700 Subject: [PATCH 01/45] perf(core): continue partial run preloads --- .changeset/continue-run-started-preload.md | 5 + packages/core/src/runtime.ts | 30 +++++- .../runtime/wait-completion-replay.test.ts | 91 ++++++++++++------- packages/world/src/events.ts | 6 +- 4 files changed, 94 insertions(+), 38 deletions(-) create mode 100644 .changeset/continue-run-started-preload.md diff --git a/.changeset/continue-run-started-preload.md b/.changeset/continue-run-started-preload.md new file mode 100644 index 0000000000..406625ce59 --- /dev/null +++ b/.changeset/continue-run-started-preload.md @@ -0,0 +1,5 @@ +--- +'@workflow/core': patch +--- + +Continue partial `run_started` event preloads from their cursor instead of reloading the event log from the beginning. diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 517d3ca7ae..d3e4f733d2 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -545,6 +545,7 @@ export function workflowEntrypoint( let workflowStartedAt = -1; let preloadedEvents: Event[] | undefined; let preloadedEventsCursor: string | null | undefined; + let preloadedEventsHasMore = false; // Latency telemetry (TTFS) state — see runtime/step-latency.ts. // Whether this invocation's FIRST event snapshot contained @@ -1089,15 +1090,24 @@ export function workflowEntrypoint( // Anchors RSFS — see the declaration above. runStartedReceivedAtMs = Date.now(); - // If the response includes events, use them to skip - // the initial events.list call and reduce TTFB. + // If the response includes events, use them as the + // beginning of the replay snapshot. A complete page + // skips the initial events.list entirely. A partial + // page is continued strictly after its cursor, so the + // runtime never re-reads the preloaded prefix. + // + // hasMore:true without a cursor violates the World + // pagination contract. Ignore that unusable preload + // and retain the full-load fallback for compatibility. if ( result.events && result.events.length > 0 && - result.hasMore !== true + (result.hasMore !== true || + typeof result.cursor === 'string') ) { preloadedEvents = result.events; preloadedEventsCursor = result.cursor; + preloadedEventsHasMore = result.hasMore === true; } if (!workflowRun.startedAt) { @@ -1279,8 +1289,20 @@ export function workflowEntrypoint( // First iteration: use preloaded events if available, // otherwise do a full load with cursor. if (preloadedEvents) { - events = preloadedEvents; + events = [...preloadedEvents]; eventsCursor = preloadedEventsCursor ?? null; + if (preloadedEventsHasMore && eventsCursor) { + // Continue from the mutation response's cursor + // rather than loading from the beginning. The list + // request omits a limit intentionally: each World + // controls its largest safe streaming page size. + const loaded = await loadWorkflowRunEvents( + runId, + eventsCursor + ); + events.push(...loaded.events); + eventsCursor = loaded.cursor ?? eventsCursor; + } } else { const loaded = await loadWorkflowRunEvents(runId); events = loaded.events; diff --git a/packages/core/src/runtime/wait-completion-replay.test.ts b/packages/core/src/runtime/wait-completion-replay.test.ts index c4ca46982a..bce0b53fb7 100644 --- a/packages/core/src/runtime/wait-completion-replay.test.ts +++ b/packages/core/src/runtime/wait-completion-replay.test.ts @@ -182,6 +182,12 @@ async function runStaleWaitReplayScenario(options: { ]; const staleEventsCursor = 'cursor-after-stale-events'; + const partialPreloadCursor = 'cursor-after-partial-preload'; + const preloadedEventCount = options.preloadedHasMore ? 3 : staleEvents.length; + const preloadedEvents = staleEvents.slice(0, preloadedEventCount); + const preloadedCursor = options.preloadedHasMore + ? partialPreloadCursor + : staleEventsCursor; const hookReceivedEvent = event({ eventType: 'hook_received', specVersion: SPEC_VERSION_CURRENT, @@ -212,12 +218,25 @@ async function runStaleWaitReplayScenario(options: { }) => { // Cursor reads simulate the optimized delta fetch. Without a cursor, the // runtime has fallen back to a full reload from the beginning. - let data = - params.pagination?.cursor === staleEventsCursor - ? durableEvents.slice(staleEvents.length) - : [...durableEvents]; + const requestedCursor = params.pagination?.cursor; + let data: Event[]; + if (requestedCursor === partialPreloadCursor) { + data = durableEvents.slice(preloadedEventCount); + } else if (requestedCursor === staleEventsCursor) { + data = durableEvents.slice(staleEvents.length); + } else if (requestedCursor) { + const cursorIndex = durableEvents.findIndex( + (event) => event.eventId === requestedCursor + ); + data = + cursorIndex >= 0 + ? durableEvents.slice(cursorIndex + 1) + : [...durableEvents]; + } else { + data = [...durableEvents]; + } if ( - params.pagination?.cursor === staleEventsCursor && + requestedCursor === staleEventsCursor && options.omitWaitCompletionFromDelta ) { data = data.filter((event) => event.eventType !== 'wait_completed'); @@ -226,9 +245,7 @@ async function runStaleWaitReplayScenario(options: { return { data, hasMore: false, - cursor: params.pagination?.cursor - ? (data.at(-1)?.eventId ?? null) - : staleEventsCursor, + cursor: data.at(-1)?.eventId ?? requestedCursor ?? null, }; } ); @@ -240,10 +257,10 @@ async function runStaleWaitReplayScenario(options: { const runStartedResponse = { run: workflowRun, - events: [...staleEvents], + events: [...preloadedEvents], ...(options.includePreloadedCursor ? { - cursor: staleEventsCursor, + cursor: preloadedCursor, hasMore: options.preloadedHasMore ?? false, } : {}), @@ -400,6 +417,8 @@ async function runStaleWaitReplayScenario(options: { listEvents, listedPages, queue, + preloadedEvents, + preloadedCursor, staleEventsCursor, waitCorrelationId, }; @@ -495,44 +514,52 @@ describe('workflow handler wait completion replay', () => { expectHookBranchQueued(result); }); - it('falls back to a full reload when preloaded events are partial', async () => { - // A run_started response can return a preloaded page and still say more - // pages exist. That page is not a complete replay input, so the handler - // must discard it and load from the beginning before completing waits. + it('continues a partial preload from its cursor without rereading the prefix', async () => { + // A run_started response can return a first page and report that more pages + // exist. The runtime must retain that prefix and load only its suffix. const result = await runStaleWaitReplayScenario({ includePreloadedCursor: true, preloadedHasMore: true, }); - // Full reload (partial preload discarded), cursor delta after wait - // completion, then the next loop iteration's incremental fetch after - // the inline drainStep execution. + // Partial-preload continuation, cursor delta after wait completion, then + // the next loop iteration's incremental fetch after the inline drainStep. expect(result.listEvents).toHaveBeenCalledTimes(3); - expect(result.listEvents.mock.calls[0]?.[0].pagination).toEqual( - expect.objectContaining({ - sortOrder: 'asc', - cursor: undefined, - }) - ); - expect(result.listEvents.mock.calls[1]?.[0].pagination).toEqual( - expect.objectContaining({ - sortOrder: 'asc', - cursor: result.staleEventsCursor, - }) - ); + expect(result.listEvents.mock.calls[0]?.[0].pagination).toEqual({ + sortOrder: 'asc', + cursor: result.preloadedCursor, + }); + expect( + result.listEvents.mock.calls.every( + ([params]) => params.pagination?.cursor !== undefined + ) + ).toBe(true); expect(result.listedPages[0]?.map((event) => event.eventType)).toEqual([ - 'run_created', - 'run_started', - 'hook_created', 'step_created', 'step_started', 'step_completed', 'wait_created', ]); + expect( + result.listedPages[0]?.some((listed) => + result.preloadedEvents.some( + (preloaded) => preloaded.eventId === listed.eventId + ) + ) + ).toBe(false); expect(result.listedPages[1]?.map((event) => event.eventType)).toEqual([ 'hook_received', 'wait_completed', ]); + expect( + result.listedPages + .flat() + .some((listed) => + result.preloadedEvents.some( + (preloaded) => preloaded.eventId === listed.eventId + ) + ) + ).toBe(false); expectHookBranchQueued(result); }); diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index 36cc7f2a43..415efecea2 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -781,8 +781,10 @@ export interface EventResult { /** * Events with data resolved. Two producers populate this: * - * - On a `run_started` response: all events up to this point, so the - * runtime can skip the initial `events.list` call and reduce TTFB. + * - On a `run_started` response: the first replay page up to this point. + * A complete page lets the runtime skip the initial `events.list`; a + * partial page is continued strictly after {@link EventResult.cursor} + * without re-reading the returned prefix. * - On a step-terminal write (`step_completed` / `step_failed`) when * the caller passed {@link CreateEventParams.sinceCursor}: the delta * of events written strictly after that cursor, so the inline loop From b7d49c5599764e4cc827abadec8a32ab5c11e114 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:17:24 -0700 Subject: [PATCH 02/45] refactor(core): simplify preload continuation --- packages/core/src/runtime.ts | 70 +++++++------------ .../runtime/wait-completion-replay.test.ts | 60 +++++++--------- packages/world/src/events.ts | 6 +- 3 files changed, 52 insertions(+), 84 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index d3e4f733d2..ee18a6c433 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { types } from 'node:util'; import { CorruptedEventLogError, @@ -543,9 +544,7 @@ export function workflowEntrypoint( // response. Undefined ⇒ no enforcement (older servers, turbo). let maxEventsLimit: number | undefined; let workflowStartedAt = -1; - let preloadedEvents: Event[] | undefined; - let preloadedEventsCursor: string | null | undefined; - let preloadedEventsHasMore = false; + let preloadedEventLog: MutableEventLog | undefined; // Latency telemetry (TTFS) state — see runtime/step-latency.ts. // Whether this invocation's FIRST event snapshot contained @@ -1001,8 +1000,7 @@ export function workflowEntrypoint( runId, runStartedEvent, // We background this purely as a write barrier and - // never read its preloaded events (preloadedEvents is - // forced to [] below), so tell the World to skip the + // never read its preloaded events, so skip the // run_started event-log preload. That trims the // run_started request the chained first step_started // waits on — shortening time-to-second-step — and the @@ -1029,13 +1027,11 @@ export function workflowEntrypoint( startedPromise.catch(() => {}); // Skip the initial events.list: nothing has been written to // the log yet on a first delivery (run_started is still in - // flight). An empty preloaded set routes iteration 1 through + // flight). An empty preload routes iteration 1 through // the no-load preloaded branch; iteration 2 then takes the // existing post-preloaded full reload to pick up a cursor - // (no spurious "cursor missing" warning). `[]` is - // intentionally truthy here — do not change the load - // branches' `if (preloadedEvents)` checks to test length. - preloadedEvents = []; + // without a spurious "cursor missing" warning. + preloadedEventLog = { events: [], cursor: null }; const now = new Date(); workflowRun = { runId, @@ -1090,24 +1086,22 @@ export function workflowEntrypoint( // Anchors RSFS — see the declaration above. runStartedReceivedAtMs = Date.now(); - // If the response includes events, use them as the - // beginning of the replay snapshot. A complete page - // skips the initial events.list entirely. A partial - // page is continued strictly after its cursor, so the - // runtime never re-reads the preloaded prefix. - // - // hasMore:true without a cursor violates the World - // pagination contract. Ignore that unusable preload - // and retain the full-load fallback for compatibility. - if ( - result.events && - result.events.length > 0 && - (result.hasMore !== true || - typeof result.cursor === 'string') - ) { - preloadedEvents = result.events; - preloadedEventsCursor = result.cursor; - preloadedEventsHasMore = result.hasMore === true; + if (result.events?.length) { + let events = result.events; + let cursor = result.cursor ?? null; + if (result.hasMore) { + assert( + cursor, + 'Partial event preload has no cursor' + ); + const loaded = await loadWorkflowRunEvents( + runId, + cursor + ); + events = events.concat(loaded.events); + cursor = loaded.cursor ?? cursor; + } + preloadedEventLog = { events, cursor }; } if (!workflowRun.startedAt) { @@ -1288,21 +1282,9 @@ export function workflowEntrypoint( } else if (cachedEvents === null) { // First iteration: use preloaded events if available, // otherwise do a full load with cursor. - if (preloadedEvents) { - events = [...preloadedEvents]; - eventsCursor = preloadedEventsCursor ?? null; - if (preloadedEventsHasMore && eventsCursor) { - // Continue from the mutation response's cursor - // rather than loading from the beginning. The list - // request omits a limit intentionally: each World - // controls its largest safe streaming page size. - const loaded = await loadWorkflowRunEvents( - runId, - eventsCursor - ); - events.push(...loaded.events); - eventsCursor = loaded.cursor ?? eventsCursor; - } + if (preloadedEventLog) { + events = preloadedEventLog.events; + eventsCursor = preloadedEventLog.cursor; } else { const loaded = await loadWorkflowRunEvents(runId); events = loaded.events; @@ -1332,7 +1314,7 @@ export function workflowEntrypoint( } eventsCursor = loaded.cursor ?? eventsCursor; events = cachedEvents; - } else if (preloadedEvents) { + } else if (preloadedEventLog) { // Iteration 2 after iteration 1 used preloaded events // (which don't carry a cursor). Do a full load now to // pick up any events written since the preloaded set diff --git a/packages/core/src/runtime/wait-completion-replay.test.ts b/packages/core/src/runtime/wait-completion-replay.test.ts index bce0b53fb7..c9c7fa3ed6 100644 --- a/packages/core/src/runtime/wait-completion-replay.test.ts +++ b/packages/core/src/runtime/wait-completion-replay.test.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { type CreateEventRequest, type Event, @@ -67,8 +68,7 @@ function getWorkflowTransformCode(workflowName: string) { * wait races with a hook payload that landed durably first. */ async function runStaleWaitReplayScenario(options: { - includePreloadedCursor: boolean; - preloadedHasMore?: boolean; + preload: 'legacy' | 'complete' | 'partial'; omitWaitCompletionFromDelta?: boolean; terminalFailureAfterWaitCompletion?: boolean; }) { @@ -182,12 +182,14 @@ async function runStaleWaitReplayScenario(options: { ]; const staleEventsCursor = 'cursor-after-stale-events'; - const partialPreloadCursor = 'cursor-after-partial-preload'; - const preloadedEventCount = options.preloadedHasMore ? 3 : staleEvents.length; - const preloadedEvents = staleEvents.slice(0, preloadedEventCount); - const preloadedCursor = options.preloadedHasMore - ? partialPreloadCursor - : staleEventsCursor; + const preloadedEvents = + options.preload === 'partial' ? staleEvents.slice(0, 3) : staleEvents; + const lastPreloadedEvent = preloadedEvents.at(-1); + assert(lastPreloadedEvent); + const preloadedCursor = + options.preload === 'partial' + ? lastPreloadedEvent.eventId + : staleEventsCursor; const hookReceivedEvent = event({ eventType: 'hook_received', specVersion: SPEC_VERSION_CURRENT, @@ -219,22 +221,16 @@ async function runStaleWaitReplayScenario(options: { // Cursor reads simulate the optimized delta fetch. Without a cursor, the // runtime has fallen back to a full reload from the beginning. const requestedCursor = params.pagination?.cursor; - let data: Event[]; - if (requestedCursor === partialPreloadCursor) { - data = durableEvents.slice(preloadedEventCount); - } else if (requestedCursor === staleEventsCursor) { - data = durableEvents.slice(staleEvents.length); + let cursorIndex = -1; + if (requestedCursor === staleEventsCursor) { + cursorIndex = staleEvents.length - 1; } else if (requestedCursor) { - const cursorIndex = durableEvents.findIndex( + cursorIndex = durableEvents.findIndex( (event) => event.eventId === requestedCursor ); - data = - cursorIndex >= 0 - ? durableEvents.slice(cursorIndex + 1) - : [...durableEvents]; - } else { - data = [...durableEvents]; + assert(cursorIndex >= 0, `Unknown event cursor: ${requestedCursor}`); } + let data = durableEvents.slice(cursorIndex + 1); if ( requestedCursor === staleEventsCursor && options.omitWaitCompletionFromDelta @@ -257,11 +253,11 @@ async function runStaleWaitReplayScenario(options: { const runStartedResponse = { run: workflowRun, - events: [...preloadedEvents], - ...(options.includePreloadedCursor + events: preloadedEvents, + ...(options.preload !== 'legacy' ? { cursor: preloadedCursor, - hasMore: options.preloadedHasMore ?? false, + hasMore: options.preload === 'partial', } : {}), }; @@ -464,7 +460,7 @@ describe('workflow handler wait completion replay', () => { // so after wait_completed it only needs the delta containing the hook and // wait completion. const result = await runStaleWaitReplayScenario({ - includePreloadedCursor: true, + preload: 'complete', }); // The first call is the cursor delta after wait completion; the second @@ -488,7 +484,7 @@ describe('workflow handler wait completion replay', () => { // Backward compatibility path for worlds/servers that return preloaded // events but do not yet return pagination metadata with them. const result = await runStaleWaitReplayScenario({ - includePreloadedCursor: false, + preload: 'legacy', }); // Full reload after wait completion, plus the next loop iteration's @@ -518,8 +514,7 @@ describe('workflow handler wait completion replay', () => { // A run_started response can return a first page and report that more pages // exist. The runtime must retain that prefix and load only its suffix. const result = await runStaleWaitReplayScenario({ - includePreloadedCursor: true, - preloadedHasMore: true, + preload: 'partial', }); // Partial-preload continuation, cursor delta after wait completion, then @@ -540,13 +535,6 @@ describe('workflow handler wait completion replay', () => { 'step_completed', 'wait_created', ]); - expect( - result.listedPages[0]?.some((listed) => - result.preloadedEvents.some( - (preloaded) => preloaded.eventId === listed.eventId - ) - ) - ).toBe(false); expect(result.listedPages[1]?.map((event) => event.eventType)).toEqual([ 'hook_received', 'wait_completed', @@ -567,7 +555,7 @@ describe('workflow handler wait completion replay', () => { // Defensive path: if the cursor read does not include the wait completion // this handler just wrote, the cursor was not a safe replay boundary. const result = await runStaleWaitReplayScenario({ - includePreloadedCursor: true, + preload: 'complete', omitWaitCompletionFromDelta: true, }); @@ -606,7 +594,7 @@ describe('workflow handler wait completion replay', () => { it('stops after wait refresh when the event log contains a terminal run event', async () => { const result = await runStaleWaitReplayScenario({ - includePreloadedCursor: true, + preload: 'complete', terminalFailureAfterWaitCompletion: true, }); diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index 415efecea2..c1cca78430 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -781,10 +781,8 @@ export interface EventResult { /** * Events with data resolved. Two producers populate this: * - * - On a `run_started` response: the first replay page up to this point. - * A complete page lets the runtime skip the initial `events.list`; a - * partial page is continued strictly after {@link EventResult.cursor} - * without re-reading the returned prefix. + * - On a `run_started` response: the first replay page. Partial pages + * continue after {@link EventResult.cursor}. * - On a step-terminal write (`step_completed` / `step_failed`) when * the caller passed {@link CreateEventParams.sinceCursor}: the delta * of events written strictly after that cursor, so the inline loop From ef184e4e9dc13d85d97ea8f5d7cf4182230f89e4 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:34:39 -0700 Subject: [PATCH 03/45] fix(core): preserve preload fallbacks --- packages/core/src/runtime.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index ee18a6c433..95ca1f919e 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1,4 +1,3 @@ -import assert from 'node:assert/strict'; import { types } from 'node:util'; import { CorruptedEventLogError, @@ -1090,15 +1089,22 @@ export function workflowEntrypoint( let events = result.events; let cursor = result.cursor ?? null; if (result.hasMore) { - assert( - cursor, - 'Partial event preload has no cursor' - ); const loaded = await loadWorkflowRunEvents( runId, - cursor + cursor ?? undefined ); - events = events.concat(loaded.events); + if (cursor) { + const preloadedIds = new Set( + events.map((event) => event.eventId) + ); + events = events.concat( + loaded.events.filter( + (event) => !preloadedIds.has(event.eventId) + ) + ); + } else { + events = loaded.events; + } cursor = loaded.cursor ?? cursor; } preloadedEventLog = { events, cursor }; From a82f28c23d1683f5eb2a580d1d4d52f47f6995c0 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:03:20 -0700 Subject: [PATCH 04/45] chore: rerun CI From c3b400bbcb04880a2ad045023936e21cd76b2c0b Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:08:54 -0700 Subject: [PATCH 05/45] fix(world): infer event create results --- .changeset/type-event-create-results.md | 10 +++++++ packages/core/src/runtime.ts | 24 ++++++----------- packages/core/src/runtime/start.ts | 7 ----- packages/core/src/runtime/step-executor.ts | 20 +++++--------- packages/core/src/types.test.ts | 23 +++++++++++++++- .../world-local/src/storage/events-storage.ts | 27 ++++++++++++++----- packages/world-postgres/src/storage.ts | 23 +++++++++++----- packages/world-vercel/src/events.ts | 11 ++++---- packages/world-vercel/src/storage.ts | 13 ++++++--- packages/world/src/events.ts | 9 +++++++ packages/world/src/interfaces.ts | 14 +++++----- packages/world/src/runs.ts | 2 ++ packages/world/src/steps.ts | 2 ++ 13 files changed, 120 insertions(+), 65 deletions(-) create mode 100644 .changeset/type-event-create-results.md diff --git a/.changeset/type-event-create-results.md b/.changeset/type-event-create-results.md new file mode 100644 index 0000000000..401758f357 --- /dev/null +++ b/.changeset/type-event-create-results.md @@ -0,0 +1,10 @@ +--- +'@workflow/core': patch +'@workflow/world': patch +'@workflow/world-local': patch +'@workflow/world-postgres': patch +'@workflow/world-vercel': patch +--- + +Infer required run and step entities, including their start times, from +`events.create` request types. diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 95ca1f919e..a05364b69d 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -24,6 +24,7 @@ import { resolveQueueNamespace, SPEC_VERSION_CURRENT, SPEC_VERSION_SUPPORTS_COMPRESSION, + type StartedWorkflowRun, WorkflowInvokePayloadSchema, type WorkflowRun, type World, @@ -1068,6 +1069,7 @@ export function workflowEntrypoint( ...Attribute.WorkflowStartedAt(workflowStartedAt), }); } else { + let startedRun: StartedWorkflowRun; try { recordRunStartedCreateStart(false); const result = await world.events.create( @@ -1075,12 +1077,7 @@ export function workflowEntrypoint( runStartedEvent, { requestId } ); - if (!result.run) { - throw new WorkflowRuntimeError( - `Event creation for 'run_started' did not return the run entity for run "${runId}"` - ); - } - workflowRun = result.run; + startedRun = result.run; maxEventsLimit = clampMaxEvents(result.maxEvents); // Anchors RSFS — see the declaration above. runStartedReceivedAtMs = Date.now(); @@ -1109,12 +1106,6 @@ export function workflowEntrypoint( } preloadedEventLog = { events, cursor }; } - - if (!workflowRun.startedAt) { - throw new WorkflowRuntimeError( - `Workflow run "${runId}" has no "startedAt" timestamp` - ); - } } catch (err) { // Run was concurrently completed/failed/cancelled if ( @@ -1148,21 +1139,22 @@ export function workflowEntrypoint( return; } } + workflowRun = startedRun; - workflowStartedAt = +workflowRun.startedAt; + workflowStartedAt = +startedRun.startedAt; span?.setAttributes({ - ...Attribute.WorkflowRunStatus(workflowRun.status), + ...Attribute.WorkflowRunStatus(startedRun.status), ...Attribute.WorkflowStartedAt(workflowStartedAt), }); - if (workflowRun.status !== 'running') { + if (startedRun.status !== 'running') { // Workflow has already completed or failed, so we can skip it runtimeLogger.info( 'Workflow already completed or failed, skipping', { workflowRunId: runId, - status: workflowRun.status, + status: startedRun.status, } ); diff --git a/packages/core/src/runtime/start.ts b/packages/core/src/runtime/start.ts index e86da84f69..596bd839df 100644 --- a/packages/core/src/runtime/start.ts +++ b/packages/core/src/runtime/start.ts @@ -542,13 +542,6 @@ export async function start( } } else { const result = runCreatedResult.value; - // Assert that the run was created - if (!result.run) { - throw new WorkflowRuntimeError( - "Missing 'run' in server response for 'run_created' event" - ); - } - // Verify server accepted our runId if (!v1Compat && result.run.runId !== runId) { throw new WorkflowRuntimeError( diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts index 70da5598c4..2d22ff9b23 100644 --- a/packages/core/src/runtime/step-executor.ts +++ b/packages/core/src/runtime/step-executor.ts @@ -6,14 +6,18 @@ import { RunExpiredError, ThrottleError, TooEarlyError, - WorkflowRuntimeError, } from '@workflow/errors'; import { createWorkflowBaseUrl, pluralize, stepDisplayName, } from '@workflow/utils'; -import type { Event, SerializedData, Step, World } from '@workflow/world'; +import type { + Event, + SerializedData, + StartedStep, + World, +} from '@workflow/world'; import { SPEC_VERSION_CURRENT, SPEC_VERSION_SUPPORTS_COMPRESSION, @@ -500,7 +504,7 @@ export async function executeStep( (params.forceOptimisticStart === true && !isOptimisticInlineStartExplicitlyDisabled())); - let step: Step; + let step: StartedStep; // `Date.now()` taken immediately before the `step_started` create is // issued (either path below) — anchors RSFS's end point. See // StepLatencyEventData.rsfs and the call sites below. @@ -628,11 +632,6 @@ export async function executeStep( : undefined ); - if (!startResult.step) { - throw new WorkflowRuntimeError( - `step_started event for "${stepId}" did not return step entity` - ); - } step = startResult.step; } catch (err) { const mapped = startErrorToResult(err); @@ -762,11 +761,6 @@ export async function executeStep( try { const attempt = step.attempt; - if (!step.startedAt) { - throw new WorkflowRuntimeError( - `Step "${stepId}" has no "startedAt" timestamp` - ); - } const stepStartedAt = step.startedAt; // Use the provided encryption key when available, otherwise resolve // through the memoized accessor declared at the top of this trace. diff --git a/packages/core/src/types.test.ts b/packages/core/src/types.test.ts index f530dddef3..9c9dc78f0f 100644 --- a/packages/core/src/types.test.ts +++ b/packages/core/src/types.test.ts @@ -1,8 +1,29 @@ import { runInNewContext } from 'node:vm'; import { FatalError } from '@workflow/errors'; -import { describe, expect, it } from 'vitest'; +import type { + EventRequestOfType, + EventResultFor, + StartedStep, + StartedWorkflowRun, + WorkflowRun, +} from '@workflow/world'; +import { describe, expect, expectTypeOf, it } from 'vitest'; import { isAbortError, promoteAbortErrorToFatal } from './types.js'; +describe('EventResultFor', () => { + it('requires the entity returned by runtime setup events', () => { + expectTypeOf< + EventResultFor>['run'] + >().toEqualTypeOf(); + expectTypeOf< + EventResultFor>['run'] + >().toEqualTypeOf(); + expectTypeOf< + EventResultFor>['step'] + >().toEqualTypeOf(); + }); +}); + describe('isAbortError', () => { it('recognizes an AbortError from another realm', () => { const error = runInNewContext( diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index 38b7072566..64e7c4224f 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -10,8 +10,11 @@ import { WorkflowWorldError, } from '@workflow/errors'; import type { + AnyEventRequest, + CreateEventParams, Event, EventResult, + EventResultFor, Hook, SerializedData, Step, @@ -50,9 +53,9 @@ import { jsonReviver, listJSONFiles, paginatedFileSystemQuery, + promoteExclusive, readJSON, readJSONWithFallback, - promoteExclusive, resolveWithinBase, taggedPath, write, @@ -70,8 +73,8 @@ import { mintRunDominantEventKey, monotonicUlid, pendingHookEventPath, - releaseHookTokenClaimIfOwnedBy, reapPendingHookEvents, + releaseHookTokenClaimIfOwnedBy, runTerminalMarkerPath, } from './helpers.js'; import { @@ -625,7 +628,11 @@ export function createEventsStorage( return { clearCache, - async create(runId, data, params): Promise { + async create( + runId: string | null, + data: T, + params?: CreateEventParams + ): Promise> { // Validate request-supplied IDs before they're concatenated into // filesystem paths. This is the primary defense against path traversal // attacks where a client supplies runId / correlationId values like @@ -653,7 +660,11 @@ export function createEventsStorage( const lockKey = tag ? `${runId}-${data.correlationId}.${tag}` : `${runId}-${data.correlationId}`; - return withInProcessLock(stepLocks, lockKey, () => createImpl()); + return (await withInProcessLock( + stepLocks, + lockKey, + createImpl + )) as EventResultFor; } // `hook_created` is serialized per-(runId, hookId) so the // "claim token, write hook entity, write event" sequence runs to @@ -682,9 +693,13 @@ export function createEventsStorage( const lockKey = tag ? `${runId}-${data.correlationId}.hook.${tag}` : `${runId}-${data.correlationId}.hook`; - return withInProcessLock(hookLocks, lockKey, () => createImpl()); + return (await withInProcessLock( + hookLocks, + lockKey, + createImpl + )) as EventResultFor; } - return createImpl(); + return (await createImpl()) as EventResultFor; async function createImpl(): Promise { // Most paths use the freshly-generated candidate eventId. The diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index b5c44e8883..e11ad5bab4 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -8,9 +8,12 @@ import { WorkflowWorldError, } from '@workflow/errors'; import type { + AnyEventRequest, AttributeChange, + CreateEventParams, Event, EventResult, + EventResultFor, ExperimentalSetAttributesResult, GetEventParams, Hook, @@ -465,7 +468,11 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { .prepare('events_get_wait_for_validation'); return { - async create(runId, data, params): Promise { + async create( + runId: string | null, + data: T, + params?: CreateEventParams + ): Promise> { let eventId: string | undefined; const getEventId = () => (eventId ??= `wevt_${ulid()}`); @@ -631,14 +638,14 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // Route to legacy handler for pre-event-sourcing runs if (isLegacySpecVersion(currentRun.specVersion)) { - return handleLegacyEventPostgres( + return (await handleLegacyEventPostgres( drizzle, effectiveRunId, getEventId(), data, currentRun, params - ); + )) as EventResultFor; } } if (data.eventType === 'attr_set' && !currentRun) { @@ -693,7 +700,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { return { event: stripEventDataRefs(parsed, resolveData), run: fullRun ? deserializeRunError(compact(fullRun)) : undefined, - }; + } as EventResultFor; } // For run_started on terminal runs, use RunExpiredError so the @@ -876,7 +883,9 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { .where(eq(Schema.runs.runId, effectiveRunId)) .limit(1); if (fullRun) { - return { run: deserializeRunError(compact(fullRun)) }; + return { + run: deserializeRunError(compact(fullRun)), + } as EventResultFor; } } @@ -1558,7 +1567,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { run, step, hook: undefined, - }; + } as EventResultFor; } } else { const [hookValue] = await drizzle @@ -1839,7 +1848,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { cursor, hasMore, ...(stepCreatedLazily ? { stepCreated: true } : {}), - }; + } as EventResultFor; }, async get( runId: string, diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index 706daa742d..a2d223a882 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -39,6 +39,7 @@ import { type Event, type EventDataPayloadField, type EventResult, + type EventResultFor, EventSchema, EventTypeSchema, type GetEventParams, @@ -592,12 +593,12 @@ export async function getWorkflowRunEvents( } as PaginatedResponse; } -export async function createWorkflowRunEvent( +export async function createWorkflowRunEvent( id: string | null, - data: AnyEventRequest, + data: T, params?: CreateEventParams, config?: APIConfig -): Promise { +): Promise> { try { // Retry transient transport failures (UND_ERR_REQ_RETRY, ECONNRESET, // socket/headers timeouts, transient 5xx) in-process for event types that @@ -607,10 +608,10 @@ export async function createWorkflowRunEvent( // the next queue delivery. Non-retryable // types (step_started, step_retrying, hook_received) run once. See // ./event-retry for the validated per-event classification. - return await withEventPostRetry( + return (await withEventPostRetry( () => createWorkflowRunEventInner(id, data, params, config), data.eventType - ); + )) as EventResultFor; } catch (err) { // 404 on hook_disposed / hook_received → already-disposed hook. if ( diff --git a/packages/world-vercel/src/storage.ts b/packages/world-vercel/src/storage.ts index 1edfb5cb38..27941258bd 100644 --- a/packages/world-vercel/src/storage.ts +++ b/packages/world-vercel/src/storage.ts @@ -1,4 +1,8 @@ -import type { Storage } from '@workflow/world'; +import type { + AnyEventRequest, + CreateEventParams, + Storage, +} from '@workflow/world'; import { createWorkflowRunEvent, getEvent, @@ -37,8 +41,11 @@ export function createStorage(config?: APIConfig): Storage { listWorkflowRunSteps(params, config)) as Storage['steps']['list'], }, events: { - create: (runId, data, params) => - createWorkflowRunEvent(runId, data, params, config), + create: ( + runId: string | null, + data: T, + params?: CreateEventParams + ) => createWorkflowRunEvent(runId, data, params, config), get: (runId, eventId, params) => getEvent(runId, eventId, params, config), list: (params) => getWorkflowRunEvents(params, config), listByCorrelationId: (params) => getWorkflowRunEvents(params, config), diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index c1cca78430..6745875804 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -809,6 +809,15 @@ export interface EventResult { maxEvents?: number; } +export type EventResultFor = + T extends EventRequestOfType<'run_created'> + ? EventResult & { run: import('./runs.js').WorkflowRun } + : T extends EventRequestOfType<'run_started'> + ? EventResult & { run: import('./runs.js').StartedWorkflowRun } + : T extends EventRequestOfType<'step_started'> + ? EventResult & { step: import('./steps.js').StartedStep } + : EventResult; + export interface GetEventParams { resolveData?: ResolveData; } diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index e42ca866e0..d57e0c8cd1 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -7,7 +7,7 @@ import type { CreateEventParams, CreateEventRequest, Event, - EventResult, + EventResultFor, GetEventParams, ListEventsByCorrelationIdParams, ListEventsParams, @@ -252,11 +252,11 @@ export interface Storage { * @param params - Optional parameters for event creation * @returns Promise resolving to the created event and run entity */ - create( + create( runId: string | null, - data: RunCreatedEventRequest, + data: T, params?: CreateEventParams - ): Promise; + ): Promise>; /** * Create an event for an existing workflow run and atomically update the entity. @@ -267,11 +267,11 @@ export interface Storage { * @param params - Optional parameters for event creation * @returns Promise resolving to the created event and affected entity */ - create( + create( runId: string, - data: CreateEventRequest, + data: T, params?: CreateEventParams - ): Promise; + ): Promise>; get( runId: string, diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index 0e83f79827..2818ad1772 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -141,6 +141,8 @@ export const WorkflowRunSchema = z.discriminatedUnion('status', [ // Inferred types export type WorkflowRun = z.infer; +/** A workflow run with its first start time materialized. */ +export type StartedWorkflowRun = WorkflowRun & { startedAt: Date }; /** * WorkflowRun with input/output fields excluded (when resolveData='none'). diff --git a/packages/world/src/steps.ts b/packages/world/src/steps.ts index 68b27dc7db..1aa8cb31fc 100644 --- a/packages/world/src/steps.ts +++ b/packages/world/src/steps.ts @@ -88,6 +88,8 @@ export function isTerminalStepStatus( } export type Step = z.infer; +/** A step with its first start time materialized. */ +export type StartedStep = Step & { startedAt: Date }; /** * Step with input/output fields excluded (when resolveData='none'). From 5e704ffeea183bba6c28b3b24ee30d288eb5796e Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:48:34 -0700 Subject: [PATCH 06/45] fix(core): preserve run state during setup --- packages/core/src/runtime.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index a05364b69d..82e0a9fc1a 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1078,6 +1078,7 @@ export function workflowEntrypoint( { requestId } ); startedRun = result.run; + workflowRun = startedRun; maxEventsLimit = clampMaxEvents(result.maxEvents); // Anchors RSFS — see the declaration above. runStartedReceivedAtMs = Date.now(); @@ -1139,8 +1140,6 @@ export function workflowEntrypoint( return; } } - workflowRun = startedRun; - workflowStartedAt = +startedRun.startedAt; span?.setAttributes({ From 387e8e3373f8838ff0255b55f0c2834900b54927 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:01:47 -0700 Subject: [PATCH 07/45] fix(world): enforce typed event results --- .changeset/type-event-create-results.md | 3 +- packages/core/src/runtime.ts | 8 +++- packages/core/src/runtime/step-executor.ts | 15 +++--- packages/core/src/types.test.ts | 13 ++++-- packages/world-local/src/storage.test.ts | 9 ++++ .../world-local/src/storage/events-storage.ts | 3 ++ packages/world-postgres/src/storage.ts | 3 ++ packages/world-postgres/test/storage.test.ts | 8 ++++ packages/world-vercel/src/events.test.ts | 46 +++++++++++++++++++ packages/world-vercel/src/events.ts | 20 +++++++- packages/world/src/events.ts | 4 +- packages/world/src/runs.ts | 2 - packages/world/src/steps.ts | 2 - 13 files changed, 113 insertions(+), 23 deletions(-) diff --git a/.changeset/type-event-create-results.md b/.changeset/type-event-create-results.md index 401758f357..a147b182ba 100644 --- a/.changeset/type-event-create-results.md +++ b/.changeset/type-event-create-results.md @@ -6,5 +6,4 @@ '@workflow/world-vercel': patch --- -Infer required run and step entities, including their start times, from -`events.create` request types. +Infer required run and step entities from `events.create` request types. diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 82e0a9fc1a..436147308f 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -24,7 +24,6 @@ import { resolveQueueNamespace, SPEC_VERSION_CURRENT, SPEC_VERSION_SUPPORTS_COMPRESSION, - type StartedWorkflowRun, WorkflowInvokePayloadSchema, type WorkflowRun, type World, @@ -1069,7 +1068,7 @@ export function workflowEntrypoint( ...Attribute.WorkflowStartedAt(workflowStartedAt), }); } else { - let startedRun: StartedWorkflowRun; + let startedRun: WorkflowRun; try { recordRunStartedCreateStart(false); const result = await world.events.create( @@ -1140,6 +1139,11 @@ export function workflowEntrypoint( return; } } + if (!startedRun.startedAt) { + throw new WorkflowRuntimeError( + `Workflow run "${runId}" has no "startedAt" timestamp` + ); + } workflowStartedAt = +startedRun.startedAt; span?.setAttributes({ diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts index 2d22ff9b23..11779b6162 100644 --- a/packages/core/src/runtime/step-executor.ts +++ b/packages/core/src/runtime/step-executor.ts @@ -6,18 +6,14 @@ import { RunExpiredError, ThrottleError, TooEarlyError, + WorkflowRuntimeError, } from '@workflow/errors'; import { createWorkflowBaseUrl, pluralize, stepDisplayName, } from '@workflow/utils'; -import type { - Event, - SerializedData, - StartedStep, - World, -} from '@workflow/world'; +import type { Event, SerializedData, Step, World } from '@workflow/world'; import { SPEC_VERSION_CURRENT, SPEC_VERSION_SUPPORTS_COMPRESSION, @@ -504,7 +500,7 @@ export async function executeStep( (params.forceOptimisticStart === true && !isOptimisticInlineStartExplicitlyDisabled())); - let step: StartedStep; + let step: Step; // `Date.now()` taken immediately before the `step_started` create is // issued (either path below) — anchors RSFS's end point. See // StepLatencyEventData.rsfs and the call sites below. @@ -761,6 +757,11 @@ export async function executeStep( try { const attempt = step.attempt; + if (!step.startedAt) { + throw new WorkflowRuntimeError( + `Step "${stepId}" has no "startedAt" timestamp` + ); + } const stepStartedAt = step.startedAt; // Use the provided encryption key when available, otherwise resolve // through the memoized accessor declared at the top of this trace. diff --git a/packages/core/src/types.test.ts b/packages/core/src/types.test.ts index 9c9dc78f0f..3ec77d72fe 100644 --- a/packages/core/src/types.test.ts +++ b/packages/core/src/types.test.ts @@ -3,8 +3,7 @@ import { FatalError } from '@workflow/errors'; import type { EventRequestOfType, EventResultFor, - StartedStep, - StartedWorkflowRun, + Step, WorkflowRun, } from '@workflow/world'; import { describe, expect, expectTypeOf, it } from 'vitest'; @@ -17,10 +16,16 @@ describe('EventResultFor', () => { >().toEqualTypeOf(); expectTypeOf< EventResultFor>['run'] - >().toEqualTypeOf(); + >().toEqualTypeOf(); expectTypeOf< EventResultFor>['step'] - >().toEqualTypeOf(); + >().toEqualTypeOf(); + expectTypeOf< + EventResultFor>['run']['startedAt'] + >().toEqualTypeOf(); + expectTypeOf< + EventResultFor>['step']['startedAt'] + >().toEqualTypeOf(); }); }); diff --git a/packages/world-local/src/storage.test.ts b/packages/world-local/src/storage.test.ts index a3e919b57d..a1f83d40ae 100644 --- a/packages/world-local/src/storage.test.ts +++ b/packages/world-local/src/storage.test.ts @@ -290,6 +290,15 @@ describe('Storage', () => { ); }); + it('should reject run_started on a non-existent run', async () => { + await expect( + storage.events.create('wrun_nonexistent', { + eventType: 'run_started', + specVersion: SPEC_VERSION_CURRENT, + }) + ).rejects.toMatchObject({ name: 'WorkflowRunNotFoundError' }); + }); + it('should update run status to completed via run_completed event', async () => { const created = await createRun(storage, { deploymentId: 'deployment-123', diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index 64e7c4224f..ab4db6d65d 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -855,6 +855,9 @@ export function createEventsStorage( if (data.eventType === 'attr_set' && !currentRun) { throw new WorkflowRunNotFoundError(effectiveRunId); } + if (data.eventType === 'run_started' && !currentRun) { + throw new WorkflowRunNotFoundError(effectiveRunId); + } // ============================================================ // VERSION COMPATIBILITY: Check run spec version diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index e11ad5bab4..c605b73883 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -651,6 +651,9 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { if (data.eventType === 'attr_set' && !currentRun) { throw new WorkflowRunNotFoundError(effectiveRunId); } + if (data.eventType === 'run_started' && !currentRun) { + throw new WorkflowRunNotFoundError(effectiveRunId); + } // Lazy step start: a step_started carrying step-creation data // (stepName + input) may arrive with no prior step_created — it creates diff --git a/packages/world-postgres/test/storage.test.ts b/packages/world-postgres/test/storage.test.ts index 1e4b422406..8d1149b646 100644 --- a/packages/world-postgres/test/storage.test.ts +++ b/packages/world-postgres/test/storage.test.ts @@ -368,6 +368,14 @@ describe('Storage (Postgres integration)', () => { expect(updated.startedAt).toBeInstanceOf(Date); }); + it('should reject run_started on a non-existent run', async () => { + await expect( + events.create('wrun_nonexistent', { + eventType: 'run_started', + }) + ).rejects.toMatchObject({ name: 'WorkflowRunNotFoundError' }); + }); + it('should update run status to completed via run_completed event', async () => { const created = await createRun(events, { deploymentId: 'deployment-123', diff --git a/packages/world-vercel/src/events.test.ts b/packages/world-vercel/src/events.test.ts index f78845fdaf..9b2e38ea05 100644 --- a/packages/world-vercel/src/events.test.ts +++ b/packages/world-vercel/src/events.test.ts @@ -195,6 +195,52 @@ describe('createWorkflowRunEvent stateUpdatedAt wire field', () => { }); }); +describe('createWorkflowRunEvent result contract', () => { + it.each([ + { + eventType: 'run_started', + data: { eventType: 'run_started', specVersion: 2 }, + }, + { + eventType: 'step_started', + data: { + eventType: 'step_started', + correlationId: 'step_1', + specVersion: 2, + }, + }, + ])('rejects a $eventType response without its entity', async ({ + eventType, + data, + }) => { + const agent = mockAgent(); + agent + .get(ORIGIN) + .intercept({ + path: `/api/v4/runs/wrun_1/events/${eventType}`, + method: 'POST', + }) + .reply(200, encode({}), { + headers: { + 'x-wf-event-id': 'evnt_1', + 'x-wf-run-id': 'wrun_1', + 'x-wf-created-at': '2026-06-10T00:00:00.000Z', + }, + }); + + await expect( + createWorkflowRunEvent('wrun_1', data as AnyEventRequest, undefined, { + token: 'test-token', + dispatcher: agent, + }) + ).rejects.toMatchObject({ + name: 'WorkflowWorldError', + code: 'SCHEMA_VALIDATION', + }); + agent.assertNoPendingInterceptors(); + }); +}); + /** * The split's meta allowlist IS the eventData wire contract on v4. The * type-level `assertEventDataWireContractExhaustive` guard in events.ts diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index a2d223a882..e19fa2e3c9 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -608,10 +608,26 @@ export async function createWorkflowRunEvent( // the next queue delivery. Non-retryable // types (step_started, step_retrying, hook_received) run once. See // ./event-retry for the validated per-event classification. - return (await withEventPostRetry( + const result = await withEventPostRetry( () => createWorkflowRunEventInner(id, data, params, config), data.eventType - )) as EventResultFor; + ); + if ( + (data.eventType === 'run_created' || data.eventType === 'run_started') && + !result.run + ) { + throw new WorkflowWorldError( + `${data.eventType} response is missing the run entity`, + { code: 'SCHEMA_VALIDATION' } + ); + } + if (data.eventType === 'step_started' && !result.step) { + throw new WorkflowWorldError( + 'step_started response is missing the step entity', + { code: 'SCHEMA_VALIDATION' } + ); + } + return result as EventResultFor; } catch (err) { // 404 on hook_disposed / hook_received → already-disposed hook. if ( diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index 6745875804..239800d615 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -813,9 +813,9 @@ export type EventResultFor = T extends EventRequestOfType<'run_created'> ? EventResult & { run: import('./runs.js').WorkflowRun } : T extends EventRequestOfType<'run_started'> - ? EventResult & { run: import('./runs.js').StartedWorkflowRun } + ? EventResult & { run: import('./runs.js').WorkflowRun } : T extends EventRequestOfType<'step_started'> - ? EventResult & { step: import('./steps.js').StartedStep } + ? EventResult & { step: import('./steps.js').Step } : EventResult; export interface GetEventParams { diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index 2818ad1772..0e83f79827 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -141,8 +141,6 @@ export const WorkflowRunSchema = z.discriminatedUnion('status', [ // Inferred types export type WorkflowRun = z.infer; -/** A workflow run with its first start time materialized. */ -export type StartedWorkflowRun = WorkflowRun & { startedAt: Date }; /** * WorkflowRun with input/output fields excluded (when resolveData='none'). diff --git a/packages/world/src/steps.ts b/packages/world/src/steps.ts index 1aa8cb31fc..68b27dc7db 100644 --- a/packages/world/src/steps.ts +++ b/packages/world/src/steps.ts @@ -88,8 +88,6 @@ export function isTerminalStepStatus( } export type Step = z.infer; -/** A step with its first start time materialized. */ -export type StartedStep = Step & { startedAt: Date }; /** * Step with input/output fields excluded (when resolveData='none'). From 4d3e2cc825f66224fb1894b1f8203745170b7f47 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:02:39 -0700 Subject: [PATCH 08/45] refactor(world): rely on event result contract --- .changeset/type-event-create-results.md | 3 +- packages/core/src/runtime.ts | 8 ++---- packages/core/src/runtime/step-executor.ts | 15 +++++----- packages/core/src/types.test.ts | 28 +------------------ .../world-local/src/storage/events-storage.ts | 21 ++++---------- packages/world-postgres/src/storage.ts | 19 ++++++------- packages/world-vercel/src/events.ts | 9 +++--- packages/world-vercel/src/storage.ts | 4 +-- packages/world/src/events.ts | 4 +-- packages/world/src/runs.ts | 2 ++ packages/world/src/steps.ts | 2 ++ 11 files changed, 39 insertions(+), 76 deletions(-) diff --git a/.changeset/type-event-create-results.md b/.changeset/type-event-create-results.md index a147b182ba..401758f357 100644 --- a/.changeset/type-event-create-results.md +++ b/.changeset/type-event-create-results.md @@ -6,4 +6,5 @@ '@workflow/world-vercel': patch --- -Infer required run and step entities from `events.create` request types. +Infer required run and step entities, including their start times, from +`events.create` request types. diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 436147308f..82e0a9fc1a 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -24,6 +24,7 @@ import { resolveQueueNamespace, SPEC_VERSION_CURRENT, SPEC_VERSION_SUPPORTS_COMPRESSION, + type StartedWorkflowRun, WorkflowInvokePayloadSchema, type WorkflowRun, type World, @@ -1068,7 +1069,7 @@ export function workflowEntrypoint( ...Attribute.WorkflowStartedAt(workflowStartedAt), }); } else { - let startedRun: WorkflowRun; + let startedRun: StartedWorkflowRun; try { recordRunStartedCreateStart(false); const result = await world.events.create( @@ -1139,11 +1140,6 @@ export function workflowEntrypoint( return; } } - if (!startedRun.startedAt) { - throw new WorkflowRuntimeError( - `Workflow run "${runId}" has no "startedAt" timestamp` - ); - } workflowStartedAt = +startedRun.startedAt; span?.setAttributes({ diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts index 11779b6162..2d22ff9b23 100644 --- a/packages/core/src/runtime/step-executor.ts +++ b/packages/core/src/runtime/step-executor.ts @@ -6,14 +6,18 @@ import { RunExpiredError, ThrottleError, TooEarlyError, - WorkflowRuntimeError, } from '@workflow/errors'; import { createWorkflowBaseUrl, pluralize, stepDisplayName, } from '@workflow/utils'; -import type { Event, SerializedData, Step, World } from '@workflow/world'; +import type { + Event, + SerializedData, + StartedStep, + World, +} from '@workflow/world'; import { SPEC_VERSION_CURRENT, SPEC_VERSION_SUPPORTS_COMPRESSION, @@ -500,7 +504,7 @@ export async function executeStep( (params.forceOptimisticStart === true && !isOptimisticInlineStartExplicitlyDisabled())); - let step: Step; + let step: StartedStep; // `Date.now()` taken immediately before the `step_started` create is // issued (either path below) — anchors RSFS's end point. See // StepLatencyEventData.rsfs and the call sites below. @@ -757,11 +761,6 @@ export async function executeStep( try { const attempt = step.attempt; - if (!step.startedAt) { - throw new WorkflowRuntimeError( - `Step "${stepId}" has no "startedAt" timestamp` - ); - } const stepStartedAt = step.startedAt; // Use the provided encryption key when available, otherwise resolve // through the memoized accessor declared at the top of this trace. diff --git a/packages/core/src/types.test.ts b/packages/core/src/types.test.ts index 3ec77d72fe..f530dddef3 100644 --- a/packages/core/src/types.test.ts +++ b/packages/core/src/types.test.ts @@ -1,34 +1,8 @@ import { runInNewContext } from 'node:vm'; import { FatalError } from '@workflow/errors'; -import type { - EventRequestOfType, - EventResultFor, - Step, - WorkflowRun, -} from '@workflow/world'; -import { describe, expect, expectTypeOf, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { isAbortError, promoteAbortErrorToFatal } from './types.js'; -describe('EventResultFor', () => { - it('requires the entity returned by runtime setup events', () => { - expectTypeOf< - EventResultFor>['run'] - >().toEqualTypeOf(); - expectTypeOf< - EventResultFor>['run'] - >().toEqualTypeOf(); - expectTypeOf< - EventResultFor>['step'] - >().toEqualTypeOf(); - expectTypeOf< - EventResultFor>['run']['startedAt'] - >().toEqualTypeOf(); - expectTypeOf< - EventResultFor>['step']['startedAt'] - >().toEqualTypeOf(); - }); -}); - describe('isAbortError', () => { it('recognizes an AbortError from another realm', () => { const error = runInNewContext( diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index ab4db6d65d..30eee5f2bf 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -14,7 +14,6 @@ import type { CreateEventParams, Event, EventResult, - EventResultFor, Hook, SerializedData, Step, @@ -628,11 +627,11 @@ export function createEventsStorage( return { clearCache, - async create( + async create( runId: string | null, - data: T, + data: AnyEventRequest, params?: CreateEventParams - ): Promise> { + ): Promise { // Validate request-supplied IDs before they're concatenated into // filesystem paths. This is the primary defense against path traversal // attacks where a client supplies runId / correlationId values like @@ -660,11 +659,7 @@ export function createEventsStorage( const lockKey = tag ? `${runId}-${data.correlationId}.${tag}` : `${runId}-${data.correlationId}`; - return (await withInProcessLock( - stepLocks, - lockKey, - createImpl - )) as EventResultFor; + return withInProcessLock(stepLocks, lockKey, createImpl); } // `hook_created` is serialized per-(runId, hookId) so the // "claim token, write hook entity, write event" sequence runs to @@ -693,13 +688,9 @@ export function createEventsStorage( const lockKey = tag ? `${runId}-${data.correlationId}.hook.${tag}` : `${runId}-${data.correlationId}.hook`; - return (await withInProcessLock( - hookLocks, - lockKey, - createImpl - )) as EventResultFor; + return withInProcessLock(hookLocks, lockKey, createImpl); } - return (await createImpl()) as EventResultFor; + return createImpl(); async function createImpl(): Promise { // Most paths use the freshly-generated candidate eventId. The diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index c605b73883..5728e932de 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -13,7 +13,6 @@ import type { CreateEventParams, Event, EventResult, - EventResultFor, ExperimentalSetAttributesResult, GetEventParams, Hook, @@ -468,11 +467,11 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { .prepare('events_get_wait_for_validation'); return { - async create( + async create( runId: string | null, - data: T, + data: AnyEventRequest, params?: CreateEventParams - ): Promise> { + ): Promise { let eventId: string | undefined; const getEventId = () => (eventId ??= `wevt_${ulid()}`); @@ -638,14 +637,14 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // Route to legacy handler for pre-event-sourcing runs if (isLegacySpecVersion(currentRun.specVersion)) { - return (await handleLegacyEventPostgres( + return handleLegacyEventPostgres( drizzle, effectiveRunId, getEventId(), data, currentRun, params - )) as EventResultFor; + ); } } if (data.eventType === 'attr_set' && !currentRun) { @@ -703,7 +702,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { return { event: stripEventDataRefs(parsed, resolveData), run: fullRun ? deserializeRunError(compact(fullRun)) : undefined, - } as EventResultFor; + }; } // For run_started on terminal runs, use RunExpiredError so the @@ -888,7 +887,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { if (fullRun) { return { run: deserializeRunError(compact(fullRun)), - } as EventResultFor; + }; } } @@ -1570,7 +1569,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { run, step, hook: undefined, - } as EventResultFor; + }; } } else { const [hookValue] = await drizzle @@ -1851,7 +1850,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { cursor, hasMore, ...(stepCreatedLazily ? { stepCreated: true } : {}), - } as EventResultFor; + }; }, async get( runId: string, diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index e19fa2e3c9..f30e9a19d3 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -39,7 +39,6 @@ import { type Event, type EventDataPayloadField, type EventResult, - type EventResultFor, EventSchema, EventTypeSchema, type GetEventParams, @@ -593,12 +592,12 @@ export async function getWorkflowRunEvents( } as PaginatedResponse; } -export async function createWorkflowRunEvent( +export async function createWorkflowRunEvent( id: string | null, - data: T, + data: AnyEventRequest, params?: CreateEventParams, config?: APIConfig -): Promise> { +): Promise { try { // Retry transient transport failures (UND_ERR_REQ_RETRY, ECONNRESET, // socket/headers timeouts, transient 5xx) in-process for event types that @@ -627,7 +626,7 @@ export async function createWorkflowRunEvent( { code: 'SCHEMA_VALIDATION' } ); } - return result as EventResultFor; + return result; } catch (err) { // 404 on hook_disposed / hook_received → already-disposed hook. if ( diff --git a/packages/world-vercel/src/storage.ts b/packages/world-vercel/src/storage.ts index 27941258bd..5227caf236 100644 --- a/packages/world-vercel/src/storage.ts +++ b/packages/world-vercel/src/storage.ts @@ -41,9 +41,9 @@ export function createStorage(config?: APIConfig): Storage { listWorkflowRunSteps(params, config)) as Storage['steps']['list'], }, events: { - create: ( + create: ( runId: string | null, - data: T, + data: AnyEventRequest, params?: CreateEventParams ) => createWorkflowRunEvent(runId, data, params, config), get: (runId, eventId, params) => getEvent(runId, eventId, params, config), diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index 239800d615..6745875804 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -813,9 +813,9 @@ export type EventResultFor = T extends EventRequestOfType<'run_created'> ? EventResult & { run: import('./runs.js').WorkflowRun } : T extends EventRequestOfType<'run_started'> - ? EventResult & { run: import('./runs.js').WorkflowRun } + ? EventResult & { run: import('./runs.js').StartedWorkflowRun } : T extends EventRequestOfType<'step_started'> - ? EventResult & { step: import('./steps.js').Step } + ? EventResult & { step: import('./steps.js').StartedStep } : EventResult; export interface GetEventParams { diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index 0e83f79827..2818ad1772 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -141,6 +141,8 @@ export const WorkflowRunSchema = z.discriminatedUnion('status', [ // Inferred types export type WorkflowRun = z.infer; +/** A workflow run with its first start time materialized. */ +export type StartedWorkflowRun = WorkflowRun & { startedAt: Date }; /** * WorkflowRun with input/output fields excluded (when resolveData='none'). diff --git a/packages/world/src/steps.ts b/packages/world/src/steps.ts index 68b27dc7db..1aa8cb31fc 100644 --- a/packages/world/src/steps.ts +++ b/packages/world/src/steps.ts @@ -88,6 +88,8 @@ export function isTerminalStepStatus( } export type Step = z.infer; +/** A step with its first start time materialized. */ +export type StartedStep = Step & { startedAt: Date }; /** * Step with input/output fields excluded (when resolveData='none'). From 64ef78ed6c036f12616ac8bce3793498425f7b23 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:14:33 -0700 Subject: [PATCH 09/45] refactor(core): unify replay event log state --- packages/core/src/runtime.ts | 298 +++++++++++------------------------ 1 file changed, 90 insertions(+), 208 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 82e0a9fc1a..1e6ca6fa8c 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { types } from 'node:util'; import { CorruptedEventLogError, @@ -323,6 +324,21 @@ function openHookAndWaitState(events: Event[]): { return { openHook, openWait }; } +type ReplayEventLog = + | { type: 'unloaded' } + | { type: 'ready'; log: MutableEventLog } + | { type: 'cached'; log: MutableEventLog }; + +function appendEventLog(log: MutableEventLog, appended: MutableEventLog): void { + const eventIds = new Set(log.events.map((event) => event.eventId)); + for (const event of appended.events) { + if (!eventIds.has(event.eventId)) { + log.events.push(event); + } + } + log.cursor = appended.cursor ?? log.cursor; +} + /** * Creates a single route which handles workflow execution requests, * executing steps inline when possible to reduce function invocations @@ -518,24 +534,9 @@ export function workflowEntrypoint( const invocationStartTime = Date.now(); let loopIteration = 0; - // Event cache: keep loaded events in memory across loop iterations. - // On the first iteration we do a full load; on subsequent iterations - // we fetch only events created after the last known cursor. - let cachedEvents: Event[] | null = null; - let eventsCursor: string | null = null; - - // Inline-delta optimization: when an inline step's terminal - // write returns the event-log delta since the pre-write - // cursor (a supporting World only), we stash it here so the - // next loop iteration consumes it in place of the incremental - // events.list round-trip. Each value is consumed exactly once - // and then cleared. Null means "no delta pending — fetch - // normally". See the consume site at the top of the loop and - // the produce site after inline executeStep. - let pendingInlineDelta: { - events: Event[]; - cursor: string | null; - } | null = null; + // `ready` can replay once without a read. `cached` must load + // events after its cursor before replaying again. + let eventLog: ReplayEventLog = { type: 'unloaded' }; // Shared state: set by either the background step path // or the run_started setup below. @@ -544,7 +545,6 @@ export function workflowEntrypoint( // response. Undefined ⇒ no enforcement (older servers, turbo). let maxEventsLimit: number | undefined; let workflowStartedAt = -1; - let preloadedEventLog: MutableEventLog | undefined; // Latency telemetry (TTFS) state — see runtime/step-latency.ts. // Whether this invocation's FIRST event snapshot contained @@ -734,22 +734,19 @@ export function workflowEntrypoint( // owned recovery and then transitions to queued/bare // retries trips the combined ceiling. This still bounds // timeouts, which write no error for the post-body guard - // to catch. The load also primes the replay's - // `cachedEvents`/`eventsCursor` (the post-step - // continuation below refreshes them once the step's - // terminal event lands). + // to catch. let bgAuthoritativeAttempt = metadata.attempt; const bgMaxRetries = getStepFunction(incomingStepName)?.maxRetries ?? DEFAULT_STEP_MAX_RETRIES; if (metadata.attempt > bgMaxRetries + 1) { const loaded = await loadWorkflowRunEvents(runId); - cachedEvents = loaded.events; - eventsCursor = loaded.cursor; bgAuthoritativeAttempt = - countStepStartedEvents(cachedEvents, incomingStepId, { - type: 'totalAttempts', - }) + 1; + countStepStartedEvents( + loaded.events, + incomingStepId, + { type: 'totalAttempts' } + ) + 1; } // Pause the replay budget while the step body runs — @@ -826,14 +823,13 @@ export function workflowEntrypoint( // Use cursor-based loading so the main loop can continue // incrementally from here. const loaded = await loadWorkflowRunEvents(runId); - cachedEvents = loaded.events; - eventsCursor = loaded.cursor; + eventLog = { type: 'cached', log: loaded }; // Check for pending steps: any step_created without // a matching step_completed or step_failed. const stepCreatedIds = new Set(); const stepTerminalIds = new Set(); - for (const e of cachedEvents) { + for (const e of loaded.events) { if (e.eventType === 'step_created') { stepCreatedIds.add(e.correlationId); } else if ( @@ -862,7 +858,7 @@ export function workflowEntrypoint( if ( isInlineOwnershipEnabled() && hasPendingStepOwnedByMessage( - cachedEvents, + loaded.events, pendingStepIds, metadata.messageId ) @@ -889,7 +885,7 @@ export function workflowEntrypoint( 'All parallel steps done, replaying inline after background step', { workflowRunId: runId } ); - const runCreatedEvent = cachedEvents.find( + const runCreatedEvent = loaded.events.find( (event) => event.eventType === 'run_created' ); let replayInput: unknown; @@ -920,7 +916,6 @@ export function workflowEntrypoint( completedAt: undefined, }; workflowStartedAt = bgStartedAt; - // cachedEvents and eventsCursor already set from load above } else { return; } @@ -1031,7 +1026,10 @@ export function workflowEntrypoint( // the no-load preloaded branch; iteration 2 then takes the // existing post-preloaded full reload to pick up a cursor // without a spurious "cursor missing" warning. - preloadedEventLog = { events: [], cursor: null }; + eventLog = { + type: 'ready', + log: { events: [], cursor: null }, + }; const now = new Date(); workflowRun = { runId, @@ -1084,28 +1082,19 @@ export function workflowEntrypoint( runStartedReceivedAtMs = Date.now(); if (result.events?.length) { - let events = result.events; - let cursor = result.cursor ?? null; + const log = { + events: [...result.events], + cursor: result.cursor ?? null, + }; if (result.hasMore) { - const loaded = await loadWorkflowRunEvents( - runId, - cursor ?? undefined + assert( + log.cursor, + 'Partial run_started event log requires a cursor' ); - if (cursor) { - const preloadedIds = new Set( - events.map((event) => event.eventId) - ); - events = events.concat( - loaded.events.filter( - (event) => !preloadedIds.has(event.eventId) - ) - ); - } else { - events = loaded.events; - } - cursor = loaded.cursor ?? cursor; + eventLog = { type: 'cached', log }; + } else { + eventLog = { type: 'ready', log }; } - preloadedEventLog = { events, cursor }; } } catch (err) { // Run was concurrently completed/failed/cancelled @@ -1245,103 +1234,31 @@ export function workflowEntrypoint( // as the `sinceCursor` for the inline-delta optimization. let preInlineWriteCursor: string | null = null; try { - // Load events — use cached events with incremental fetch on subsequent iterations. - // The server always returns a cursor when there are events (even on the - // final page), so we can reliably use it for incremental loading. - let events: Event[]; - if (pendingInlineDelta && cachedEvents) { - // Fast path: the previous iteration's inline step - // terminal write returned the authoritative event-log - // delta since the pre-write cursor, so we consume it - // here instead of issuing an incremental events.list. - // The delta is byte-for-byte what events.list(cursor) - // would have returned at write time — it includes this - // handler's own step events, any attr_set the step body - // wrote, and any in-band events (e.g. hook_received, - // wait_completed) another writer appended since the - // cursor — so skipping the fetch cannot drop events or - // skew the prefix from the server's log. - const delta = pendingInlineDelta; - pendingInlineDelta = null; - if (delta.events.length > 0) { - const existingIds = new Set( - cachedEvents.map((e) => e.eventId) - ); - for (const e of delta.events) { - if (!existingIds.has(e.eventId)) { - existingIds.add(e.eventId); - cachedEvents.push(e); - } - } - } - eventsCursor = delta.cursor ?? eventsCursor; - events = cachedEvents; - } else if (cachedEvents === null) { - // First iteration: use preloaded events if available, - // otherwise do a full load with cursor. - if (preloadedEventLog) { - events = preloadedEventLog.events; - eventsCursor = preloadedEventLog.cursor; - } else { - const loaded = await loadWorkflowRunEvents(runId); - events = loaded.events; - eventsCursor = loaded.cursor; - } - } else if (eventsCursor) { - // Subsequent iteration: fetch only new events since last cursor + let log: MutableEventLog; + if (eventLog.type === 'ready') { + log = eventLog.log; + } else { + const load = + eventLog.type === 'cached' && eventLog.log.cursor + ? { + type: 'append' as const, + log: eventLog.log, + cursor: eventLog.log.cursor, + } + : { type: 'replace' as const }; const loaded = await loadWorkflowRunEvents( runId, - eventsCursor + load.type === 'append' ? load.cursor : undefined ); - // Dedupe by eventId: a previous iteration may have - // appended a refreshed wait-completion delta before - // the next loop observes the advanced cursor, so an - // incremental fetch can return events we already have - // locally. - if (loaded.events.length > 0) { - const existingIds = new Set( - cachedEvents.map((e) => e.eventId) - ); - for (const e of loaded.events) { - if (!existingIds.has(e.eventId)) { - existingIds.add(e.eventId); - cachedEvents.push(e); - } - } + if (load.type === 'append') { + appendEventLog(load.log, loaded); + log = load.log; + } else { + log = loaded; } - eventsCursor = loaded.cursor ?? eventsCursor; - events = cachedEvents; - } else if (preloadedEventLog) { - // Iteration 2 after iteration 1 used preloaded events - // (which don't carry a cursor). Do a full load now to - // pick up any events written since the preloaded set - // and obtain a cursor for subsequent incremental - // loads. This is the expected path, not a bug. - runtimeLogger.debug( - 'No cursor after preloaded-events first iteration; doing full reload to pick up cursor.', - { workflowRunId: runId } - ); - const loaded = await loadWorkflowRunEvents(runId); - cachedEvents = loaded.events; - eventsCursor = loaded.cursor; - events = cachedEvents; - } else { - // No cursor available despite having cached events - // and no preloaded-events explanation. All World - // implementations are required to return a cursor - // when there are events, so this signals a bug in - // the World. Fall back to a full reload to avoid - // stale data. - runtimeLogger.warn( - 'Event cursor missing after initial load — falling back to full reload. ' + - 'This indicates a bug in the World implementation.', - { workflowRunId: runId } - ); - const loaded = await loadWorkflowRunEvents(runId); - cachedEvents = loaded.events; - eventsCursor = loaded.cursor; - events = cachedEvents; } + eventLog = { type: 'cached', log }; + let events = log.events; // Detect concurrent completion via the event log: if // any other handler wrote a terminal run event, exit @@ -1383,14 +1300,10 @@ export function workflowEntrypoint( })); for (const waitEvent of waitsToComplete) { - const waitLog: MutableEventLog = { - events, - cursor: eventsCursor, - }; try { await withPreconditionRetry( runId, - waitLog, + log, (stateUpdatedAt) => world.events.create(runId, waitEvent, { requestId, @@ -1409,9 +1322,6 @@ export function workflowEntrypoint( continue; } throw err; - } finally { - // Reloads inside the guard may have advanced the cursor. - eventsCursor = waitLog.cursor; } } @@ -1424,10 +1334,10 @@ export function workflowEntrypoint( // reload for older worlds that cannot give us a stable // cursor, or if the cursor delta does not include the // wait completion this handler just attempted. - if (eventsCursor) { + if (log.cursor) { const loaded = await loadWorkflowRunEvents( runId, - eventsCursor + log.cursor ); const completedWaitIdsAfterCursor = new Set( loaded.events @@ -1442,26 +1352,15 @@ export function workflowEntrypoint( ); if (sawAllWaitCompletions) { - const existingIds = new Set( - events.map((e) => e.eventId) - ); - for (const event of loaded.events) { - if (!existingIds.has(event.eventId)) { - existingIds.add(event.eventId); - events.push(event); - } - } - eventsCursor = loaded.cursor ?? eventsCursor; + appendEventLog(log, loaded); } else { - const loaded = await loadWorkflowRunEvents(runId); - events = loaded.events; - eventsCursor = loaded.cursor; + log = await loadWorkflowRunEvents(runId); } } else { - const loaded = await loadWorkflowRunEvents(runId); - events = loaded.events; - eventsCursor = loaded.cursor; + log = await loadWorkflowRunEvents(runId); } + eventLog = { type: 'cached', log }; + events = log.events; } // Completing elapsed waits refreshes the event snapshot. @@ -1487,9 +1386,6 @@ export function workflowEntrypoint( ); } - // Update cache reference (may have been set for first time) - cachedEvents = events; - // Latency telemetry: judge TTFS eligibility against the // invocation's first snapshot. Waits completed above // would already disqualify via the event-type check, so @@ -1513,8 +1409,8 @@ export function workflowEntrypoint( // terminal write so it can return the event-log delta — // letting the next iteration skip the incremental // events.list. Captured here because nothing between this - // point and the inline executeStep mutates eventsCursor. - preInlineWriteCursor = eventsCursor; + // point and the inline executeStep mutates the event log. + preInlineWriteCursor = log.cursor; // Replay workflow runtimeLogger.debug('Starting workflow replay', { @@ -1645,21 +1541,11 @@ export function workflowEntrypoint( // guard per-create (rather than wrapping the whole call) // so a retry never re-issues an already-created event. const suspensionStart = Date.now(); - // The snapshot refresh above always sets cachedEvents - // before the replay can suspend. Re-narrow it for this - // catch scope instead of defaulting to an empty array: - // that fallback would silently disarm the precondition - // guard (no snapshot sent) and let a mid-suspension - // reload merge into a throwaway array. - if (!cachedEvents) { - throw new Error( - 'Invariant violation: workflow suspended before its event log was loaded' - ); - } - const suspensionLog: MutableEventLog = { - events: cachedEvents, - cursor: eventsCursor, - }; + assert( + eventLog.type === 'cached', + 'Workflow suspended before its event log was loaded' + ); + const suspensionLog = eventLog.log; let suspensionResult: Awaited< ReturnType >; @@ -1762,7 +1648,6 @@ export function workflowEntrypoint( }); return; } - eventsCursor = suspensionLog.cursor; preStepBlockingMs += suspensionResult.hookCreationMs; if ( suspensionResult.hasAttributeEvents && @@ -2090,7 +1975,7 @@ export function workflowEntrypoint( // Open hooks/waits in the cumulative log, computed // once for the two gates below. const openHookWaitState = openHookAndWaitState( - cachedEvents ?? [] + suspensionLog.events ); // Inline-delta fast path gate. We request the delta — @@ -2220,7 +2105,7 @@ export function workflowEntrypoint( // cumulative log, resume/parallel invocations are possible // for the rest of the run, so turbo must latch off // permanently — checked here via `openHookAndWaitState` - // over the cumulative `cachedEvents`. + // over the cumulative event log. // // NOTE: `WORKFLOW_SEQUENTIAL_REPLAYS=1` (per-run flow // topics consumed with `maxConcurrency: 1`) would in @@ -2258,7 +2143,7 @@ export function workflowEntrypoint( // snapshot has a local-clock createdAt, so under // turbo only the run-id ULID timestamp is trusted. const latencyTracking = computeStepLatencyTracking({ - events: cachedEvents ?? [], + events: suspensionLog.events, invocationStartedClean: invocationStartedClean === true, runCreatedAtMs: @@ -2269,7 +2154,7 @@ export function workflowEntrypoint( preStepBlockingMs, preStepBlockingBeforeAttrMs, // This suspension's own hook/wait writes are not in - // cachedEvents yet, so report them explicitly. + // the loaded event log yet, so report them explicitly. suspensionHasWaits: err.waitCount > 0 || suspensionResult.waitTimeout !== undefined, @@ -2289,7 +2174,7 @@ export function workflowEntrypoint( // this is a no-op outside guarded deployments; Worlds // that don't enforce the guard ignore it. const inlineClaimStateUpdatedAt = - stateUpdatedAtForCreate(cachedEvents ?? []); + stateUpdatedAtForCreate(suspensionLog.events); replayBudget.pause(); let stepResults: Awaited< @@ -2337,7 +2222,7 @@ export function workflowEntrypoint( s.lazyStepInput !== undefined ? 1 : countStepStartedEvents( - cachedEvents, + suspensionLog.events, s.correlationId, { type: 'ownedBy', @@ -2593,12 +2478,8 @@ export function workflowEntrypoint( // (completed/failed/skipped/gone) — loop back to replay // (the workflow observes the terminal events on replay). // - // If the single inline step's terminal write returned an - // inline delta (supporting World + the single-step gate - // above), stash it so the next iteration's load consumes - // it instead of issuing an incremental events.list. Only - // the completed path carries a delta; multi-step batches - // never request one. + // A complete inline delta is fresh enough for the next + // replay, so consume it without another events.list. if (inlineExecutions.length === 1) { const only = stepResults[0]; if ( @@ -2606,9 +2487,10 @@ export function workflowEntrypoint( only.inlineDelta && !only.inlineDelta.hasMore ) { - pendingInlineDelta = { - events: only.inlineDelta.events, - cursor: only.inlineDelta.cursor, + appendEventLog(suspensionLog, only.inlineDelta); + eventLog = { + type: 'ready', + log: suspensionLog, }; } } From e449ff8350c73df5a896fa32a858de0fa9423874 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:44:40 -0700 Subject: [PATCH 10/45] refactor(core): make replay log states exact --- packages/core/src/runtime.ts | 130 ++++++++++++++++++++--------------- 1 file changed, 73 insertions(+), 57 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 1e6ca6fa8c..980ea818b6 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -25,7 +25,6 @@ import { resolveQueueNamespace, SPEC_VERSION_CURRENT, SPEC_VERSION_SUPPORTS_COMPRESSION, - type StartedWorkflowRun, WorkflowInvokePayloadSchema, type WorkflowRun, type World, @@ -325,9 +324,22 @@ function openHookAndWaitState(events: Event[]): { } type ReplayEventLog = - | { type: 'unloaded' } + | { type: 'loadAll' } | { type: 'ready'; log: MutableEventLog } - | { type: 'cached'; log: MutableEventLog }; + | { + type: 'loadAfter'; + log: MutableEventLog & { cursor: string }; + }; + +function nextEventLogLoad(log: MutableEventLog): ReplayEventLog { + if (log.cursor === null) { + return { type: 'loadAll' }; + } + return { + type: 'loadAfter', + log: { events: log.events, cursor: log.cursor }, + }; +} function appendEventLog(log: MutableEventLog, appended: MutableEventLog): void { const eventIds = new Set(log.events.map((event) => event.eventId)); @@ -534,9 +546,9 @@ export function workflowEntrypoint( const invocationStartTime = Date.now(); let loopIteration = 0; - // `ready` can replay once without a read. `cached` must load - // events after its cursor before replaying again. - let eventLog: ReplayEventLog = { type: 'unloaded' }; + // `ready` can replay once without a read. The other states + // describe the next load exactly. + let eventLog: ReplayEventLog = { type: 'loadAll' }; // Shared state: set by either the background step path // or the run_started setup below. @@ -823,7 +835,7 @@ export function workflowEntrypoint( // Use cursor-based loading so the main loop can continue // incrementally from here. const loaded = await loadWorkflowRunEvents(runId); - eventLog = { type: 'cached', log: loaded }; + eventLog = nextEventLogLoad(loaded); // Check for pending steps: any step_created without // a matching step_completed or step_failed. @@ -1067,7 +1079,6 @@ export function workflowEntrypoint( ...Attribute.WorkflowStartedAt(workflowStartedAt), }); } else { - let startedRun: StartedWorkflowRun; try { recordRunStartedCreateStart(false); const result = await world.events.create( @@ -1075,27 +1086,59 @@ export function workflowEntrypoint( runStartedEvent, { requestId } ); - startedRun = result.run; - workflowRun = startedRun; + workflowRun = result.run; maxEventsLimit = clampMaxEvents(result.maxEvents); // Anchors RSFS — see the declaration above. runStartedReceivedAtMs = Date.now(); if (result.events?.length) { - const log = { - events: [...result.events], - cursor: result.cursor ?? null, - }; + const events = [...result.events]; if (result.hasMore) { assert( - log.cursor, + result.cursor, 'Partial run_started event log requires a cursor' ); - eventLog = { type: 'cached', log }; + eventLog = { + type: 'loadAfter', + log: { + events, + cursor: result.cursor, + }, + }; } else { - eventLog = { type: 'ready', log }; + eventLog = { + type: 'ready', + log: { + events, + cursor: result.cursor ?? null, + }, + }; } } + + workflowStartedAt = +result.run.startedAt; + span?.setAttributes({ + ...Attribute.WorkflowRunStatus(result.run.status), + ...Attribute.WorkflowStartedAt(workflowStartedAt), + }); + + if (result.run.status !== 'running') { + runtimeLogger.info( + 'Workflow already completed or failed, skipping', + { + workflowRunId: runId, + status: result.run.status, + } + ); + + // TODO: for `cancel`, we actually want to propagate a WorkflowCancelled event + // inside the workflow context so the user can gracefully exit. this is SIGTERM + // TODO: furthermore, there should be a timeout or a way to force cancel SIGKILL + // so that we actually exit here without replaying the workflow at all, in the case + // the replaying the workflow is itself failing. + + return; + } } catch (err) { // Run was concurrently completed/failed/cancelled if ( @@ -1129,33 +1172,12 @@ export function workflowEntrypoint( return; } } - workflowStartedAt = +startedRun.startedAt; - - span?.setAttributes({ - ...Attribute.WorkflowRunStatus(startedRun.status), - ...Attribute.WorkflowStartedAt(workflowStartedAt), - }); - - if (startedRun.status !== 'running') { - // Workflow has already completed or failed, so we can skip it - runtimeLogger.info( - 'Workflow already completed or failed, skipping', - { - workflowRunId: runId, - status: startedRun.status, - } - ); - - // TODO: for `cancel`, we actually want to propagate a WorkflowCancelled event - // inside the workflow context so the user can gracefully exit. this is SIGTERM - // TODO: furthermore, there should be a timeout or a way to force cancel SIGKILL - // so that we actually exit here without replaying the workflow at all, in the case - // the replaying the workflow is itself failing. - - return; - } } // end else (non-turbo run_started) } // end if (!workflowRun) + assert( + workflowRun, + 'Workflow run must be loaded before replay' + ); // Resolve the encryption key for this run's deployment. // Used eagerly here since both runWorkflow (input @@ -1238,26 +1260,19 @@ export function workflowEntrypoint( if (eventLog.type === 'ready') { log = eventLog.log; } else { - const load = - eventLog.type === 'cached' && eventLog.log.cursor - ? { - type: 'append' as const, - log: eventLog.log, - cursor: eventLog.log.cursor, - } - : { type: 'replace' as const }; const loaded = await loadWorkflowRunEvents( runId, - load.type === 'append' ? load.cursor : undefined + eventLog.type === 'loadAfter' + ? eventLog.log.cursor + : undefined ); - if (load.type === 'append') { - appendEventLog(load.log, loaded); - log = load.log; + if (eventLog.type === 'loadAfter') { + appendEventLog(eventLog.log, loaded); + log = eventLog.log; } else { log = loaded; } } - eventLog = { type: 'cached', log }; let events = log.events; // Detect concurrent completion via the event log: if @@ -1359,7 +1374,6 @@ export function workflowEntrypoint( } else { log = await loadWorkflowRunEvents(runId); } - eventLog = { type: 'cached', log }; events = log.events; } @@ -1411,6 +1425,7 @@ export function workflowEntrypoint( // events.list. Captured here because nothing between this // point and the inline executeStep mutates the event log. preInlineWriteCursor = log.cursor; + eventLog = { type: 'ready', log }; // Replay workflow runtimeLogger.debug('Starting workflow replay', { @@ -1542,7 +1557,7 @@ export function workflowEntrypoint( // so a retry never re-issues an already-created event. const suspensionStart = Date.now(); assert( - eventLog.type === 'cached', + eventLog.type === 'ready', 'Workflow suspended before its event log was loaded' ); const suspensionLog = eventLog.log; @@ -1648,6 +1663,7 @@ export function workflowEntrypoint( }); return; } + eventLog = nextEventLogLoad(suspensionLog); preStepBlockingMs += suspensionResult.hookCreationMs; if ( suspensionResult.hasAttributeEvents && From a5d821244dfd2b3f5ef918f0eb98f0b771b1be1e Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:04:34 -0700 Subject: [PATCH 11/45] fix(core): harden run start preload recovery --- packages/core/src/runtime.ts | 15 +++--- packages/core/src/runtime/step-executor.ts | 11 ++-- .../runtime/wait-completion-replay.test.ts | 52 ++++++++++++++----- packages/world-local/src/storage.test.ts | 42 +++++++++++++++ .../world-local/src/storage/events-storage.ts | 44 +++++++++------- packages/world-vercel/src/events.test.ts | 52 ++++++++++++++++--- packages/world-vercel/src/events.ts | 15 +++--- 7 files changed, 176 insertions(+), 55 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index abc1f88e47..605c4ca330 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -39,6 +39,7 @@ import { type StepInvocationQueueItem, WorkflowSuspension } from './global.js'; import { runtimeLogger } from './logger.js'; import { getStepFunction } from './private.js'; import { ReplayPayloadCache } from './replay-payload-cache.js'; +import { COMPUTE_INSTANCE_ID } from './runtime/compute-instance.js'; import { getMaxEventsOverride, getMaxQueueDeliveries, @@ -66,7 +67,6 @@ import { handleReplayBudgetExhausted, ReplayBudget, } from './runtime/replay-budget.js'; -import { COMPUTE_INSTANCE_ID } from './runtime/compute-instance.js'; import { runIdCreatedAt } from './runtime/run-id-time.js'; import { DEFAULT_STEP_MAX_RETRIES, @@ -1090,11 +1090,7 @@ export function workflowEntrypoint( if (result.events?.length) { const events = [...result.events]; - if (result.hasMore) { - assert( - result.cursor, - 'Partial run_started event log requires a cursor' - ); + if (result.hasMore && result.cursor) { eventLog = { type: 'loadAfter', log: { @@ -1102,6 +1098,8 @@ export function workflowEntrypoint( cursor: result.cursor, }, }; + } else if (result.hasMore) { + eventLog = { type: 'loadAll' }; } else { eventLog = { type: 'ready', @@ -1113,6 +1111,11 @@ export function workflowEntrypoint( } } + if (!result.run.startedAt) { + throw new WorkflowRuntimeError( + `Workflow run "${runId}" has no "startedAt" timestamp` + ); + } workflowStartedAt = +result.run.startedAt; span?.setAttributes({ ...Attribute.WorkflowRunStatus(result.run.status), diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts index 932bd90e3a..d92f9b3b1c 100644 --- a/packages/core/src/runtime/step-executor.ts +++ b/packages/core/src/runtime/step-executor.ts @@ -6,6 +6,7 @@ import { RunExpiredError, ThrottleError, TooEarlyError, + WorkflowRuntimeError, } from '@workflow/errors'; import { createWorkflowBaseUrl, @@ -23,9 +24,9 @@ import { SPEC_VERSION_CURRENT, SPEC_VERSION_SUPPORTS_COMPRESSION, } from '@workflow/world'; -import type { PayloadKey } from '../serialization/encryption.js'; import { runtimeLogger, stepLogger } from '../logger.js'; import { getStepFunction } from '../private.js'; +import type { PayloadKey } from '../serialization/encryption.js'; import { cancelAbortReaders, dehydrateStepError, @@ -42,12 +43,11 @@ import { normalizeUnknownError, promoteAbortErrorToFatal, } from '../types.js'; - +import { COMPUTE_INSTANCE_ID } from './compute-instance.js'; import { isOptimisticInlineStartEnabled, isOptimisticInlineStartExplicitlyDisabled, } from './constants.js'; -import { COMPUTE_INSTANCE_ID } from './compute-instance.js'; import { getPortLazy } from './get-port-lazy.js'; import { memoizeEncryptionKey } from './helpers.js'; import { @@ -771,6 +771,11 @@ export async function executeStep( try { const attempt = step.attempt; + if (!step.startedAt) { + throw new WorkflowRuntimeError( + `Step "${stepId}" has no "startedAt" timestamp` + ); + } const stepStartedAt = step.startedAt; // Use the provided encryption key when available, otherwise resolve // through the memoized accessor declared at the top of this trace. diff --git a/packages/core/src/runtime/wait-completion-replay.test.ts b/packages/core/src/runtime/wait-completion-replay.test.ts index c9c7fa3ed6..14e31c52d8 100644 --- a/packages/core/src/runtime/wait-completion-replay.test.ts +++ b/packages/core/src/runtime/wait-completion-replay.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { type CreateEventRequest, type Event, + type EventResult, SPEC_VERSION_CURRENT, type WorkflowRun, type World, @@ -68,7 +69,7 @@ function getWorkflowTransformCode(workflowName: string) { * wait races with a hook payload that landed durably first. */ async function runStaleWaitReplayScenario(options: { - preload: 'legacy' | 'complete' | 'partial'; + preload: 'legacy' | 'complete' | 'partial' | 'partialWithoutCursor'; omitWaitCompletionFromDelta?: boolean; terminalFailureAfterWaitCompletion?: boolean; }) { @@ -182,14 +183,16 @@ async function runStaleWaitReplayScenario(options: { ]; const staleEventsCursor = 'cursor-after-stale-events'; - const preloadedEvents = - options.preload === 'partial' ? staleEvents.slice(0, 3) : staleEvents; + const partialPreload = + options.preload === 'partial' || options.preload === 'partialWithoutCursor'; + const preloadedEvents = partialPreload + ? staleEvents.slice(0, 3) + : staleEvents; const lastPreloadedEvent = preloadedEvents.at(-1); assert(lastPreloadedEvent); - const preloadedCursor = - options.preload === 'partial' - ? lastPreloadedEvent.eventId - : staleEventsCursor; + const preloadedCursor = partialPreload + ? lastPreloadedEvent.eventId + : staleEventsCursor; const hookReceivedEvent = event({ eventType: 'hook_received', specVersion: SPEC_VERSION_CURRENT, @@ -251,16 +254,16 @@ async function runStaleWaitReplayScenario(options: { // the scenario degenerates into a run failure instead of a suspension. registerStepFunction('drainStep', async () => undefined); - const runStartedResponse = { + const runStartedResponse: EventResult = { run: workflowRun, events: preloadedEvents, - ...(options.preload !== 'legacy' - ? { - cursor: preloadedCursor, - hasMore: options.preload === 'partial', - } - : {}), }; + if (options.preload === 'partialWithoutCursor') { + runStartedResponse.hasMore = true; + } else if (options.preload !== 'legacy') { + runStartedResponse.cursor = preloadedCursor; + runStartedResponse.hasMore = options.preload === 'partial'; + } const createEvent = vi.fn( async (_runId: string, request: CreateEventRequest) => { @@ -551,6 +554,27 @@ describe('workflow handler wait completion replay', () => { expectHookBranchQueued(result); }); + it('falls back to a full reload when a partial preload omits its cursor', async () => { + const result = await runStaleWaitReplayScenario({ + preload: 'partialWithoutCursor', + }); + + expect(result.listEvents.mock.calls[0]?.[0].pagination).toEqual({ + sortOrder: 'asc', + cursor: undefined, + }); + expect(result.listedPages[0]?.map((event) => event.eventType)).toEqual([ + 'run_created', + 'run_started', + 'hook_created', + 'step_created', + 'step_started', + 'step_completed', + 'wait_created', + ]); + expectHookBranchQueued(result); + }); + it('falls back to a full reload when the cursor delta misses the attempted wait completion', async () => { // Defensive path: if the cursor read does not include the wait completion // this handler just wrote, the cursor was not a safe replay boundary. diff --git a/packages/world-local/src/storage.test.ts b/packages/world-local/src/storage.test.ts index 51f98ef24e..f26c4821c3 100644 --- a/packages/world-local/src/storage.test.ts +++ b/packages/world-local/src/storage.test.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -1276,6 +1277,47 @@ describe('Storage', () => { expect(fileExists).toBe(true); }); + it('returns a resumable partial preload when run_started is retried', async () => { + await storage.events.create(testRunId, { + eventType: 'run_started', + specVersion: SPEC_VERSION_CURRENT, + }); + + for (let index = 0; index < 999; index++) { + await storage.events.create(testRunId, { + eventType: 'attr_set', + specVersion: SPEC_VERSION_CURRENT, + eventData: { + changes: [{ key: 'index', value: String(index) }], + writer: { type: 'workflow' }, + }, + }); + } + + const preloaded = await storage.events.create(testRunId, { + eventType: 'run_started', + specVersion: SPEC_VERSION_CURRENT, + }); + assert(preloaded.events); + assert(preloaded.cursor); + expect(preloaded.events).toHaveLength(1000); + expect(preloaded.hasMore).toBe(true); + + const suffix = await storage.events.list({ + runId: testRunId, + pagination: { + sortOrder: 'asc', + cursor: preloaded.cursor, + }, + }); + const all = await storage.events.list({ + runId: testRunId, + pagination: { sortOrder: 'asc', limit: 2000 }, + }); + + expect([...preloaded.events, ...suffix.data]).toEqual(all.data); + }, 30_000); + it('should handle run completed events', async () => { const eventData = { eventType: 'run_completed' as const, diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index 2b66ad1d01..2b5d30fa76 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -606,6 +606,18 @@ export function createEventsStorage( rememberStoredEvent(event, eventPath, serializedEvent); } + const preloadRunEvents = (runId: string) => + paginatedFileSystemQuery({ + directory: path.join(basedir, 'events'), + schema: EventSchema, + cachedItems: eventCache, + filePrefix: `${runId}-`, + sortOrder: 'asc', + limit: 1000, + getCreatedAt: getObjectCreatedAt('evnt'), + getId: (event) => event.eventId, + }); + // Per-instance in-process mutexes. Two storage instances sharing // one data directory get independent lock maps, which makes them // behave like two separate OS processes from the locking @@ -1202,12 +1214,17 @@ export function createEventsStorage( // Reuse currentRun from validation (already read above) if (currentRun) { // If already running, return the run without inserting a - // duplicate event. This makes run_started idempotent for - // concurrent invocations. We omit preloaded events here - // because this is a rare race-condition path — the runtime - // falls back to loadWorkflowRunEvents(). + // duplicate event. This makes run_started idempotent for + // concurrent invocations. if (currentRun.status === 'running') { - return { run: currentRun, maxEvents: getMaxEventsPerRun() }; + const preloaded = await preloadRunEvents(effectiveRunId); + return { + run: currentRun, + events: preloaded.data, + cursor: preloaded.cursor, + hasMore: preloaded.hasMore, + maxEvents: getMaxEventsPerRun(), + }; } run = await writeRunUnderLifecycleLock( @@ -2410,19 +2427,10 @@ export function createEventsStorage( let cursor: string | null | undefined; let hasMore: boolean | undefined; if (data.eventType === 'run_started' && run) { - const allEvents = await paginatedFileSystemQuery({ - directory: path.join(basedir, 'events'), - schema: EventSchema, - cachedItems: eventCache, - filePrefix: `${effectiveRunId}-`, - sortOrder: 'asc', - limit: 1000, - getCreatedAt: getObjectCreatedAt('evnt'), - getId: (e) => e.eventId, - }); - events = allEvents.data; - cursor = allEvents.cursor; - hasMore = allEvents.hasMore; + const preloaded = await preloadRunEvents(effectiveRunId); + events = preloaded.data; + cursor = preloaded.cursor; + hasMore = preloaded.hasMore; } // Inline-delta optimization: on a step-terminal write the inline diff --git a/packages/world-vercel/src/events.test.ts b/packages/world-vercel/src/events.test.ts index d70d301c63..095dd98c80 100644 --- a/packages/world-vercel/src/events.test.ts +++ b/packages/world-vercel/src/events.test.ts @@ -13,6 +13,7 @@ import { encode as encodeRunId, REGION_IDS } from './run-id/index.js'; import { WORKFLOW_SERVER_URL_OVERRIDE } from './utils.js'; const ORIGIN = WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; +const STARTED_AT = new Date('2026-06-10T00:00:00.000Z'); function mockAgent() { const agent = new MockAgent(); @@ -136,7 +137,13 @@ describe('createWorkflowRunEvent stateUpdatedAt wire field', () => { 200, (opts: { body?: unknown }) => { capturedMeta = decodePostedMeta(opts.body); - return encode({ run: { runId: 'wrun_1', status: 'running' } }); + return encode({ + run: { + runId: 'wrun_1', + status: 'running', + startedAt: STARTED_AT, + }, + }); }, { headers: { @@ -172,7 +179,13 @@ describe('createWorkflowRunEvent stateUpdatedAt wire field', () => { 200, (opts: { body?: unknown }) => { capturedMeta = decodePostedMeta(opts.body); - return encode({ run: { runId: 'wrun_1', status: 'running' } }); + return encode({ + run: { + runId: 'wrun_1', + status: 'running', + startedAt: STARTED_AT, + }, + }); }, { headers: { @@ -198,21 +211,38 @@ describe('createWorkflowRunEvent stateUpdatedAt wire field', () => { describe('createWorkflowRunEvent result contract', () => { it.each([ { + case: 'run_started without its run', eventType: 'run_started', data: { eventType: 'run_started', specVersion: 2 }, + response: {}, }, { + case: 'step_started without its step', eventType: 'step_started', data: { eventType: 'step_started', correlationId: 'step_1', specVersion: 2, }, + response: {}, }, - ])('rejects a $eventType response without its entity', async ({ - eventType, - data, - }) => { + { + case: 'run_started without startedAt', + eventType: 'run_started', + data: { eventType: 'run_started', specVersion: 2 }, + response: { run: { runId: 'wrun_1', status: 'running' } }, + }, + { + case: 'step_started without startedAt', + eventType: 'step_started', + data: { + eventType: 'step_started', + correlationId: 'step_1', + specVersion: 2, + }, + response: { step: { stepId: 'step_1', status: 'running' } }, + }, + ])('rejects $case', async ({ eventType, data, response }) => { const agent = mockAgent(); agent .get(ORIGIN) @@ -220,7 +250,7 @@ describe('createWorkflowRunEvent result contract', () => { path: `/api/v4/runs/wrun_1/events/${eventType}`, method: 'POST', }) - .reply(200, encode({}), { + .reply(200, encode(response), { headers: { 'x-wf-event-id': 'evnt_1', 'x-wf-run-id': 'wrun_1', @@ -258,7 +288,13 @@ async function postStepStartedMeta( 200, (opts: { body?: unknown }) => { capturedMeta = decodePostedMeta(opts.body); - return encode({ step: { stepId: 'step_1', status: 'running' } }); + return encode({ + step: { + stepId: 'step_1', + status: 'running', + startedAt: STARTED_AT, + }, + }); }, { headers: { diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index 962cf0ae84..57a55661d8 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -622,18 +622,21 @@ export async function createWorkflowRunEvent( () => createWorkflowRunEventInner(id, data, params, config), data.eventType ); - if ( - (data.eventType === 'run_created' || data.eventType === 'run_started') && - !result.run - ) { + if (data.eventType === 'run_created' && !result.run) { throw new WorkflowWorldError( `${data.eventType} response is missing the run entity`, { code: 'SCHEMA_VALIDATION' } ); } - if (data.eventType === 'step_started' && !result.step) { + if (data.eventType === 'run_started' && !result.run?.startedAt) { + throw new WorkflowWorldError( + 'run_started response is missing run.startedAt', + { code: 'SCHEMA_VALIDATION' } + ); + } + if (data.eventType === 'step_started' && !result.step?.startedAt) { throw new WorkflowWorldError( - 'step_started response is missing the step entity', + 'step_started response is missing step.startedAt', { code: 'SCHEMA_VALIDATION' } ); } From ec0594a182f0f364d0cdba2b5362b47027b64390 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:10:32 -0700 Subject: [PATCH 12/45] test(world-local): allow slow preload coverage --- packages/world-local/src/storage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/world-local/src/storage.test.ts b/packages/world-local/src/storage.test.ts index f26c4821c3..d564485555 100644 --- a/packages/world-local/src/storage.test.ts +++ b/packages/world-local/src/storage.test.ts @@ -1316,7 +1316,7 @@ describe('Storage', () => { }); expect([...preloaded.events, ...suffix.data]).toEqual(all.data); - }, 30_000); + }, 120_000); it('should handle run completed events', async () => { const eventData = { From 9b6730dce35880aa14d687db4c79127a1db11db2 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:56:34 -0700 Subject: [PATCH 13/45] fix(core): preserve event result inference through recovery --- packages/core/src/runtime/helpers.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/runtime/helpers.ts b/packages/core/src/runtime/helpers.ts index 5ce8dc38e1..810ceecd7c 100644 --- a/packages/core/src/runtime/helpers.ts +++ b/packages/core/src/runtime/helpers.ts @@ -7,7 +7,7 @@ import type { CreateEventParams, CreateEventRequest, Event, - EventResult, + EventResultFor, HealthCheckPayload, ValidQueueName, WorkflowRun, @@ -766,10 +766,10 @@ export async function withPreconditionRetry( } /** Creates one event on a bound run, carrying replay-recovery telemetry. */ -export type EventCreator = ( - data: CreateEventRequest, +export type EventCreator = ( + data: T, params?: CreateEventParams -) => Promise; +) => Promise>; /** * CORS headers for health check responses. From de618976af4160ba6f06690e0ea3f1af7bbc9d58 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:08:25 -0700 Subject: [PATCH 14/45] refactor(core): simplify preload state transition Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/core/src/runtime.ts | 33 ++++++-------------------- packages/world-postgres/src/storage.ts | 4 +--- 2 files changed, 8 insertions(+), 29 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 953ad118d1..bf1e26be22 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1518,32 +1518,13 @@ export function workflowEntrypoint( runStartedReceivedAtMs = Date.now(); if (result.events?.length) { - const events = [...result.events]; - if (result.hasMore && result.cursor) { - eventLog = { - type: 'loadAfter', - log: { - events, - cursor: result.cursor, - }, - }; - } else if (result.hasMore) { - eventLog = { type: 'loadAll' }; - } else { - eventLog = { - type: 'ready', - log: { - events, - cursor: result.cursor ?? null, - }, - }; - } - } - - if (!result.run.startedAt) { - throw new WorkflowRuntimeError( - `Workflow run "${runId}" has no "startedAt" timestamp` - ); + const log = { + events: [...result.events], + cursor: result.cursor ?? null, + }; + eventLog = result.hasMore + ? nextEventLogLoad(log) + : { type: 'ready', log }; } workflowStartedAt = +result.run.startedAt; span?.setAttributes({ diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index 48b2748280..375ded1e2f 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -893,9 +893,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { .where(eq(Schema.runs.runId, effectiveRunId)) .limit(1); if (fullRun) { - return { - run: deserializeRunError(compact(fullRun)), - }; + return { run: deserializeRunError(compact(fullRun)) }; } } From 67370e18cd5082e1d5efb50060cb198fcc54267d Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:53:07 -0700 Subject: [PATCH 15/45] refactor(runtime): reuse event pages without duplicate reads --- packages/core/src/runtime.ts | 50 +++++++---------- packages/core/src/runtime/helpers.ts | 2 +- packages/core/src/runtime/start.ts | 3 +- .../runtime/wait-completion-replay.test.ts | 5 +- packages/world-local/src/storage.test.ts | 32 ++++++++--- .../world-local/src/storage/events-storage.ts | 55 +++++++++---------- packages/world-postgres/src/storage.ts | 10 ++-- packages/world-postgres/test/storage.test.ts | 10 ++++ packages/world/src/events.ts | 10 ++-- 9 files changed, 93 insertions(+), 84 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index bf1e26be22..9a0360f579 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1414,14 +1414,6 @@ export function workflowEntrypoint( } : {}), }; - const recordRunStartedCreateStart = ( - skipPreload: boolean - ) => { - span?.addEvent('workflow.run_started.create.start', { - 'workflow.run_started.skip_preload': skipPreload, - }); - }; - if (turbo && runInput) { // Turbo: background `run_started` and synthesize the run // entity locally so replay can begin without waiting for @@ -1431,7 +1423,9 @@ export function workflowEntrypoint( // barrier is consumed by every downstream write (suspension // handler, optimistic step_started, terminal run writes) so // nothing is written before the run exists. - recordRunStartedCreateStart(true); + span?.addEvent('workflow.run_started.create.start', { + 'workflow.run_started.skip_preload': true, + }); const startedPromise = createEvent( runStartedEvent, // We background this purely as a write barrier and @@ -1449,17 +1443,14 @@ export function workflowEntrypoint( // maxEventsLimit every loop iteration, so a value that lands // shortly after start still enforces well before a runaway // log approaches the ceiling. - startedPromise.then( - (r) => { - const limit = clampMaxEvents(r?.maxEvents); + void startedPromise + .then((r) => { + const limit = clampMaxEvents(r.maxEvents); if (limit !== undefined) maxEventsLimit = limit; - }, - () => {} - ); - // Attach a no-op rejection handler so an early failure - // never surfaces as an unhandledRejection before a consumer - // (await/then) is attached; consumers still observe it. - startedPromise.catch(() => {}); + }) + // Prevent an early failure from surfacing as an + // unhandledRejection; runReadyBarrier still observes it. + .catch(() => {}); // Skip the initial events.list: nothing has been written to // the log yet on a first delivery (run_started is still in // flight). An empty preload routes iteration 1 through @@ -1508,7 +1499,9 @@ export function workflowEntrypoint( }); } else { try { - recordRunStartedCreateStart(false); + span?.addEvent('workflow.run_started.create.start', { + 'workflow.run_started.skip_preload': false, + }); const result = await createEvent(runStartedEvent, { requestId, }); @@ -3108,20 +3101,15 @@ export function workflowEntrypoint( // (completed/failed/skipped/gone) — loop back to replay // (the workflow observes the terminal events on replay). // - // A complete inline delta is fresh enough for the next - // replay, so consume it without another events.list. + // Reuse any inline delta. If it is partial, continue + // from its cursor instead of reading the page again. if (inlineExecutions.length === 1) { const only = stepResults[0]; - if ( - only.type === 'completed' && - only.inlineDelta && - !only.inlineDelta.hasMore - ) { + if (only.type === 'completed' && only.inlineDelta) { appendEventLog(suspensionLog, only.inlineDelta); - eventLog = { - type: 'ready', - log: suspensionLog, - }; + eventLog = only.inlineDelta.hasMore + ? nextEventLogLoad(suspensionLog) + : { type: 'ready', log: suspensionLog }; } } } else { diff --git a/packages/core/src/runtime/helpers.ts b/packages/core/src/runtime/helpers.ts index a0d4877952..eee469f723 100644 --- a/packages/core/src/runtime/helpers.ts +++ b/packages/core/src/runtime/helpers.ts @@ -541,7 +541,7 @@ function shouldRetryWithoutEventCursor( export async function loadWorkflowRunEvents( runId: string, afterCursor?: string -): Promise<{ events: Event[]; cursor: string | null }> { +): Promise { const incremental = afterCursor !== undefined; return trace( incremental ? 'workflow.loadNewEvents' : 'workflow.loadEvents', diff --git a/packages/core/src/runtime/start.ts b/packages/core/src/runtime/start.ts index 9c2663c230..3bbd9ba8d1 100644 --- a/packages/core/src/runtime/start.ts +++ b/packages/core/src/runtime/start.ts @@ -643,8 +643,7 @@ export async function start( span?.setAttributes({ ...Attribute.WorkflowRunId(runId), ...Attribute.DeploymentId(deploymentId), - ...(runCreatedResult.status === 'fulfilled' && - runCreatedResult.value.run + ...(runCreatedResult.status === 'fulfilled' ? Attribute.WorkflowRunStatus(runCreatedResult.value.run.status) : {}), }); diff --git a/packages/core/src/runtime/wait-completion-replay.test.ts b/packages/core/src/runtime/wait-completion-replay.test.ts index 14e31c52d8..76efdc663e 100644 --- a/packages/core/src/runtime/wait-completion-replay.test.ts +++ b/packages/core/src/runtime/wait-completion-replay.test.ts @@ -545,12 +545,13 @@ describe('workflow handler wait completion replay', () => { expect( result.listedPages .flat() - .some((listed) => + .filter((listed) => result.preloadedEvents.some( (preloaded) => preloaded.eventId === listed.eventId ) ) - ).toBe(false); + .map((event) => event.eventType) + ).toEqual([]); expectHookBranchQueued(result); }); diff --git a/packages/world-local/src/storage.test.ts b/packages/world-local/src/storage.test.ts index d564485555..5baffc8d9d 100644 --- a/packages/world-local/src/storage.test.ts +++ b/packages/world-local/src/storage.test.ts @@ -1318,6 +1318,28 @@ describe('Storage', () => { expect([...preloaded.events, ...suffix.data]).toEqual(all.data); }, 120_000); + it('skips the run_started preload when requested', async () => { + const started = await storage.events.create( + testRunId, + { + eventType: 'run_started', + specVersion: SPEC_VERSION_CURRENT, + }, + { skipPreload: true } + ); + expect(started.events).toBeUndefined(); + + const retried = await storage.events.create( + testRunId, + { + eventType: 'run_started', + specVersion: SPEC_VERSION_CURRENT, + }, + { skipPreload: true } + ); + expect(retried.events).toBeUndefined(); + }); + it('should handle run completed events', async () => { const eventData = { eventType: 'run_completed' as const, @@ -1440,13 +1462,9 @@ describe('Storage', () => { }); it('truncates the delta and surfaces hasMore=true when it exceeds one page, matching events.list', async () => { - // Safety property the runtime relies on (see the limit/hasMore/fallback - // contract at events-storage.ts and the consume gate in runtime.ts): - // the inline-delta query uses paginatedFileSystemQuery's default page - // size, so a delta larger than one page is truncated and MUST report - // hasMore=true. The runtime refuses to consume a truncated delta and - // falls back to the exhaustive events.list loop, so a partial page can - // never be mistaken for the complete delta. + // The inline-delta query uses paginatedFileSystemQuery's default page + // size, so a larger delta is truncated and MUST report hasMore=true. + // The runtime consumes this page and continues from its cursor. await updateRun(storage, testRunId, 'run_started'); await createHook(storage, testRunId, { diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index 2b5d30fa76..9e20c4b8ff 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -15,6 +15,7 @@ import type { Event, EventResult, Hook, + PaginationOptions, SerializedData, Step, Storage, @@ -606,14 +607,15 @@ export function createEventsStorage( rememberStoredEvent(event, eventPath, serializedEvent); } - const preloadRunEvents = (runId: string) => + const queryRunEvents = (runId: string, pagination: PaginationOptions) => paginatedFileSystemQuery({ directory: path.join(basedir, 'events'), schema: EventSchema, cachedItems: eventCache, filePrefix: `${runId}-`, - sortOrder: 'asc', - limit: 1000, + sortOrder: pagination.sortOrder ?? 'asc', + limit: pagination.limit, + cursor: pagination.cursor, getCreatedAt: getObjectCreatedAt('evnt'), getId: (event) => event.eventId, }); @@ -854,17 +856,12 @@ export function createEventsStorage( } } - // run_failed on a non-existent run is rejected to match the - // postgres and vercel worlds, which both surface this as a - // WorkflowRunNotFoundError rather than silently persisting an - // event for a run that was never created. - if (data.eventType === 'run_failed' && !currentRun) { - throw new WorkflowRunNotFoundError(effectiveRunId); - } - if (data.eventType === 'attr_set' && !currentRun) { - throw new WorkflowRunNotFoundError(effectiveRunId); - } - if (data.eventType === 'run_started' && !currentRun) { + if ( + !currentRun && + (data.eventType === 'run_failed' || + data.eventType === 'attr_set' || + data.eventType === 'run_started') + ) { throw new WorkflowRunNotFoundError(effectiveRunId); } @@ -1217,7 +1214,15 @@ export function createEventsStorage( // duplicate event. This makes run_started idempotent for // concurrent invocations. if (currentRun.status === 'running') { - const preloaded = await preloadRunEvents(effectiveRunId); + if (params?.skipPreload) { + return { + run: currentRun, + maxEvents: getMaxEventsPerRun(), + }; + } + const preloaded = await queryRunEvents(effectiveRunId, { + limit: 1000, + }); return { run: currentRun, events: preloaded.data, @@ -2426,8 +2431,10 @@ export function createEventsStorage( let events: Event[] | undefined; let cursor: string | null | undefined; let hasMore: boolean | undefined; - if (data.eventType === 'run_started' && run) { - const preloaded = await preloadRunEvents(effectiveRunId); + if (data.eventType === 'run_started' && run && !params?.skipPreload) { + const preloaded = await queryRunEvents(effectiveRunId, { + limit: 1000, + }); events = preloaded.data; cursor = preloaded.cursor; hasMore = preloaded.hasMore; @@ -2523,19 +2530,7 @@ export function createEventsStorage( const { runId } = params; assertSafeEntityId('runId', runId); const resolveData = params.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION; - const result = await paginatedFileSystemQuery({ - directory: path.join(basedir, 'events'), - schema: EventSchema, - cachedItems: eventCache, - filePrefix: `${runId}-`, - // Events in chronological order (oldest first) by default, - // different from the default for other list calls. - sortOrder: params.pagination?.sortOrder ?? 'asc', - limit: params.pagination?.limit, - cursor: params.pagination?.cursor, - getCreatedAt: getObjectCreatedAt('evnt'), - getId: (event) => event.eventId, - }); + const result = await queryRunEvents(runId, params.pagination ?? {}); // If resolveData is "none", remove eventData from events if (resolveData === 'none') { diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index 375ded1e2f..00ce51ac4f 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -653,10 +653,10 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { ); } } - if (data.eventType === 'attr_set' && !currentRun) { - throw new WorkflowRunNotFoundError(effectiveRunId); - } - if (data.eventType === 'run_started' && !currentRun) { + if ( + !currentRun && + (data.eventType === 'attr_set' || data.eventType === 'run_started') + ) { throw new WorkflowRunNotFoundError(effectiveRunId); } @@ -1831,7 +1831,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { let allEvents: Event[] | undefined; let cursor: string | null | undefined; let hasMore: boolean | undefined; - if (data.eventType === 'run_started' && run) { + if (data.eventType === 'run_started' && run && !params?.skipPreload) { const eventRows = await drizzle .select() .from(Schema.events) diff --git a/packages/world-postgres/test/storage.test.ts b/packages/world-postgres/test/storage.test.ts index 8d1149b646..401e010be7 100644 --- a/packages/world-postgres/test/storage.test.ts +++ b/packages/world-postgres/test/storage.test.ts @@ -1157,6 +1157,16 @@ describe('Storage (Postgres integration)', () => { expect(result.event.eventType).toBe('run_completed'); expect(result.event.correlationId).toBeUndefined(); }); + + it('skips the run_started preload when requested', async () => { + const result = await events.create( + testRunId, + { eventType: 'run_started' }, + { skipPreload: true } + ); + + expect(result.events).toBeUndefined(); + }); }); describe('list', () => { diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index f6e4b0e25d..acea8ee4b0 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -823,11 +823,9 @@ export interface CreateEventParams { * are everything sorted strictly after `sinceCursor`, `cursor` is the * position past the last returned event, and `hasMore` indicates a * further page exists. A World MAY return a single page and set - * `hasMore: true` rather than paginating to exhaustion — the runtime - * does not consume a truncated delta, it falls back to a full - * incremental fetch whenever `hasMore` is true. (For that reason a step - * body emitting more in-band events than one page silently bypasses this - * fast path, which is correct but forgoes the saved round-trip.) + * `hasMore: true` rather than paginating to exhaustion. The runtime + * consumes that page and continues from its cursor, so it never reads the + * returned prefix again. * Returning these fields at all is OPTIONAL — a World that omits them is * fully supported; the runtime falls back to `events.list`. This * preserves the same divergence guarantees as the fetch path because the @@ -850,7 +848,7 @@ export interface CreateEventParams { * option end-to-end (cf. {@link sinceCursor}) so the single name greps * across the SDK and the backend. */ - skipPreload?: boolean; + skipPreload?: true; } /** From c61ffe985a3529a55e84a971927c5ffceff1b70b Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:34:38 -0700 Subject: [PATCH 16/45] refactor(world-vercel): preserve opaque event payloads --- packages/world-vercel/src/event-coerce.ts | 42 ------- packages/world-vercel/src/events-v4.test.ts | 11 +- packages/world-vercel/src/events-v4.ts | 36 +++--- packages/world-vercel/src/events.test.ts | 54 ++++++++- packages/world-vercel/src/events.ts | 119 ++++--------------- packages/world-vercel/src/serialized-data.ts | 32 ----- 6 files changed, 100 insertions(+), 194 deletions(-) delete mode 100644 packages/world-vercel/src/event-coerce.ts diff --git a/packages/world-vercel/src/event-coerce.ts b/packages/world-vercel/src/event-coerce.ts deleted file mode 100644 index eb9ca34ed2..0000000000 --- a/packages/world-vercel/src/event-coerce.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Shared event-decoding helper. - * - * Lives in its own module because both `events.ts` (which owns the public - * event functions) and `events-v4.ts` (the wire-level client, imported *by* - * `events.ts`) need it — putting it in `events.ts` would make that import - * circular. - */ - -import { type Event, EventSchema, EventTypeSchema } from '@workflow/world'; - -/** - * Run an assembled event through EventSchema so per-event-type - * z.coerce.date() (wait_created.resumeAt, wait_completed.resumeAt, - * step_retrying.retryAfter) converts the ISO strings the backing store - * returns back into Date instances — the workflow runtime calls .getTime() on - * these and would otherwise crash. safeParse: pass the event through - * unchanged if it doesn't match a known shape (legacy / mid-rollout). - * - * Used by every path that hands events to the runtime: GET/LIST frames - * (via buildEventFromV4), the POST response's `event` / preloaded `events` - * bag, and the events a rejecting backend attaches to a 412 — all of these - * can carry events read back from the backing store, where nested eventData - * dates are stored as ISO strings. - */ -export function coerceEventDates(raw: Record): Event { - const parsed = EventSchema.safeParse(raw); - if (parsed.success) return parsed.data as unknown as Event; - if (EventTypeSchema.safeParse(raw.eventType).success) { - // The raw-event fallback is for unknown/future event types. A parse - // failure on a *known* type means a schema/coercion regression that - // would otherwise only surface later as a crash deep in the runtime - // (e.g. .getTime() on a resumeAt that stayed a string) — leave a - // breadcrumb at the actual failure point. - console.debug( - `[workflow:world-vercel] v4 event ${raw.eventId} failed ` + - `EventSchema parse for known eventType '${raw.eventType}'; ` + - `passing through unparsed: ${parsed.error.message}` - ); - } - return raw as unknown as Event; -} diff --git a/packages/world-vercel/src/events-v4.test.ts b/packages/world-vercel/src/events-v4.test.ts index 181c678fec..ff3b076dcb 100644 --- a/packages/world-vercel/src/events-v4.test.ts +++ b/packages/world-vercel/src/events-v4.test.ts @@ -576,7 +576,7 @@ describe('createWorkflowRunEventV4 over HTTP', () => { agent.assertNoPendingInterceptors(); }); - it('ignores a 412 payload whose events do not narrow to events', async () => { + it('ignores a 412 payload containing an unknown event type', async () => { const origin = WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; const agent = new MockAgent(); @@ -594,7 +594,14 @@ describe('createWorkflowRunEventV4 over HTTP', () => { success: false, code: 'precondition-failed', message: 'Run state is stale', - events: [{ noEventId: true }], + events: [ + { + eventId: 'evnt_future', + runId: 'wrun_1', + eventType: 'future_event', + createdAt: '2026-06-10T00:00:00.000Z', + }, + ], }), { headers: { 'content-type': 'application/json' } } ); diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index dc8d963c37..2d21d5731a 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -21,9 +21,12 @@ * bytes — this module stays at the wire-bytes layer. */ -import { type Event, getEventDataPayloadField } from '@workflow/world'; +import { + type Event, + EventSchema, + getEventDataPayloadField, +} from '@workflow/world'; import { decode } from 'cbor-x'; -import { coerceEventDates } from './event-coerce.js'; import { decodeFrames, encodeFrame, V4_FRAME_CONTENT_TYPE } from './frames.js'; import { getEventsDispatcher } from './http-client.js'; import { @@ -431,9 +434,9 @@ function decodePreconditionDetails(json: { const candidate = raw as Record; if (typeof candidate.eventId !== 'string') return undefined; if (hasUnusablePayload(candidate)) return undefined; - // Same decoder the success-path delta uses: the JSON body carries nested - // eventData dates as ISO strings and the runtime calls .getTime() on them. - events.push(coerceEventDates(candidate)); + const event = EventSchema.safeParse(candidate); + if (!event.success) return undefined; + events.push(event.data); } return { events, @@ -551,13 +554,7 @@ export async function createWorkflowRunEventV4( return { eventId, runId, createdAt, body }; } -/** - * Decoded event entity returned by GET /api/v4/runs/:runId/events/:eventId. - * The server CBOR-encodes the full entity with refs resolved server-side, - * so the payload field (input/output/result/error/payload/metadata - * depending on eventType) already contains the resolved bytes — the - * adapter layer doesn't need to splice them in. - */ +/** Unvalidated event metadata decoded from a frame's CBOR block. */ export interface DecodedV4Event { eventId: string; runId: string; @@ -594,7 +591,7 @@ export async function getEventV4( runId: string, eventId: string, config?: APIConfig -): Promise<{ event: DecodedV4Event; body: Uint8Array }> { +): Promise { const { baseUrl, headers } = await getHttpConfig(config); const url = `${baseUrl}/v4/runs/${encodeURIComponent(runId)}/events/${encodeURIComponent(eventId)}`; @@ -643,20 +640,17 @@ export interface ListEventsV4Params { } /** - * A single event extracted from a v4 LIST frame. Mirrors `DecodedV4Event` - * but also carries the raw payload bytes — for payload-bearing events the - * server emits the resolved bytes in the frame body (so it never has to - * decode them) and the SDK is expected to splice them back into the - * appropriate `eventData` field. + * One decoded v4 frame. GET, LIST, and streamed POST responses all use this + * exact shape. */ -export interface ListedEventV4 { +export interface DecodedEventFrame { event: DecodedV4Event; /** Resolved payload bytes. Empty for events without a payload. */ body: Uint8Array; } export interface ListEventsV4Result { - events: ListedEventV4[]; + events: DecodedEventFrame[]; /** * Trailing cursor. Present even on the final page — it doubles as the * resume point for incremental loads — so it is NOT a reliable "more @@ -703,7 +697,7 @@ async function consumeListFrameStream( // cast only works around TS's lib type omitting the async iterator. const chunks = response.body as unknown as AsyncIterable; - const events: ListedEventV4[] = []; + const events: DecodedEventFrame[] = []; let next: string | undefined; let hasMore: boolean | undefined; let sawEndSentinel = false; diff --git a/packages/world-vercel/src/events.test.ts b/packages/world-vercel/src/events.test.ts index 2e39241dda..0c1d08676f 100644 --- a/packages/world-vercel/src/events.test.ts +++ b/packages/world-vercel/src/events.test.ts @@ -1,3 +1,4 @@ +import { gzipSync } from 'node:zlib'; import type { AnyEventRequest, CreateEventParams } from '@workflow/world'; import { decode, encode } from 'cbor-x'; import { ulid } from 'ulid'; @@ -815,7 +816,11 @@ describe('createWorkflowRunEvent response coercion', () => { runId: taggedRunId, eventType: 'run_created', createdAt: '2026-06-10T00:00:01.000Z', - eventData: {}, + eventData: { + deploymentId: 'dpl_1', + workflowName: 'wf', + input: new TextEncoder().encode('[]'), + }, }, }), { @@ -1093,6 +1098,7 @@ describe('getWorkflowRunEvents remoteRefBehavior mapping', () => { createdAt: '2026-06-10T00:00:00.000Z', eventData: { input: { _type: 'RemoteRef', _ref: 's3rf:wrun_1/input' }, + deploymentId: 'dpl_1', workflowName: 'wf', }, }, @@ -1132,9 +1138,13 @@ describe('getWorkflowRunEvents remoteRefBehavior mapping', () => { agent.assertNoPendingInterceptors(); }); - it('sends remoteRefBehavior=resolve by default and splices the body bytes', async () => { + it('sends remoteRefBehavior=resolve and preserves opaque body bytes', async () => { const agent = mockAgent(); - const body = new TextEncoder().encode('"payload"'); + const serialized = new TextEncoder().encode('devl["payload"]'); + const compressed = gzipSync(serialized); + const body = new Uint8Array(4 + compressed.byteLength); + body.set(new TextEncoder().encode('gzip')); + body.set(compressed, 4); agent .get(ORIGIN) .intercept({ @@ -1158,6 +1168,42 @@ describe('getWorkflowRunEvents remoteRefBehavior mapping', () => { expect(eventData?.input).toEqual(body); agent.assertNoPendingInterceptors(); }); + + it('rejects a malformed event frame', async () => { + const agent = mockAgent(); + const frames = Buffer.concat([ + encodeFrame( + { + eventId: 'evnt_1', + runId: 'wrun_1', + eventType: 'wait_created', + correlationId: 'wait_1', + createdAt: '2026-06-10T00:00:00.000Z', + eventData: { resumeAt: 'not-a-date' }, + }, + new Uint8Array() + ), + encodeFrame({ _end: 1 }, new Uint8Array()), + ]); + agent + .get(ORIGIN) + .intercept({ + path: '/api/v4/runs/wrun_1/events', + method: 'GET', + query: { remoteRefBehavior: 'resolve' }, + }) + .reply(200, frames, { + headers: { 'content-type': V4_FRAME_CONTENT_TYPE }, + }); + + await expect( + getWorkflowRunEvents( + { runId: 'wrun_1' }, + { token: 'test-token', dispatcher: agent } + ) + ).rejects.toThrow(); + agent.assertNoPendingInterceptors(); + }); }); describe('getWorkflowRunEvents legacy structured-error compatibility', () => { @@ -1253,7 +1299,7 @@ describe('getWorkflowRunEvents hasMore mapping', () => { { eventId: 'evnt_1', runId: 'wrun_1', - eventType: 'run_created', + eventType: 'run_cancelled', createdAt: '2026-06-10T00:00:00.000Z', eventData: {}, }, diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index 0c4e693413..b76897023c 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -52,22 +52,17 @@ import { type WorkflowRun, } from '@workflow/world'; import { decode } from 'cbor-x'; -import { coerceEventDates } from './event-coerce.js'; import { withEventPostRetry } from './event-retry.js'; import { createWorkflowRunEventV4, - type DecodedV4Event, + type DecodedEventFrame, getEventsByCorrelationIdV4, getEventV4, getWorkflowRunEventsV4, } from './events-v4.js'; import { decode as decodeRunId } from './run-id/index.js'; import { cancelWorkflowRunV1, createWorkflowRunV1 } from './runs.js'; -import { - hasSerializedDataFormatPrefix, - normalizeEventData, - normalizeSerializedData, -} from './serialized-data.js'; +import { hasSerializedDataFormatPrefix } from './serialized-data.js'; import { deserializeStep } from './steps.js'; import { type APIConfig, @@ -448,10 +443,6 @@ export function splitEventDataForV4(data: AnyEventRequest): SplitEventData { return { payload, meta }; } -function coerceNormalizedEvent(raw: Record): Event { - return coerceEventDates(normalizeEventData(raw)); -} - function decodeLegacyStructuredError(payload: Uint8Array): unknown { if (hasSerializedDataFormatPrefix(payload)) { return payload; @@ -468,66 +459,28 @@ function decodeLegacyStructuredError(payload: Uint8Array): unknown { } /** - * Turn a v4 event (frame meta + frame body) into the Event shape the - * workflow runtime expects. + * Turn a v4 frame into the Event shape promised by the World interface. * - * Both GET single-event and LIST use the same frame format: meta is the - * full event entity with the payload field as a RefDescriptor, body is - * the resolved payload bytes (possibly empty). This helper splices the - * body bytes into `eventData[fieldName]`, normalizing any zstd wrapper - * back to the raw devalue-with-format-prefix Uint8Array the runtime's - * hydrate helpers (hydrateStepIO, hydrateRunError, …) consume. Stable-line - * structured errors are the exception: the backend stored those as CBOR, - * so they are decoded after checking that the payload is not a current - * format-prefixed serialized value. + * The frame body is opaque serialized data. Keep it byte-for-byte so the + * runtime and observability hydration layers own decompression. Stable-line + * structured errors predate the serialized-data format and remain the sole + * exception. */ -function buildEventFromV4( - decoded: DecodedV4Event, - payloadBody: Uint8Array, - resolveData: 'none' | 'all' -): Event { - const eventData = (decoded.eventData ?? {}) as Record; - - if (payloadBody.byteLength > 0) { - const payloadField = getEventDataPayloadField(decoded.eventType); - const normalizedPayload = normalizeSerializedData(payloadBody); - if (payloadField && normalizedPayload instanceof Uint8Array) { +function decodeEventFrame({ event, body }: DecodedEventFrame): Event { + const eventData = { ...event.eventData }; + + if (body.byteLength > 0) { + const payloadField = getEventDataPayloadField(event.eventType); + if (payloadField) { eventData[payloadField] = legacyStructuredErrorEventTypes.has( - decoded.eventType + event.eventType ) - ? decodeLegacyStructuredError(normalizedPayload) - : normalizedPayload; + ? decodeLegacyStructuredError(body) + : body; } } - const raw = { - eventId: decoded.eventId, - runId: decoded.runId, - eventType: decoded.eventType, - createdAt: - decoded.createdAt instanceof Date - ? decoded.createdAt - : new Date(decoded.createdAt), - ...(decoded.occurredAt !== undefined - ? { - occurredAt: - decoded.occurredAt instanceof Date - ? decoded.occurredAt - : new Date(decoded.occurredAt), - } - : {}), - ...(decoded.correlationId ? { correlationId: decoded.correlationId } : {}), - eventData, - ...(decoded.specVersion !== undefined - ? { specVersion: decoded.specVersion } - : {}), - }; - - const event = coerceNormalizedEvent(raw); - - // For resolveData='none', strip eventData entirely. Reuse the world- - // side helper so behavior stays in sync with other backends. - return resolveData === 'none' ? stripEventDataRefs(event, 'none') : event; + return EventSchema.parse({ ...event, eventData }); } // ============================================================================= @@ -541,10 +494,8 @@ export async function getEvent( config?: APIConfig ): Promise { const resolveData = params?.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION; - const { event, body } = await getEventV4(runId, eventId, config); - // Same shape as a LIST frame — splice the body bytes into - // eventData[payloadField] in buildEventFromV4. - return buildEventFromV4(event, body, resolveData); + const event = decodeEventFrame(await getEventV4(runId, eventId, config)); + return stripEventDataRefs(event, resolveData); } export async function getWorkflowRunEvents( @@ -572,8 +523,8 @@ export async function getWorkflowRunEvents( ? getEventsByCorrelationIdV4(params.correlationId, wirePagination, config) : getWorkflowRunEventsV4(params.runId, wirePagination, config)); - const events = result.events.map((listed) => - buildEventFromV4(listed.event, listed.body, resolveData) + const events = result.events.map((frame) => + stripEventDataRefs(decodeEventFrame(frame), resolveData) ); return { @@ -748,32 +699,14 @@ async function createWorkflowRunEventInner( config ); - // The server already CBOR-decoded into result.body — just thread the - // fields through. This is the runtime's event-append path (world.events - // .create is only ever called from the workflow runtime, never from - // o11y), and the runtime re-hydrates every payload it consumes through - // the decompress-aware helpers (hydrateStepReturnValue, hydrateRunError, - // …). So we deliberately do NOT decompress here: doing so would be - // redundant work on the TTFB-sensitive run_started/inline-delta path and - // would make the runtime's deserialize compression telemetry report - // `codec: none` for payloads that were compressed at rest. gzip/zstd - // normalization for o11y/display lives on the read paths (getEvent, - // getWorkflowRunEvents, getStep, getRun, getHook). - // - // `event`/`events` go through coerceEventDates only: they can be read - // back from the backing store server-side (e.g. the run_started TTFB - // preload queries the event log), where nested eventData dates are ISO - // strings — same coercion the GET/LIST path applies. The returned event - // honors the caller's resolveData: 'none' strips payload fields, - // matching the v3 path's stripEventAndLegacyRefs behavior. + // Event payloads remain opaque here, just as they do in frame responses. + // EventSchema validates the response and converts stored ISO timestamps + // back into the Date instances promised by the World interface. const resolveData = params?.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION; const body = result.body; return { event: body.event - ? stripEventDataRefs( - coerceEventDates(body.event as Record), - resolveData - ) + ? stripEventDataRefs(EventSchema.parse(body.event), resolveData) : undefined, run: body.run ? deserializeError(body.run as Record) @@ -784,7 +717,7 @@ async function createWorkflowRunEventInner( hook: body.hook as EventResult['hook'], wait: body.wait as EventResult['wait'], events: body.events - ? (body.events as Record[]).map(coerceEventDates) + ? body.events.map((event) => EventSchema.parse(event)) : undefined, cursor: body.cursor ?? undefined, hasMore: body.hasMore, diff --git a/packages/world-vercel/src/serialized-data.ts b/packages/world-vercel/src/serialized-data.ts index 6107d3707c..8f17150fd4 100644 --- a/packages/world-vercel/src/serialized-data.ts +++ b/packages/world-vercel/src/serialized-data.ts @@ -1,5 +1,4 @@ import { WorkflowWorldError } from '@workflow/errors'; -import { getEventDataRefFields } from '@workflow/world'; const FORMAT_PREFIX_LENGTH = 4; const DEVALUE_FORMAT_PREFIX = 'devl'; @@ -103,34 +102,3 @@ export function normalizeHookData>( metadata: normalizeSerializedData(hook.metadata), }; } - -export function normalizeEventData>( - event: T -): T { - const eventData = event.eventData; - if (!eventData || typeof eventData !== 'object') { - return event; - } - - const eventType = typeof event.eventType === 'string' ? event.eventType : ''; - const refFields = getEventDataRefFields(eventType); - if (refFields.length === 0) { - return event; - } - - const normalizedEventData = { ...(eventData as Record) }; - let changed = false; - for (const field of refFields) { - if (!(field in normalizedEventData)) { - continue; - } - const before = normalizedEventData[field]; - const after = normalizeSerializedData(before); - if (after !== before) { - normalizedEventData[field] = after; - changed = true; - } - } - - return changed ? { ...event, eventData: normalizedEventData } : event; -} From 8289a2afbd3187ec3887e4e4726d16ea6724c383 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:01:48 -0700 Subject: [PATCH 17/45] Validate v4 event create responses --- packages/world-vercel/src/events-v4.test.ts | 104 +++++++++++++-- packages/world-vercel/src/events-v4.ts | 80 ++++++----- packages/world-vercel/src/events.test.ts | 139 ++++++++++++++------ packages/world-vercel/src/events.ts | 22 +--- 4 files changed, 238 insertions(+), 107 deletions(-) diff --git a/packages/world-vercel/src/events-v4.test.ts b/packages/world-vercel/src/events-v4.test.ts index ff3b076dcb..b566a485cf 100644 --- a/packages/world-vercel/src/events-v4.test.ts +++ b/packages/world-vercel/src/events-v4.test.ts @@ -6,6 +6,7 @@ import { TooEarlyError, WorkflowWorldError, } from '@workflow/errors'; +import type { AnyEventRequest } from '@workflow/world'; import { decode, encode } from 'cbor-x'; import { MockAgent } from 'undici'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -19,6 +20,33 @@ import { import { encodeFrame, V4_FRAME_CONTENT_TYPE } from './frames.js'; import { WORKFLOW_SERVER_URL_OVERRIDE } from './utils.js'; +const CREATED_AT = '2026-06-10T00:00:00.000Z'; + +function createEventBody( + event: AnyEventRequest, + entities: Record = {} +) { + return encode({ + event: { + ...event, + eventId: 'evnt_1', + runId: 'wrun_1', + createdAt: CREATED_AT, + }, + ...entities, + }); +} + +const runningRun = { + runId: 'wrun_1', + status: 'running', + deploymentId: 'dpl_1', + workflowName: 'workflow', + startedAt: CREATED_AT, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, +}; + /** * The v4 client must preserve the typed-error contract of the v3 * `makeRequest` path — the workflow runtime branches on these types @@ -375,13 +403,35 @@ describe('createWorkflowRunEventV4 over HTTP', () => { path: '/api/v4/runs/wrun_1/events/step_completed', method: 'POST', }) - .reply(200, encode({ step: { stepId: 'step_1', status: 'completed' } }), { - headers: { - 'x-wf-event-id': 'evnt_1', - 'x-wf-run-id': 'wrun_1', - 'x-wf-created-at': '2026-06-10T00:00:00.000Z', - }, - }); + .reply( + 200, + createEventBody( + { + eventType: 'step_completed', + specVersion: 2, + correlationId: 'step_1', + eventData: { result: new Uint8Array() }, + }, + { + step: { + runId: 'wrun_1', + stepId: 'step_1', + stepName: 'step', + status: 'completed', + attempt: 1, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + }, + } + ), + { + headers: { + 'x-wf-event-id': 'evnt_1', + 'x-wf-run-id': 'wrun_1', + 'x-wf-created-at': CREATED_AT, + }, + } + ); const result = await createWorkflowRunEventV4( { @@ -436,7 +486,10 @@ describe('createWorkflowRunEventV4 over HTTP', () => { 200, (opts: { body?: unknown }) => { captureMeta(opts.body); - return encode({ run: { runId: 'wrun_1', status: 'running' } }); + return createEventBody( + { eventType: 'run_started', specVersion: 5 }, + { run: runningRun } + ); }, { headers: { @@ -488,7 +541,10 @@ describe('createWorkflowRunEventV4 over HTTP', () => { string, unknown >; - return encode({ run: { runId: 'wrun_1', status: 'running' } }); + return createEventBody( + { eventType: 'run_started', specVersion: 5 }, + { run: runningRun } + ); }, { headers: { @@ -775,7 +831,12 @@ describe('createWorkflowRunEventV4 over HTTP', () => { string, unknown >; - return encode({ wait: { waitId: 'wait_1' } }); + return createEventBody({ + eventType: 'wait_created', + specVersion: 5, + correlationId: 'wait_1', + eventData: { resumeAt: CREATED_AT }, + }); }, { headers: { @@ -828,7 +889,12 @@ describe('createWorkflowRunEventV4 over HTTP', () => { string, unknown >; - return encode({ wait: { waitId: 'wait_1' } }); + return createEventBody({ + eventType: 'wait_created', + specVersion: 5, + correlationId: 'wait_1', + eventData: { resumeAt: CREATED_AT }, + }); }, { headers: { @@ -883,7 +949,12 @@ describe('createWorkflowRunEventV4 over HTTP', () => { string, unknown >; - return encode({ wait: { waitId: 'wait_1' } }); + return createEventBody({ + eventType: 'wait_created', + specVersion: 5, + correlationId: 'wait_1', + eventData: { resumeAt: CREATED_AT }, + }); }, { headers: { @@ -936,7 +1007,12 @@ describe('createWorkflowRunEventV4 over HTTP', () => { string, unknown >; - return encode({ wait: { waitId: 'wait_1' } }); + return createEventBody({ + eventType: 'wait_created', + specVersion: 5, + correlationId: 'wait_1', + eventData: { resumeAt: CREATED_AT }, + }); }, { headers: { @@ -1008,7 +1084,7 @@ describe('v4 POST frame meta forwards every field the splitter produces', () => 200, (opts: { body?: unknown }) => { captured = decodeFrameMeta(opts.body); - return encode({}); + return createEventBody(data); }, { headers: { diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 2d21d5731a..3f5d78d1cb 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -21,12 +21,18 @@ * bytes — this module stays at the wire-bytes layer. */ +import { WorkflowWorldError } from '@workflow/errors'; import { type Event, EventSchema, + type EventType, getEventDataPayloadField, + HookSchema, + WaitSchema, + WorkflowRunSchema, } from '@workflow/world'; import { decode } from 'cbor-x'; +import { z } from 'zod'; import { decodeFrames, encodeFrame, V4_FRAME_CONTENT_TYPE } from './frames.js'; import { getEventsDispatcher } from './http-client.js'; import { @@ -34,6 +40,7 @@ import { instrumentedFetch, parseRetryAfter, } from './http-core.js'; +import { deserializeStep, StepWireSchema } from './steps.js'; import { type APIConfig, getHttpConfig } from './utils.js'; /** @@ -107,7 +114,7 @@ export const V4_RESPONSE_HEADERS = { export interface CreateEventV4Input { // runId is required even for run_created, because the payload is keyed under the runId runId: string; - eventType: string; + eventType: EventType; /** Opaque payload bytes. Pass undefined for events that don't carry * user data (e.g. step_started). */ payload?: Uint8Array; @@ -210,7 +217,7 @@ export interface CreateEventV4Input { * barrier only and never reads the preloaded log, so it asks the server to * skip the list+resolve. Acted on by the server only for run_started; * older servers ignore it and preload as before. */ - skipPreload?: boolean; + skipPreload?: true; /** * Epoch ms (the ULID time of the latest event the runtime has loaded * during replay). Sent by replay-context creates so the backend can @@ -251,38 +258,24 @@ export interface PreconditionFailureDetails { cursor?: string; } +const CreateEventV4BodySchema = z.object({ + event: EventSchema, + run: WorkflowRunSchema.optional(), + step: StepWireSchema.transform(deserializeStep).optional(), + hook: HookSchema.optional(), + wait: WaitSchema.optional(), + events: z.array(EventSchema).optional(), + cursor: z.string().nullable().optional(), + hasMore: z.boolean().optional(), + stepCreated: z.literal(true).optional(), + maxEvents: z.number().int().positive().optional(), +}); + export interface CreateEventV4Result { eventId: string; runId: string; createdAt: string; - /** - * Materialized-entity bag — CBOR-decoded from the response body. The - * server hands back the same shape v2/v3 use for EventResult so the - * adapter layer can drop these fields into its return value unchanged. - * Keys are unset when the event type doesn't materialize that entity - * kind. - */ - body: { - event?: unknown; - run?: unknown; - step?: unknown; - hook?: unknown; - wait?: unknown; - events?: unknown[]; - cursor?: string | null; - hasMore?: boolean; - /** - * Lazy step start: true when the server's step_started created the step - * on this call. Absent from older servers (safe default: not the lazy - * creator). Threaded into EventResult.stepCreated by the events adapter. - */ - stepCreated?: boolean; - /** - * Server-owned per-run event ceiling, returned on run-lifecycle responses. - * Absent from older servers. Threaded into EventResult.maxEvents. - */ - maxEvents?: number; - }; + body: z.infer; } /** Build the CBOR meta map for a v4 POST frame. Drops undefined entries @@ -544,12 +537,29 @@ export async function createWorkflowRunEventV4( throw new Error('v4 createEvent: response missing required x-wf-* headers'); } - // Decode the materialized-entity bag from the CBOR response body. const bodyBytes = new Uint8Array(await response.arrayBuffer()); - const body = - bodyBytes.byteLength > 0 - ? (decode(bodyBytes) as CreateEventV4Result['body']) - : {}; + if (bodyBytes.byteLength === 0) { + throw new Error('v4 createEvent: empty response body'); + } + const parsedBody = CreateEventV4BodySchema.safeParse(decode(bodyBytes)); + if (!parsedBody.success) { + throw new WorkflowWorldError('v4 createEvent: invalid response body', { + code: 'SCHEMA_VALIDATION', + cause: parsedBody.error, + }); + } + const body = parsedBody.data; + + if ( + body.event.eventId !== eventId || + body.event.runId !== runId || + body.event.eventType !== input.eventType + ) { + throw new WorkflowWorldError( + 'v4 createEvent: response event does not match request', + { code: 'SCHEMA_VALIDATION' } + ); + } return { eventId, runId, createdAt, body }; } diff --git a/packages/world-vercel/src/events.test.ts b/packages/world-vercel/src/events.test.ts index 0c1d08676f..0a7cfdef72 100644 --- a/packages/world-vercel/src/events.test.ts +++ b/packages/world-vercel/src/events.test.ts @@ -16,6 +16,31 @@ import { WORKFLOW_SERVER_URL_OVERRIDE } from './utils.js'; const ORIGIN = WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; const STARTED_AT = new Date('2026-06-10T00:00:00.000Z'); +function createEventBody( + event: AnyEventRequest, + entities: Record = {} +) { + return encode({ + event: { + ...event, + eventId: 'evnt_1', + runId: 'wrun_1', + createdAt: STARTED_AT, + }, + ...entities, + }); +} + +const runningRun = { + runId: 'wrun_1', + status: 'running', + deploymentId: 'dpl_1', + workflowName: 'workflow', + startedAt: STARTED_AT, + createdAt: STARTED_AT, + updatedAt: STARTED_AT, +}; + function mockAgent() { const agent = new MockAgent(); agent.disableNetConnect(); @@ -140,13 +165,10 @@ describe('createWorkflowRunEvent precondition snapshot wire fields', () => { 200, (opts: { body?: unknown }) => { capturedMeta = decodePostedMeta(opts.body); - return encode({ - run: { - runId: 'wrun_1', - status: 'running', - startedAt: STARTED_AT, - }, - }); + return createEventBody( + { eventType: 'run_started', specVersion: 2 }, + { run: runningRun } + ); }, { headers: { @@ -182,13 +204,10 @@ describe('createWorkflowRunEvent precondition snapshot wire fields', () => { 200, (opts: { body?: unknown }) => { capturedMeta = decodePostedMeta(opts.body); - return encode({ - run: { - runId: 'wrun_1', - status: 'running', - startedAt: STARTED_AT, - }, - }); + return createEventBody( + { eventType: 'run_started', specVersion: 2 }, + { run: runningRun } + ); }, { headers: { @@ -224,13 +243,10 @@ describe('createWorkflowRunEvent precondition snapshot wire fields', () => { 200, (opts: { body?: unknown }) => { capturedMeta = decodePostedMeta(opts.body); - return encode({ - run: { - runId: 'wrun_1', - status: 'running', - startedAt: STARTED_AT, - }, - }); + return createEventBody( + { eventType: 'run_started', specVersion: 2 }, + { run: runningRun } + ); }, { headers: { @@ -271,13 +287,10 @@ describe('createWorkflowRunEvent precondition snapshot wire fields', () => { 200, (opts: { body?: unknown }) => { capturedMeta = decodePostedMeta(opts.body); - return encode({ - run: { - runId: 'wrun_1', - status: 'running', - startedAt: STARTED_AT, - }, - }); + return createEventBody( + { eventType: 'run_started', specVersion: 2 }, + { run: runningRun } + ); }, { headers: { @@ -434,13 +447,25 @@ async function postStepStartedMeta( 200, (opts: { body?: unknown }) => { capturedMeta = decodePostedMeta(opts.body); - return encode({ - step: { - stepId: 'step_1', - status: 'running', - startedAt: STARTED_AT, + return createEventBody( + { + eventType: 'step_started', + specVersion: 2, + correlationId: 'step_1', }, - }); + { + step: { + runId: 'wrun_1', + stepId: 'step_1', + stepName: 'step', + status: 'running', + attempt: 1, + startedAt: STARTED_AT, + createdAt: STARTED_AT, + updatedAt: STARTED_AT, + }, + } + ); }, { headers: { @@ -506,7 +531,21 @@ describe('createWorkflowRunEvent replayDivergenceCount wire field', () => { 200, (opts: { body?: unknown }) => { capturedMeta = decodePostedMeta(opts.body); - return encode({ run: { runId: 'wrun_1', status: 'completed' } }); + return createEventBody( + { + eventType: 'run_completed', + specVersion: 2, + eventData: { output: new Uint8Array() }, + }, + { + run: { + ...runningRun, + status: 'completed', + output: new Uint8Array(), + completedAt: STARTED_AT, + }, + } + ); }, { headers: { @@ -809,7 +848,11 @@ describe('createWorkflowRunEvent response coercion', () => { run: { runId: taggedRunId, status: 'running', + deploymentId: 'dpl_1', + workflowName: 'wf', startedAt: new Date('2026-06-10T00:00:01.000Z'), + createdAt: new Date('2026-06-10T00:00:01.000Z'), + updatedAt: new Date('2026-06-10T00:00:01.000Z'), }, event: { eventId: 'evnt_1', @@ -866,13 +909,19 @@ describe('createWorkflowRunEvent response coercion', () => { 200, (opts: { body?: unknown }) => { capturedMeta = decodePostedMeta(opts.body); - return encode({ - run: { - runId: 'wrun_1', - status: 'running', - startedAt: new Date('2026-06-10T00:00:04.000Z'), + return createEventBody( + { + eventType: 'run_started', + specVersion: 2, + occurredAt, }, - }); + { + run: { + ...runningRun, + startedAt: new Date('2026-06-10T00:00:04.000Z'), + }, + } + ); }, { headers: { @@ -917,7 +966,11 @@ describe('createWorkflowRunEvent response coercion', () => { run: { runId: 'wrun_1', status: 'running', + deploymentId: 'dpl_1', + workflowName: 'workflow', startedAt: new Date('2026-06-10T00:00:01.000Z'), + createdAt: new Date('2026-06-10T00:00:00.000Z'), + updatedAt: new Date('2026-06-10T00:00:01.000Z'), }, event: { eventId: 'evnt_2', @@ -996,7 +1049,9 @@ describe('createWorkflowRunEvent response coercion', () => { wait: { waitId: 'wait_1', runId: 'wrun_1', - status: 'pending', + status: 'waiting', + createdAt: STARTED_AT, + updatedAt: STARTED_AT, }, }), { diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index b76897023c..0925f02491 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -63,11 +63,9 @@ import { import { decode as decodeRunId } from './run-id/index.js'; import { cancelWorkflowRunV1, createWorkflowRunV1 } from './runs.js'; import { hasSerializedDataFormatPrefix } from './serialized-data.js'; -import { deserializeStep } from './steps.js'; import { type APIConfig, DEFAULT_RESOLVE_DATA_OPTION, - deserializeError, makeRequest, } from './utils.js'; @@ -705,20 +703,12 @@ async function createWorkflowRunEventInner( const resolveData = params?.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION; const body = result.body; return { - event: body.event - ? stripEventDataRefs(EventSchema.parse(body.event), resolveData) - : undefined, - run: body.run - ? deserializeError(body.run as Record) - : undefined, - step: body.step - ? deserializeStep(body.step as Parameters[0]) - : undefined, - hook: body.hook as EventResult['hook'], - wait: body.wait as EventResult['wait'], - events: body.events - ? body.events.map((event) => EventSchema.parse(event)) - : undefined, + event: stripEventDataRefs(body.event, resolveData), + run: body.run, + step: body.step, + hook: body.hook, + wait: body.wait, + events: body.events, cursor: body.cursor ?? undefined, hasMore: body.hasMore, // Lazy step start: thread the server's "I created the step on this call" From 538fe5915cc1a65c56c49e3113fa5c0c84dedfe7 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:11:57 -0700 Subject: [PATCH 18/45] Validate v4 event frame metadata --- packages/world-vercel/src/events-v4.test.ts | 31 +++++++++++++++ packages/world-vercel/src/events-v4.ts | 44 ++++++++++++--------- 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/packages/world-vercel/src/events-v4.test.ts b/packages/world-vercel/src/events-v4.test.ts index b566a485cf..63fc12e47e 100644 --- a/packages/world-vercel/src/events-v4.test.ts +++ b/packages/world-vercel/src/events-v4.test.ts @@ -186,6 +186,37 @@ describe('getWorkflowRunEventsV4 over HTTP', () => { agent.assertNoPendingInterceptors(); }); + it('rejects an unknown event type', async () => { + const origin = + WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; + const agent = new MockAgent(); + agent.disableNetConnect(); + + agent + .get(origin) + .intercept({ path: '/api/v4/runs/wrun_1/events', method: 'GET' }) + .reply( + 200, + Buffer.concat([ + encodeFrame( + { eventType: 'future_event', eventData: {} }, + new Uint8Array() + ), + encodeFrame({ _end: 1 }, new Uint8Array()), + ]), + { headers: { 'content-type': V4_FRAME_CONTENT_TYPE } } + ); + + await expect( + getWorkflowRunEventsV4( + 'wrun_1', + {}, + { token: 'test-token', dispatcher: agent } + ) + ).rejects.toThrow(); + agent.assertNoPendingInterceptors(); + }); + it('captures an explicit hasMore from the sentinel, independent of next', async () => { const origin = WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 3f5d78d1cb..aeb0d01b77 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -26,6 +26,7 @@ import { type Event, EventSchema, type EventType, + EventTypeSchema, getEventDataPayloadField, HookSchema, WaitSchema, @@ -258,19 +259,36 @@ export interface PreconditionFailureDetails { cursor?: string; } -const CreateEventV4BodySchema = z.object({ +const CreateEventV4BodyBaseSchema = z.object({ event: EventSchema, run: WorkflowRunSchema.optional(), step: StepWireSchema.transform(deserializeStep).optional(), hook: HookSchema.optional(), wait: WaitSchema.optional(), - events: z.array(EventSchema).optional(), - cursor: z.string().nullable().optional(), - hasMore: z.boolean().optional(), stepCreated: z.literal(true).optional(), maxEvents: z.number().int().positive().optional(), }); +const CreateEventV4BodySchema = z.union([ + CreateEventV4BodyBaseSchema.extend({ + events: z.array(EventSchema), + cursor: z.string().nullable(), + hasMore: z.boolean(), + }), + CreateEventV4BodyBaseSchema.extend({ + events: z.undefined().optional(), + cursor: z.undefined().optional(), + hasMore: z.undefined().optional(), + }), +]); + +const EventFrameMetaSchema = z + .object({ + eventType: EventTypeSchema, + eventData: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(); + export interface CreateEventV4Result { eventId: string; runId: string; @@ -564,18 +582,6 @@ export async function createWorkflowRunEventV4( return { eventId, runId, createdAt, body }; } -/** Unvalidated event metadata decoded from a frame's CBOR block. */ -export interface DecodedV4Event { - eventId: string; - runId: string; - eventType: string; - correlationId?: string; - createdAt: Date | string; - occurredAt?: Date | string; - specVersion?: number; - eventData?: Record; -} - function readHeader( responseHeaders: Record, name: string @@ -629,7 +635,7 @@ export async function getEventV4( // GET emits a single frame (no sentinel); decodeFrames returns at EOF // after yielding it. for await (const frame of decodeFrames(chunks)) { - return { event: frame.meta as unknown as DecodedV4Event, body: frame.body }; + return { event: EventFrameMetaSchema.parse(frame.meta), body: frame.body }; } throw new Error(`v4 getEvent: empty frame stream for ${eventId}`); } @@ -654,7 +660,7 @@ export interface ListEventsV4Params { * exact shape. */ export interface DecodedEventFrame { - event: DecodedV4Event; + event: z.infer; /** Resolved payload bytes. Empty for events without a payload. */ body: Uint8Array; } @@ -719,7 +725,7 @@ async function consumeListFrameStream( break; } events.push({ - event: frame.meta as unknown as DecodedV4Event, + event: EventFrameMetaSchema.parse(frame.meta), body: frame.body, }); } From f83312198db2b66db16261e8217f51dbba5fbdbe Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:43:54 -0700 Subject: [PATCH 19/45] Remove invalid v4 response identity check --- packages/world-vercel/src/events-v4.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index aeb0d01b77..960004b6b0 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -568,17 +568,6 @@ export async function createWorkflowRunEventV4( } const body = parsedBody.data; - if ( - body.event.eventId !== eventId || - body.event.runId !== runId || - body.event.eventType !== input.eventType - ) { - throw new WorkflowWorldError( - 'v4 createEvent: response event does not match request', - { code: 'SCHEMA_VALIDATION' } - ); - } - return { eventId, runId, createdAt, body }; } From d04d5cc597233d144de61e10f0254ccc1b320b29 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:47:38 -0700 Subject: [PATCH 20/45] Return validated v4 event bodies directly --- .../world-vercel/src/events-retry.test.ts | 21 +++++------- packages/world-vercel/src/events-v4.test.ts | 5 +-- packages/world-vercel/src/events-v4.ts | 32 ++----------------- packages/world-vercel/src/events.ts | 3 +- 4 files changed, 12 insertions(+), 49 deletions(-) diff --git a/packages/world-vercel/src/events-retry.test.ts b/packages/world-vercel/src/events-retry.test.ts index 68be2d0982..962753a776 100644 --- a/packages/world-vercel/src/events-retry.test.ts +++ b/packages/world-vercel/src/events-retry.test.ts @@ -38,19 +38,14 @@ const stepCompleted = () => ({ }); const v4Success = () => ({ - eventId: 'evnt_1', - runId: RUN_ID, - createdAt: '2020-01-01T00:00:00.000Z', - body: { - event: { - eventId: 'evnt_1', - runId: RUN_ID, - eventType: 'step_completed', - correlationId: 'step_1', - specVersion: 2, - createdAt: '2020-01-01T00:00:00.000Z', - eventData: { stepName: 's', workflowName: 'w' }, - }, + event: { + eventId: 'evnt_1', + runId: RUN_ID, + eventType: 'step_completed', + correlationId: 'step_1', + specVersion: 2, + createdAt: '2020-01-01T00:00:00.000Z', + eventData: { stepName: 's', workflowName: 'w' }, }, }); diff --git a/packages/world-vercel/src/events-v4.test.ts b/packages/world-vercel/src/events-v4.test.ts index 63fc12e47e..c6166c9d7c 100644 --- a/packages/world-vercel/src/events-v4.test.ts +++ b/packages/world-vercel/src/events-v4.test.ts @@ -475,10 +475,7 @@ describe('createWorkflowRunEventV4 over HTTP', () => { { token: 'test-token', dispatcher: agent } ); - expect(result.eventId).toBe('evnt_1'); - expect(result.runId).toBe('wrun_1'); - expect(result.createdAt).toBe('2026-06-10T00:00:00.000Z'); - expect(result.body.step).toMatchObject({ stepId: 'step_1' }); + expect(result.step).toMatchObject({ stepId: 'step_1' }); agent.assertNoPendingInterceptors(); }); diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 960004b6b0..5f41650f7b 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -102,16 +102,6 @@ function headersToRecord(headers: Headers): Record { return out; } -/** - * POST surfaces these so callers can read the created eventId without - * decoding the CBOR response body - */ -export const V4_RESPONSE_HEADERS = { - eventId: 'x-wf-event-id', - runId: 'x-wf-run-id', - createdAt: 'x-wf-created-at', -} as const; - export interface CreateEventV4Input { // runId is required even for run_created, because the payload is keyed under the runId runId: string; @@ -289,12 +279,7 @@ const EventFrameMetaSchema = z }) .passthrough(); -export interface CreateEventV4Result { - eventId: string; - runId: string; - createdAt: string; - body: z.infer; -} +export type CreateEventV4Result = z.infer; /** Build the CBOR meta map for a v4 POST frame. Drops undefined entries * so the wire shape matches what the server expects to see. */ @@ -544,17 +529,6 @@ export async function createWorkflowRunEventV4( 'createEvent' ); - const eventId = response.headers.get(V4_RESPONSE_HEADERS.eventId); - const runId = response.headers.get(V4_RESPONSE_HEADERS.runId); - const createdAt = response.headers.get(V4_RESPONSE_HEADERS.createdAt); - if ( - typeof eventId !== 'string' || - typeof runId !== 'string' || - typeof createdAt !== 'string' - ) { - throw new Error('v4 createEvent: response missing required x-wf-* headers'); - } - const bodyBytes = new Uint8Array(await response.arrayBuffer()); if (bodyBytes.byteLength === 0) { throw new Error('v4 createEvent: empty response body'); @@ -566,9 +540,7 @@ export async function createWorkflowRunEventV4( cause: parsedBody.error, }); } - const body = parsedBody.data; - - return { eventId, runId, createdAt, body }; + return parsedBody.data; } function readHeader( diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index 0925f02491..21e1991fe9 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -653,7 +653,7 @@ async function createWorkflowRunEventInner( const { payload, meta } = splitEventDataForV4(data); - const result = await createWorkflowRunEventV4( + const body = await createWorkflowRunEventV4( { runId: id, eventType: data.eventType, @@ -701,7 +701,6 @@ async function createWorkflowRunEventInner( // EventSchema validates the response and converts stored ISO timestamps // back into the Date instances promised by the World interface. const resolveData = params?.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION; - const body = result.body; return { event: stripEventDataRefs(body.event, resolveData), run: body.run, From 197ecaa1053281d2ec4590115715fc49cf3c5758 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:23:50 -0700 Subject: [PATCH 21/45] Reuse event result entity types --- packages/world/src/events.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index acea8ee4b0..84c63606d9 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -1,7 +1,11 @@ import { z } from 'zod'; import { AttributeChangesSchema } from './attributes.js'; +import type { Hook } from './hooks.js'; +import type { StartedWorkflowRun, WorkflowRun } from './runs.js'; import { SerializedDataSchema } from './serialization.js'; import type { PaginationOptions, ResolveData } from './shared.js'; +import type { StartedStep, Step } from './steps.js'; +import type { Wait } from './waits.js'; // Event type enum export const EventTypeSchema = z.enum([ @@ -861,13 +865,13 @@ export interface EventResult { /** The created event (optional for legacy compatibility) */ event?: Event; /** The workflow run entity (for run_* events) */ - run?: import('./runs.js').WorkflowRun; + run?: WorkflowRun; /** The step entity (for step_* events) */ - step?: import('./steps.js').Step; + step?: Step; /** The hook entity (for hook_created events) */ - hook?: import('./hooks.js').Hook; + hook?: Hook; /** The wait entity (for wait_created/wait_completed events) */ - wait?: import('./waits.js').Wait; + wait?: Wait; /** * Events with data resolved. Two producers populate this: * @@ -901,11 +905,11 @@ export interface EventResult { export type EventResultFor = T extends EventRequestOfType<'run_created'> - ? EventResult & { run: import('./runs.js').WorkflowRun } + ? EventResult & { run: WorkflowRun } : T extends EventRequestOfType<'run_started'> - ? EventResult & { run: import('./runs.js').StartedWorkflowRun } + ? EventResult & { run: StartedWorkflowRun } : T extends EventRequestOfType<'step_started'> - ? EventResult & { step: import('./steps.js').StartedStep } + ? EventResult & { step: StartedStep } : EventResult; export interface GetEventParams { From e5a7a605fc85b72907993fa4d1c028edad07b014 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:03:59 -0700 Subject: [PATCH 22/45] Simplify event creation result types --- packages/core/src/runtime.ts | 8 +++- packages/core/src/runtime/helpers.ts | 9 ---- packages/core/src/runtime/step-executor.ts | 7 ++- .../core/src/runtime/suspension-handler.ts | 20 +++++---- packages/world-vercel/src/events-v4.test.ts | 2 +- packages/world-vercel/src/events-v4.ts | 45 +++++++++++++++---- packages/world-vercel/src/events.test.ts | 16 +++++-- packages/world-vercel/src/events.ts | 8 ++-- packages/world/src/events.ts | 19 ++++---- packages/world/src/interfaces.ts | 6 +-- 10 files changed, 88 insertions(+), 52 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 9a0360f579..49060cfe93 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -19,6 +19,8 @@ import { workflowDisplayName, } from '@workflow/utils/parse-name'; import { + type CreateEventParams, + type CreateEventRequest, type Event, getQueueTopicPrefix, isLegacySpecVersion, @@ -57,7 +59,6 @@ import { import { countStepStartedEvents } from './runtime/count-step-started-events.js'; import { appendUniqueEvents, - type EventCreator, getQueueOverhead, getWorkflowQueueName, handleHealthCheckMessage, @@ -726,7 +727,10 @@ export function workflowEntrypoint( const replayRecoveryReporter = replayDivergence ? new ReplayRecoveryReporter(replayDivergence.count) : ReplayRecoveryReporter.inert(); - const createEvent: EventCreator = (data, params) => + const createEvent = ( + data: T, + params?: CreateEventParams + ) => replayRecoveryReporter.withEventCreate(params, (p) => world.events.create(runId, data, p) ); diff --git a/packages/core/src/runtime/helpers.ts b/packages/core/src/runtime/helpers.ts index eee469f723..4365085131 100644 --- a/packages/core/src/runtime/helpers.ts +++ b/packages/core/src/runtime/helpers.ts @@ -4,10 +4,7 @@ import { WorkflowWorldError, } from '@workflow/errors'; import type { - CreateEventParams, - CreateEventRequest, Event, - EventResultFor, HealthCheckPayload, ValidQueueName, WorkflowRun, @@ -817,12 +814,6 @@ export function preconditionEventDelta( }; } -/** Creates one event on a bound run, carrying replay-recovery telemetry. */ -export type EventCreator = ( - data: T, - params?: CreateEventParams -) => Promise>; - /** * CORS headers for health check responses. * Allows the observability UI to check endpoint health from a different origin. diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts index 21f3328d39..6393ba2954 100644 --- a/packages/core/src/runtime/step-executor.ts +++ b/packages/core/src/runtime/step-executor.ts @@ -15,6 +15,7 @@ import { } from '@workflow/utils'; import type { CreateEventParams, + CreateEventRequest, Event, EventResult, SerializedData, @@ -51,7 +52,6 @@ import { } from './constants.js'; import { getPortLazy } from './get-port-lazy.js'; import { - type EventCreator, memoizeEncryptionKey, type PreconditionSnapshotParams, } from './helpers.js'; @@ -273,7 +273,10 @@ export async function executeStep( (params.runSpecVersion ?? 0) >= SPEC_VERSION_SUPPORTS_COMPRESSION; const replayRecoveryReporter = params.replayRecoveryReporter ?? ReplayRecoveryReporter.inert(); - const createEvent: EventCreator = (data, eventParams) => + const createEvent = ( + data: T, + eventParams?: CreateEventParams + ) => replayRecoveryReporter.withEventCreate(eventParams, (p) => world.events.create(workflowRunId, data, p) ); diff --git a/packages/core/src/runtime/suspension-handler.ts b/packages/core/src/runtime/suspension-handler.ts index 8cc8dbf208..e1c4bf5b70 100644 --- a/packages/core/src/runtime/suspension-handler.ts +++ b/packages/core/src/runtime/suspension-handler.ts @@ -9,7 +9,9 @@ import { } from '@workflow/errors'; import { AttributeValidationError, + type CreateEventParams, type CreateEventRequest, + type EventResult, type SerializedData, SPEC_VERSION_CURRENT, SPEC_VERSION_SUPPORTS_COMPRESSION, @@ -29,11 +31,7 @@ import { dehydrateStepArguments } from '../serialization.js'; import * as Attribute from '../telemetry/semantic-conventions.js'; import { getAbortStreamIdFromToken } from '../util.js'; import { getMaxInlineSteps } from './constants.js'; -import { - type EventCreator, - type LoadedEventLog, - preconditionSnapshotParams, -} from './helpers.js'; +import { type LoadedEventLog, preconditionSnapshotParams } from './helpers.js'; import { ReplayRecoveryReporter } from './replay-recovery-reporter.js'; export interface SuspensionHandlerParams { @@ -142,7 +140,10 @@ async function createHookEvent({ hookEvent: CreateEventRequest; queueItem: HookInvocationQueueItem; requestId?: string; - createEvent: EventCreator; + createEvent: ( + data: CreateEventRequest, + params?: CreateEventParams + ) => Promise; }): Promise<{ hasHookConflict: boolean; hasAwaitedHookCreation: boolean; @@ -264,7 +265,7 @@ export async function handleSuspension({ // that commits after replay recovered. All suspension events are // non-run_created events on this run's `runId`. const reporter = replayRecoveryReporter ?? ReplayRecoveryReporter.inert(); - const createEvent: EventCreator = (data, params) => + const createEvent = (data: CreateEventRequest, params?: CreateEventParams) => reporter.withEventCreate(params, (p) => world.events.create(runId, data, p) ); @@ -275,7 +276,10 @@ export async function handleSuspension({ // because the event's correlation id was minted by *this* replay's seeded // sequence, so re-committing it against a corrected log would persist an // event no correct replay produces. - const createGuarded: EventCreator = (data, params) => + const createGuarded = ( + data: CreateEventRequest, + params?: CreateEventParams + ) => eventLog ? createEvent(data, { ...params, diff --git a/packages/world-vercel/src/events-v4.test.ts b/packages/world-vercel/src/events-v4.test.ts index c6166c9d7c..8be3d1cab8 100644 --- a/packages/world-vercel/src/events-v4.test.ts +++ b/packages/world-vercel/src/events-v4.test.ts @@ -1112,7 +1112,7 @@ describe('v4 POST frame meta forwards every field the splitter produces', () => 200, (opts: { body?: unknown }) => { captured = decodeFrameMeta(opts.body); - return createEventBody(data); + return createEventBody(data, { run: runningRun }); }, { headers: { diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 5f41650f7b..e12c3dfe24 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -24,6 +24,7 @@ import { WorkflowWorldError } from '@workflow/errors'; import { type Event, + type EventResult, EventSchema, type EventType, EventTypeSchema, @@ -259,19 +260,45 @@ const CreateEventV4BodyBaseSchema = z.object({ maxEvents: z.number().int().positive().optional(), }); -const CreateEventV4BodySchema = z.union([ - CreateEventV4BodyBaseSchema.extend({ +const CreateEventV4PageSchema = z.union([ + z.object({ events: z.array(EventSchema), cursor: z.string().nullable(), hasMore: z.boolean(), }), - CreateEventV4BodyBaseSchema.extend({ + z.object({ events: z.undefined().optional(), cursor: z.undefined().optional(), hasMore: z.undefined().optional(), }), ]); +function createEventV4BodySchema( + eventType: T +): z.ZodType & { event: Event }> { + let bodySchema: z.ZodType = CreateEventV4BodyBaseSchema; + if (eventType === 'run_created') { + bodySchema = CreateEventV4BodyBaseSchema.extend({ + run: WorkflowRunSchema, + }); + } else if (eventType === 'run_started') { + bodySchema = CreateEventV4BodyBaseSchema.extend({ + run: WorkflowRunSchema.and(z.object({ startedAt: z.coerce.date() })), + }); + } else if (eventType === 'step_started') { + bodySchema = CreateEventV4BodyBaseSchema.extend({ + step: StepWireSchema.extend({ + startedAt: z.coerce.date(), + }).transform(deserializeStep), + }); + } + + return z.intersection( + bodySchema, + CreateEventV4PageSchema + ) as unknown as z.ZodType & { event: Event }>; +} + const EventFrameMetaSchema = z .object({ eventType: EventTypeSchema, @@ -279,8 +306,6 @@ const EventFrameMetaSchema = z }) .passthrough(); -export type CreateEventV4Result = z.infer; - /** Build the CBOR meta map for a v4 POST frame. Drops undefined entries * so the wire shape matches what the server expects to see. */ function buildPostFrameMeta( @@ -506,10 +531,10 @@ export function throwForErrorResponse( * The frame meta's `eventType` remains authoritative — the backend * cross-checks the two and logs (but does not reject) a mismatch. */ -export async function createWorkflowRunEventV4( - input: CreateEventV4Input, +export async function createWorkflowRunEventV4( + input: T, config?: APIConfig -): Promise { +): Promise & { event: Event }> { // getHttpConfig sets the Authorization header (explicit config.token or // per-request OIDC fallback) — same contract as the v3 makeRequest path. const { baseUrl, headers: baseHeaders } = await getHttpConfig(config); @@ -533,7 +558,9 @@ export async function createWorkflowRunEventV4( if (bodyBytes.byteLength === 0) { throw new Error('v4 createEvent: empty response body'); } - const parsedBody = CreateEventV4BodySchema.safeParse(decode(bodyBytes)); + const parsedBody = createEventV4BodySchema( + input.eventType + ).safeParse(decode(bodyBytes)); if (!parsedBody.success) { throw new WorkflowWorldError('v4 createEvent: invalid response body', { code: 'SCHEMA_VALIDATION', diff --git a/packages/world-vercel/src/events.test.ts b/packages/world-vercel/src/events.test.ts index 0a7cfdef72..b419741404 100644 --- a/packages/world-vercel/src/events.test.ts +++ b/packages/world-vercel/src/events.test.ts @@ -389,7 +389,7 @@ describe('createWorkflowRunEvent result contract', () => { case: 'run_started without startedAt', eventType: 'run_started', data: { eventType: 'run_started', specVersion: 2 }, - response: { run: { runId: 'wrun_1', status: 'running' } }, + response: { run: { ...runningRun, startedAt: undefined } }, }, { case: 'step_started without startedAt', @@ -399,7 +399,17 @@ describe('createWorkflowRunEvent result contract', () => { correlationId: 'step_1', specVersion: 2, }, - response: { step: { stepId: 'step_1', status: 'running' } }, + response: { + step: { + runId: 'wrun_1', + stepId: 'step_1', + stepName: 'step', + status: 'running', + attempt: 1, + createdAt: STARTED_AT, + updatedAt: STARTED_AT, + }, + }, }, ])('rejects $case', async ({ eventType, data, response }) => { const agent = mockAgent(); @@ -409,7 +419,7 @@ describe('createWorkflowRunEvent result contract', () => { path: `/api/v4/runs/wrun_1/events/${eventType}`, method: 'POST', }) - .reply(200, encode(response), { + .reply(200, createEventBody(data as AnyEventRequest, response), { headers: { 'x-wf-event-id': 'evnt_1', 'x-wf-run-id': 'wrun_1', diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index 21e1991fe9..861fb6773b 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -539,12 +539,12 @@ export async function getWorkflowRunEvents( } as PaginatedResponse; } -export async function createWorkflowRunEvent( +export async function createWorkflowRunEvent( id: string | null, - data: AnyEventRequest, + data: T, params?: CreateEventParams, config?: APIConfig -): Promise { +): Promise> { try { // Retry transient transport failures (UND_ERR_REQ_RETRY, ECONNRESET, // socket/headers timeouts, transient 5xx) in-process for event types that @@ -576,7 +576,7 @@ export async function createWorkflowRunEvent( { code: 'SCHEMA_VALIDATION' } ); } - return result; + return result as EventResult; } catch (err) { // 404 on hook_disposed / hook_received → already-disposed hook. if ( diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index 84c63606d9..5f005a6f7f 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -861,7 +861,7 @@ export interface CreateEventParams { * * Note: `event` is optional to support legacy runs where event storage is skipped. */ -export interface EventResult { +export type EventResult = { /** The created event (optional for legacy compatibility) */ event?: Event; /** The workflow run entity (for run_* events) */ @@ -901,16 +901,13 @@ export interface EventResult { stepCreated?: boolean; /** Server-owned max event count for the run (run-lifecycle responses); the runtime enforces it. */ maxEvents?: number; -} - -export type EventResultFor = - T extends EventRequestOfType<'run_created'> - ? EventResult & { run: WorkflowRun } - : T extends EventRequestOfType<'run_started'> - ? EventResult & { run: StartedWorkflowRun } - : T extends EventRequestOfType<'step_started'> - ? EventResult & { step: StartedStep } - : EventResult; +} & (T extends 'run_created' + ? { run: WorkflowRun } + : T extends 'run_started' + ? { run: StartedWorkflowRun } + : T extends 'step_started' + ? { step: StartedStep } + : unknown); export interface GetEventParams { resolveData?: ResolveData; diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index ff45d87ba4..c16ee6850d 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -7,7 +7,7 @@ import type { CreateEventParams, CreateEventRequest, Event, - EventResultFor, + EventResult, GetEventParams, ListEventsByCorrelationIdParams, ListEventsParams, @@ -256,7 +256,7 @@ export interface Storage { runId: string | null, data: T, params?: CreateEventParams - ): Promise>; + ): Promise>; /** * Create an event for an existing workflow run and atomically update the entity. @@ -271,7 +271,7 @@ export interface Storage { runId: string, data: T, params?: CreateEventParams - ): Promise>; + ): Promise>; get( runId: string, From 52b4c01ece5e3c1142d55a88d8d63ba1398ef1d9 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:07:15 -0700 Subject: [PATCH 23/45] Use concrete run creation result --- packages/world/src/interfaces.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index c16ee6850d..f69a09d299 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -252,11 +252,11 @@ export interface Storage { * @param params - Optional parameters for event creation * @returns Promise resolving to the created event and run entity */ - create( + create( runId: string | null, - data: T, + data: RunCreatedEventRequest, params?: CreateEventParams - ): Promise>; + ): Promise>; /** * Create an event for an existing workflow run and atomically update the entity. From 7efca66c141e8082e87bb6afba6001c86a7bd15d Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:11:24 -0700 Subject: [PATCH 24/45] Preserve generic event storage implementation --- packages/world/src/interfaces.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index f69a09d299..c16ee6850d 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -252,11 +252,11 @@ export interface Storage { * @param params - Optional parameters for event creation * @returns Promise resolving to the created event and run entity */ - create( + create( runId: string | null, - data: RunCreatedEventRequest, + data: T, params?: CreateEventParams - ): Promise>; + ): Promise>; /** * Create an event for an existing workflow run and atomically update the entity. From 22e35b28846e1870b46572189e0a6c3fee31ee98 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:13:19 -0700 Subject: [PATCH 25/45] Validate v4 event responses without casts --- packages/world-vercel/src/events-v4.test.ts | 24 +++++- packages/world-vercel/src/events-v4.ts | 86 +++++++++++---------- packages/world-vercel/src/events.ts | 18 +++-- 3 files changed, 74 insertions(+), 54 deletions(-) diff --git a/packages/world-vercel/src/events-v4.test.ts b/packages/world-vercel/src/events-v4.test.ts index 8be3d1cab8..586b57b605 100644 --- a/packages/world-vercel/src/events-v4.test.ts +++ b/packages/world-vercel/src/events-v4.test.ts @@ -159,7 +159,11 @@ describe('getWorkflowRunEventsV4 over HTTP', () => { runId: 'wrun_1', eventType: 'run_created', createdAt: '2026-06-10T00:00:00.000Z', - eventData: {}, + eventData: { + deploymentId: 'dpl_1', + workflowName: 'workflow', + input: null, + }, }, body ), @@ -232,7 +236,11 @@ describe('getWorkflowRunEventsV4 over HTTP', () => { runId: 'wrun_1', eventType: 'run_created', createdAt: '2026-06-10T00:00:00.000Z', - eventData: {}, + eventData: { + deploymentId: 'dpl_1', + workflowName: 'workflow', + input: null, + }, }, new Uint8Array(0) ), @@ -302,7 +310,11 @@ describe('getWorkflowRunEventsV4 over HTTP', () => { runId: 'wrun_1', eventType: 'run_created', createdAt: '2026-06-10T00:00:00.000Z', - eventData: {}, + eventData: { + deploymentId: 'dpl_1', + workflowName: 'workflow', + input: null, + }, }, new Uint8Array(0) ); @@ -344,7 +356,11 @@ describe('getEventV4 over HTTP', () => { runId: 'wrun_1', eventType: 'run_created', createdAt: '2026-06-10T00:00:00.000Z', - eventData: {}, + eventData: { + deploymentId: 'dpl_1', + workflowName: 'workflow', + input: null, + }, }, body ), diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index e12c3dfe24..a4abe0faaf 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -27,7 +27,6 @@ import { type EventResult, EventSchema, type EventType, - EventTypeSchema, getEventDataPayloadField, HookSchema, WaitSchema, @@ -273,38 +272,38 @@ const CreateEventV4PageSchema = z.union([ }), ]); -function createEventV4BodySchema( - eventType: T -): z.ZodType & { event: Event }> { - let bodySchema: z.ZodType = CreateEventV4BodyBaseSchema; - if (eventType === 'run_created') { - bodySchema = CreateEventV4BodyBaseSchema.extend({ - run: WorkflowRunSchema, - }); - } else if (eventType === 'run_started') { - bodySchema = CreateEventV4BodyBaseSchema.extend({ - run: WorkflowRunSchema.and(z.object({ startedAt: z.coerce.date() })), - }); - } else if (eventType === 'step_started') { - bodySchema = CreateEventV4BodyBaseSchema.extend({ - step: StepWireSchema.extend({ - startedAt: z.coerce.date(), - }).transform(deserializeStep), - }); - } - - return z.intersection( - bodySchema, - CreateEventV4PageSchema - ) as unknown as z.ZodType & { event: Event }>; -} - -const EventFrameMetaSchema = z - .object({ - eventType: EventTypeSchema, - eventData: z.record(z.string(), z.unknown()).optional(), - }) - .passthrough(); +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_completed: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + run_failed: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + run_cancelled: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + attr_set: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + step_created: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + step_completed: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + step_failed: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + step_retrying: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + hook_created: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + hook_received: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + hook_disposed: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + hook_conflict: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + wait_created: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + wait_completed: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), +}; /** Build the CBOR meta map for a v4 POST frame. Drops undefined entries * so the wire shape matches what the server expects to see. */ @@ -531,10 +530,10 @@ export function throwForErrorResponse( * The frame meta's `eventType` remains authoritative — the backend * cross-checks the two and logs (but does not reject) a mismatch. */ -export async function createWorkflowRunEventV4( - input: T, +export async function createWorkflowRunEventV4( + input: CreateEventV4Input & { eventType: T }, config?: APIConfig -): Promise & { event: Event }> { +): Promise & { event: Event }> { // getHttpConfig sets the Authorization header (explicit config.token or // per-request OIDC fallback) — same contract as the v3 makeRequest path. const { baseUrl, headers: baseHeaders } = await getHttpConfig(config); @@ -558,9 +557,12 @@ export async function createWorkflowRunEventV4( if (bodyBytes.byteLength === 0) { throw new Error('v4 createEvent: empty response body'); } - const parsedBody = createEventV4BodySchema( - input.eventType - ).safeParse(decode(bodyBytes)); + const schema: z.ZodType & { event: Event }> = + CreateEventV4BodySchemas[input.eventType].refine( + ({ event }) => event.eventType === input.eventType, + { path: ['event', 'eventType'] } + ); + const parsedBody = schema.safeParse(decode(bodyBytes)); if (!parsedBody.success) { throw new WorkflowWorldError('v4 createEvent: invalid response body', { code: 'SCHEMA_VALIDATION', @@ -623,7 +625,7 @@ export async function getEventV4( // GET emits a single frame (no sentinel); decodeFrames returns at EOF // after yielding it. for await (const frame of decodeFrames(chunks)) { - return { event: EventFrameMetaSchema.parse(frame.meta), body: frame.body }; + return { event: EventSchema.parse(frame.meta), body: frame.body }; } throw new Error(`v4 getEvent: empty frame stream for ${eventId}`); } @@ -648,7 +650,7 @@ export interface ListEventsV4Params { * exact shape. */ export interface DecodedEventFrame { - event: z.infer; + event: Event; /** Resolved payload bytes. Empty for events without a payload. */ body: Uint8Array; } @@ -713,7 +715,7 @@ async function consumeListFrameStream( break; } events.push({ - event: EventFrameMetaSchema.parse(frame.meta), + event: EventSchema.parse(frame.meta), body: frame.body, }); } diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index 861fb6773b..b1e6cafdb9 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -465,20 +465,22 @@ function decodeLegacyStructuredError(payload: Uint8Array): unknown { * exception. */ function decodeEventFrame({ event, body }: DecodedEventFrame): Event { - const eventData = { ...event.eventData }; - if (body.byteLength > 0) { const payloadField = getEventDataPayloadField(event.eventType); if (payloadField) { - eventData[payloadField] = legacyStructuredErrorEventTypes.has( - event.eventType - ) - ? decodeLegacyStructuredError(body) - : body; + return EventSchema.parse({ + ...event, + eventData: { + ...event.eventData, + [payloadField]: legacyStructuredErrorEventTypes.has(event.eventType) + ? decodeLegacyStructuredError(body) + : body, + }, + }); } } - return EventSchema.parse({ ...event, eventData }); + return event; } // ============================================================================= From a66e57a721601cc0dd53a71a1f997d5551c71cc3 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:18:09 -0700 Subject: [PATCH 26/45] Parse v4 event frames once --- packages/world-vercel/src/events.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index b1e6cafdb9..9b7d61110c 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -468,8 +468,7 @@ function decodeEventFrame({ event, body }: DecodedEventFrame): Event { if (body.byteLength > 0) { const payloadField = getEventDataPayloadField(event.eventType); if (payloadField) { - return EventSchema.parse({ - ...event, + return Object.assign({}, event, { eventData: { ...event.eventData, [payloadField]: legacyStructuredErrorEventTypes.has(event.eventType) From 4bc7e07b42d76a0cdc70ddd7da1d30195f84f895 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:26:46 -0700 Subject: [PATCH 27/45] Reuse the default v4 event body schema --- packages/world-vercel/src/events-v4.ts | 32 +++++++++++++++----------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index a4abe0faaf..f2cdc15284 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -272,6 +272,10 @@ const CreateEventV4PageSchema = z.union([ }), ]); +const CreateEventV4BodySchema = CreateEventV4BodyBaseSchema.and( + CreateEventV4PageSchema +); + const CreateEventV4BodySchemas: { [T in EventType]: z.ZodType & { event: Event }>; } = { @@ -289,20 +293,20 @@ const CreateEventV4BodySchemas: { startedAt: step.startedAt, })), }).and(CreateEventV4PageSchema), - run_completed: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), - run_failed: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), - run_cancelled: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), - attr_set: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), - step_created: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), - step_completed: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), - step_failed: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), - step_retrying: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), - hook_created: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), - hook_received: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), - hook_disposed: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), - hook_conflict: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), - wait_created: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), - wait_completed: CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema), + run_completed: CreateEventV4BodySchema, + run_failed: CreateEventV4BodySchema, + run_cancelled: CreateEventV4BodySchema, + attr_set: CreateEventV4BodySchema, + step_created: CreateEventV4BodySchema, + step_completed: CreateEventV4BodySchema, + step_failed: CreateEventV4BodySchema, + step_retrying: CreateEventV4BodySchema, + hook_created: CreateEventV4BodySchema, + hook_received: CreateEventV4BodySchema, + hook_disposed: CreateEventV4BodySchema, + hook_conflict: CreateEventV4BodySchema, + wait_created: CreateEventV4BodySchema, + wait_completed: CreateEventV4BodySchema, }; /** Build the CBOR meta map for a v4 POST frame. Drops undefined entries From f79718c071b7b5c438ef4e94f49a5ab03a50f740 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:03:09 -0700 Subject: [PATCH 28/45] Simplify event preload state --- packages/core/src/runtime.ts | 40 +++++++--------- .../world-local/src/storage/events-storage.ts | 40 ++++++++-------- packages/world-postgres/src/storage.ts | 23 ++++++---- packages/world-vercel/src/events.ts | 30 ++---------- packages/world/src/events.ts | 46 +++++++++---------- packages/world/src/interfaces.ts | 6 +-- 6 files changed, 80 insertions(+), 105 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 49060cfe93..5b72087b32 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -450,31 +450,25 @@ function openHookAndWaitState(events: Event[]): { openHook: boolean; openWait: boolean; } { - const disposedHookIds = new Set(); - const completedWaitIds = new Set(); - for (const e of events) { - if (e.eventType === 'hook_disposed') disposedHookIds.add(e.correlationId); - else if (e.eventType === 'wait_completed') { - completedWaitIds.add(e.correlationId); + const hooks = new Set(); + const waits = new Set(); + for (const event of events) { + switch (event.eventType) { + case 'hook_created': + hooks.add(event.correlationId); + break; + case 'hook_disposed': + hooks.delete(event.correlationId); + break; + case 'wait_created': + waits.add(event.correlationId); + break; + case 'wait_completed': + waits.delete(event.correlationId); + break; } } - let openHook = false; - let openWait = false; - for (const e of events) { - if ( - e.eventType === 'hook_created' && - !disposedHookIds.has(e.correlationId) - ) { - openHook = true; - } else if ( - e.eventType === 'wait_created' && - !completedWaitIds.has(e.correlationId) - ) { - openWait = true; - } - if (openHook && openWait) break; - } - return { openHook, openWait }; + return { openHook: hooks.size > 0, openWait: waits.size > 0 }; } type ReplayEventLog = diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index 9e20c4b8ff..e44e1e8b14 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -15,6 +15,7 @@ import type { Event, EventResult, Hook, + PaginatedResponse, PaginationOptions, SerializedData, Step, @@ -2428,16 +2429,11 @@ export function createEventsStorage( // For run_started: preload one page of events so the runtime can skip // the initial events.list call when hasMore is false. - let events: Event[] | undefined; - let cursor: string | null | undefined; - let hasMore: boolean | undefined; + let eventPage: PaginatedResponse | undefined; if (data.eventType === 'run_started' && run && !params?.skipPreload) { - const preloaded = await queryRunEvents(effectiveRunId, { + eventPage = await queryRunEvents(effectiveRunId, { limit: 1000, }); - events = preloaded.data; - cursor = preloaded.cursor; - hasMore = preloaded.hasMore; } // Inline-delta optimization: on a step-terminal write the inline @@ -2474,21 +2470,19 @@ export function createEventsStorage( // consume side (runtime.ts) only stashes the delta when `!hasMore` // and otherwise falls back to the exhaustive `events.list` loop, so a // truncated page is never consumed as if it were the full delta. - const delta = await paginatedFileSystemQuery({ - directory: path.join(basedir, 'events'), - schema: EventSchema, - filePrefix: `${effectiveRunId}-`, + const delta = await queryRunEvents(effectiveRunId, { sortOrder: 'asc', cursor: params.sinceCursor, - getCreatedAt: getObjectCreatedAt('evnt'), - getId: (e) => e.eventId, }); - events = + eventPage = resolveData === 'none' - ? delta.data.map((e) => stripEventDataRefs(e, resolveData)) - : delta.data; - cursor = delta.cursor; - hasMore = delta.hasMore; + ? { + ...delta, + data: delta.data.map((event) => + stripEventDataRefs(event, resolveData) + ), + } + : delta; } // Return EventResult with event and any created/updated entity @@ -2498,9 +2492,13 @@ export function createEventsStorage( step, hook, wait, - events, - cursor, - hasMore, + ...(eventPage + ? { + events: eventPage.data, + cursor: eventPage.cursor, + hasMore: eventPage.hasMore, + } + : {}), ...(stepCreatedLazily ? { stepCreated: true } : {}), // Per-run event ceiling (mirrors the Vercel World). ...(run ? { maxEvents: getMaxEventsPerRun() } : {}), diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index 00ce51ac4f..c4430be66b 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -1828,22 +1828,23 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // For run_started: include all events so the runtime can skip // the initial events.list call and reduce TTFB. - let allEvents: Event[] | undefined; - let cursor: string | null | undefined; - let hasMore: boolean | undefined; + let eventPage: PaginatedResponse | undefined; if (data.eventType === 'run_started' && run && !params?.skipPreload) { const eventRows = await drizzle .select() .from(Schema.events) .where(eq(Schema.events.runId, effectiveRunId)) .orderBy(Schema.events.eventId); - allEvents = eventRows.map((e) => { + const data = eventRows.map((e) => { e.eventData ||= e.eventDataJson; const parsed = EventSchema.parse(compact(e)); return stripEventDataRefs(parsed, resolveData); }); - cursor = allEvents.at(-1)?.eventId ?? null; - hasMore = false; + eventPage = { + data, + cursor: data.at(-1)?.eventId ?? null, + hasMore: false, + }; } return { @@ -1852,9 +1853,13 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { step, hook, wait, - events: allEvents, - cursor, - hasMore, + ...(eventPage + ? { + events: eventPage.data, + cursor: eventPage.cursor, + hasMore: eventPage.hasMore, + } + : {}), ...(stepCreatedLazily ? { stepCreated: true } : {}), }; }, diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index 9b7d61110c..c1ee861946 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -509,14 +509,9 @@ export async function getWorkflowRunEvents( // full bodies regardless; buildEventFromV4 still strips them when // resolveData is 'none', so this is purely a bandwidth optimization and is // safe against an older backend. - const wirePagination = { - cursor: pagination?.cursor ?? undefined, - limit: pagination?.limit, - sortOrder: pagination?.sortOrder, - remoteRefBehavior: (resolveData === 'none' ? 'lazy' : 'resolve') as - | 'lazy' - | 'resolve', - }; + const remoteRefBehavior: 'lazy' | 'resolve' = + resolveData === 'none' ? 'lazy' : 'resolve'; + const wirePagination = { ...pagination, remoteRefBehavior }; const result = await ('correlationId' in params ? getEventsByCorrelationIdV4(params.correlationId, wirePagination, config) @@ -702,22 +697,5 @@ async function createWorkflowRunEventInner( // EventSchema validates the response and converts stored ISO timestamps // back into the Date instances promised by the World interface. const resolveData = params?.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION; - return { - event: stripEventDataRefs(body.event, resolveData), - run: body.run, - step: body.step, - hook: body.hook, - wait: body.wait, - events: body.events, - cursor: body.cursor ?? undefined, - hasMore: body.hasMore, - // Lazy step start: thread the server's "I created the step on this call" - // signal through so the owned-inline runtime path can gate body execution - // on it. Absent from older servers → undefined → safe default. - ...(body.stepCreated ? { stepCreated: true } : {}), - // Server-supplied per-run event ceiling; absent from older servers. - ...(typeof body.maxEvents === 'number' - ? { maxEvents: body.maxEvents } - : {}), - }; + return { ...body, event: stripEventDataRefs(body.event, resolveData) }; } diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index 5f005a6f7f..02099a39be 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -872,21 +872,6 @@ export type EventResult = { hook?: Hook; /** The wait entity (for wait_created/wait_completed events) */ wait?: Wait; - /** - * Events with data resolved. Two producers populate this: - * - * - On a `run_started` response: the first replay page. Partial pages - * continue after {@link EventResult.cursor}. - * - On a step-terminal write (`step_completed` / `step_failed`) when - * the caller passed {@link CreateEventParams.sinceCursor}: the delta - * of events written strictly after that cursor, so the inline loop - * can skip the per-step incremental `events.list` round-trip. - */ - events?: Event[]; - /** Pagination cursor for `events`, matching events.list semantics. */ - cursor?: string | null; - /** Whether additional event pages are available for `events`. */ - hasMore?: boolean; /** * Lazy step start: set to `true` only when a `step_started` event with * step-creation data atomically *created* the step on this call (the @@ -898,16 +883,31 @@ export type EventResult = { * (undefined) on the legacy path and from older servers/worlds, which is * the safe default (treated as "not the lazy creator"). */ - stepCreated?: boolean; + stepCreated?: true; /** Server-owned max event count for the run (run-lifecycle responses); the runtime enforces it. */ maxEvents?: number; -} & (T extends 'run_created' - ? { run: WorkflowRun } - : T extends 'run_started' - ? { run: StartedWorkflowRun } - : T extends 'step_started' - ? { step: StartedStep } - : unknown); +} & ( + | { + /** Events returned with this write. */ + events: Event[]; + /** Pagination cursor for `events`, matching events.list semantics. */ + cursor: string | null; + /** Whether additional event pages are available for `events`. */ + hasMore: boolean; + } + | { + events?: undefined; + cursor?: undefined; + hasMore?: undefined; + } +) & + (T extends 'run_created' + ? { run: WorkflowRun } + : T extends 'run_started' + ? { run: StartedWorkflowRun } + : T extends 'step_started' + ? { step: StartedStep } + : unknown); export interface GetEventParams { resolveData?: ResolveData; diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index c16ee6850d..f69a09d299 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -252,11 +252,11 @@ export interface Storage { * @param params - Optional parameters for event creation * @returns Promise resolving to the created event and run entity */ - create( + create( runId: string | null, - data: T, + data: RunCreatedEventRequest, params?: CreateEventParams - ): Promise>; + ): Promise>; /** * Create an event for an existing workflow run and atomically update the entity. From 07d3bb1ebeb55c4f0200fa41b300711ca1bc7021 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:09:37 -0700 Subject: [PATCH 29/45] Narrow event page result states --- .../world-local/src/storage/events-storage.ts | 19 ++++++++++--------- packages/world-postgres/src/storage.ts | 18 ++++++++++-------- packages/world/src/interfaces.ts | 6 +++--- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index e44e1e8b14..f631ece83e 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -2485,24 +2485,25 @@ export function createEventsStorage( : delta; } - // Return EventResult with event and any created/updated entity - return { + const result = { event: filteredEvent, run, step, hook, wait, - ...(eventPage - ? { - events: eventPage.data, - cursor: eventPage.cursor, - hasMore: eventPage.hasMore, - } - : {}), ...(stepCreatedLazily ? { stepCreated: true } : {}), // Per-run event ceiling (mirrors the Vercel World). ...(run ? { maxEvents: getMaxEventsPerRun() } : {}), }; + + if (!eventPage) return result; + + return { + ...result, + events: eventPage.data, + cursor: eventPage.cursor, + hasMore: eventPage.hasMore, + }; } // end createImpl }, diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index c4430be66b..e01e4f056f 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -1847,21 +1847,23 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { }; } - return { + const eventResult = { event: stripEventDataRefs(parsed, resolveData), run, step, hook, wait, - ...(eventPage - ? { - events: eventPage.data, - cursor: eventPage.cursor, - hasMore: eventPage.hasMore, - } - : {}), ...(stepCreatedLazily ? { stepCreated: true } : {}), }; + + if (!eventPage) return eventResult; + + return { + ...eventResult, + events: eventPage.data, + cursor: eventPage.cursor, + hasMore: eventPage.hasMore, + }; }, async get( runId: string, diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index f69a09d299..c16ee6850d 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -252,11 +252,11 @@ export interface Storage { * @param params - Optional parameters for event creation * @returns Promise resolving to the created event and run entity */ - create( + create( runId: string | null, - data: RunCreatedEventRequest, + data: T, params?: CreateEventParams - ): Promise>; + ): Promise>; /** * Create an event for an existing workflow run and atomically update the entity. From 13fe3eafbe5e7d907ef6a5451dbae894e7d41b20 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:10:53 -0700 Subject: [PATCH 30/45] Preserve literal event result flags --- packages/world-local/src/storage/events-storage.ts | 2 +- packages/world-postgres/src/storage.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index f631ece83e..26ead18730 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -2485,7 +2485,7 @@ export function createEventsStorage( : delta; } - const result = { + const result: EventResult = { event: filteredEvent, run, step, diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index e01e4f056f..b2d4c32255 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -1847,7 +1847,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { }; } - const eventResult = { + const eventResult: EventResult = { event: stripEventDataRefs(parsed, resolveData), run, step, From ebab9e98d1ae92c70b0e89948eafc08070179094 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:30:01 -0700 Subject: [PATCH 31/45] Accept hook conflict event responses --- packages/world-vercel/src/events-v4.test.ts | 43 +++++++++++++++++++++ packages/world-vercel/src/events-v4.ts | 5 ++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/world-vercel/src/events-v4.test.ts b/packages/world-vercel/src/events-v4.test.ts index 586b57b605..eaed4ef3e8 100644 --- a/packages/world-vercel/src/events-v4.test.ts +++ b/packages/world-vercel/src/events-v4.test.ts @@ -495,6 +495,49 @@ describe('createWorkflowRunEventV4 over HTTP', () => { agent.assertNoPendingInterceptors(); }); + it('accepts hook_conflict from a hook_created request', async () => { + const origin = + WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; + const agent = new MockAgent(); + agent.disableNetConnect(); + + agent + .get(origin) + .intercept({ + path: '/api/v4/runs/wrun_1/events/hook_created', + method: 'POST', + }) + .reply( + 200, + createEventBody({ + eventType: 'hook_conflict', + specVersion: 5, + correlationId: 'hook_1', + eventData: { token: 'token', conflictingRunId: 'wrun_2' }, + }), + { + headers: { + 'x-wf-event-id': 'evnt_1', + 'x-wf-run-id': 'wrun_1', + 'x-wf-created-at': CREATED_AT, + }, + } + ); + + const result = await createWorkflowRunEventV4( + { + runId: 'wrun_1', + eventType: 'hook_created', + specVersion: 5, + correlationId: 'hook_1', + }, + { token: 'test-token', dispatcher: agent } + ); + + expect(result.event.eventType).toBe('hook_conflict'); + agent.assertNoPendingInterceptors(); + }); + it('forwards skipPreload in the run_started frame meta (turbo preload opt-out)', async () => { const origin = WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index f2cdc15284..bd6fa8f3ea 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -563,7 +563,10 @@ export async function createWorkflowRunEventV4( } const schema: z.ZodType & { event: Event }> = CreateEventV4BodySchemas[input.eventType].refine( - ({ event }) => event.eventType === input.eventType, + ({ event }) => + event.eventType === input.eventType || + (input.eventType === 'hook_created' && + event.eventType === 'hook_conflict'), { path: ['event', 'eventType'] } ); const parsedBody = schema.safeParse(decode(bodyBytes)); From ff6e662602dda7a218b283fe08bfc0cc82785197 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:51:05 -0700 Subject: [PATCH 32/45] Remove redundant optional event page schemas --- packages/world-vercel/src/events-v4.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index bd6fa8f3ea..b164bb63cc 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -266,9 +266,9 @@ const CreateEventV4PageSchema = z.union([ hasMore: z.boolean(), }), z.object({ - events: z.undefined().optional(), - cursor: z.undefined().optional(), - hasMore: z.undefined().optional(), + events: z.undefined(), + cursor: z.undefined(), + hasMore: z.undefined(), }), ]); From 50263be07ecdb4fff54c79f5a06db695c3afab33 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:06:16 -0700 Subject: [PATCH 33/45] Simplify preloaded event log access --- packages/core/src/runtime.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index f3a2c3c537..08cf71d860 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1600,11 +1600,10 @@ export function workflowEntrypoint( // hook_received). Best-effort: the win lands only when the // producer's write beat this consumer's load; otherwise we // fall through to the idempotent re-ensure below. - const preloadedLog = - eventLog.type === 'loadAll' ? undefined : eventLog.log; const alreadyPreloaded = hookInput.resumeId !== undefined && - preloadedLog?.events.some( + eventLog.type !== 'loadAll' && + eventLog.log.events.some( (e) => e.eventType === 'hook_received' && e.resumeId === hookInput.resumeId @@ -1720,8 +1719,8 @@ export function workflowEntrypoint( // it in ascending eventId order and is idempotent if a later // list re-observes it. Only when the World returns no event // do we fall back to reloading the complete log. - if (preloadedLog && ensuredEvent) { - insertEventByEventId(preloadedLog.events, ensuredEvent); + if (eventLog.type !== 'loadAll' && ensuredEvent) { + insertEventByEventId(eventLog.log.events, ensuredEvent); } else { eventLog = { type: 'loadAll' }; } From 8b1b9f824ea7a55a4c4763b07546516720c461fb Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:15:00 -0700 Subject: [PATCH 34/45] Flatten replay event log state --- packages/core/src/runtime.ts | 85 ++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 43 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 08cf71d860..d52a70dfd3 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -474,11 +474,8 @@ function openHookAndWaitState(events: Event[]): { type ReplayEventLog = | { type: 'loadAll' } - | { type: 'ready'; log: LoadedEventLog } - | { - type: 'loadAfter'; - log: LoadedEventLog & { cursor: string }; - }; + | ({ type: 'ready' } & LoadedEventLog) + | ({ type: 'loadAfter'; cursor: string } & LoadedEventLog); function nextEventLogLoad(log: LoadedEventLog): ReplayEventLog { if (log.cursor === null) { @@ -486,7 +483,8 @@ function nextEventLogLoad(log: LoadedEventLog): ReplayEventLog { } return { type: 'loadAfter', - log: { events: log.events, cursor: log.cursor }, + events: log.events, + cursor: log.cursor, }; } @@ -1011,26 +1009,26 @@ export function workflowEntrypoint( allowDelta && preconditionRestarts === 1 ? preconditionEventDelta(error, runId) : null; - const log = - eventLog.type === 'loadAll' ? null : eventLog.log; // A delta is only usable if there is a loaded log to merge // it into; without one the restart must load everything. - const usedDelta = delta !== null && log !== null; + const usedDelta = + delta !== null && eventLog.type !== 'loadAll'; // Snapshot the set being discarded while it is still in // hand; the comparison happens once the next load resolves. - preconditionRestartBaseline = log - ? { - ids: new Set( - log.events.map((event) => event.eventId) - ), - restart: preconditionRestarts, - reason, - source: usedDelta ? 'inline-delta' : 'full-reload', - } - : null; - if (delta && log) { - appendEventLog(log, delta); - eventLog = { type: 'ready', log }; + preconditionRestartBaseline = + eventLog.type !== 'loadAll' + ? { + ids: new Set( + eventLog.events.map((event) => event.eventId) + ), + restart: preconditionRestarts, + reason, + source: usedDelta ? 'inline-delta' : 'full-reload', + } + : null; + if (delta && eventLog.type !== 'loadAll') { + appendEventLog(eventLog, delta); + eventLog = { ...eventLog, type: 'ready' }; } else { // MUST be a full, cursor-less reload. The cursor filters // by lexicographic event id while a hole is defined by @@ -1458,7 +1456,8 @@ export function workflowEntrypoint( // without a spurious "cursor missing" warning. eventLog = { type: 'ready', - log: { events: [], cursor: null }, + events: [], + cursor: null, }; const now = new Date(); workflowRun = { @@ -1510,13 +1509,13 @@ export function workflowEntrypoint( runStartedReceivedAtMs = Date.now(); if (result.events?.length) { - const log = { + const loaded = { events: [...result.events], cursor: result.cursor ?? null, }; eventLog = result.hasMore - ? nextEventLogLoad(log) - : { type: 'ready', log }; + ? nextEventLogLoad(loaded) + : { ...loaded, type: 'ready' }; } workflowStartedAt = +result.run.startedAt; span?.setAttributes({ @@ -1603,7 +1602,7 @@ export function workflowEntrypoint( const alreadyPreloaded = hookInput.resumeId !== undefined && eventLog.type !== 'loadAll' && - eventLog.log.events.some( + eventLog.events.some( (e) => e.eventType === 'hook_received' && e.resumeId === hookInput.resumeId @@ -1720,7 +1719,7 @@ export function workflowEntrypoint( // list re-observes it. Only when the World returns no event // do we fall back to reloading the complete log. if (eventLog.type !== 'loadAll' && ensuredEvent) { - insertEventByEventId(eventLog.log.events, ensuredEvent); + insertEventByEventId(eventLog.events, ensuredEvent); } else { eventLog = { type: 'loadAll' }; } @@ -1806,17 +1805,17 @@ export function workflowEntrypoint( try { let log: LoadedEventLog; if (eventLog.type === 'ready') { - log = eventLog.log; + log = eventLog; } else { const loaded = await loadWorkflowRunEvents( runId, eventLog.type === 'loadAfter' - ? eventLog.log.cursor + ? eventLog.cursor : undefined ); if (eventLog.type === 'loadAfter') { - appendEventLog(eventLog.log, loaded); - log = eventLog.log; + appendEventLog(eventLog, loaded); + log = eventLog; } else { log = loaded; } @@ -1970,7 +1969,7 @@ export function workflowEntrypoint( // events.list. Captured here because nothing between this // point and the inline executeStep mutates the event log. preInlineWriteCursor = log.cursor; - eventLog = { type: 'ready', log }; + eventLog = { ...log, type: 'ready' }; // Replay workflow runtimeLogger.debug('Starting workflow replay', { @@ -2106,7 +2105,7 @@ export function workflowEntrypoint( eventLog.type === 'ready', 'Workflow suspended before its event log was loaded' ); - const suspensionLog = eventLog.log; + const loaded = eventLog; let suspensionResult: Awaited< ReturnType >; @@ -2117,7 +2116,7 @@ export function workflowEntrypoint( run: workflowRun, span, requestId, - eventLog: suspensionLog, + eventLog: loaded, runReadyBarrier, replayRecoveryReporter, }); @@ -2222,7 +2221,7 @@ export function workflowEntrypoint( }); return; } - eventLog = nextEventLogLoad(suspensionLog); + eventLog = nextEventLogLoad(loaded); preStepBlockingMs += suspensionResult.hookCreationMs; if ( suspensionResult.hasAttributeEvents && @@ -2550,7 +2549,7 @@ export function workflowEntrypoint( // Open hooks/waits in the cumulative log, computed // once for the two gates below. const openHookWaitState = openHookAndWaitState( - suspensionLog.events + loaded.events ); // Inline-delta fast path gate. We request the delta — @@ -2718,7 +2717,7 @@ export function workflowEntrypoint( // snapshot has a local-clock createdAt, so under // turbo only the run-id ULID timestamp is trusted. const latencyTracking = computeStepLatencyTracking({ - events: suspensionLog.events, + events: loaded.events, invocationStartedClean: invocationStartedClean === true, runCreatedAtMs: @@ -2750,7 +2749,7 @@ export function workflowEntrypoint( // outside guarded deployments; Worlds that don't // enforce the guard ignore it. const inlineClaimSnapshot = preconditionSnapshotParams( - suspensionLog.events, + loaded.events, preInlineWriteCursor ); @@ -2800,7 +2799,7 @@ export function workflowEntrypoint( s.lazyStepInput !== undefined ? 1 : countStepStartedEvents( - suspensionLog.events, + loaded.events, s.correlationId, { type: 'ownedBy', @@ -3089,10 +3088,10 @@ export function workflowEntrypoint( if (inlineExecutions.length === 1) { const only = stepResults[0]; if (only.type === 'completed' && only.inlineDelta) { - appendEventLog(suspensionLog, only.inlineDelta); + appendEventLog(loaded, only.inlineDelta); eventLog = only.inlineDelta.hasMore - ? nextEventLogLoad(suspensionLog) - : { type: 'ready', log: suspensionLog }; + ? nextEventLogLoad(loaded) + : { ...loaded, type: 'ready' }; } } } else { From 6710565e14f9338c05f7c2d52daa3987829a7ff2 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:44:52 -0700 Subject: [PATCH 35/45] Simplify replay event log state --- packages/core/src/runtime.ts | 109 +++++++++++++++++------------------ 1 file changed, 54 insertions(+), 55 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index d52a70dfd3..9098e724c1 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1797,32 +1797,26 @@ export function workflowEntrypoint( } let replayStart = 0; - // Cursor of this iteration's event log before any inline - // writes advance it — declared at try scope so the - // suspension catch (which runs the inline step) can read it - // as the `sinceCursor` for the inline-delta optimization. - let preInlineWriteCursor: string | null = null; try { - let log: LoadedEventLog; + let loaded: LoadedEventLog; if (eventLog.type === 'ready') { - log = eventLog; + loaded = eventLog; } else { - const loaded = await loadWorkflowRunEvents( + const page = await loadWorkflowRunEvents( runId, eventLog.type === 'loadAfter' ? eventLog.cursor : undefined ); if (eventLog.type === 'loadAfter') { - appendEventLog(eventLog, loaded); - log = eventLog; + appendEventLog(eventLog, page); + loaded = eventLog; } else { - log = loaded; + loaded = page; } } - let events = log.events; - reportPreconditionRestartReload(events); + reportPreconditionRestartReload(loaded.events); // Detect concurrent completion via the event log: if // any other handler wrote a terminal run event, exit @@ -1830,18 +1824,18 @@ export function workflowEntrypoint( // derived from these events, so checking the log here // gives us the same signal as a runs.get() round-trip // without the extra request per loop iteration. - if (hasRecordedTerminalRunEvent(events, runId)) { + if (hasRecordedTerminalRunEvent(loaded.events, runId)) { return; } // Complete elapsed waits const now = Date.now(); const completedWaitIds = new Set( - events + loaded.events .filter((e) => e.eventType === 'wait_completed') .map((e) => e.correlationId) ); - const waitsToComplete = events + const waitsToComplete = loaded.events .filter( ( e @@ -1867,7 +1861,10 @@ export function workflowEntrypoint( try { await createEvent(waitEvent, { requestId, - ...preconditionSnapshotParams(events, log.cursor), + ...preconditionSnapshotParams( + loaded.events, + loaded.cursor + ), }); } catch (err) { if (EntityConflictError.is(err)) { @@ -1893,13 +1890,13 @@ export function workflowEntrypoint( // reload for older worlds that cannot give us a stable // cursor, or if the cursor delta does not include the // wait completion this handler just attempted. - if (log.cursor) { - const loaded = await loadWorkflowRunEvents( + if (loaded.cursor) { + const page = await loadWorkflowRunEvents( runId, - log.cursor + loaded.cursor ); const completedWaitIdsAfterCursor = new Set( - loaded.events + page.events .filter((e) => e.eventType === 'wait_completed') .map((e) => e.correlationId) ); @@ -1911,14 +1908,13 @@ export function workflowEntrypoint( ); if (sawAllWaitCompletions) { - appendEventLog(log, loaded); + appendEventLog(loaded, page); } else { - log = await loadWorkflowRunEvents(runId); + loaded = await loadWorkflowRunEvents(runId); } } else { - log = await loadWorkflowRunEvents(runId); + loaded = await loadWorkflowRunEvents(runId); } - events = log.events; } // Completing elapsed waits refreshes the event snapshot. @@ -1926,7 +1922,7 @@ export function workflowEntrypoint( // event after the initial snapshot but before this // replay. Once the event log records that outcome, this // delivery is done. - if (hasRecordedTerminalRunEvent(events, runId)) { + if (hasRecordedTerminalRunEvent(loaded.events, runId)) { return; } @@ -1936,10 +1932,10 @@ export function workflowEntrypoint( // run_failed / MAX_EVENTS_EXCEEDED. if ( maxEventsLimit !== undefined && - events.length >= maxEventsLimit + loaded.events.length >= maxEventsLimit ) { throw new MaxEventsExceededError( - events.length, + loaded.events.length, maxEventsLimit ); } @@ -1952,7 +1948,7 @@ export function workflowEntrypoint( // committed pre-step attr_set, and the detour it marks // is subtracted via preStepAttrStartMs regardless of // which invocation wrote it (see runtime/step-latency.ts). - invocationStartedClean ??= events.every( + invocationStartedClean ??= loaded.events.every( (e) => e.eventType === 'run_created' || e.eventType === 'run_started' || @@ -1968,14 +1964,13 @@ export function workflowEntrypoint( // letting the next iteration skip the incremental // events.list. Captured here because nothing between this // point and the inline executeStep mutates the event log. - preInlineWriteCursor = log.cursor; - eventLog = { ...log, type: 'ready' }; + eventLog = { ...loaded, type: 'ready' }; // Replay workflow runtimeLogger.debug('Starting workflow replay', { workflowRunId: runId, loopIteration, - eventCount: events.length, + eventCount: loaded.events.length, }); replayStart = Date.now(); // Start every missing decrypt/decompress operation before @@ -1983,12 +1978,12 @@ export function workflowEntrypoint( // consumers still deserialize and resolve in event order. const payloadPrewarm = replayPayloadCache.prewarm( workflowRun, - events + loaded.events ); const result = await runWorkflow( workflowCode, workflowRun, - events, + loaded.events, encryptionKey, replayPayloadCache, // Turbo: the end-of-run drain inside runWorkflow commits @@ -2025,7 +2020,10 @@ export function workflowEntrypoint( }, { requestId, - ...preconditionSnapshotParams(events, log.cursor), + ...preconditionSnapshotParams( + loaded.events, + loaded.cursor + ), } ); } catch (err) { @@ -2105,7 +2103,6 @@ export function workflowEntrypoint( eventLog.type === 'ready', 'Workflow suspended before its event log was loaded' ); - const loaded = eventLog; let suspensionResult: Awaited< ReturnType >; @@ -2116,7 +2113,7 @@ export function workflowEntrypoint( run: workflowRun, span, requestId, - eventLog: loaded, + eventLog, runReadyBarrier, replayRecoveryReporter, }); @@ -2221,7 +2218,6 @@ export function workflowEntrypoint( }); return; } - eventLog = nextEventLogLoad(loaded); preStepBlockingMs += suspensionResult.hookCreationMs; if ( suspensionResult.hasAttributeEvents && @@ -2261,6 +2257,7 @@ export function workflowEntrypoint( // paying a delivery round-trip here would only add // latency before the workflow's next step. if (suspensionResult.hasAttributeEvents) { + eventLog = nextEventLogLoad(eventLog); continue; } @@ -2549,16 +2546,15 @@ export function workflowEntrypoint( // Open hooks/waits in the cumulative log, computed // once for the two gates below. const openHookWaitState = openHookAndWaitState( - loaded.events + eventLog.events ); // Inline-delta fast path gate. We request the delta — // and on the next iteration consume it in place of the // events.list — only when ALL hold: // - // - We have a real prior cursor to diff against - // (`preInlineWriteCursor`; a World may return none on - // the initial load). + // - We have a real prior cursor to diff against (a + // World may return none on the initial load). // - This is the clean single-step sequential case: // this suspension produced exactly one step and no // waits (`err.{step,wait}Count`), that one step is @@ -2622,7 +2618,7 @@ export function workflowEntrypoint( world.capabilities?.preconditionGuard === true; const requestInlineDelta = - typeof preInlineWriteCursor === 'string' && + typeof eventLog.cursor === 'string' && err.stepCount === 1 && err.waitCount === 0 && pendingSteps.length === 1 && @@ -2717,7 +2713,7 @@ export function workflowEntrypoint( // snapshot has a local-clock createdAt, so under // turbo only the run-id ULID timestamp is trusted. const latencyTracking = computeStepLatencyTracking({ - events: loaded.events, + events: eventLog.events, invocationStartedClean: invocationStartedClean === true, runCreatedAtMs: @@ -2749,8 +2745,8 @@ export function workflowEntrypoint( // outside guarded deployments; Worlds that don't // enforce the guard ignore it. const inlineClaimSnapshot = preconditionSnapshotParams( - loaded.events, - preInlineWriteCursor + eventLog.events, + eventLog.cursor ); replayBudget.pause(); @@ -2759,8 +2755,9 @@ export function workflowEntrypoint( >[]; const stepExecutionPromises = inlineExecutions.map( (s, stepIndex) => { - const run = () => - executeStep({ + const run = () => { + assert(eventLog.type === 'ready'); + return executeStep({ world, workflowRunId: runId, workflowDeploymentId: workflowRun.deploymentId, @@ -2799,7 +2796,7 @@ export function workflowEntrypoint( s.lazyStepInput !== undefined ? 1 : countStepStartedEvents( - loaded.events, + eventLog.events, s.correlationId, { type: 'ownedBy', @@ -2836,14 +2833,14 @@ export function workflowEntrypoint( latencyTracking ? { latencyTracking } : {}), - ...(requestInlineDelta && preInlineWriteCursor + ...(requestInlineDelta && eventLog.cursor ? { - inlineDeltaSinceCursor: - preInlineWriteCursor, + inlineDeltaSinceCursor: eventLog.cursor, } : {}), replayRecoveryReporter, }); + }; // Invariant bookkeeping: this invocation owns // these bodies until they settle — see // assertNoInFlightOwnedSteps. @@ -3088,12 +3085,14 @@ export function workflowEntrypoint( if (inlineExecutions.length === 1) { const only = stepResults[0]; if (only.type === 'completed' && only.inlineDelta) { - appendEventLog(loaded, only.inlineDelta); + appendEventLog(eventLog, only.inlineDelta); eventLog = only.inlineDelta.hasMore - ? nextEventLogLoad(loaded) - : { ...loaded, type: 'ready' }; + ? nextEventLogLoad(eventLog) + : { ...eventLog, type: 'ready' }; + continue; } } + eventLog = nextEventLogLoad(eventLog); } else { // Stale-snapshot rejection of a guarded write made // directly by the replay loop — the result-bearing From 659f2c19307002a1355ebaa1d7c930f5fcb9859d Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:47:47 -0700 Subject: [PATCH 36/45] Use one replay event log --- packages/core/src/runtime.ts | 67 ++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 9098e724c1..98411dc0bc 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1798,10 +1798,7 @@ export function workflowEntrypoint( let replayStart = 0; try { - let loaded: LoadedEventLog; - if (eventLog.type === 'ready') { - loaded = eventLog; - } else { + if (eventLog.type !== 'ready') { const page = await loadWorkflowRunEvents( runId, eventLog.type === 'loadAfter' @@ -1810,13 +1807,14 @@ export function workflowEntrypoint( ); if (eventLog.type === 'loadAfter') { appendEventLog(eventLog, page); - loaded = eventLog; + eventLog = { ...eventLog, type: 'ready' }; } else { - loaded = page; + eventLog = { ...page, type: 'ready' }; } } + assert(eventLog.type === 'ready'); - reportPreconditionRestartReload(loaded.events); + reportPreconditionRestartReload(eventLog.events); // Detect concurrent completion via the event log: if // any other handler wrote a terminal run event, exit @@ -1824,18 +1822,18 @@ export function workflowEntrypoint( // derived from these events, so checking the log here // gives us the same signal as a runs.get() round-trip // without the extra request per loop iteration. - if (hasRecordedTerminalRunEvent(loaded.events, runId)) { + if (hasRecordedTerminalRunEvent(eventLog.events, runId)) { return; } // Complete elapsed waits const now = Date.now(); const completedWaitIds = new Set( - loaded.events + eventLog.events .filter((e) => e.eventType === 'wait_completed') .map((e) => e.correlationId) ); - const waitsToComplete = loaded.events + const waitsToComplete = eventLog.events .filter( ( e @@ -1862,8 +1860,8 @@ export function workflowEntrypoint( await createEvent(waitEvent, { requestId, ...preconditionSnapshotParams( - loaded.events, - loaded.cursor + eventLog.events, + eventLog.cursor ), }); } catch (err) { @@ -1890,10 +1888,10 @@ export function workflowEntrypoint( // reload for older worlds that cannot give us a stable // cursor, or if the cursor delta does not include the // wait completion this handler just attempted. - if (loaded.cursor) { + if (eventLog.cursor) { const page = await loadWorkflowRunEvents( runId, - loaded.cursor + eventLog.cursor ); const completedWaitIdsAfterCursor = new Set( page.events @@ -1908,12 +1906,18 @@ export function workflowEntrypoint( ); if (sawAllWaitCompletions) { - appendEventLog(loaded, page); + appendEventLog(eventLog, page); } else { - loaded = await loadWorkflowRunEvents(runId); + eventLog = { + ...(await loadWorkflowRunEvents(runId)), + type: 'ready', + }; } } else { - loaded = await loadWorkflowRunEvents(runId); + eventLog = { + ...(await loadWorkflowRunEvents(runId)), + type: 'ready', + }; } } @@ -1922,7 +1926,7 @@ export function workflowEntrypoint( // event after the initial snapshot but before this // replay. Once the event log records that outcome, this // delivery is done. - if (hasRecordedTerminalRunEvent(loaded.events, runId)) { + if (hasRecordedTerminalRunEvent(eventLog.events, runId)) { return; } @@ -1932,10 +1936,10 @@ export function workflowEntrypoint( // run_failed / MAX_EVENTS_EXCEEDED. if ( maxEventsLimit !== undefined && - loaded.events.length >= maxEventsLimit + eventLog.events.length >= maxEventsLimit ) { throw new MaxEventsExceededError( - loaded.events.length, + eventLog.events.length, maxEventsLimit ); } @@ -1948,29 +1952,18 @@ export function workflowEntrypoint( // committed pre-step attr_set, and the detour it marks // is subtracted via preStepAttrStartMs regardless of // which invocation wrote it (see runtime/step-latency.ts). - invocationStartedClean ??= loaded.events.every( + invocationStartedClean ??= eventLog.events.every( (e) => e.eventType === 'run_created' || e.eventType === 'run_started' || e.eventType === 'attr_set' ); - // Snapshot the cursor as it stands for this iteration's - // event log, before any inline writes (step_created via - // handleSuspension, step_started/step_completed via - // executeStep) advance it. This is the `sinceCursor` - // handed to a supporting World on the inline step's - // terminal write so it can return the event-log delta — - // letting the next iteration skip the incremental - // events.list. Captured here because nothing between this - // point and the inline executeStep mutates the event log. - eventLog = { ...loaded, type: 'ready' }; - // Replay workflow runtimeLogger.debug('Starting workflow replay', { workflowRunId: runId, loopIteration, - eventCount: loaded.events.length, + eventCount: eventLog.events.length, }); replayStart = Date.now(); // Start every missing decrypt/decompress operation before @@ -1978,12 +1971,12 @@ export function workflowEntrypoint( // consumers still deserialize and resolve in event order. const payloadPrewarm = replayPayloadCache.prewarm( workflowRun, - loaded.events + eventLog.events ); const result = await runWorkflow( workflowCode, workflowRun, - loaded.events, + eventLog.events, encryptionKey, replayPayloadCache, // Turbo: the end-of-run drain inside runWorkflow commits @@ -2021,8 +2014,8 @@ export function workflowEntrypoint( { requestId, ...preconditionSnapshotParams( - loaded.events, - loaded.cursor + eventLog.events, + eventLog.cursor ), } ); From d655bba33b8fde5438ebaf748715713902b4b1ce Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:40:25 -0700 Subject: [PATCH 37/45] fix(next): preserve edits made during full HMR rebuilds --- .changeset/fix-next-hmr-build-race.md | 5 +++++ packages/next/src/builder-eager.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-next-hmr-build-race.md diff --git a/.changeset/fix-next-hmr-build-race.md b/.changeset/fix-next-hmr-build-race.md new file mode 100644 index 0000000000..cc84c71d97 --- /dev/null +++ b/.changeset/fix-next-hmr-build-race.md @@ -0,0 +1,5 @@ +--- +'@workflow/next': patch +--- + +Preserve source changes made during a full development rebuild for the next HMR pass. diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 3c3a472e31..8db00f8ee9 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -255,6 +255,10 @@ export async function getNextBuilderEager( const newInputFiles = await this.getInputFiles(); options.inputFiles = newInputFiles; + // Snapshot before building so edits made during the build remain + // dirty and trigger the file event already queued behind this task. + await refreshSourceSnapshots(); + await stepsCtx?.dispose(); await workflowsCtx.interimBundleCtx.dispose(); @@ -275,7 +279,6 @@ export async function getNextBuilderEager( }; await writeManifest(newCombined.manifest); - await refreshSourceSnapshots(); }; const isWatchableFile = (path: string) => From 005de8e94be9782f61f8356c6f495104a925672d Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:40:28 -0700 Subject: [PATCH 38/45] chore(core): log dormant hook replays --- packages/core/src/runtime.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 457929a510..8b53a52828 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -2701,6 +2701,21 @@ export function workflowEntrypoint( // queued (or no work needs scheduling). Exit and let // the queue drive subsequent replays. if (inlineExecutions.length === 0) { + if ( + eventLog.events.at(-1)?.eventType === + 'hook_received' + ) { + runtimeLogger.warn( + 'Workflow replay ended at hook_received without scheduling work', + { + workflowRunId: runId, + messageId: metadata.messageId, + eventCount: eventLog.events.length, + pendingSteps: pendingSteps.length, + queuedDispatches: dispatches.length, + } + ); + } // A `hook.getConflict()` awaiter needs an immediate // re-invocation: the replay consumes the // just-committed hook_created and resolves the From a37b6def900466463b5dde71ebb728f55ea1d7ae Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:42:33 -0700 Subject: [PATCH 39/45] fix(next): commit HMR snapshots after rebuilds --- packages/next/src/builder-eager.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 8db00f8ee9..24d6cca6a8 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -151,7 +151,7 @@ export async function getNextBuilderEager( ? pathname : resolve(this.config.workingDir, pathname) ).replace(/\\/g, '/'); - const sourceSnapshots = new Map(); + let sourceSnapshots = new Map(); const watchableExtensions = new Set([ '.js', @@ -201,14 +201,17 @@ export async function getNextBuilderEager( const readSourceSnapshot = (file: string) => createSourceSnapshot({ file, detectWorkflowPatterns }); - const refreshSourceSnapshots = () => - replaceSourceSnapshots({ + const readSourceSnapshots = async () => { + const snapshots = new Map(); + await replaceSourceSnapshots({ discoveredEntries, inputFiles: options.inputFiles, normalizePath, readSnapshot: readSourceSnapshot, - sourceSnapshots, + sourceSnapshots: snapshots, }); + return snapshots; + }; const mergeCombinedManifest = ( nextStepsManifest: WorkflowManifest @@ -257,7 +260,7 @@ export async function getNextBuilderEager( // Snapshot before building so edits made during the build remain // dirty and trigger the file event already queued behind this task. - await refreshSourceSnapshots(); + const nextSourceSnapshots = await readSourceSnapshots(); await stepsCtx?.dispose(); await workflowsCtx.interimBundleCtx.dispose(); @@ -279,6 +282,7 @@ export async function getNextBuilderEager( }; await writeManifest(newCombined.manifest); + sourceSnapshots = nextSourceSnapshots; }; const isWatchableFile = (path: string) => @@ -413,7 +417,7 @@ export async function getNextBuilderEager( } }; - await refreshSourceSnapshots(); + sourceSnapshots = await readSourceSnapshots(); let { files: knownFiles, aliases: knownFileAliases, From 056425e8724b1c2d13dcc220f4397e03b43f74ac Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:01:02 -0700 Subject: [PATCH 40/45] fix(next): ignore duplicate HMR file events --- packages/core/src/runtime.ts | 15 --------------- packages/next/src/builder-eager.ts | 5 ++++- packages/next/src/watch-rebuild.test.ts | 2 +- packages/next/src/watch-rebuild.ts | 19 +++++++++++++------ 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 8b53a52828..457929a510 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -2701,21 +2701,6 @@ export function workflowEntrypoint( // queued (or no work needs scheduling). Exit and let // the queue drive subsequent replays. if (inlineExecutions.length === 0) { - if ( - eventLog.events.at(-1)?.eventType === - 'hook_received' - ) { - runtimeLogger.warn( - 'Workflow replay ended at hook_received without scheduling work', - { - workflowRunId: runId, - messageId: metadata.messageId, - eventCount: eventLog.events.length, - pendingSteps: pendingSteps.length, - queuedDispatches: dispatches.length, - } - ); - } // A `hook.getConflict()` awaiter needs an immediate // re-invocation: the replay consumes the // just-committed hook_created and resolves the diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 24d6cca6a8..75e24daf58 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -446,8 +446,11 @@ export async function getNextBuilderEager( sourceSnapshots, }); if (decision.kind === 'none') { + if (decision.snapshots.size === 0) { + return; + } logDevHmr('workflow dev hmr: skip'); - for (const [file, snapshot] of decision.snapshots || []) { + for (const [file, snapshot] of decision.snapshots) { sourceSnapshots.set(file, snapshot); } return; diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index ac75e6e0b7..974893828e 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -185,6 +185,6 @@ export const allWorkflows = {} as const; sourceSnapshots, }); - expect(decision.kind).toBe('none'); + expect(decision).toEqual({ kind: 'none', snapshots: new Map() }); }); }); diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index 2799804d2f..04178637fc 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; export interface DiscoveredEntriesLike { @@ -14,6 +15,7 @@ export interface FileChanges { } export interface SourceSnapshot { + contentHash: string; importSignature: string; definitionSignature: string; hasDirective: boolean; @@ -21,7 +23,7 @@ export interface SourceSnapshot { } export type RebuildDecision = - | { kind: 'none'; snapshots?: Map } + | { kind: 'none'; snapshots: Map } | { kind: 'hot'; refreshStepRegistrations: boolean; @@ -216,6 +218,9 @@ export const createSourceSnapshotFromSource = ( const patterns = detectWorkflowPatterns(sourceWithoutComments); return { + contentHash: createHash('sha256') + .update(sourceWithoutComments) + .digest('base64url'), importSignature: extractImportSignature(sourceWithoutComments), definitionSignature: extractDefinitionSignature(sourceWithoutComments), hasDirective: patterns.hasDirective, @@ -317,7 +322,9 @@ const snapshotChangedFile = async ({ return false; } - nextSnapshots.set(file, nextSnapshot); + if (previousSnapshot.contentHash !== nextSnapshot.contentHash) { + nextSnapshots.set(file, nextSnapshot); + } return true; }; @@ -385,7 +392,9 @@ const pruneStaleAddedFiles = async ({ nextAddedFiles.push(file); continue; } - snapshots.set(file, nextSnapshot); + if (previousSnapshot.contentHash !== nextSnapshot.contentHash) { + snapshots.set(file, nextSnapshot); + } } catch { nextAddedFiles.push(file); } @@ -585,9 +594,7 @@ export const classifyRebuild = async ({ normalizePath, }); if (changedRelevantFiles.length === 0) { - return prunedAddedFiles.snapshots.size > 0 - ? { kind: 'none', snapshots: prunedAddedFiles.snapshots } - : { kind: 'none' }; + return { kind: 'none', snapshots: prunedAddedFiles.snapshots }; } try { From cfd6b84ef4224ffdc4955cdfbd711f720b0b1350 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:15:47 -0700 Subject: [PATCH 41/45] test(next): expect deduplicated HMR removal event --- packages/core/e2e/dev.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index f5f3dc610b..74bc19a492 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -1353,7 +1353,7 @@ ${apiFileContent}` }, { description: 'workflow file removed from API import', - expectedLogCounts: { full: 1, skip: 1 }, + expectedLogCounts: { full: 1 }, write: async () => { await fs.rm(files.addedWorkflow, { force: true }); await fs.writeFile( From 3cf5d38ad2df7c3200b8f6f404d5629485489236 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:29:29 -0700 Subject: [PATCH 42/45] fix(next): distinguish duplicate HMR notifications --- packages/core/e2e/dev.test.ts | 2 +- packages/next/src/builder-eager.ts | 6 +++--- packages/next/src/watch-rebuild.test.ts | 2 +- packages/next/src/watch-rebuild.ts | 13 +++++++++++++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index 74bc19a492..f5f3dc610b 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -1353,7 +1353,7 @@ ${apiFileContent}` }, { description: 'workflow file removed from API import', - expectedLogCounts: { full: 1 }, + expectedLogCounts: { full: 1, skip: 1 }, write: async () => { await fs.rm(files.addedWorkflow, { force: true }); await fs.writeFile( diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 75e24daf58..292295bbcf 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -445,10 +445,10 @@ export async function getNextBuilderEager( readSnapshot: readSourceSnapshot, sourceSnapshots, }); + if (decision.kind === 'duplicate') { + return; + } if (decision.kind === 'none') { - if (decision.snapshots.size === 0) { - return; - } logDevHmr('workflow dev hmr: skip'); for (const [file, snapshot] of decision.snapshots) { sourceSnapshots.set(file, snapshot); diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index 974893828e..8bf8f74d93 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -185,6 +185,6 @@ export const allWorkflows = {} as const; sourceSnapshots, }); - expect(decision).toEqual({ kind: 'none', snapshots: new Map() }); + expect(decision).toEqual({ kind: 'duplicate' }); }); }); diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index 04178637fc..2565bd79ed 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -23,6 +23,7 @@ export interface SourceSnapshot { } export type RebuildDecision = + | { kind: 'duplicate' } | { kind: 'none'; snapshots: Map } | { kind: 'hot'; @@ -594,6 +595,15 @@ export const classifyRebuild = async ({ normalizePath, }); if (changedRelevantFiles.length === 0) { + if ( + prunedAddedFiles.snapshots.size === 0 && + fileChanges.addedFiles.length > 0 && + normalizedFileChanges.addedFiles.length === 0 && + fileChanges.modifiedFiles.length === 0 && + fileChanges.removedFiles.length === 0 + ) { + return { kind: 'duplicate' }; + } return { kind: 'none', snapshots: prunedAddedFiles.snapshots }; } @@ -606,6 +616,9 @@ export const classifyRebuild = async ({ if (!snapshots) { return { kind: 'full' }; } + if (snapshots.size === 0) { + return { kind: 'duplicate' }; + } return workflowEntryFilesChanged({ changedFiles: changedRelevantFiles, discoveredEntries, From 4d3cd69907ecba69b364a1d0afa7af9741781e0f Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:39:30 -0700 Subject: [PATCH 43/45] fix(next): ignore HMR notifications without source changes --- packages/core/e2e/dev.test.ts | 6 +++--- packages/next/src/builder-eager.ts | 2 +- packages/next/src/watch-rebuild.test.ts | 2 +- packages/next/src/watch-rebuild.ts | 17 +++++------------ 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index f5f3dc610b..db1541dd2c 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -1353,7 +1353,7 @@ ${apiFileContent}` }, { description: 'workflow file removed from API import', - expectedLogCounts: { full: 1, skip: 1 }, + expectedLogCounts: { full: 1 }, write: async () => { await fs.rm(files.addedWorkflow, { force: true }); await fs.writeFile( @@ -1396,12 +1396,12 @@ ${apiFileContent}` const unrelatedLogCursor = await readDevServerLogCursor(); await fs.writeFile(files.unrelated, 'export const unrelated = true;\n'); snapshot = await expectGeneratedArtifactsUnchanged(snapshot); - await expectHmrLogCounts(unrelatedLogCursor, { skip: 1 }); + await expectHmrLogCounts(unrelatedLogCursor, {}); const unrelatedRemovalLogCursor = await readDevServerLogCursor(); await fs.unlink(files.unrelated); snapshot = await expectGeneratedArtifactsUnchanged(snapshot); - await expectHmrLogCounts(unrelatedRemovalLogCursor, { skip: 1 }); + await expectHmrLogCounts(unrelatedRemovalLogCursor, {}); } ); }); diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 292295bbcf..81c32644b8 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -445,7 +445,7 @@ export async function getNextBuilderEager( readSnapshot: readSourceSnapshot, sourceSnapshots, }); - if (decision.kind === 'duplicate') { + if (decision.kind === 'ignored') { return; } if (decision.kind === 'none') { diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index 8bf8f74d93..f2fd364a06 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -185,6 +185,6 @@ export const allWorkflows = {} as const; sourceSnapshots, }); - expect(decision).toEqual({ kind: 'duplicate' }); + expect(decision).toEqual({ kind: 'ignored' }); }); }); diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index 2565bd79ed..ba0bae86b3 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -23,7 +23,7 @@ export interface SourceSnapshot { } export type RebuildDecision = - | { kind: 'duplicate' } + | { kind: 'ignored' } | { kind: 'none'; snapshots: Map } | { kind: 'hot'; @@ -595,16 +595,9 @@ export const classifyRebuild = async ({ normalizePath, }); if (changedRelevantFiles.length === 0) { - if ( - prunedAddedFiles.snapshots.size === 0 && - fileChanges.addedFiles.length > 0 && - normalizedFileChanges.addedFiles.length === 0 && - fileChanges.modifiedFiles.length === 0 && - fileChanges.removedFiles.length === 0 - ) { - return { kind: 'duplicate' }; - } - return { kind: 'none', snapshots: prunedAddedFiles.snapshots }; + return prunedAddedFiles.snapshots.size > 0 + ? { kind: 'none', snapshots: prunedAddedFiles.snapshots } + : { kind: 'ignored' }; } try { @@ -617,7 +610,7 @@ export const classifyRebuild = async ({ return { kind: 'full' }; } if (snapshots.size === 0) { - return { kind: 'duplicate' }; + return { kind: 'ignored' }; } return workflowEntryFilesChanged({ changedFiles: changedRelevantFiles, From 3389f686a8f8247794b45ca800f964f1b74fa49c Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:42:18 -0700 Subject: [PATCH 44/45] chore: move Next HMR fix to separate PR --- .changeset/fix-next-hmr-build-race.md | 5 ----- packages/core/e2e/dev.test.ts | 6 +++--- packages/next/src/builder-eager.ts | 24 +++++++----------------- packages/next/src/watch-rebuild.test.ts | 2 +- packages/next/src/watch-rebuild.ts | 21 ++++----------------- 5 files changed, 15 insertions(+), 43 deletions(-) delete mode 100644 .changeset/fix-next-hmr-build-race.md diff --git a/.changeset/fix-next-hmr-build-race.md b/.changeset/fix-next-hmr-build-race.md deleted file mode 100644 index cc84c71d97..0000000000 --- a/.changeset/fix-next-hmr-build-race.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@workflow/next': patch ---- - -Preserve source changes made during a full development rebuild for the next HMR pass. diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index db1541dd2c..f5f3dc610b 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -1353,7 +1353,7 @@ ${apiFileContent}` }, { description: 'workflow file removed from API import', - expectedLogCounts: { full: 1 }, + expectedLogCounts: { full: 1, skip: 1 }, write: async () => { await fs.rm(files.addedWorkflow, { force: true }); await fs.writeFile( @@ -1396,12 +1396,12 @@ ${apiFileContent}` const unrelatedLogCursor = await readDevServerLogCursor(); await fs.writeFile(files.unrelated, 'export const unrelated = true;\n'); snapshot = await expectGeneratedArtifactsUnchanged(snapshot); - await expectHmrLogCounts(unrelatedLogCursor, {}); + await expectHmrLogCounts(unrelatedLogCursor, { skip: 1 }); const unrelatedRemovalLogCursor = await readDevServerLogCursor(); await fs.unlink(files.unrelated); snapshot = await expectGeneratedArtifactsUnchanged(snapshot); - await expectHmrLogCounts(unrelatedRemovalLogCursor, {}); + await expectHmrLogCounts(unrelatedRemovalLogCursor, { skip: 1 }); } ); }); diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 81c32644b8..3c3a472e31 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -151,7 +151,7 @@ export async function getNextBuilderEager( ? pathname : resolve(this.config.workingDir, pathname) ).replace(/\\/g, '/'); - let sourceSnapshots = new Map(); + const sourceSnapshots = new Map(); const watchableExtensions = new Set([ '.js', @@ -201,17 +201,14 @@ export async function getNextBuilderEager( const readSourceSnapshot = (file: string) => createSourceSnapshot({ file, detectWorkflowPatterns }); - const readSourceSnapshots = async () => { - const snapshots = new Map(); - await replaceSourceSnapshots({ + const refreshSourceSnapshots = () => + replaceSourceSnapshots({ discoveredEntries, inputFiles: options.inputFiles, normalizePath, readSnapshot: readSourceSnapshot, - sourceSnapshots: snapshots, + sourceSnapshots, }); - return snapshots; - }; const mergeCombinedManifest = ( nextStepsManifest: WorkflowManifest @@ -258,10 +255,6 @@ export async function getNextBuilderEager( const newInputFiles = await this.getInputFiles(); options.inputFiles = newInputFiles; - // Snapshot before building so edits made during the build remain - // dirty and trigger the file event already queued behind this task. - const nextSourceSnapshots = await readSourceSnapshots(); - await stepsCtx?.dispose(); await workflowsCtx.interimBundleCtx.dispose(); @@ -282,7 +275,7 @@ export async function getNextBuilderEager( }; await writeManifest(newCombined.manifest); - sourceSnapshots = nextSourceSnapshots; + await refreshSourceSnapshots(); }; const isWatchableFile = (path: string) => @@ -417,7 +410,7 @@ export async function getNextBuilderEager( } }; - sourceSnapshots = await readSourceSnapshots(); + await refreshSourceSnapshots(); let { files: knownFiles, aliases: knownFileAliases, @@ -445,12 +438,9 @@ export async function getNextBuilderEager( readSnapshot: readSourceSnapshot, sourceSnapshots, }); - if (decision.kind === 'ignored') { - return; - } if (decision.kind === 'none') { logDevHmr('workflow dev hmr: skip'); - for (const [file, snapshot] of decision.snapshots) { + for (const [file, snapshot] of decision.snapshots || []) { sourceSnapshots.set(file, snapshot); } return; diff --git a/packages/next/src/watch-rebuild.test.ts b/packages/next/src/watch-rebuild.test.ts index f2fd364a06..ac75e6e0b7 100644 --- a/packages/next/src/watch-rebuild.test.ts +++ b/packages/next/src/watch-rebuild.test.ts @@ -185,6 +185,6 @@ export const allWorkflows = {} as const; sourceSnapshots, }); - expect(decision).toEqual({ kind: 'ignored' }); + expect(decision.kind).toBe('none'); }); }); diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index ba0bae86b3..2799804d2f 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; export interface DiscoveredEntriesLike { @@ -15,7 +14,6 @@ export interface FileChanges { } export interface SourceSnapshot { - contentHash: string; importSignature: string; definitionSignature: string; hasDirective: boolean; @@ -23,8 +21,7 @@ export interface SourceSnapshot { } export type RebuildDecision = - | { kind: 'ignored' } - | { kind: 'none'; snapshots: Map } + | { kind: 'none'; snapshots?: Map } | { kind: 'hot'; refreshStepRegistrations: boolean; @@ -219,9 +216,6 @@ export const createSourceSnapshotFromSource = ( const patterns = detectWorkflowPatterns(sourceWithoutComments); return { - contentHash: createHash('sha256') - .update(sourceWithoutComments) - .digest('base64url'), importSignature: extractImportSignature(sourceWithoutComments), definitionSignature: extractDefinitionSignature(sourceWithoutComments), hasDirective: patterns.hasDirective, @@ -323,9 +317,7 @@ const snapshotChangedFile = async ({ return false; } - if (previousSnapshot.contentHash !== nextSnapshot.contentHash) { - nextSnapshots.set(file, nextSnapshot); - } + nextSnapshots.set(file, nextSnapshot); return true; }; @@ -393,9 +385,7 @@ const pruneStaleAddedFiles = async ({ nextAddedFiles.push(file); continue; } - if (previousSnapshot.contentHash !== nextSnapshot.contentHash) { - snapshots.set(file, nextSnapshot); - } + snapshots.set(file, nextSnapshot); } catch { nextAddedFiles.push(file); } @@ -597,7 +587,7 @@ export const classifyRebuild = async ({ if (changedRelevantFiles.length === 0) { return prunedAddedFiles.snapshots.size > 0 ? { kind: 'none', snapshots: prunedAddedFiles.snapshots } - : { kind: 'ignored' }; + : { kind: 'none' }; } try { @@ -609,9 +599,6 @@ export const classifyRebuild = async ({ if (!snapshots) { return { kind: 'full' }; } - if (snapshots.size === 0) { - return { kind: 'ignored' }; - } return workflowEntryFilesChanged({ changedFiles: changedRelevantFiles, discoveredEntries, From 8239d83f0f8f9a1e877d026a298b31e45340a53d Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:23 -0700 Subject: [PATCH 45/45] fix(core): complete partial preloads before QuickJS replay --- packages/core/src/runtime.ts | 28 ++-- .../runtime/quickjs-partial-preload.test.ts | 127 ++++++++++++++++++ 2 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/runtime/quickjs-partial-preload.test.ts diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 9f2446c8ff..ac816eebe2 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -922,12 +922,6 @@ export function workflowEntrypoint( // response. Undefined ⇒ no enforcement (older servers, turbo). let maxEventsLimit: number | undefined; let workflowStartedAt = -1; - // True only when `eventLog` is known to be the COMPLETE - // event log (the lazy hook fast path's validated - // hasMore-false preload). Lets QuickJS trust it as-is — - // its own heuristic only recognizes first-invocation - // (run_created/run_started-only) preloads. - let preloadedEventsComplete = false; // Latency telemetry (TTFS) state — see runtime/step-latency.ts. // Whether this invocation's FIRST event snapshot contained @@ -1242,7 +1236,6 @@ export function workflowEntrypoint( // An incremental load therefore heals the hole only by // luck. eventLog = { type: 'loadAll' }; - preloadedEventsComplete = false; // The corrected log inserts the missing events BELOW the // length already scanned for payload prewarming, shifting // every later position. Only a full rescan sees them. @@ -1844,11 +1837,6 @@ export function workflowEntrypoint( events: result.events, cursor: result.cursor, }; - // The validated preload is the COMPLETE log (hasMore - // false), so QuickJS may trust it as-is instead of - // refetching — its own first-invocation heuristic - // only recognizes run_created/run_started preloads. - preloadedEventsComplete = true; workflowStartedAt = +result.run.startedAt; span?.setAttributes({ ...Attribute.WorkflowRunStatus(result.run.status), @@ -2285,7 +2273,6 @@ export function workflowEntrypoint( insertEventByEventId(eventLog.events, ensuredEvent); } else { eventLog = { type: 'loadAll' }; - preloadedEventsComplete = false; } } // end else (re-ensure needed) } @@ -2401,6 +2388,13 @@ export function workflowEntrypoint( // turbo runReadyBarrier the way handleSuspension // does. await awaitRunReady(); + if (eventLog.type === 'loadAfter') { + appendEventLog( + eventLog, + await loadWorkflowRunEvents(runId, eventLog.cursor) + ); + eventLog = { ...eventLog, type: 'ready' }; + } // Lazy import: the QuickJS entrypoint's import chain // embeds the base64 WASM binary + extensions // (~1.3 MB decoded at module scope). Loading it here @@ -2414,10 +2408,10 @@ export function workflowEntrypoint( workflowName, workflowRun, preloadedEvents: - eventLog.type === 'loadAll' - ? undefined - : eventLog.events, - preloadedEventsComplete, + eventLog.type === 'ready' + ? eventLog.events + : undefined, + preloadedEventsComplete: eventLog.type === 'ready', runInput, parentSpan: span, maxEventsLimit, diff --git a/packages/core/src/runtime/quickjs-partial-preload.test.ts b/packages/core/src/runtime/quickjs-partial-preload.test.ts new file mode 100644 index 0000000000..d9d3edf528 --- /dev/null +++ b/packages/core/src/runtime/quickjs-partial-preload.test.ts @@ -0,0 +1,127 @@ +import { + type Event, + SPEC_VERSION_CURRENT, + type WorkflowRun, + type World, +} from '@workflow/world'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { workflowEntrypoint } from '../runtime.js'; +import { setWorld } from './world.js'; + +vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn() })); +vi.mock('@workflow/utils/get-port', () => ({ + getPort: vi.fn().mockResolvedValue(3000), +})); + +const runWorkflowWithQuickJS = vi.fn(); +vi.mock('./quickjs-entrypoint.js', () => ({ runWorkflowWithQuickJS })); + +describe('QuickJS partial run_started preload', () => { + afterEach(() => { + setWorld(undefined); + vi.clearAllMocks(); + }); + + it('loads only the suffix before replay', async () => { + const runId = 'wrun_quickjs_partial_preload'; + const workflowName = 'workflow'; + const startedAt = new Date('2026-05-19T12:00:00.000Z'); + const workflowRun: WorkflowRun = { + runId, + workflowName, + status: 'running', + input: new Uint8Array(), + deploymentId: 'dpl_quickjs_partial_preload', + specVersion: SPEC_VERSION_CURRENT, + executionContext: { workflowVm: 'quickjs' }, + startedAt, + createdAt: startedAt, + updatedAt: startedAt, + }; + const event = ( + eventId: string, + data: Omit + ): Event => ({ + ...data, + runId, + eventId, + createdAt: startedAt, + }); + const runCreated = event('evnt_1', { + eventType: 'run_created', + specVersion: SPEC_VERSION_CURRENT, + eventData: { + deploymentId: workflowRun.deploymentId, + workflowName, + input: workflowRun.input, + }, + }); + const runStarted = event('evnt_2', { + eventType: 'run_started', + specVersion: SPEC_VERSION_CURRENT, + }); + const hookCreated = event('evnt_3', { + eventType: 'hook_created', + specVersion: SPEC_VERSION_CURRENT, + correlationId: 'hook_1', + eventData: { token: 'token' }, + }); + const preloadCursor = 'cursor-after-run-started'; + const listEvents = vi.fn(async () => ({ + data: [hookCreated], + cursor: hookCreated.eventId, + hasMore: false, + })); + let dispatch: + | (( + message: unknown, + metadata: { queueName: string; messageId: string; attempt: number } + ) => Promise) + | undefined; + + setWorld({ + specVersion: SPEC_VERSION_CURRENT, + capabilities: {}, + createQueueHandler: vi.fn((_prefix, handler) => { + dispatch = handler; + return vi.fn(); + }), + events: { + list: listEvents, + create: vi.fn(async () => ({ + event: runStarted, + run: workflowRun, + events: [runCreated, runStarted], + cursor: preloadCursor, + hasMore: true, + })), + }, + queue: vi.fn().mockResolvedValue({ messageId: 'msg_queued' }), + getEncryptionKeyForRun: vi.fn().mockResolvedValue(undefined), + } as unknown as World); + + await workflowEntrypoint('// QuickJS is mocked')( + new Request('https://example.test') + ); + expect(dispatch).toBeDefined(); + await dispatch?.( + { runId }, + { + queueName: `__wkf_workflow_${workflowName}`, + messageId: 'msg_workflow', + attempt: 1, + } + ); + + expect(listEvents).toHaveBeenCalledWith({ + runId, + pagination: { sortOrder: 'asc', cursor: preloadCursor }, + }); + expect(runWorkflowWithQuickJS).toHaveBeenCalledWith( + expect.objectContaining({ + preloadedEvents: [runCreated, runStarted, hookCreated], + preloadedEventsComplete: true, + }) + ); + }); +});