Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stream-tail-web-cursors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/web': patch
---

Resume dashboard stream readers from the last delivered chunk without replaying the current tail.
6 changes: 4 additions & 2 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import fs from 'node:fs';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
Expand Down Expand Up @@ -1056,6 +1056,7 @@
const streamName = `${run.runId.replace('wrun_', 'strm_')}_user`;
const paginatedChunks: Uint8Array[] = [];
let cursor: string | null = null;
let hasMore: boolean;
do {
const page = await world.streams.getChunks(run.runId, streamName, {
limit: 1, // small page size to exercise pagination
Expand All @@ -1065,10 +1066,11 @@
paginatedChunks.push(chunk.data);
}
cursor = page.cursor;
if (!page.hasMore) {
hasMore = page.hasMore;
if (!hasMore) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

if (!hasMore) expect(page.done).toBe(true) asserts an implication the contract explicitly disclaims. The getChunks docs already say new chunks may appear in future requests even after hasMore is false, and this PR's premise is that hasMore: false on an open stream is a normal terminal state for a page loop. The assertion only passes here because the stream is already closed before the loop runs.

That makes it a trap for whoever copies this loop against a live stream, and it does not add coverage beyond the expect(paginatedChunks).toHaveLength(streamChunks.length) below it. I would drop it, or hoist it to a single assertion after the loop with a comment that the stream was closed first.

Since this is the loop shape users will copy, it would also be worth asserting the open-stream case somewhere: read a stream that is still open, confirm hasMore goes false with a non-null cursor, write another chunk, and confirm the cursor resumes after the delivered chunks. That is the behavior the whole stack turns on and the e2e suite does not cover it yet.

expect(page.done).toBe(true);
}
} while (cursor);
} while (hasMore);

// Both methods should return the same number of chunks
expect(paginatedChunks).toHaveLength(streamChunks.length);
Expand Down
2 changes: 1 addition & 1 deletion packages/web/app/components/run-detail-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ export function RunDetailView({
isLive: streamIsLive,
isInitialLoading: streamIsInitialLoading,
error: streamError,
} = useStreamReader(env, selectedStreamId, runId, encryptionKey, run.status);
} = useStreamReader(selectedStreamId, runId, encryptionKey, run.status);

