diff --git a/.changeset/events-exact-id-search.md b/.changeset/events-exact-id-search.md new file mode 100644 index 0000000000..3767b0ddcc --- /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 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 db83be83f5..148a07bd9f 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -3,12 +3,23 @@ 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, + looksLikeWorkflowIdSearchInput, + type ExactIdSearchResult, + 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 +732,12 @@ 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, + signal?: AbortSignal + ) => Promise; } function EventRow({ @@ -743,6 +760,7 @@ function EventRow({ onCacheEventData, encryptionKey, onEncryptedDataDetected, + suppressGroupDimming = false, }: { event: Event; index: number; @@ -763,6 +781,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( @@ -804,7 +824,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 @@ -1155,7 +1175,9 @@ export function EventListView({ onDecrypt, isDecrypting = false, hasEncryptedData: hasEncryptedDataProp = false, + onExactIdSearch, }: EventsListProps) { + const toast = useToast(); const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>( 'asc' ); @@ -1171,25 +1193,42 @@ export function EventListView({ [onSortOrderChange] ); + 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); + 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 +1242,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 +1337,139 @@ 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) { + searchRequestRef.current += 1; + setSearchResults(null); + setSearchResultsTruncated(false); + setSearchError(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); + setSearchError(null); + + const abortController = new AbortController(); + + const timer = setTimeout(() => { + void (async () => { + try { + const results = await onExactIdSearch( + parsed.id, + parsed.kind, + abortController.signal + ); + if ( + abortController.signal.aborted || + searchRequestRef.current !== requestId + ) { + return; + } + + 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; + } + + setSearchResults(results.events); + setSearchResultsTruncated(Boolean(results.truncated)); + setSearchNotFound(false); + setSearchError(null); + setSelectedGroupKey( + parsed.kind === 'event' + ? (() => { + const first = results.events[0]; + if (!first) return undefined; + return isRunLevel(first.eventType) + ? '__run__' + : (first.correlationId ?? undefined); + })() + : parsed.id + ); + virtuosoRef.current?.scrollToIndex({ + index: 0, + align: 'start', + behavior: 'smooth', + }); + } catch { + if ( + abortController.signal.aborted || + searchRequestRef.current !== requestId + ) { + return; + } + setSearchResults([]); + setSearchResultsTruncated(false); + setSearchNotFound(false); + setSearchError('Failed to search events. Try again.'); + setSelectedGroupKey(undefined); + } finally { + if ( + searchRequestRef.current === requestId && + !abortController.signal.aborted + ) { + setSearchLoading(false); } } + })(); + }, 300); + + return () => { + clearTimeout(timer); + abortController.abort(); + }; + }, [searchQuery, onExactIdSearch]); + + const handleSearchKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key !== 'Enter') { + return; + } + + const trimmed = searchQuery.trim(); + if ( + !trimmed || + parseExactWorkflowSearchId(trimmed) || + !onExactIdSearch || + !looksLikeWorkflowIdSearchInput(trimmed) + ) { + return; } - } - if (bestMatch) { - setSelectedGroupKey(bestMatch.groupKey); - virtuosoRef.current?.scrollToIndex({ - index: bestMatch.index, - align: 'center', - behavior: 'smooth', - }); - } - }, [searchQuery, searchIndex]); + + toast.info('Enter a full step ID, wait ID, hook 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 +1515,6 @@ export function EventListView({ ); } - if (!isLoading && (!events || events.length === 0)) { - return ( -
- No events found -
- ); - } - return ( setSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + disabled={!onExactIdSearch} + title={ + onExactIdSearch + ? undefined + : 'Exact ID search is unavailable in this view.' + } style={{ marginLeft: -16, paddingInline: 12, @@ -1489,6 +1599,8 @@ export function EventListView({ outline: 'none', height: 40, width: '100%', + opacity: onExactIdSearch ? 1 : 0.5, + cursor: onExactIdSearch ? 'text' : 'not-allowed', }} /> @@ -1535,8 +1647,21 @@ export function EventListView({ {/* Virtualized event rows or refetching skeleton */} - {isRefetching ? ( + {isRefetching || searchLoading ? ( + ) : sortedEvents.length === 0 ? ( +
+ {searchNotFound && searchQuery.trim() + ? `No events found for ${searchQuery.trim()}` + : searchError + ? searchError + : 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?.(); @@ -1574,6 +1703,7 @@ export function EventListView({ onCacheEventData={cacheEventData} encryptionKey={encryptionKey} onEncryptedDataDetected={handleEncryptedDataDetected} + suppressGroupDimming={isExactSearchActive} /> ); }} @@ -1591,10 +1721,15 @@ export function EventListView({ }} > - {sortedEvents.length} event - {sortedEvents.length !== 1 ? 's' : ''} loaded + {isExactSearchActive + ? 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`} - {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('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(); + expect(parseExactWorkflowSearchId('hook_01KSG94')).toBeNull(); + expect(parseExactWorkflowSearchId('evnt_01KSG94')).toBeNull(); + expect( + 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/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..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 @@ -1,15 +1,27 @@ +'use client'; + +import type { Event } from '@workflow/world'; +import type { + ExactIdSearchResult, + 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; 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. @@ -39,6 +51,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 +82,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 +92,7 @@ export function useEventsListData( setLoading(false); isFetchingRef.current = false; } - }, [env, runId, sortOrder]); + }, [env, runId, sortOrder, hydrateEvents]); useEffect(() => { if (enabled) fetchInitial(); @@ -115,16 +129,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 +140,78 @@ 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, + signal?: AbortSignal + ): Promise => { + if (signal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + + if (kind === 'event') { + const { error: fetchError, result } = await unwrapServerActionResult( + fetchEvent(env, runId, id, 'none') + ); + if (fetchError || signal?.aborted) { + return fetchError + ? { status: 'error', message: fetchError.message } + : (() => { + throw new DOMException('Aborted', 'AbortError'); + })(); + } + const [event] = await hydrateEvents([result]); + return event?.runId === runId + ? { status: 'ok', events: [event] } + : { status: 'not_found' }; + } + + const matched: Event[] = []; + let nextCursor: string | undefined; + let pagesFetched = 0; + let truncated = false; + do { + if (signal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + + const { error: fetchError, result } = await unwrapServerActionResult( + fetchEventsByCorrelationId(env, id, { + cursor: nextCursor, + sortOrder, + limit: 100, + withData: false, + }) + ); + if (fetchError) { + return { status: 'error', message: fetchError.message }; + } + if (signal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + + pagesFetched += 1; + 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 = + !hitPageCap && result.hasMore && result.cursor + ? result.cursor + : undefined; + } while (nextCursor); + + return matched.length > 0 + ? { status: 'ok', events: matched, truncated: truncated || undefined } + : { status: 'not_found' }; + }, + [env, runId, sortOrder, hydrateEvents] + ); return { events, @@ -143,5 +220,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 */