diff --git a/.changeset/typed-replay-stream-failures.md b/.changeset/typed-replay-stream-failures.md new file mode 100644 index 0000000000..3bcec935c4 --- /dev/null +++ b/.changeset/typed-replay-stream-failures.md @@ -0,0 +1,5 @@ +--- +'@workflow/world-vercel': patch +--- + +Classify malformed replay responses as typed world failures and resume incomplete streams from their last validated event. diff --git a/packages/world-vercel/src/events-v4.test.ts b/packages/world-vercel/src/events-v4.test.ts index bf21528397..5532638a14 100644 --- a/packages/world-vercel/src/events-v4.test.ts +++ b/packages/world-vercel/src/events-v4.test.ts @@ -456,7 +456,10 @@ describe('getWorkflowRunEventsV4 over HTTP', () => { {}, { token: 'test-token', dispatcher: agent } ) - ).rejects.toThrow(); + ).rejects.toMatchObject({ + name: 'WorkflowWorldError', + code: 'SCHEMA_VALIDATION', + }); agent.assertNoPendingInterceptors(); }); @@ -536,53 +539,13 @@ describe('getWorkflowRunEventsV4 over HTTP', () => { {}, { token: 'test-token', dispatcher: agent } ) - ).rejects.toThrow(); - }); - - it('throws when the stream ends without the end sentinel (truncated response)', async () => { - const origin = - WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; - const agent = new MockAgent(); - agent.disableNetConnect(); - - // A complete event frame but NO `{_end: 1}` sentinel — what a response - // truncated on a frame boundary looks like. Returning this as a - // successful page would silently drop events with hasMore=false. - const frames = encodeFrame( - { - eventId: 'evnt_1', - runId: 'wrun_1', - eventType: 'run_created', - createdAt: '2026-06-10T00:00:00.000Z', - eventData: { - deploymentId: 'dpl_1', - workflowName: 'workflow', - input: null, - }, - }, - new Uint8Array(0) - ); - - agent - .get(origin) - .intercept({ - path: '/api/v4/runs/wrun_1/events?limit=500', - method: 'GET', - }) - .reply(200, frames, { - headers: { 'content-type': V4_FRAME_CONTENT_TYPE }, - }); - - await expect( - getWorkflowRunEventsV4( - 'wrun_1', - { limit: 500 }, - { token: 'test-token', dispatcher: agent } - ) - ).rejects.toThrow(/end-of-stream sentinel/); + ).rejects.toMatchObject({ + name: 'WorkflowWorldError', + code: 'SCHEMA_VALIDATION', + }); }); - it('resumes a truncated full stream after its last accepted event', async () => { + it('resumes a truncated full stream after its last complete event', async () => { const origin = WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; const agent = new MockAgent(); @@ -620,23 +583,26 @@ describe('getWorkflowRunEventsV4 over HTTP', () => { }) .reply( 200, - Buffer.concat([ - encodeFrame( - { - eventId: 'evnt_2', - runId: 'wrun_1', - eventType: 'run_started', - createdAt: CREATED_AT, - }, - new Uint8Array() - ), - encodeFrame( - { _end: 1, next: 'eid:evnt_2', hasMore: false }, - new Uint8Array() - ), - ]), + encodeFrame( + { + eventId: 'evnt_2', + runId: 'wrun_1', + eventType: 'run_started', + createdAt: CREATED_AT, + }, + new Uint8Array() + ), { headers: { 'content-type': V4_FRAME_CONTENT_TYPE } } ); + agent + .get(origin) + .intercept({ + path: '/api/v4/runs/wrun_1/events?returnAll=true&cursor=eid%3Aevnt_2', + method: 'GET', + }) + .reply(200, encodeFrame({ _end: 1, hasMore: false }, new Uint8Array()), { + headers: { 'content-type': V4_FRAME_CONTENT_TYPE }, + }); const result = await getWorkflowRunEventsV4( 'wrun_1', @@ -652,6 +618,94 @@ describe('getWorkflowRunEventsV4 over HTTP', () => { expect(result.hasMore).toBe(false); agent.assertNoPendingInterceptors(); }); + + it('limits truncated full-stream recovery to three continuations', async () => { + const origin = + WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; + const agent = new MockAgent(); + agent.disableNetConnect(); + + for (const [cursor, eventId] of [ + [undefined, 'evnt_1'], + ['eid:evnt_1', 'evnt_2'], + ['eid:evnt_2', 'evnt_3'], + ] as const) { + agent + .get(origin) + .intercept({ + path: + '/api/v4/runs/wrun_1/events?returnAll=true' + + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''), + method: 'GET', + }) + .reply( + 200, + encodeFrame( + { + eventId, + runId: 'wrun_1', + eventType: 'run_started', + createdAt: CREATED_AT, + }, + new Uint8Array() + ), + { headers: { 'content-type': V4_FRAME_CONTENT_TYPE } } + ); + } + + await expect( + getWorkflowRunEventsV4( + 'wrun_1', + {}, + { token: 'test-token', dispatcher: agent } + ) + ).rejects.toThrow( + 'frame stream ended without the end-of-stream sentinel (1 events read)' + ); + agent.assertNoPendingInterceptors(); + }); + + it('surfaces a truncated stream that provides no recovery cursor', async () => { + const origin = + WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; + const agent = new MockAgent(); + agent.disableNetConnect(); + const completeFrame = encodeFrame( + { + eventId: 'evnt_1', + runId: 'wrun_1', + eventType: 'run_created', + createdAt: CREATED_AT, + eventData: { + deploymentId: 'dpl_1', + workflowName: 'workflow', + input: null, + }, + }, + new Uint8Array() + ); + + agent + .get(origin) + .intercept({ + path: '/api/v4/runs/wrun_1/events?returnAll=true', + method: 'GET', + }) + .reply(200, completeFrame.slice(0, -1), { + headers: { 'content-type': V4_FRAME_CONTENT_TYPE }, + }); + await expect( + getWorkflowRunEventsV4( + 'wrun_1', + {}, + { token: 'test-token', dispatcher: agent } + ) + ).rejects.toMatchObject({ + name: 'WorkflowWorldError', + code: 'TRANSPORT', + }); + agent.assertNoPendingInterceptors(); + }); }); /** @@ -861,6 +915,50 @@ describe('v4 transport uses global fetch (observability)', () => { }); describe('createWorkflowRunEventV4 over HTTP', () => { + it.each([ + ['an empty body', () => new Response(), 'PARSE_ERROR'], + [ + 'malformed CBOR', + () => new Response(new Uint8Array([0xff, 0xfe, 0xfd])), + 'PARSE_ERROR', + ], + [ + 'a body read failure', + () => + new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error('socket closed')); + }, + }) + ), + 'TRANSPORT', + ], + ])('classifies %s', async (_case, response, code) => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(response()); + + try { + await expect( + createWorkflowRunEventV4( + { + runId: 'wrun_1', + eventType: 'step_completed', + specVersion: 2, + correlationId: 'step_1', + }, + { token: 'test-token' } + ) + ).rejects.toMatchObject({ + name: 'WorkflowWorldError', + code, + }); + } finally { + fetchSpy.mockRestore(); + } + }); + it('POSTs to the /events/:eventType alias and decodes the response', async () => { const origin = WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com'; @@ -1035,6 +1133,173 @@ describe('createWorkflowRunEventV4 over HTTP', () => { agent.assertNoPendingInterceptors(); }); + it.each([ + ['continues a truncated run_started replay', 'eid:evnt_2', true], + ['rejects a continuation without its trailing cursor', undefined, false], + ['rejects an empty continuation cursor', '', false], + ['rejects a non-advancing continuation cursor', 'eid:evnt_1', false], + ])('%s', async (_name, suffixCursor, succeeds) => { + 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/run_started', + method: 'POST', + headers: { accept: V4_FRAME_CONTENT_TYPE }, + }) + .reply( + 200, + encodeFrame( + { + eventId: 'evnt_1', + runId: 'wrun_1', + eventType: 'run_created', + createdAt: CREATED_AT, + eventData: { + deploymentId: 'dpl_1', + workflowName: 'workflow', + input: null, + }, + }, + new Uint8Array() + ), + { + headers: { + 'content-type': V4_FRAME_CONTENT_TYPE, + 'x-wf-max-events': '10000', + }, + } + ); + agent + .get(origin) + .intercept({ + path: '/api/v4/runs/wrun_1/events?returnAll=true&cursor=eid%3Aevnt_1&remoteRefBehavior=resolve', + method: 'GET', + }) + .reply( + 200, + Buffer.concat([ + encodeFrame( + { + eventId: 'evnt_2', + runId: 'wrun_1', + eventType: 'run_started', + createdAt: CREATED_AT, + }, + new Uint8Array() + ), + encodeFrame( + { + _end: 1, + ...(suffixCursor !== undefined ? { next: suffixCursor } : {}), + hasMore: false, + }, + new Uint8Array() + ), + ]), + { headers: { 'content-type': V4_FRAME_CONTENT_TYPE } } + ); + + const request = createWorkflowRunStartedEventV4( + { runId: 'wrun_1', specVersion: 5 }, + { token: 'test-token', dispatcher: agent } + ); + if (succeeds) { + const result = await request; + expect(result.events.map((event) => event.eventId)).toEqual([ + 'evnt_1', + 'evnt_2', + ]); + expect(result.cursor).toBe(suffixCursor); + } else { + await expect(request).rejects.toMatchObject({ + code: 'SCHEMA_VALIDATION', + message: 'v4 listEvents: response did not advance cursor', + }); + } + agent.assertNoPendingInterceptors(); + }); + + it('shares the three-continuation limit with a partial run_started POST', 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/run_started', + method: 'POST', + headers: { accept: V4_FRAME_CONTENT_TYPE }, + }) + .reply( + 200, + encodeFrame( + { + eventId: 'evnt_1', + runId: 'wrun_1', + eventType: 'run_created', + createdAt: CREATED_AT, + eventData: { + deploymentId: 'dpl_1', + workflowName: 'workflow', + input: null, + }, + }, + new Uint8Array() + ), + { + headers: { + 'content-type': V4_FRAME_CONTENT_TYPE, + 'x-wf-max-events': '10000', + }, + } + ); + + for (const [cursor, eventId] of [ + ['eid:evnt_1', 'evnt_2'], + ['eid:evnt_2', 'evnt_3'], + ['eid:evnt_3', 'evnt_4'], + ] as const) { + agent + .get(origin) + .intercept({ + path: + '/api/v4/runs/wrun_1/events?returnAll=true' + + `&cursor=${encodeURIComponent(cursor)}&remoteRefBehavior=resolve`, + method: 'GET', + }) + .reply( + 200, + encodeFrame( + { + eventId, + runId: 'wrun_1', + eventType: 'run_started', + createdAt: CREATED_AT, + }, + new Uint8Array() + ), + { headers: { 'content-type': V4_FRAME_CONTENT_TYPE } } + ); + } + + await expect( + createWorkflowRunStartedEventV4( + { runId: 'wrun_1', specVersion: 5 }, + { token: 'test-token', dispatcher: agent } + ) + ).rejects.toThrow( + 'frame stream ended without the end-of-stream sentinel (1 events read)' + ); + agent.assertNoPendingInterceptors(); + }); + it('requires the event-stream response requested by run_started', 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 6ffdbc51da..12bc8adea5 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -42,6 +42,7 @@ import { type DecodedFrame, decodeFrames, encodeFrame, + IncompleteFrameError, V4_FRAME_CONTENT_TYPE, } from './frames.js'; import { @@ -809,7 +810,9 @@ export async function createWorkflowRunEventV4( const contentType = response.headers.get('content-type'); if (contentType?.startsWith(V4_FRAME_CONTENT_TYPE)) { - throw new Error('v4 createEvent: unexpected event page'); + throw new WorkflowWorldError('v4 createEvent: unexpected event page', { + code: 'SCHEMA_VALIDATION', + }); } return decodeCreateEventResponse(response, input.eventType); @@ -822,9 +825,19 @@ async function decodeCreateEventResponse( response: FrameResponseLike, eventType: T ): Promise & { event: Event }> { - const bodyBytes = new Uint8Array(await response.arrayBuffer()); + let bodyBytes: Uint8Array; + try { + bodyBytes = new Uint8Array(await response.arrayBuffer()); + } catch (cause) { + throw new WorkflowWorldError( + 'v4 createEvent: failed to read response body', + { code: 'TRANSPORT', cause } + ); + } if (bodyBytes.byteLength === 0) { - throw new Error('v4 createEvent: empty response body'); + throw new WorkflowWorldError('v4 createEvent: empty response body', { + code: 'PARSE_ERROR', + }); } const schema: z.ZodType & { event: Event }> = CreateEventV4BodySchemas[eventType].refine( @@ -833,7 +846,16 @@ async function decodeCreateEventResponse( (eventType === 'hook_created' && event.eventType === 'hook_conflict'), { path: ['event', 'eventType'] } ); - const parsedBody = schema.safeParse(decode(bodyBytes)); + let decoded: unknown; + try { + decoded = decode(bodyBytes); + } catch (cause) { + throw new WorkflowWorldError('v4 createEvent: invalid CBOR response body', { + code: 'PARSE_ERROR', + cause, + }); + } + const parsedBody = schema.safeParse(decoded); if (!parsedBody.success) { throw new WorkflowWorldError('v4 createEvent: invalid response body', { code: 'SCHEMA_VALIDATION', @@ -852,9 +874,13 @@ export async function createWorkflowRunStartedEventV4( 'event-stream', config ); - const events: Event[] = []; - const page = await consumeEventFrameStream(response, 'createEvent', events); - assert(page.cursor, 'v4 createEvent: event stream missing cursor'); + const page = await consumeReplayLogResponse(response, input.runId, config); + if (!page.cursor) { + throw new WorkflowWorldError( + 'v4 createEvent: event stream missing cursor', + { code: 'SCHEMA_VALIDATION' } + ); + } const maxEvents = MaxEventsHeaderSchema.safeParse( response.headers.get(MAX_EVENTS_HEADER) ); @@ -865,7 +891,7 @@ export async function createWorkflowRunStartedEventV4( }); } - return { events, ...page, maxEvents: maxEvents.data }; + return { ...page, maxEvents: maxEvents.data }; } /** One event of a v4 batch POST, index-aligned with the response results. */ @@ -1286,9 +1312,10 @@ export type HookReceivedPreloadV4Result = * A server that supports the lazy-hook replay stream answers the consumer's * idempotent re-ensure with the run's complete replay log as v4 frames: * the same event-frame sequence LIST uses, ending with the `_end` sentinel. - * A truncated stream (EOF without the sentinel) throws; the write is - * deduplicated by the server's `(runId, resumeId)` constraint, so retrying - * the whole request is safe and converges on the same canonical event. + * A truncated stream resumes after its last validated event. If it ends before + * any event is available to form a cursor, the write is deduplicated by the + * server's `(runId, resumeId)` constraint, so retrying the whole request is + * still safe and converges on the same canonical event. */ export async function createHookReceivedPreloadEventV4( input: CreateEventV4InputBase, @@ -1308,14 +1335,12 @@ export async function createHookReceivedPreloadEventV4( }; } - const events: Event[] = []; - const page = await consumeEventFrameStream(response, 'createEvent', events); + const page = await consumeReplayLogResponse(response, input.runId, config); const maxEvents = MaxEventsHeaderSchema.safeParse( response.headers.get(MAX_EVENTS_HEADER) ); return { kind: 'stream', - events, ...page, canonicalEventId: response.headers.get(EVENT_ID_HEADER) ?? undefined, maxEvents: maxEvents.success ? maxEvents.data : undefined, @@ -1445,40 +1470,115 @@ function streamErrorFrameToError( ); } +type EventFrameStreamResult = ListEventsV4Result & { + partialError?: WorkflowWorldError; +}; + +const MAX_PARTIAL_STREAM_RETRIES = 2; + +function partialEventFrameStream( + events: Event[], + partialError: WorkflowWorldError +): EventFrameStreamResult { + const eventId = events.at(-1)?.eventId; + if (!eventId) throw partialError; + return { events, cursor: `eid:${eventId}`, hasMore: true, partialError }; +} + async function consumeEventFrameStream( response: Response, - opName: string, - events: Event[] -): Promise> { + opName: string +): Promise { const contentType = response.headers.get('content-type'); if (!contentType?.startsWith(V4_FRAME_CONTENT_TYPE)) { - throw new Error( - `v4 ${opName}: expected ${V4_FRAME_CONTENT_TYPE}, got ${contentType ?? '(none)'}` + throw new WorkflowWorldError( + `v4 ${opName}: expected ${V4_FRAME_CONTENT_TYPE}, got ${contentType ?? '(none)'}`, + { code: 'SCHEMA_VALIDATION' } ); } + if (!response.body) { + throw new WorkflowWorldError(`v4 ${opName}: response body is missing`, { + code: 'TRANSPORT', + }); + } - const chunks = response.body as unknown as AsyncIterable; - - for await (const frame of decodeFrames(chunks)) { - if (frame.meta._end === 1) { - const end = EventStreamEndSchema.parse(frame.meta); - return { cursor: end.next ?? null, hasMore: end.hasMore }; + const events: Event[] = []; + try { + for await (const frame of decodeFrames(response.body)) { + if (frame.meta._end === 1) { + const end = EventStreamEndSchema.parse(frame.meta); + return { + events, + cursor: end.next ?? null, + hasMore: end.hasMore, + }; + } + if (frame.meta._error === 1) { + throw streamErrorFrameToError(frame.meta, opName); + } + if (Object.keys(frame.meta).some((key) => key.startsWith('_'))) { + throw new Error(`v4 ${opName}: unexpected control frame`); + } + events.push(decodeEventFrame(frame)); } - if (frame.meta._error === 1) { - throw streamErrorFrameToError(frame.meta, opName); + } catch (cause) { + if (CorruptedEventLogError.is(cause) || WorkflowWorldError.is(cause)) { + throw cause; } - if (Object.keys(frame.meta).some((key) => key.startsWith('_'))) { - throw new Error(`v4 ${opName}: unexpected control frame`); - } - events.push(decodeEventFrame(frame)); + const incomplete = cause instanceof IncompleteFrameError; + const error = new WorkflowWorldError( + `v4 ${opName}: ${incomplete ? 'incomplete' : 'invalid'} event frame stream`, + { + code: incomplete ? 'TRANSPORT' : 'SCHEMA_VALIDATION', + cause, + } + ); + if (!incomplete) throw error; + return partialEventFrameStream(events, error); } - throw new Error( - `v4 ${opName}: frame stream ended without the end-of-stream sentinel ` + - `(${events.length} events read) — truncated response?` + return partialEventFrameStream( + events, + new WorkflowWorldError( + `v4 ${opName}: frame stream ended without the end-of-stream sentinel ` + + `(${events.length} events read)`, + { code: 'TRANSPORT' } + ) ); } +/** + * Finish a replay-log POST without throwing away frames that were already + * validated. A graceful partial page and a transport-truncated body both + * continue with the ordinary GET endpoint from the response's last cursor. + */ +async function consumeReplayLogResponse( + response: Response, + runId: string, + config?: APIConfig +): Promise { + const page = await consumeEventFrameStream(response, 'createEvent'); + if (!page.hasMore) return page; + if (!page.cursor) { + if (page.partialError) throw page.partialError; + throw new WorkflowWorldError( + 'v4 createEvent: partial event stream missing cursor', + { code: 'SCHEMA_VALIDATION' } + ); + } + + const suffix = await getWorkflowRunEventsV4( + runId, + { cursor: page.cursor, remoteRefBehavior: 'resolve' }, + config + ); + return { + events: [...page.events, ...suffix.events], + cursor: suffix.cursor ?? page.cursor, + hasMore: suffix.hasMore, + }; +} + /** * Drive a v4 frame-stream list response into an in-memory page. Used by * both the by-runId and by-correlationId list endpoints. The wire @@ -1492,16 +1592,15 @@ async function consumeListFrameStream( url: string, headers: Headers, config: APIConfig | undefined, - opName: string, - events: Event[] -): Promise> { + opName: string +): Promise { const response = await fetchV4( url, { method: 'GET', headers }, config, opName ); - return consumeEventFrameStream(response, opName, events); + return consumeEventFrameStream(response, opName); } /** @@ -1533,8 +1632,10 @@ function paginationToQuery(params: ListEventsV4Params): string { * cursor from the sentinel frame. * * Eagerly drains the stream into memory to match the existing - * `getWorkflowRunEvents` contract. A truncated full response resumes - * after its last validated event instead of downloading accepted frames again. + * `getWorkflowRunEvents` contract. A truncated full response resumes after its + * last validated event until the sentinel arrives, for up to two retries. A + * forward-progress guard prevents retry loops. Explicitly paginated requests + * retain their one-page contract and surface truncation to the caller. */ export async function getWorkflowRunEventsV4( runId: string, @@ -1543,36 +1644,46 @@ export async function getWorkflowRunEventsV4( ): Promise { const { baseUrl, headers } = await getHttpConfig(config); const events: Event[] = []; - let cursor = params.cursor; + let cursor = params.cursor ?? null; + let partialStreamRetries = 0; + let consumed: EventFrameStreamResult; - while (true) { + do { const url = `${baseUrl}/v4/runs/${encodeURIComponent(runId)}/events` + - paginationToQuery({ ...params, cursor }); - try { - const page = await consumeListFrameStream( - url, - headers, - config, - 'listEvents', - events - ); - return { events, ...page }; - } catch (error) { - if (CorruptedEventLogError.is(error) || WorkflowWorldError.is(error)) { - throw error; - } - const lastEvent = events.at(-1); + paginationToQuery({ ...params, cursor: cursor ?? undefined }); + consumed = await consumeListFrameStream(url, headers, config, 'listEvents'); + const cursorAdvanced = !!consumed.cursor && consumed.cursor !== cursor; + if (consumed.partialError) { if ( params.limit !== undefined || - !lastEvent || - `eid:${lastEvent.eventId}` === cursor + !cursorAdvanced || + partialStreamRetries === MAX_PARTIAL_STREAM_RETRIES ) { - throw error; + throw consumed.partialError; } - cursor = `eid:${lastEvent.eventId}`; + assert(consumed.cursor); + partialStreamRetries++; + cursor = consumed.cursor; + } else if ( + !cursorAdvanced && + (consumed.events.length > 0 || consumed.hasMore) + ) { + throw new WorkflowWorldError( + 'v4 listEvents: response did not advance cursor', + { code: 'SCHEMA_VALIDATION' } + ); } - } + for (const event of consumed.events) { + events.push(event); + } + } while (consumed.partialError); + + return { + events, + cursor: consumed.cursor || (partialStreamRetries > 0 ? cursor : null), + hasMore: consumed.hasMore, + }; } /** @@ -1601,13 +1712,12 @@ export async function getEventsByCorrelationIdV4( sp.set('runId', runId); appendListParams(sp, params); const url = `${baseUrl}/v4/events?${sp.toString()}`; - const events: Event[] = []; - const page = await consumeListFrameStream( + const consumed = await consumeListFrameStream( url, headers, config, - 'listEventsByCorrelationId', - events + 'listEventsByCorrelationId' ); - return { events, ...page }; + if (consumed.partialError) throw consumed.partialError; + return consumed; } diff --git a/packages/world-vercel/src/events.test.ts b/packages/world-vercel/src/events.test.ts index 5fea9675a4..4a36c778a5 100644 --- a/packages/world-vercel/src/events.test.ts +++ b/packages/world-vercel/src/events.test.ts @@ -4,7 +4,7 @@ import type { AnyEventRequest, CreateEventParams } from '@workflow/world'; import { decode, encode } from 'cbor-x'; import { ulid } from 'ulid'; import { MockAgent } from 'undici'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createWorkflowRunEvent, getWorkflowRunEvents, @@ -1046,6 +1046,52 @@ describe('createWorkflowRunEvent response coercion', () => { agent.assertNoPendingInterceptors(); }); + it('classifies a run_started stream missing lifecycle events as a world schema error', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + Buffer.concat([ + encodeFrame( + { + eventId: 'evnt_1', + runId: 'wrun_1', + eventType: 'run_started', + createdAt: STARTED_AT, + specVersion: 5, + eventData: {}, + }, + new Uint8Array() + ), + encodeFrame( + { _end: 1, next: 'eid:evnt_1', hasMore: false }, + new Uint8Array() + ), + ]), + { + headers: { + 'content-type': V4_FRAME_CONTENT_TYPE, + 'x-wf-max-events': '10000', + }, + } + ) + ); + + try { + await expect( + createWorkflowRunEvent( + 'wrun_1', + { eventType: 'run_started', specVersion: 5 }, + undefined, + { token: 'test-token' } + ) + ).rejects.toMatchObject({ + name: 'WorkflowWorldError', + code: 'SCHEMA_VALIDATION', + }); + } finally { + fetchSpy.mockRestore(); + } + }); + it('threads the wait entity through to the EventResult', async () => { const agent = mockAgent(); agent @@ -1182,7 +1228,10 @@ describe('getWorkflowRunEvents remoteRefBehavior mapping', () => { }, body ), - encodeFrame({ _end: 1, hasMore: false }, new Uint8Array(0)), + encodeFrame( + { _end: 1, next: 'eid:evnt_1', hasMore: false }, + new Uint8Array(0) + ), ]); } @@ -1329,7 +1378,10 @@ describe('getWorkflowRunEvents legacy structured-error compatibility', () => { }, body ), - encodeFrame({ _end: 1, hasMore: false }, new Uint8Array(0)), + encodeFrame( + { _end: 1, next: 'eid:evnt_1', hasMore: false }, + new Uint8Array(0) + ), ]); } @@ -1557,8 +1609,8 @@ describe('createWorkflowRunEvent hook_received replay preload', () => { return out; } - function hookReplayStreamResponse(): Uint8Array { - return concatFrames([ + function hookReplayFrames(): Uint8Array[] { + return [ encodeFrame( { eventId: 'evnt_1', @@ -1614,7 +1666,11 @@ describe('createWorkflowRunEvent hook_received replay preload', () => { { _end: 1, next: 'eid:evnt_4', hasMore: false }, new Uint8Array() ), - ]); + ]; + } + + function hookReplayStreamResponse(): Uint8Array { + return concatFrames(hookReplayFrames()); } it('decodes a streamed replay log into event + reconstructed run + page', async () => { @@ -1842,7 +1898,7 @@ describe('createWorkflowRunEvent hook_received replay preload', () => { agent.assertNoPendingInterceptors(); }); - it('rejects a truncated preload stream (no end sentinel)', async () => { + it('continues a truncated preload after its last validated event', async () => { const agent = mockAgent(); agent .get(ORIGIN) @@ -1851,30 +1907,35 @@ describe('createWorkflowRunEvent hook_received replay preload', () => { method: 'POST', headers: { accept: V4_FRAME_CONTENT_TYPE }, }) - .reply( - 200, - encodeFrame( - { - eventId: 'evnt_4', - runId: 'wrun_1', - eventType: 'hook_received', - correlationId: 'hook_1', - createdAt: new Date('2026-06-10T00:00:03.000Z'), - specVersion: 2, - resumeId: RESUME_ID, - eventData: { token: 'tok-preload' }, - }, - PAYLOAD - ), - { headers: { 'content-type': V4_FRAME_CONTENT_TYPE } } - ); - - await expect( - createWorkflowRunEvent('wrun_1', hookReceivedRequest(), preloadParams, { - token: 'test-token', - dispatcher: agent, + .reply(200, concatFrames(hookReplayFrames().slice(0, 2)), { + headers: { + 'content-type': V4_FRAME_CONTENT_TYPE, + 'x-wf-event-id': 'evnt_4', + 'x-wf-max-events': '10000', + }, + }); + agent + .get(ORIGIN) + .intercept({ + path: /\/api\/v4\/runs\/wrun_1\/events\?.*cursor=eid%3Aevnt_2/, + method: 'GET', }) - ).rejects.toThrow(/end-of-stream sentinel/); + .reply(200, concatFrames(hookReplayFrames().slice(2)), { + headers: { + 'content-type': V4_FRAME_CONTENT_TYPE, + }, + }); + + const result = await createWorkflowRunEvent( + 'wrun_1', + hookReceivedRequest(), + preloadParams, + { token: 'test-token', dispatcher: agent } + ); + + expect(result.event?.eventId).toBe('evnt_4'); + expect(result.events).toHaveLength(4); + expect(result.maxEvents).toBe(10000); agent.assertNoPendingInterceptors(); }); diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index 92d0be103a..3588849b10 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -759,13 +759,15 @@ async function createWorkflowRunEventInner( (event) => event.eventType === 'run_started' ); if (!runCreated) { - throw new Error( - 'v4 createEvent: run_started stream is missing run_created' + throw new WorkflowWorldError( + 'v4 createEvent: run_started stream is missing run_created', + { code: 'SCHEMA_VALIDATION' } ); } if (!runStarted) { - throw new Error( - 'v4 createEvent: run_started stream is missing run_started' + throw new WorkflowWorldError( + 'v4 createEvent: run_started stream is missing run_started', + { code: 'SCHEMA_VALIDATION' } ); } diff --git a/packages/world-vercel/src/frames.ts b/packages/world-vercel/src/frames.ts index 93809ab179..2e839dd6e7 100644 --- a/packages/world-vercel/src/frames.ts +++ b/packages/world-vercel/src/frames.ts @@ -18,6 +18,9 @@ export interface DecodedFrame { body: Uint8Array; } +/** The response body stopped before the next complete frame was available. */ +export class IncompleteFrameError extends Error {} + // The protocol consumer validates the event or control-frame shape after the // body is available. The byte codec only requires a CBOR object here. const CborObjectSchema = z.record(z.string(), z.unknown()); @@ -71,7 +74,14 @@ export async function* decodeFrames( const parts: Uint8Array[] = [buffer]; let byteLength = buffer.byteLength; while (byteLength < needed) { - const chunk = await chunks.next(); + let chunk: IteratorResult; + try { + chunk = await chunks.next(); + } catch (cause) { + throw new IncompleteFrameError('decodeFrames: source stream failed', { + cause, + }); + } if (chunk.done) return false; if (chunk.value.byteLength === 0) continue; parts.push(chunk.value); @@ -104,12 +114,12 @@ export async function* decodeFrames( take(4); if (!(await refill(metaLen))) { - throw new Error('decodeFrames: truncated meta block'); + throw new IncompleteFrameError('decodeFrames: truncated meta block'); } const meta = CborObjectSchema.parse(decode(take(metaLen))); if (!(await refill(4))) { - throw new Error('decodeFrames: truncated body length'); + throw new IncompleteFrameError('decodeFrames: truncated body length'); } const bodyLen = new DataView( buffer.buffer, @@ -119,7 +129,7 @@ export async function* decodeFrames( take(4); if (bodyLen > 0 && !(await refill(bodyLen))) { - throw new Error('decodeFrames: truncated body bytes'); + throw new IncompleteFrameError('decodeFrames: truncated body bytes'); } // Slice (not subarray) so the yielded body owns its bytes, so later // reads into the buffer won't overwrite it; bodyLen 0 yields empty.