const handleCancelClick = () => {
setShowCancelDialog(true);
Expand Down
18 changes: 6 additions & 12 deletions packages/web/app/lib/client/workflow-streams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe('readStream', () => {
headers: { 'X-Stream-Cursor': 'abc123', 'X-Stream-Done': 'false' },
});

const result = await readStream(env, 'stream-1', 'run-1');
const result = await readStream('stream-1', 'run-1', null);
expect(result.body).toBe(mockBody);
expect(result.cursor).toBe('abc123');
expect(result.done).toBe(false);
Expand All @@ -93,13 +93,7 @@ describe('readStream', () => {
headers: { 'X-Stream-Done': 'true' },
});

const result = await readStream(
env,
'stream-1',
'run-1',
undefined,
'cur_xyz'
);
const result = await readStream('stream-1', 'run-1', 'cur_xyz');
expect(result.done).toBe(true);
expect(result.cursor).toBeNull();
expect(globalThis.fetch).toHaveBeenCalledWith(
Expand All @@ -111,7 +105,7 @@ describe('readStream', () => {
it('throws WorkflowWebAPIError when response is not ok', async () => {
mockFetchResponse({ ok: false, status: 500 });

await expect(readStream(env, 'stream-1', 'run-1')).rejects.toThrow(
await expect(readStream('stream-1', 'run-1', null)).rejects.toThrow(
'Failed to read stream: 500'
);
});
Expand All @@ -129,23 +123,23 @@ describe('readStream', () => {
}),
});

await expect(readStream(env, 'stream-1', 'run-1')).rejects.toThrow(
await expect(readStream('stream-1', 'run-1', null)).rejects.toThrow(
'invalid stream'
);
});

it('throws WorkflowWebAPIError when body is null', async () => {
mockFetchResponse({ ok: true, body: null });

await expect(readStream(env, 'stream-1', 'run-1')).rejects.toThrow(
await expect(readStream('stream-1', 'run-1', null)).rejects.toThrow(
'Failed to read stream: no body'
);
});

it('wraps non-WorkflowWebAPIError in WorkflowWebAPIError', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('network fail'));

const err = await readStream(env, 'stream-1', 'run-1').catch((e) => e);
const err = await readStream('stream-1', 'run-1', null).catch((e) => e);
expect(err).toBeInstanceOf(WorkflowWebAPIError);
expect(err.message).toBe('Failed to read stream');
expect(err.cause).toBeInstanceOf(TypeError);
Expand Down
5 changes: 2 additions & 3 deletions packages/web/app/lib/client/workflow-streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,10 @@ export interface StreamResponse {
}

export async function readStream(
_env: EnvMap,
streamId: string,
runId: string,
signal?: AbortSignal,
cursor?: string | null
cursor: string | null,
signal?: AbortSignal
): Promise<StreamResponse> {
try {
const params = new URLSearchParams({ runId });
Expand Down
8 changes: 3 additions & 5 deletions packages/web/app/lib/hooks/use-stream-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ vi.mock('~/lib/workflow-api-client', () => ({
readStream: vi.fn(),
}));

const env = {};

function emptyStreamResponse(done: boolean): StreamResponse {
return {
body: new ReadableStream({
Expand All @@ -36,7 +34,7 @@ describe('useStreamReader status', () => {
);

const { result, unmount } = renderHook(() =>
useStreamReader(env, 'stream-1', 'run-1', null, 'running')
useStreamReader('stream-1', 'run-1', null, 'running')
);

expect(result.current.isInitialLoading).toBe(true);
Expand All @@ -56,7 +54,7 @@ describe('useStreamReader status', () => {
vi.mocked(readStream).mockResolvedValue(emptyStreamResponse(true));

const { result } = renderHook(() =>
useStreamReader(env, 'stream-1', 'run-1', null, 'running')
useStreamReader('stream-1', 'run-1', null, 'running')
);

await waitFor(() => {
Expand All @@ -69,7 +67,7 @@ describe('useStreamReader status', () => {
vi.mocked(readStream).mockResolvedValue(emptyStreamResponse(false));

const { result } = renderHook(() =>
useStreamReader(env, 'stream-1', 'run-1', null, 'completed')
useStreamReader('stream-1', 'run-1', null, 'completed')
);

await waitFor(() => {
Expand Down
84 changes: 28 additions & 56 deletions packages/web/app/lib/hooks/use-stream-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
import { getWebRevivers } from '@workflow/web-shared';
import type { WorkflowRunStatus } from '@workflow/world';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { EnvMap } from '~/lib/types';
import { readStream } from '~/lib/workflow-api-client';

export interface StreamChunk {
Expand All @@ -21,7 +20,7 @@ const FRAME_HEADER_SIZE = 4;
const ENCRYPTED_PLACEHOLDER = '[Encrypted]';
const POLL_INTERVAL_MS = 3000;

function isRunActive(status?: WorkflowRunStatus): boolean {
function isRunActive(status: WorkflowRunStatus): boolean {
return status === 'pending' || status === 'running';
}

Expand Down Expand Up @@ -49,21 +48,16 @@ function detectEncoding(data: Uint8Array): StreamEncoding {
}

export function useStreamReader(
env: EnvMap,
streamId: string | null,
runId?: string,
encryptionKey?: Uint8Array | null,
runStatus?: WorkflowRunStatus
runId: string,
encryptionKey: Uint8Array | null,
runStatus: WorkflowRunStatus
) {
const [chunks, setChunks] = useState<StreamChunk[]>([]);
const [isLive, setIsLive] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(
Boolean(streamId && runId)
);
const [isInitialLoading, setIsInitialLoading] = useState(Boolean(streamId));
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const chunkIdRef = useRef(0);
const frameCountRef = useRef(0);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const runStatusRef = useRef(runStatus);
runStatusRef.current = runStatus;
Expand Down Expand Up @@ -132,24 +126,22 @@ export function useStreamReader(
setChunks([]);
setError(null);
setIsLive(false);
setIsInitialLoading(Boolean(streamId && runId));
setIsInitialLoading(Boolean(streamId));
chunkIdRef.current = 0;
frameCountRef.current = 0;
serverCursorRef.current = null;

if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}

if (!streamId || !runId) {
if (!streamId) {
setIsInitialLoading(false);
return;
}

let mounted = true;
const abortController = new AbortController();
abortControllerRef.current = abortController;

const revivers = getWebRevivers();

Expand All @@ -168,37 +160,33 @@ export function useStreamReader(
* Fetch stream data and parse frames.
*
* When `cursor` is provided, the server only returns chunks after that
* position (incremental fetch). `skipFrames` skips N frames from the
* response to handle the overlap from cursor-based pagination.
* position (incremental fetch).
*/
const fetchAndParse = async (
targetBuffer: StreamChunk[],
cryptoKey: PayloadKey | undefined,
options?: { skipFrames?: number; cursor?: string | null }
cursor: string | null
): Promise<
| { encrypted: true }
| {
encrypted: false;
frameCount: number;
chunks: StreamChunk[];
cursor: string | null;
done: boolean;
}
> => {
const streamResponse = await readStream(
env,
streamId,
runId,
abortController.signal,
options?.cursor
cursor,
abortController.signal
);

const skipFrames = options?.skipFrames ?? 0;
const chunks: StreamChunk[] = [];
const reader = streamResponse.body.getReader();
const decoder = new TextDecoder();
let buffer = new Uint8Array(0);
let encoding: StreamEncoding | null = null;
let textRemainder = '';
let frameIndex = 0;

const appendToBuffer = (data: Uint8Array) => {
const newBuffer = new Uint8Array(buffer.length + data.length);
Expand All @@ -213,10 +201,7 @@ export function useStreamReader(
const { value, done } = await reader.read();
if (done) {
if (encoding === 'legacy' && textRemainder.trim()) {
frameIndex++;
if (frameIndex > skipFrames) {
targetBuffer.push(parseLegacyLine(textRemainder.trim()));
}
chunks.push(parseLegacyLine(textRemainder.trim()));
textRemainder = '';
}
break;
Expand All @@ -243,10 +228,7 @@ export function useStreamReader(
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) {
frameIndex++;
if (frameIndex > skipFrames) {
targetBuffer.push(parseLegacyLine(trimmed));
}
chunks.push(parseLegacyLine(trimmed));
}
}
continue;
Expand Down Expand Up @@ -277,17 +259,12 @@ export function useStreamReader(
);
offset += FRAME_HEADER_SIZE + frameLength;

frameIndex++;
if (frameIndex <= skipFrames) {
continue;
}

const result = await processFrame(frameData, cryptoKey, revivers);
if (result.encrypted) {
reader.cancel().catch(() => {});
return { encrypted: true };
}
targetBuffer.push(result.chunk);
chunks.push(result.chunk);

framesInBatch++;
if (framesInBatch % YIELD_EVERY_N_FRAMES === 0) {
Expand All @@ -301,7 +278,7 @@ export function useStreamReader(

return {
encrypted: false,
frameCount: frameIndex,
chunks,
cursor: streamResponse.cursor,
done: streamResponse.done,
};
Expand All @@ -315,8 +292,7 @@ export function useStreamReader(
? await deriveRunPayloadKeys(encryptionKey)
: undefined;

const initialChunks: StreamChunk[] = [];
const result = await fetchAndParse(initialChunks, cryptoKey);
const result = await fetchAndParse(cryptoKey, null);

if (result.encrypted) {
if (mounted) {
Expand All @@ -327,12 +303,11 @@ export function useStreamReader(
return;
}

frameCountRef.current = result.frameCount;
serverCursorRef.current = result.cursor;

if (!mounted || abortController.signal.aborted) return;

setChunks(initialChunks);
setChunks(result.chunks);
setIsInitialLoading(false);

// If the stream itself is done, no need to poll regardless of run status
Expand All @@ -346,18 +321,15 @@ export function useStreamReader(
const poll = async () => {
if (!mounted || abortController.signal.aborted) return;
try {
const newChunks: StreamChunk[] = [];
const pollResult = await fetchAndParse(newChunks, cryptoKey, {
cursor: serverCursorRef.current,
skipFrames: frameCountRef.current,
});
const pollResult = await fetchAndParse(
cryptoKey,
serverCursorRef.current
);
if (!pollResult.encrypted) {
frameCountRef.current = pollResult.frameCount;
if (pollResult.cursor) {
serverCursorRef.current = pollResult.cursor;
}
if (newChunks.length > 0 && mounted) {
setChunks((prev) => [...prev, ...newChunks]);
serverCursorRef.current =
pollResult.cursor ?? serverCursorRef.current;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Blocking

Dropping skipFrames is the correct pairing for the World contract in #3475, and the initial-load and poll paths here are internally consistent. The problem is that it is correct only against that contract, and @workflow/web and the World packages are separately versioned, so users can land on a mismatched pair.

Against a World still on the old contract, the open tail returns cursor: null. readStreamChunksServerAction then reports cursor: null, this hook leaves serverCursorRef.current at null, and the next poll re-reads from index 0 with nothing skipping the frames already rendered, so the whole stream is appended again every POLL_INTERVAL_MS. I ran the new pair against an old-contract World: 3 chunks render, one poll leaves 7 rendered entries, a second leaves 12. It grows quadratically in poll count for the life of the run, and the failure mode is duplicated output rather than an error, so nothing surfaces it.

"Old contract" is not hypothetical here. It is any pinned @workflow/world-local or @workflow/world-postgres older than #3475, plus every community World implementing what docs/content/worlds/v4/building-a-world.mdx and the v5 copy currently document.

Two ways out. Make the cursor change additive in #3475, adding a new resumeCursor field and leaving cursor alone, so both pairings work and this hook can prefer the new field when present. Or declare the coupling: a peer or version floor on the World packages, and a minor rather than a patch. As written the changeset is a patch on @workflow/web alone, which is the one thing that cannot express the dependency.

Worth noting the reverse skew is already handled well: readStreamChunksServerAction only throws when hasMore && !result.cursor, which an old-contract World never produces. So the tolerance is there, it just is not enough on its own.

@vercel vercel Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A @workflow/web reader relying solely on the new tail-cursor contract silently and quadratically duplicates open-stream output when paired with an older dynamically-resolved World package that returns cursor: null at the open-stream tail.

Fix on Vercel

if (pollResult.chunks.length > 0 && mounted) {
setChunks((prev) => [...prev, ...pollResult.chunks]);
}
if (pollResult.done) {
setIsLive(false);
Expand Down Expand Up @@ -396,7 +368,7 @@ export function useStreamReader(
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [env, streamId, runId, encryptionKey, processFrame]);
}, [streamId, runId, encryptionKey, processFrame]);

// When run finishes, stop polling
useEffect(() => {
Expand Down
7 changes: 1 addition & 6 deletions packages/web/app/routes/api.stream.$streamId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const cursor = url.searchParams.get('cursor') ?? undefined;

try {
const result = await readStreamChunksServerAction(
{},
streamId,
runId,
cursor
);
const result = await readStreamChunksServerAction(streamId, runId, cursor);

if (!('buffer' in result)) {
return Response.json(result, { status: 500 });
Expand Down
Loading
Loading