From 3db42262058ae08326dbb07c2871fa20ce2e320d Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 25 May 2026 12:35:24 -0700 Subject: [PATCH 1/7] Add server-backed exact ID search to the Events tab. Replace client-side substring filtering with API lookups for full correlation and event IDs so searches work beyond the first loaded page. Co-authored-by: Cursor --- .changeset/events-exact-id-search.md | 6 + .../src/components/event-list-view.tsx | 225 +++++++++++------- packages/web-shared/src/index.ts | 5 + .../src/lib/exact-event-search-id.ts | 44 ++++ .../test/exact-event-search-id.test.ts | 49 ++++ .../web/app/components/run-detail-view.tsx | 2 + .../lib/client/hooks/use-events-list-data.ts | 95 ++++++-- packages/web/app/lib/rpc-client.ts | 17 ++ packages/web/app/routes/api.rpc.tsx | 7 + .../server/workflow-server-actions.server.ts | 38 +++ 10 files changed, 382 insertions(+), 106 deletions(-) create mode 100644 .changeset/events-exact-id-search.md create mode 100644 packages/web-shared/src/lib/exact-event-search-id.ts create mode 100644 packages/web-shared/test/exact-event-search-id.test.ts diff --git a/.changeset/events-exact-id-search.md b/.changeset/events-exact-id-search.md new file mode 100644 index 0000000000..cc0b5a2ef3 --- /dev/null +++ b/.changeset/events-exact-id-search.md @@ -0,0 +1,6 @@ +--- +"@workflow/web-shared": patch +"@workflow/web": patch +--- + +Add server-backed exact ID search to the Events tab. Pasting a full correlation ID (`step_`, `wait_`, or `hook_`) or event ID (`evnt_`) fetches matching events from the API instead of filtering the loaded page client-side. diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index db83be83f5..c5b1c25ff7 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -3,12 +3,21 @@ import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name'; import type { Event, WorkflowRun } from '@workflow/world'; import { Check, ChevronRight, Copy } from 'lucide-react'; -import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react'; +import type { + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + ReactNode, +} from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import { isEncryptedMarker } from '../lib/hydration'; +import { + parseExactWorkflowSearchId, + type ExactWorkflowSearchIdKind, +} from '../lib/exact-event-search-id'; import { DecryptButton } from './ui/decrypt-button'; import { formatDuration } from '../lib/utils'; +import { useToast } from '../lib/toast'; import { DataInspector, DecryptClickContext } from './ui/data-inspector'; import { ErrorStackBlock, @@ -721,6 +730,11 @@ interface EventsListProps { isDecrypting?: boolean; /** Run-level hint: the run contains encrypted data (from probe). */ hasEncryptedData?: boolean; + /** Fetch events for an exact correlation or event ID. */ + onExactIdSearch?: ( + id: string, + kind: ExactWorkflowSearchIdKind + ) => Promise; } function EventRow({ @@ -1155,7 +1169,9 @@ export function EventListView({ onDecrypt, isDecrypting = false, hasEncryptedData: hasEncryptedDataProp = false, + onExactIdSearch, }: EventsListProps) { + const toast = useToast(); const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>( 'asc' ); @@ -1171,25 +1187,40 @@ export function EventListView({ [onSortOrderChange] ); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState(null); + const [searchLoading, setSearchLoading] = useState(false); + const [searchNotFound, setSearchNotFound] = useState(false); + const searchRequestRef = useRef(0); + const virtuosoRef = useRef(null); + + const parsedSearchId = useMemo( + () => parseExactWorkflowSearchId(searchQuery), + [searchQuery] + ); + const isExactSearchActive = searchResults !== null; + const sortedEvents = useMemo(() => { - if (!events || events.length === 0) return []; + const sourceEvents = isExactSearchActive ? searchResults : (events ?? []); + if (sourceEvents.length === 0) return []; const dir = effectiveSortOrder === 'desc' ? -1 : 1; - return [...events].sort( + return [...sourceEvents].sort( (a, b) => dir * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) ); - }, [events, effectiveSortOrder]); + }, [events, effectiveSortOrder, isExactSearchActive, searchResults]); // Detect encrypted fields across all loaded events (inline eventData). const hasEncryptedInlineData = useMemo(() => { - if (!events) return false; - for (const event of events) { + const sourceEvents = isExactSearchActive ? searchResults : events; + if (!sourceEvents) return false; + for (const event of sourceEvents) { const ed = (event as Record).eventData; if (hasEncryptedValues(ed)) return true; } return false; - }, [events]); + }, [events, isExactSearchActive, searchResults]); // Tracks whether any expanded row's lazy-loaded data contained encrypted markers. // Set to true by EventRow via onEncryptedDataDetected; never reset (sticky). @@ -1203,8 +1234,12 @@ export function EventListView({ hasEncryptedDataProp || hasEncryptedInlineData || foundEncryptedInLazyData; const { correlationNameMap, workflowName } = useMemo( - () => buildNameMaps(events ?? null, run ?? null), - [events, run] + () => + buildNameMaps( + isExactSearchActive ? searchResults : (events ?? null), + run ?? null + ), + [events, isExactSearchActive, run, searchResults] ); const durationMap = useMemo( @@ -1294,68 +1329,88 @@ export function EventListView({ return first >= 0 ? { first, last } : null; }, [activeGroupKey, sortedEvents]); - const [searchQuery, setSearchQuery] = useState(''); - const virtuosoRef = useRef(null); - - const searchIndex = useMemo(() => { - const entries: { - fields: string[]; - groupKey?: string; - eventId: string; - index: number; - }[] = []; - for (let i = 0; i < sortedEvents.length; i++) { - const ev = sortedEvents[i]; - const isRun = isRunLevel(ev.eventType); - const name = isRun - ? (workflowName ?? '') - : ev.correlationId - ? (correlationNameMap.get(ev.correlationId) ?? '') - : ''; - entries.push({ - fields: [ - ev.eventId, - ev.correlationId ?? '', - ev.eventType, - formatEventType(ev.eventType), - name, - ].map((f) => f.toLowerCase()), - groupKey: ev.correlationId ?? (isRun ? '__run__' : undefined), - eventId: ev.eventId, - index: i, - }); - } - return entries; - }, [sortedEvents, correlationNameMap, workflowName]); - useEffect(() => { - const q = searchQuery.trim().toLowerCase(); - if (!q) { + const trimmed = searchQuery.trim(); + if (!trimmed) { + setSearchResults(null); + setSearchLoading(false); + setSearchNotFound(false); setSelectedGroupKey(undefined); return; } - let bestMatch: (typeof searchIndex)[number] | null = null; - let bestScore = 0; - for (const entry of searchIndex) { - for (const field of entry.fields) { - if (field && field.includes(q)) { - const score = q.length / field.length; - if (score > bestScore) { - bestScore = score; - bestMatch = entry; + + const parsed = parseExactWorkflowSearchId(trimmed); + if (!parsed || !onExactIdSearch) { + setSearchResults(null); + setSearchLoading(false); + setSearchNotFound(false); + return; + } + + const requestId = ++searchRequestRef.current; + setSearchLoading(true); + setSearchNotFound(false); + + const timer = setTimeout(() => { + void (async () => { + try { + const results = await onExactIdSearch(parsed.id, parsed.kind); + if (searchRequestRef.current !== requestId) { + return; + } + + if (!results || results.length === 0) { + setSearchResults([]); + setSearchNotFound(true); + setSelectedGroupKey(undefined); + return; + } + + setSearchResults(results); + setSearchNotFound(false); + setSelectedGroupKey( + parsed.kind === 'event' + ? (results[0]?.correlationId ?? undefined) + : parsed.id + ); + virtuosoRef.current?.scrollToIndex({ + index: 0, + align: 'start', + behavior: 'smooth', + }); + } catch { + if (searchRequestRef.current !== requestId) { + return; + } + setSearchResults([]); + setSearchNotFound(true); + setSelectedGroupKey(undefined); + } finally { + if (searchRequestRef.current === requestId) { + setSearchLoading(false); } } + })(); + }, 300); + + return () => clearTimeout(timer); + }, [searchQuery, onExactIdSearch, effectiveSortOrder]); + + const handleSearchKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key !== 'Enter') { + return; + } + + const trimmed = searchQuery.trim(); + if (!trimmed || parseExactWorkflowSearchId(trimmed) || !onExactIdSearch) { + return; } - } - if (bestMatch) { - setSelectedGroupKey(bestMatch.groupKey); - virtuosoRef.current?.scrollToIndex({ - index: bestMatch.index, - align: 'center', - behavior: 'smooth', - }); - } - }, [searchQuery, searchIndex]); + + toast.info('Enter a full correlation ID or event ID'); + }, + [searchQuery, onExactIdSearch, toast] + ); // Track whether we've ever had events to distinguish initial load from refetch const hasHadEventsRef = useRef(false); @@ -1401,17 +1456,6 @@ export function EventListView({ ); } - if (!isLoading && (!events || events.length === 0)) { - return ( -
- No events found -
- ); - } - return ( setSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} style={{ marginLeft: -16, paddingInline: 12, @@ -1535,8 +1580,19 @@ export function EventListView({ {/* Virtualized event rows or refetching skeleton */} - {isRefetching ? ( + {isRefetching || searchLoading ? ( + ) : sortedEvents.length === 0 ? ( +
+ {searchNotFound && searchQuery.trim() + ? `No events found for ${searchQuery.trim()}` + : parsedSearchId && searchQuery.trim() && !onExactIdSearch + ? 'Exact ID search is unavailable in this view.' + : 'No events found'} +
) : ( { - if (!hasMoreEvents || isLoadingMoreEvents) { + if ( + isExactSearchActive || + !hasMoreEvents || + isLoadingMoreEvents + ) { return; } void onLoadMoreEvents?.(); @@ -1591,10 +1651,13 @@ export function EventListView({ }} > - {sortedEvents.length} event - {sortedEvents.length !== 1 ? 's' : ''} loaded + {isExactSearchActive + ? searchNotFound + ? `No events found for ${searchQuery.trim()}` + : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} for ${searchQuery.trim()}` + : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} loaded`} - {hasMoreEvents && ( + {!isExactSearchActive && hasMoreEvents && (
{ + it('accepts full step IDs', () => { + const id = 'step_01KSG94DWMWZRQBK04D3GS2CAQ'; + expect(parseExactWorkflowSearchId(id)).toEqual({ + kind: 'step', + id, + }); + }); + + it('accepts full wait IDs', () => { + const id = 'wait_01KSG94DWMWZRQBK04D3GS2CAQ'; + expect(parseExactWorkflowSearchId(id)).toEqual({ + kind: 'wait', + id, + }); + }); + + it('accepts full hook IDs', () => { + const id = 'hook_01KSG94DWMWZRQBK04D3GS2CAQ'; + expect(parseExactWorkflowSearchId(id)).toEqual({ + kind: 'hook', + id, + }); + }); + + it('accepts full event IDs', () => { + const id = 'evnt_01KSG94CMGCPMC3PPACDCJR9AQ'; + expect(parseExactWorkflowSearchId(id)).toEqual({ + kind: 'event', + id, + }); + }); + + it('rejects partial IDs', () => { + expect(parseExactWorkflowSearchId('step_01KSG94')).toBeNull(); + expect(parseExactWorkflowSearchId('wait_01KSG94')).toBeNull(); + expect(parseExactWorkflowSearchId('hook_01KSG94')).toBeNull(); + expect(parseExactWorkflowSearchId('evnt_01KSG94')).toBeNull(); + }); + + it('rejects run IDs', () => { + expect( + parseExactWorkflowSearchId('wrun_01KSG94CFWFBPBYWW3PX7SF73W') + ).toBeNull(); + }); +}); diff --git a/packages/web/app/components/run-detail-view.tsx b/packages/web/app/components/run-detail-view.tsx index 5f6b422813..da151a425b 100644 --- a/packages/web/app/components/run-detail-view.tsx +++ b/packages/web/app/components/run-detail-view.tsx @@ -363,6 +363,7 @@ export function RunDetailView({ hasMore: hasMoreEventsTab, loadingMore: loadingMoreEventsTab, loadMore: loadMoreEventsTab, + searchByExactId, } = useEventsListData(env, runId, { sortOrder: eventsSortOrder, encryptionKey: encryptionKey ?? undefined, @@ -807,6 +808,7 @@ export function RunDetailView({ onDecrypt={handleDecrypt} isDecrypting={isDecrypting} hasEncryptedData={hasEncryptedData} + onExactIdSearch={searchByExactId} />
diff --git a/packages/web/app/lib/client/hooks/use-events-list-data.ts b/packages/web/app/lib/client/hooks/use-events-list-data.ts index 9451306fc5..2abc8288db 100644 --- a/packages/web/app/lib/client/hooks/use-events-list-data.ts +++ b/packages/web/app/lib/client/hooks/use-events-list-data.ts @@ -1,11 +1,18 @@ +'use client'; + +import type { Event } from '@workflow/world'; +import type { ExactWorkflowSearchIdKind } from '@workflow/web-shared'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { hydrateResourceIO, hydrateResourceIOWithKey, } from '@workflow/web-shared'; -import type { Event } from '@workflow/world'; -import { useCallback, useEffect, useRef, useState } from 'react'; import { unwrapServerActionResult } from '~/lib/client/workflow-errors'; -import { fetchEvents } from '~/lib/rpc-client'; +import { + fetchEvent, + fetchEvents, + fetchEventsByCorrelationId, +} from '~/lib/rpc-client'; import type { EnvMap } from '~/lib/types'; const INITIAL_PAGE_SIZE = 100; @@ -39,6 +46,17 @@ export function useEventsListData( const encryptionKeyRef = useRef(encryptionKey); encryptionKeyRef.current = encryptionKey; + const hydrateEvents = useCallback(async (rawEvents: Event[]) => { + const hydrated = rawEvents.map(hydrateResourceIO); + const key = encryptionKeyRef.current; + if (key) { + return Promise.all( + hydrated.map((ev) => hydrateResourceIOWithKey(ev, key)) + ); + } + return hydrated; + }, []); + const fetchInitial = useCallback(async () => { if (isFetchingRef.current) return; isFetchingRef.current = true; @@ -59,16 +77,7 @@ export function useEventsListData( if (fetchError) { setError(fetchError); } else { - const hydrated = result.data.map(hydrateResourceIO); - const key = encryptionKeyRef.current; - if (key) { - const decrypted = await Promise.all( - hydrated.map((ev) => hydrateResourceIOWithKey(ev, key)) - ); - setEvents(decrypted); - } else { - setEvents(hydrated); - } + setEvents(await hydrateEvents(result.data)); setCursor(result.hasMore ? result.cursor : undefined); setHasMore(Boolean(result.hasMore)); } @@ -78,7 +87,7 @@ export function useEventsListData( setLoading(false); isFetchingRef.current = false; } - }, [env, runId, sortOrder]); + }, [env, runId, sortOrder, hydrateEvents]); useEffect(() => { if (enabled) fetchInitial(); @@ -115,16 +124,8 @@ export function useEventsListData( setError(fetchError); } else { if (result.data.length > 0) { - const hydrated = result.data.map(hydrateResourceIO); - const key = encryptionKeyRef.current; - if (key) { - const decrypted = await Promise.all( - hydrated.map((ev) => hydrateResourceIOWithKey(ev, key)) - ); - setEvents((prev) => [...prev, ...decrypted]); - } else { - setEvents((prev) => [...prev, ...hydrated]); - } + const hydrated = await hydrateEvents(result.data); + setEvents((prev) => [...prev, ...hydrated]); } setCursor(result.hasMore ? result.cursor : undefined); setHasMore(Boolean(result.hasMore)); @@ -134,7 +135,50 @@ export function useEventsListData( } finally { setLoadingMore(false); } - }, [env, runId, sortOrder, cursor, loadingMore]); + }, [env, runId, sortOrder, cursor, loadingMore, hydrateEvents]); + + const searchByExactId = useCallback( + async ( + id: string, + kind: ExactWorkflowSearchIdKind + ): Promise => { + if (kind === 'event') { + const { error: fetchError, result } = await unwrapServerActionResult( + fetchEvent(env, runId, id, 'none') + ); + if (fetchError) { + return null; + } + const [event] = await hydrateEvents([result]); + return event.runId === runId ? [event] : null; + } + + const matched: Event[] = []; + let nextCursor: string | undefined; + do { + const { error: fetchError, result } = await unwrapServerActionResult( + fetchEventsByCorrelationId(env, id, { + cursor: nextCursor, + sortOrder, + limit: 100, + withData: false, + }) + ); + if (fetchError) { + return null; + } + + const hydrated = await hydrateEvents(result.data); + matched.push(...hydrated.filter((event) => event.runId === runId)); + + nextCursor = + result.hasMore && result.cursor ? result.cursor : undefined; + } while (nextCursor); + + return matched.length > 0 ? matched : null; + }, + [env, runId, sortOrder, hydrateEvents] + ); return { events, @@ -143,5 +187,6 @@ export function useEventsListData( hasMore, loadingMore, loadMore, + searchByExactId, }; } diff --git a/packages/web/app/lib/rpc-client.ts b/packages/web/app/lib/rpc-client.ts index 9e6a8435a7..2357faff3a 100644 --- a/packages/web/app/lib/rpc-client.ts +++ b/packages/web/app/lib/rpc-client.ts @@ -124,6 +124,23 @@ export async function fetchEvent( return rpc('fetchEvent', { worldEnv, runId, eventId, resolveData }); } +export async function fetchEventsByCorrelationId( + worldEnv: EnvMap, + correlationId: string, + params: { + cursor?: string; + sortOrder?: 'asc' | 'desc'; + limit?: number; + withData?: boolean; + } +): Promise>> { + return rpc('fetchEventsByCorrelationId', { + worldEnv, + correlationId, + params, + }); +} + export async function fetchHooks( worldEnv: EnvMap, params: { diff --git a/packages/web/app/routes/api.rpc.tsx b/packages/web/app/routes/api.rpc.tsx index 316a4e628e..a3824ab447 100644 --- a/packages/web/app/routes/api.rpc.tsx +++ b/packages/web/app/routes/api.rpc.tsx @@ -11,6 +11,7 @@ import { cancelRun, fetchEvent, fetchEvents, + fetchEventsByCorrelationId, fetchHook, fetchHooks, fetchRun, @@ -41,6 +42,12 @@ const handlers = { fetchEvents(p.worldEnv ?? {}, p.runId, p.params ?? {}), fetchEvent: (p: any) => fetchEvent(p.worldEnv ?? {}, p.runId, p.eventId, p.resolveData), + fetchEventsByCorrelationId: (p: any) => + fetchEventsByCorrelationId( + p.worldEnv ?? {}, + p.correlationId, + p.params ?? {} + ), fetchHooks: (p: any) => fetchHooks(p.worldEnv ?? {}, p.params ?? {}), fetchHook: (p: any) => fetchHook(p.worldEnv ?? {}, p.hookId, p.resolveData), cancelRun: (p: any) => cancelRun(p.worldEnv ?? {}, p.runId), diff --git a/packages/web/app/server/workflow-server-actions.server.ts b/packages/web/app/server/workflow-server-actions.server.ts index ca072d1b77..3bdfe975fb 100644 --- a/packages/web/app/server/workflow-server-actions.server.ts +++ b/packages/web/app/server/workflow-server-actions.server.ts @@ -731,6 +731,44 @@ export async function fetchEvent( } } +/** + * Fetch paginated events for a step correlation ID. + */ +export async function fetchEventsByCorrelationId( + worldEnv: EnvMap, + correlationId: string, + params: { + cursor?: string; + sortOrder?: 'asc' | 'desc'; + limit?: number; + withData?: boolean; + } +): Promise>> { + const { cursor, sortOrder = 'asc', limit = 100, withData = false } = params; + try { + const world = await getWorldFromEnv(worldEnv); + const result = await world.events.listByCorrelationId({ + correlationId, + pagination: { cursor, limit, sortOrder }, + resolveData: withData ? 'all' : 'none', + }); + return createResponse({ + data: result.data as unknown as Event[], + cursor: result.cursor ?? undefined, + hasMore: result.hasMore, + }); + } catch (error) { + return createServerActionError>( + error, + 'world.events.listByCorrelationId', + { + correlationId, + ...params, + } + ); + } +} + /** * Fetch paginated list of hooks */ From d5af10ce0fe1c44cd740baaa997c4cc389969465 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 25 May 2026 12:52:57 -0700 Subject: [PATCH 2/7] Fix exact ID search dimming and support wrun_ correlation IDs. Disable group dimming for server search results and accept run IDs in the exact ID parser so run-level correlation search works. Co-authored-by: Cursor --- .../src/components/event-list-view.tsx | 10 ++++++++-- .../web-shared/src/lib/exact-event-search-id.ts | 16 +++++++++++++--- .../test/exact-event-search-id.test.ts | 15 +++++++++------ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index c5b1c25ff7..a8ab900fc4 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -757,6 +757,7 @@ function EventRow({ onCacheEventData, encryptionKey, onEncryptedDataDetected, + suppressGroupDimming = false, }: { event: Event; index: number; @@ -777,6 +778,8 @@ function EventRow({ onCacheEventData: (eventId: string, data: unknown) => void; encryptionKey?: Uint8Array; onEncryptedDataDetected?: () => void; + /** Exact-ID search results should not dim unrelated rows. */ + suppressGroupDimming?: boolean; }) { const [isLoading, setIsLoading] = useState(false); const [loadedEventData, setLoadedEventData] = useState( @@ -818,7 +821,7 @@ function EventRow({ const hasActive = activeGroupKey !== undefined; const isRelated = rowGroupKey !== undefined && rowGroupKey === activeGroupKey; - const isDimmed = hasActive && !isRelated; + const isDimmed = hasActive && !isRelated && !suppressGroupDimming; const isPulsing = hasActive && isRelated; // Gutter state derived from selectedGroupRange @@ -1371,7 +1374,9 @@ export function EventListView({ setSelectedGroupKey( parsed.kind === 'event' ? (results[0]?.correlationId ?? undefined) - : parsed.id + : parsed.kind === 'run' + ? '__run__' + : parsed.id ); virtuosoRef.current?.scrollToIndex({ index: 0, @@ -1634,6 +1639,7 @@ export function EventListView({ onCacheEventData={cacheEventData} encryptionKey={encryptionKey} onEncryptedDataDetected={handleEncryptedDataDetected} + suppressGroupDimming={isExactSearchActive} /> ); }} diff --git a/packages/web-shared/src/lib/exact-event-search-id.ts b/packages/web-shared/src/lib/exact-event-search-id.ts index 28adc5d7af..ed28f62d7f 100644 --- a/packages/web-shared/src/lib/exact-event-search-id.ts +++ b/packages/web-shared/src/lib/exact-event-search-id.ts @@ -3,9 +3,15 @@ const WORKFLOW_ULID_BODY = '[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}'; const STEP_ID_PATTERN = new RegExp(`^step_${WORKFLOW_ULID_BODY}$`); const WAIT_ID_PATTERN = new RegExp(`^wait_${WORKFLOW_ULID_BODY}$`); const HOOK_ID_PATTERN = new RegExp(`^hook_${WORKFLOW_ULID_BODY}$`); +const RUN_ID_PATTERN = new RegExp(`^wrun_${WORKFLOW_ULID_BODY}$`); const EVENT_ID_PATTERN = new RegExp(`^evnt_${WORKFLOW_ULID_BODY}$`); -export type ExactWorkflowSearchIdKind = 'step' | 'wait' | 'hook' | 'event'; +export type ExactWorkflowSearchIdKind = + | 'step' + | 'wait' + | 'hook' + | 'run' + | 'event'; export type ExactWorkflowSearchId = { kind: ExactWorkflowSearchIdKind; @@ -13,8 +19,8 @@ export type ExactWorkflowSearchId = { }; /** - * Returns a parsed workflow ID when `query` is a full step, wait, hook, or event ID. - * Partial IDs and run IDs (wrun_) are ignored. + * Returns a parsed workflow ID when `query` is a full correlation or event ID. + * Partial IDs are ignored. */ export function parseExactWorkflowSearchId( query: string @@ -36,6 +42,10 @@ export function parseExactWorkflowSearchId( return { kind: 'hook', id: trimmed }; } + if (RUN_ID_PATTERN.test(trimmed)) { + return { kind: 'run', id: trimmed }; + } + if (EVENT_ID_PATTERN.test(trimmed)) { return { kind: 'event', id: trimmed }; } diff --git a/packages/web-shared/test/exact-event-search-id.test.ts b/packages/web-shared/test/exact-event-search-id.test.ts index 04f3d37920..1dea74ff00 100644 --- a/packages/web-shared/test/exact-event-search-id.test.ts +++ b/packages/web-shared/test/exact-event-search-id.test.ts @@ -26,6 +26,14 @@ describe('parseExactWorkflowSearchId', () => { }); }); + it('accepts full run IDs as correlation IDs', () => { + const id = 'wrun_01KSG94CFWFBPBYWW3PX7SF73W'; + expect(parseExactWorkflowSearchId(id)).toEqual({ + kind: 'run', + id, + }); + }); + it('accepts full event IDs', () => { const id = 'evnt_01KSG94CMGCPMC3PPACDCJR9AQ'; expect(parseExactWorkflowSearchId(id)).toEqual({ @@ -38,12 +46,7 @@ describe('parseExactWorkflowSearchId', () => { expect(parseExactWorkflowSearchId('step_01KSG94')).toBeNull(); expect(parseExactWorkflowSearchId('wait_01KSG94')).toBeNull(); expect(parseExactWorkflowSearchId('hook_01KSG94')).toBeNull(); + expect(parseExactWorkflowSearchId('wrun_01KSG94')).toBeNull(); expect(parseExactWorkflowSearchId('evnt_01KSG94')).toBeNull(); }); - - it('rejects run IDs', () => { - expect( - parseExactWorkflowSearchId('wrun_01KSG94CFWFBPBYWW3PX7SF73W') - ).toBeNull(); - }); }); From f1d59bff89dd8a3ec0dfa38624dded06fd2f9040 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 25 May 2026 12:59:03 -0700 Subject: [PATCH 3/7] Fix dimmed row when searching by event ID for run-level events. Map selectedGroupKey to __run__ for run-level search results so the matched row is treated as related instead of dimmed. Co-authored-by: Cursor --- packages/web-shared/src/components/event-list-view.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index a8ab900fc4..32e4ba69f6 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -1373,7 +1373,13 @@ export function EventListView({ setSearchNotFound(false); setSelectedGroupKey( parsed.kind === 'event' - ? (results[0]?.correlationId ?? undefined) + ? (() => { + const first = results[0]; + if (!first) return undefined; + return isRunLevel(first.eventType) + ? '__run__' + : (first.correlationId ?? undefined); + })() : parsed.kind === 'run' ? '__run__' : parsed.id From ad0a9aead4ff53eea0471821e4b4519192f39c6b Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 25 May 2026 13:07:03 -0700 Subject: [PATCH 4/7] Remove run ID search from Events tab exact ID lookup. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow-server only accepts step, wait, and hook correlation IDs — not wrun_. Update the search placeholder and validation toast accordingly. Co-authored-by: Cursor --- .changeset/events-exact-id-search.md | 2 +- .../src/components/event-list-view.tsx | 8 +++----- .../web-shared/src/lib/exact-event-search-id.ts | 16 +++------------- .../test/exact-event-search-id.test.ts | 14 ++++---------- 4 files changed, 11 insertions(+), 29 deletions(-) diff --git a/.changeset/events-exact-id-search.md b/.changeset/events-exact-id-search.md index cc0b5a2ef3..3767b0ddcc 100644 --- a/.changeset/events-exact-id-search.md +++ b/.changeset/events-exact-id-search.md @@ -3,4 +3,4 @@ "@workflow/web": patch --- -Add server-backed exact ID search to the Events tab. Pasting a full correlation ID (`step_`, `wait_`, or `hook_`) or event ID (`evnt_`) fetches matching events from the API instead of filtering the loaded page client-side. +Add server-backed exact ID search to the Events tab. Pasting a full step ID (`step_`), wait ID (`wait_`), hook ID (`hook_`), or event ID (`evnt_`) fetches matching events from the API instead of filtering the loaded page client-side. diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index 32e4ba69f6..7c8520ad5e 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -1380,9 +1380,7 @@ export function EventListView({ ? '__run__' : (first.correlationId ?? undefined); })() - : parsed.kind === 'run' - ? '__run__' - : parsed.id + : parsed.id ); virtuosoRef.current?.scrollToIndex({ index: 0, @@ -1418,7 +1416,7 @@ export function EventListView({ return; } - toast.info('Enter a full correlation ID or event ID'); + toast.info('Enter a full step ID, wait ID, hook ID, or event ID'); }, [searchQuery, onExactIdSearch, toast] ); @@ -1531,7 +1529,7 @@ export function EventListView({
setSearchQuery(e.target.value)} onKeyDown={handleSearchKeyDown} diff --git a/packages/web-shared/src/lib/exact-event-search-id.ts b/packages/web-shared/src/lib/exact-event-search-id.ts index ed28f62d7f..ca58dc7b71 100644 --- a/packages/web-shared/src/lib/exact-event-search-id.ts +++ b/packages/web-shared/src/lib/exact-event-search-id.ts @@ -3,15 +3,9 @@ const WORKFLOW_ULID_BODY = '[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}'; const STEP_ID_PATTERN = new RegExp(`^step_${WORKFLOW_ULID_BODY}$`); const WAIT_ID_PATTERN = new RegExp(`^wait_${WORKFLOW_ULID_BODY}$`); const HOOK_ID_PATTERN = new RegExp(`^hook_${WORKFLOW_ULID_BODY}$`); -const RUN_ID_PATTERN = new RegExp(`^wrun_${WORKFLOW_ULID_BODY}$`); const EVENT_ID_PATTERN = new RegExp(`^evnt_${WORKFLOW_ULID_BODY}$`); -export type ExactWorkflowSearchIdKind = - | 'step' - | 'wait' - | 'hook' - | 'run' - | 'event'; +export type ExactWorkflowSearchIdKind = 'step' | 'wait' | 'hook' | 'event'; export type ExactWorkflowSearchId = { kind: ExactWorkflowSearchIdKind; @@ -19,8 +13,8 @@ export type ExactWorkflowSearchId = { }; /** - * Returns a parsed workflow ID when `query` is a full correlation or event ID. - * Partial IDs are ignored. + * Returns a parsed workflow ID when `query` is a full step, wait, hook, or event ID. + * Partial IDs and run IDs (`wrun_`) are ignored. */ export function parseExactWorkflowSearchId( query: string @@ -42,10 +36,6 @@ export function parseExactWorkflowSearchId( return { kind: 'hook', id: trimmed }; } - if (RUN_ID_PATTERN.test(trimmed)) { - return { kind: 'run', id: trimmed }; - } - if (EVENT_ID_PATTERN.test(trimmed)) { return { kind: 'event', id: trimmed }; } diff --git a/packages/web-shared/test/exact-event-search-id.test.ts b/packages/web-shared/test/exact-event-search-id.test.ts index 1dea74ff00..9adb5d7826 100644 --- a/packages/web-shared/test/exact-event-search-id.test.ts +++ b/packages/web-shared/test/exact-event-search-id.test.ts @@ -26,14 +26,6 @@ describe('parseExactWorkflowSearchId', () => { }); }); - it('accepts full run IDs as correlation IDs', () => { - const id = 'wrun_01KSG94CFWFBPBYWW3PX7SF73W'; - expect(parseExactWorkflowSearchId(id)).toEqual({ - kind: 'run', - id, - }); - }); - it('accepts full event IDs', () => { const id = 'evnt_01KSG94CMGCPMC3PPACDCJR9AQ'; expect(parseExactWorkflowSearchId(id)).toEqual({ @@ -42,11 +34,13 @@ describe('parseExactWorkflowSearchId', () => { }); }); - it('rejects partial IDs', () => { + it('rejects partial IDs and run IDs', () => { expect(parseExactWorkflowSearchId('step_01KSG94')).toBeNull(); expect(parseExactWorkflowSearchId('wait_01KSG94')).toBeNull(); expect(parseExactWorkflowSearchId('hook_01KSG94')).toBeNull(); - expect(parseExactWorkflowSearchId('wrun_01KSG94')).toBeNull(); expect(parseExactWorkflowSearchId('evnt_01KSG94')).toBeNull(); + expect( + parseExactWorkflowSearchId('wrun_01KSG94CFWFBPBYWW3PX7SF73W') + ).toBeNull(); }); }); From 2b0feda9b3f80bd987fcc70cde95bd450ed21043 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 25 May 2026 13:18:50 -0700 Subject: [PATCH 5/7] Harden exact ID search UX and correlation fetch limits. Normalize lowercase ULIDs, scope Enter toasts to ID-like input, abort stale searches, disable search when unavailable, expand parser tests, and cap correlation pagination in workflow web. Co-authored-by: Cursor --- .../src/components/event-list-view.tsx | 39 +++++++++++-- packages/web-shared/src/index.ts | 1 + .../src/lib/exact-event-search-id.ts | 55 ++++++++++++------- .../test/exact-event-search-id.test.ts | 47 +++++++++++++++- .../lib/client/hooks/use-events-list-data.ts | 15 ++++- 5 files changed, 127 insertions(+), 30 deletions(-) diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index 7c8520ad5e..6a7165dcab 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -13,6 +13,7 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import { isEncryptedMarker } from '../lib/hydration'; import { parseExactWorkflowSearchId, + looksLikeWorkflowIdSearchInput, type ExactWorkflowSearchIdKind, } from '../lib/exact-event-search-id'; import { DecryptButton } from './ui/decrypt-button'; @@ -733,7 +734,8 @@ interface EventsListProps { /** Fetch events for an exact correlation or event ID. */ onExactIdSearch?: ( id: string, - kind: ExactWorkflowSearchIdKind + kind: ExactWorkflowSearchIdKind, + signal?: AbortSignal ) => Promise; } @@ -1354,10 +1356,16 @@ export function EventListView({ setSearchLoading(true); setSearchNotFound(false); + const abortController = new AbortController(); + const timer = setTimeout(() => { void (async () => { try { - const results = await onExactIdSearch(parsed.id, parsed.kind); + const results = await onExactIdSearch( + parsed.id, + parsed.kind, + abortController.signal + ); if (searchRequestRef.current !== requestId) { return; } @@ -1388,7 +1396,10 @@ export function EventListView({ behavior: 'smooth', }); } catch { - if (searchRequestRef.current !== requestId) { + if ( + abortController.signal.aborted || + searchRequestRef.current !== requestId + ) { return; } setSearchResults([]); @@ -1402,8 +1413,11 @@ export function EventListView({ })(); }, 300); - return () => clearTimeout(timer); - }, [searchQuery, onExactIdSearch, effectiveSortOrder]); + return () => { + clearTimeout(timer); + abortController.abort(); + }; + }, [searchQuery, onExactIdSearch]); const handleSearchKeyDown = useCallback( (event: ReactKeyboardEvent) => { @@ -1412,7 +1426,12 @@ export function EventListView({ } const trimmed = searchQuery.trim(); - if (!trimmed || parseExactWorkflowSearchId(trimmed) || !onExactIdSearch) { + if ( + !trimmed || + parseExactWorkflowSearchId(trimmed) || + !onExactIdSearch || + !looksLikeWorkflowIdSearchInput(trimmed) + ) { return; } @@ -1533,6 +1552,12 @@ export function EventListView({ value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={handleSearchKeyDown} + disabled={!onExactIdSearch} + title={ + onExactIdSearch + ? undefined + : 'Exact ID search is unavailable in this view.' + } style={{ marginLeft: -16, paddingInline: 12, @@ -1543,6 +1568,8 @@ export function EventListView({ outline: 'none', height: 40, width: '100%', + opacity: onExactIdSearch ? 1 : 0.5, + cursor: onExactIdSearch ? 'text' : 'not-allowed', }} /> diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 01ae5beb21..cc5e91b05f 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -16,6 +16,7 @@ export { export type { EventAnalysis } from './lib/event-analysis'; export { parseExactWorkflowSearchId, + looksLikeWorkflowIdSearchInput, type ExactWorkflowSearchId, type ExactWorkflowSearchIdKind, } from './lib/exact-event-search-id'; diff --git a/packages/web-shared/src/lib/exact-event-search-id.ts b/packages/web-shared/src/lib/exact-event-search-id.ts index ca58dc7b71..6e5b9498d0 100644 --- a/packages/web-shared/src/lib/exact-event-search-id.ts +++ b/packages/web-shared/src/lib/exact-event-search-id.ts @@ -1,9 +1,11 @@ const WORKFLOW_ULID_BODY = '[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}'; -const STEP_ID_PATTERN = new RegExp(`^step_${WORKFLOW_ULID_BODY}$`); -const WAIT_ID_PATTERN = new RegExp(`^wait_${WORKFLOW_ULID_BODY}$`); -const HOOK_ID_PATTERN = new RegExp(`^hook_${WORKFLOW_ULID_BODY}$`); -const EVENT_ID_PATTERN = new RegExp(`^evnt_${WORKFLOW_ULID_BODY}$`); +const STEP_ID_PATTERN = new RegExp(`^step_(${WORKFLOW_ULID_BODY})$`, 'i'); +const WAIT_ID_PATTERN = new RegExp(`^wait_(${WORKFLOW_ULID_BODY})$`, 'i'); +const HOOK_ID_PATTERN = new RegExp(`^hook_(${WORKFLOW_ULID_BODY})$`, 'i'); +const EVENT_ID_PATTERN = new RegExp(`^evnt_(${WORKFLOW_ULID_BODY})$`, 'i'); + +const WORKFLOW_ID_PREFIX_PATTERN = /^(step_|wait_|hook_|evnt_|wrun_)/i; export type ExactWorkflowSearchIdKind = 'step' | 'wait' | 'hook' | 'event'; @@ -12,9 +14,23 @@ export type ExactWorkflowSearchId = { id: string; }; +function matchPrefixedId( + pattern: RegExp, + prefix: 'step' | 'wait' | 'hook' | 'evnt', + kind: ExactWorkflowSearchIdKind, + query: string +): ExactWorkflowSearchId | null { + const match = query.match(pattern); + if (!match) { + return null; + } + return { kind, id: `${prefix}_${match[1].toUpperCase()}` }; +} + /** * Returns a parsed workflow ID when `query` is a full step, wait, hook, or event ID. - * Partial IDs and run IDs (`wrun_`) are ignored. + * Partial IDs and run IDs (`wrun_`) are ignored. ULID bodies are matched case-insensitively + * and normalized to uppercase in the returned ID. */ export function parseExactWorkflowSearchId( query: string @@ -24,21 +40,20 @@ export function parseExactWorkflowSearchId( return null; } - if (STEP_ID_PATTERN.test(trimmed)) { - return { kind: 'step', id: trimmed }; - } - - if (WAIT_ID_PATTERN.test(trimmed)) { - return { kind: 'wait', id: trimmed }; - } - - if (HOOK_ID_PATTERN.test(trimmed)) { - return { kind: 'hook', id: trimmed }; - } + return ( + matchPrefixedId(STEP_ID_PATTERN, 'step', 'step', trimmed) ?? + matchPrefixedId(WAIT_ID_PATTERN, 'wait', 'wait', trimmed) ?? + matchPrefixedId(HOOK_ID_PATTERN, 'hook', 'hook', trimmed) ?? + matchPrefixedId(EVENT_ID_PATTERN, 'evnt', 'event', trimmed) + ); +} - if (EVENT_ID_PATTERN.test(trimmed)) { - return { kind: 'event', id: trimmed }; +/** True when input looks like the user is attempting an ID search (including partial). */ +export function looksLikeWorkflowIdSearchInput(query: string): boolean { + const trimmed = query.trim(); + if (!WORKFLOW_ID_PREFIX_PATTERN.test(trimmed)) { + return false; } - - return null; + // Distinguish IDs (contain digits) from event-type strings like step_started. + return /\d/.test(trimmed); } diff --git a/packages/web-shared/test/exact-event-search-id.test.ts b/packages/web-shared/test/exact-event-search-id.test.ts index 9adb5d7826..1843baddff 100644 --- a/packages/web-shared/test/exact-event-search-id.test.ts +++ b/packages/web-shared/test/exact-event-search-id.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { parseExactWorkflowSearchId } from '../src/lib/exact-event-search-id.js'; +import { + looksLikeWorkflowIdSearchInput, + parseExactWorkflowSearchId, +} from '../src/lib/exact-event-search-id.js'; describe('parseExactWorkflowSearchId', () => { it('accepts full step IDs', () => { @@ -34,6 +37,23 @@ describe('parseExactWorkflowSearchId', () => { }); }); + it('normalizes lowercase ULID bodies to uppercase', () => { + expect( + parseExactWorkflowSearchId('step_01ksg94dwmwzrqbk04d3gs2caq') + ).toEqual({ + kind: 'step', + id: 'step_01KSG94DWMWZRQBK04D3GS2CAQ', + }); + }); + + it('trims leading and trailing whitespace', () => { + const id = 'evnt_01KSG94CMGCPMC3PPACDCJR9AQ'; + expect(parseExactWorkflowSearchId(` ${id} `)).toEqual({ + kind: 'event', + id, + }); + }); + it('rejects partial IDs and run IDs', () => { expect(parseExactWorkflowSearchId('step_01KSG94')).toBeNull(); expect(parseExactWorkflowSearchId('wait_01KSG94')).toBeNull(); @@ -43,4 +63,29 @@ describe('parseExactWorkflowSearchId', () => { parseExactWorkflowSearchId('wrun_01KSG94CFWFBPBYWW3PX7SF73W') ).toBeNull(); }); + + it('rejects IDs with illegal Crockford characters or wrong length', () => { + expect( + parseExactWorkflowSearchId('step_01ISG94DWMWZRQBK04D3GS2CAQ') + ).toBeNull(); + expect( + parseExactWorkflowSearchId('step_01KSG94DWMWZRQBK04D3GS2CA') + ).toBeNull(); + expect( + parseExactWorkflowSearchId('step_01KSG94DWMWZRQBK04D3GS2CAQQ') + ).toBeNull(); + }); +}); + +describe('looksLikeWorkflowIdSearchInput', () => { + it('matches known workflow ID prefixes', () => { + expect(looksLikeWorkflowIdSearchInput('step_01KSG94')).toBe(true); + expect(looksLikeWorkflowIdSearchInput('wrun_01KSG94')).toBe(true); + expect(looksLikeWorkflowIdSearchInput('EVNT_01KSG94')).toBe(true); + }); + + it('does not match free-text search input', () => { + expect(looksLikeWorkflowIdSearchInput('parseInvoice')).toBe(false); + expect(looksLikeWorkflowIdSearchInput('step_started')).toBe(false); + }); }); diff --git a/packages/web/app/lib/client/hooks/use-events-list-data.ts b/packages/web/app/lib/client/hooks/use-events-list-data.ts index 2abc8288db..d473e47c58 100644 --- a/packages/web/app/lib/client/hooks/use-events-list-data.ts +++ b/packages/web/app/lib/client/hooks/use-events-list-data.ts @@ -17,6 +17,8 @@ import type { EnvMap } from '~/lib/types'; const INITIAL_PAGE_SIZE = 100; const LOAD_MORE_PAGE_SIZE = 100; +/** Max pages when fetching correlation ID search results (100 events/page). */ +const MAX_CORRELATION_SEARCH_PAGES = 30; /** * Independent event fetching for the Events tab. @@ -140,7 +142,8 @@ export function useEventsListData( const searchByExactId = useCallback( async ( id: string, - kind: ExactWorkflowSearchIdKind + kind: ExactWorkflowSearchIdKind, + _signal?: AbortSignal ): Promise => { if (kind === 'event') { const { error: fetchError, result } = await unwrapServerActionResult( @@ -150,11 +153,12 @@ export function useEventsListData( return null; } const [event] = await hydrateEvents([result]); - return event.runId === runId ? [event] : null; + return event?.runId === runId ? [event] : null; } const matched: Event[] = []; let nextCursor: string | undefined; + let pagesFetched = 0; do { const { error: fetchError, result } = await unwrapServerActionResult( fetchEventsByCorrelationId(env, id, { @@ -168,11 +172,16 @@ export function useEventsListData( return null; } + pagesFetched += 1; const hydrated = await hydrateEvents(result.data); matched.push(...hydrated.filter((event) => event.runId === runId)); nextCursor = - result.hasMore && result.cursor ? result.cursor : undefined; + pagesFetched < MAX_CORRELATION_SEARCH_PAGES && + result.hasMore && + result.cursor + ? result.cursor + : undefined; } while (nextCursor); return matched.length > 0 ? matched : null; From eae9079613e60827c4cbbdae6f3c71b5c1913e8b Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 25 May 2026 13:24:18 -0700 Subject: [PATCH 6/7] Fix search clear race and surface truncated correlation results. Guard successful exact-ID search against aborted requests, invalidate in-flight work when the input clears, and return truncation metadata from correlation pagination. Co-authored-by: Cursor --- .../src/components/event-list-view.tsx | 27 ++++++++++---- packages/web-shared/src/index.ts | 1 + .../src/lib/exact-event-search-id.ts | 9 +++++ .../lib/client/hooks/use-events-list-data.ts | 35 +++++++++++++------ 4 files changed, 55 insertions(+), 17 deletions(-) diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index 6a7165dcab..966ff69c56 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -14,6 +14,7 @@ import { isEncryptedMarker } from '../lib/hydration'; import { parseExactWorkflowSearchId, looksLikeWorkflowIdSearchInput, + type ExactIdSearchResult, type ExactWorkflowSearchIdKind, } from '../lib/exact-event-search-id'; import { DecryptButton } from './ui/decrypt-button'; @@ -736,7 +737,7 @@ interface EventsListProps { id: string, kind: ExactWorkflowSearchIdKind, signal?: AbortSignal - ) => Promise; + ) => Promise; } function EventRow({ @@ -1194,6 +1195,7 @@ export function EventListView({ const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); + const [searchResultsTruncated, setSearchResultsTruncated] = useState(false); const [searchLoading, setSearchLoading] = useState(false); const [searchNotFound, setSearchNotFound] = useState(false); const searchRequestRef = useRef(0); @@ -1337,7 +1339,9 @@ export function EventListView({ useEffect(() => { const trimmed = searchQuery.trim(); if (!trimmed) { + searchRequestRef.current += 1; setSearchResults(null); + setSearchResultsTruncated(false); setSearchLoading(false); setSearchNotFound(false); setSelectedGroupKey(undefined); @@ -1366,23 +1370,28 @@ export function EventListView({ parsed.kind, abortController.signal ); - if (searchRequestRef.current !== requestId) { + if ( + abortController.signal.aborted || + searchRequestRef.current !== requestId + ) { return; } - if (!results || results.length === 0) { + if (!results || results.events.length === 0) { setSearchResults([]); + setSearchResultsTruncated(false); setSearchNotFound(true); setSelectedGroupKey(undefined); return; } - setSearchResults(results); + setSearchResults(results.events); + setSearchResultsTruncated(Boolean(results.truncated)); setSearchNotFound(false); setSelectedGroupKey( parsed.kind === 'event' ? (() => { - const first = results[0]; + const first = results.events[0]; if (!first) return undefined; return isRunLevel(first.eventType) ? '__run__' @@ -1403,10 +1412,14 @@ export function EventListView({ return; } setSearchResults([]); + setSearchResultsTruncated(false); setSearchNotFound(true); setSelectedGroupKey(undefined); } finally { - if (searchRequestRef.current === requestId) { + if ( + searchRequestRef.current === requestId && + !abortController.signal.aborted + ) { setSearchLoading(false); } } @@ -1691,7 +1704,7 @@ export function EventListView({ {isExactSearchActive ? searchNotFound ? `No events found for ${searchQuery.trim()}` - : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} for ${searchQuery.trim()}` + : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} for ${searchQuery.trim()}${searchResultsTruncated ? ' (results may be truncated)' : ''}` : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} loaded`} {!isExactSearchActive && hasMoreEvents && ( diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index cc5e91b05f..ef721d030f 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -19,6 +19,7 @@ export { looksLikeWorkflowIdSearchInput, type ExactWorkflowSearchId, type ExactWorkflowSearchIdKind, + type ExactIdSearchResult, } from './lib/exact-event-search-id'; export { analyzeEvents, diff --git a/packages/web-shared/src/lib/exact-event-search-id.ts b/packages/web-shared/src/lib/exact-event-search-id.ts index 6e5b9498d0..01a9afe515 100644 --- a/packages/web-shared/src/lib/exact-event-search-id.ts +++ b/packages/web-shared/src/lib/exact-event-search-id.ts @@ -1,3 +1,5 @@ +import type { Event } from '@workflow/world'; + const WORKFLOW_ULID_BODY = '[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}'; const STEP_ID_PATTERN = new RegExp(`^step_(${WORKFLOW_ULID_BODY})$`, 'i'); @@ -14,6 +16,12 @@ export type ExactWorkflowSearchId = { id: string; }; +export type ExactIdSearchResult = { + events: Event[]; + /** Set when correlation pagination hits the client-side page cap. */ + truncated?: boolean; +}; + function matchPrefixedId( pattern: RegExp, prefix: 'step' | 'wait' | 'hook' | 'evnt', @@ -55,5 +63,6 @@ export function looksLikeWorkflowIdSearchInput(query: string): boolean { return false; } // Distinguish IDs (contain digits) from event-type strings like step_started. + // Assumes workflow event types do not include digits in their names. return /\d/.test(trimmed); } diff --git a/packages/web/app/lib/client/hooks/use-events-list-data.ts b/packages/web/app/lib/client/hooks/use-events-list-data.ts index d473e47c58..ddbefbf818 100644 --- a/packages/web/app/lib/client/hooks/use-events-list-data.ts +++ b/packages/web/app/lib/client/hooks/use-events-list-data.ts @@ -1,7 +1,10 @@ 'use client'; import type { Event } from '@workflow/world'; -import type { ExactWorkflowSearchIdKind } from '@workflow/web-shared'; +import type { + ExactIdSearchResult, + ExactWorkflowSearchIdKind, +} from '@workflow/web-shared'; import { useCallback, useEffect, useRef, useState } from 'react'; import { hydrateResourceIO, @@ -143,23 +146,32 @@ export function useEventsListData( async ( id: string, kind: ExactWorkflowSearchIdKind, - _signal?: AbortSignal - ): Promise => { + signal?: AbortSignal + ): Promise => { + if (signal?.aborted) { + return null; + } + if (kind === 'event') { const { error: fetchError, result } = await unwrapServerActionResult( fetchEvent(env, runId, id, 'none') ); - if (fetchError) { + if (fetchError || signal?.aborted) { return null; } const [event] = await hydrateEvents([result]); - return event?.runId === runId ? [event] : null; + return event?.runId === runId ? { events: [event] } : null; } const matched: Event[] = []; let nextCursor: string | undefined; let pagesFetched = 0; + let truncated = false; do { + if (signal?.aborted) { + return null; + } + const { error: fetchError, result } = await unwrapServerActionResult( fetchEventsByCorrelationId(env, id, { cursor: nextCursor, @@ -168,7 +180,7 @@ export function useEventsListData( withData: false, }) ); - if (fetchError) { + if (fetchError || signal?.aborted) { return null; } @@ -176,15 +188,18 @@ export function useEventsListData( const hydrated = await hydrateEvents(result.data); matched.push(...hydrated.filter((event) => event.runId === runId)); + const hitPageCap = pagesFetched >= MAX_CORRELATION_SEARCH_PAGES; + truncated = + truncated || (hitPageCap && Boolean(result.hasMore && result.cursor)); nextCursor = - pagesFetched < MAX_CORRELATION_SEARCH_PAGES && - result.hasMore && - result.cursor + !hitPageCap && result.hasMore && result.cursor ? result.cursor : undefined; } while (nextCursor); - return matched.length > 0 ? matched : null; + return matched.length > 0 + ? { events: matched, truncated: truncated || undefined } + : null; }, [env, runId, sortOrder, hydrateEvents] ); From f2f4224ef0c169130cac926fb0917cb22a9bf2de Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 25 May 2026 13:38:49 -0700 Subject: [PATCH 7/7] Differentiate exact ID search errors from not-found results. Return a discriminated union from onExactIdSearch and show search errors in the Events tab instead of mislabeling them as missing IDs. Co-authored-by: Cursor --- .../src/components/event-list-view.tsx | 40 ++++++++++++++----- .../src/lib/exact-event-search-id.ts | 9 ++--- .../lib/client/hooks/use-events-list-data.ts | 27 ++++++++----- 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index 966ff69c56..148a07bd9f 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -737,7 +737,7 @@ interface EventsListProps { id: string, kind: ExactWorkflowSearchIdKind, signal?: AbortSignal - ) => Promise; + ) => Promise; } function EventRow({ @@ -1196,6 +1196,7 @@ export function EventListView({ const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); const [searchResultsTruncated, setSearchResultsTruncated] = useState(false); + const [searchError, setSearchError] = useState(null); const [searchLoading, setSearchLoading] = useState(false); const [searchNotFound, setSearchNotFound] = useState(false); const searchRequestRef = useRef(0); @@ -1342,6 +1343,7 @@ export function EventListView({ searchRequestRef.current += 1; setSearchResults(null); setSearchResultsTruncated(false); + setSearchError(null); setSearchLoading(false); setSearchNotFound(false); setSelectedGroupKey(undefined); @@ -1359,6 +1361,7 @@ export function EventListView({ const requestId = ++searchRequestRef.current; setSearchLoading(true); setSearchNotFound(false); + setSearchError(null); const abortController = new AbortController(); @@ -1377,10 +1380,23 @@ export function EventListView({ return; } - if (!results || results.events.length === 0) { + if (results.status === 'error') { + setSearchResults([]); + setSearchResultsTruncated(false); + setSearchNotFound(false); + setSearchError(results.message); + setSelectedGroupKey(undefined); + return; + } + + if ( + results.status === 'not_found' || + (results.status === 'ok' && results.events.length === 0) + ) { setSearchResults([]); setSearchResultsTruncated(false); setSearchNotFound(true); + setSearchError(null); setSelectedGroupKey(undefined); return; } @@ -1388,6 +1404,7 @@ export function EventListView({ setSearchResults(results.events); setSearchResultsTruncated(Boolean(results.truncated)); setSearchNotFound(false); + setSearchError(null); setSelectedGroupKey( parsed.kind === 'event' ? (() => { @@ -1413,7 +1430,8 @@ export function EventListView({ } setSearchResults([]); setSearchResultsTruncated(false); - setSearchNotFound(true); + setSearchNotFound(false); + setSearchError('Failed to search events. Try again.'); setSelectedGroupKey(undefined); } finally { if ( @@ -1638,9 +1656,11 @@ export function EventListView({ > {searchNotFound && searchQuery.trim() ? `No events found for ${searchQuery.trim()}` - : parsedSearchId && searchQuery.trim() && !onExactIdSearch - ? 'Exact ID search is unavailable in this view.' - : 'No events found'} + : searchError + ? searchError + : parsedSearchId && searchQuery.trim() && !onExactIdSearch + ? 'Exact ID search is unavailable in this view.' + : 'No events found'} ) : ( {isExactSearchActive - ? searchNotFound - ? `No events found for ${searchQuery.trim()}` - : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} for ${searchQuery.trim()}${searchResultsTruncated ? ' (results may be truncated)' : ''}` + ? searchError + ? searchError + : searchNotFound + ? `No events found for ${searchQuery.trim()}` + : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} for ${searchQuery.trim()}${searchResultsTruncated ? ' (results may be truncated)' : ''}` : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} loaded`} {!isExactSearchActive && hasMoreEvents && ( diff --git a/packages/web-shared/src/lib/exact-event-search-id.ts b/packages/web-shared/src/lib/exact-event-search-id.ts index 01a9afe515..283b1a484b 100644 --- a/packages/web-shared/src/lib/exact-event-search-id.ts +++ b/packages/web-shared/src/lib/exact-event-search-id.ts @@ -16,11 +16,10 @@ export type ExactWorkflowSearchId = { id: string; }; -export type ExactIdSearchResult = { - events: Event[]; - /** Set when correlation pagination hits the client-side page cap. */ - truncated?: boolean; -}; +export type ExactIdSearchResult = + | { status: 'ok'; events: Event[]; truncated?: boolean } + | { status: 'not_found' } + | { status: 'error'; message: string }; function matchPrefixedId( pattern: RegExp, diff --git a/packages/web/app/lib/client/hooks/use-events-list-data.ts b/packages/web/app/lib/client/hooks/use-events-list-data.ts index ddbefbf818..8daf975c15 100644 --- a/packages/web/app/lib/client/hooks/use-events-list-data.ts +++ b/packages/web/app/lib/client/hooks/use-events-list-data.ts @@ -147,9 +147,9 @@ export function useEventsListData( id: string, kind: ExactWorkflowSearchIdKind, signal?: AbortSignal - ): Promise => { + ): Promise => { if (signal?.aborted) { - return null; + throw new DOMException('Aborted', 'AbortError'); } if (kind === 'event') { @@ -157,10 +157,16 @@ export function useEventsListData( fetchEvent(env, runId, id, 'none') ); if (fetchError || signal?.aborted) { - return null; + return fetchError + ? { status: 'error', message: fetchError.message } + : (() => { + throw new DOMException('Aborted', 'AbortError'); + })(); } const [event] = await hydrateEvents([result]); - return event?.runId === runId ? { events: [event] } : null; + return event?.runId === runId + ? { status: 'ok', events: [event] } + : { status: 'not_found' }; } const matched: Event[] = []; @@ -169,7 +175,7 @@ export function useEventsListData( let truncated = false; do { if (signal?.aborted) { - return null; + throw new DOMException('Aborted', 'AbortError'); } const { error: fetchError, result } = await unwrapServerActionResult( @@ -180,8 +186,11 @@ export function useEventsListData( withData: false, }) ); - if (fetchError || signal?.aborted) { - return null; + if (fetchError) { + return { status: 'error', message: fetchError.message }; + } + if (signal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); } pagesFetched += 1; @@ -198,8 +207,8 @@ export function useEventsListData( } while (nextCursor); return matched.length > 0 - ? { events: matched, truncated: truncated || undefined } - : null; + ? { status: 'ok', events: matched, truncated: truncated || undefined } + : { status: 'not_found' }; }, [env, runId, sortOrder, hydrateEvents] );