@workflow/web-shared 4.1.5 → 4.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,12 +3,23 @@
3
3
  import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
4
4
  import type { Event, WorkflowRun } from '@workflow/world';
5
5
  import { Check, ChevronRight, Copy } from 'lucide-react';
6
- import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
6
+ import type {
7
+ KeyboardEvent as ReactKeyboardEvent,
8
+ MouseEvent as ReactMouseEvent,
9
+ ReactNode,
10
+ } from 'react';
7
11
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
12
  import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
9
13
  import { isEncryptedMarker } from '../lib/hydration';
14
+ import {
15
+ parseExactWorkflowSearchId,
16
+ looksLikeWorkflowIdSearchInput,
17
+ type ExactIdSearchResult,
18
+ type ExactWorkflowSearchIdKind,
19
+ } from '../lib/exact-event-search-id';
10
20
  import { DecryptButton } from './ui/decrypt-button';
11
21
  import { formatDuration } from '../lib/utils';
22
+ import { useToast } from '../lib/toast';
12
23
  import { DataInspector, DecryptClickContext } from './ui/data-inspector';
13
24
  import {
14
25
  ErrorStackBlock,
@@ -142,7 +153,7 @@ function buildNameMaps(
142
153
  return { correlationNameMap, workflowName };
143
154
  }
144
155
 
145
- interface DurationInfo {
156
+ export interface DurationInfo {
146
157
  /** Time from created → started (ms) */
147
158
  queued?: number;
148
159
  /** Time from started → completed/failed/cancelled (ms) */
@@ -154,19 +165,30 @@ interface DurationInfo {
154
165
  * created ↔ started (queued) and started ↔ completed/failed/cancelled (ran).
155
166
  * Also computes run-level durations under the key '__run__'.
156
167
  */
157
- function buildDurationMap(events: Event[]): Map<string, DurationInfo> {
168
+ export function buildDurationMap(events: Event[]): Map<string, DurationInfo> {
169
+ // Process events in chronological order so the result doesn't depend on
170
+ // the caller's sort direction. Retried steps emit multiple `step_started`
171
+ // events for the same correlationId; the queued duration must be measured
172
+ // against the first one, not the last.
173
+ const chronological = [...events].sort(
174
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
175
+ );
176
+
158
177
  const createdTimes = new Map<string, number>();
178
+ const firstStartedTimes = new Map<string, number>();
159
179
  const startedTimes = new Map<string, number>();
160
180
  const durations = new Map<string, DurationInfo>();
161
181
 
162
- for (const event of events) {
182
+ for (const event of chronological) {
163
183
  const ts = new Date(event.createdAt).getTime();
164
184
  const key = event.correlationId ?? '__run__';
165
185
  const type: string = event.eventType;
166
186
 
167
187
  // Track created times (first event for each correlation)
168
188
  if (type === 'step_created' || type === 'run_created') {
169
- createdTimes.set(key, ts);
189
+ if (!createdTimes.has(key)) {
190
+ createdTimes.set(key, ts);
191
+ }
170
192
  }
171
193
 
172
194
  // Track started times & compute queued duration
@@ -176,16 +198,21 @@ function buildDurationMap(events: Event[]): Map<string, DurationInfo> {
176
198
  type === 'workflow_started'
177
199
  ) {
178
200
  startedTimes.set(key, ts);
179
- // If no explicit created event was seen, use the started time as created
180
- if (!createdTimes.has(key)) {
181
- createdTimes.set(key, ts);
182
- }
183
- const createdAt = createdTimes.get(key);
184
- const info = durations.get(key) ?? {};
185
- if (createdAt !== undefined) {
186
- info.queued = ts - createdAt;
201
+ // The queued duration is anchored on the first start event only
202
+ // subsequent step_started events come from retries.
203
+ if (!firstStartedTimes.has(key)) {
204
+ firstStartedTimes.set(key, ts);
205
+ // If no explicit created event was seen, use the started time as created
206
+ if (!createdTimes.has(key)) {
207
+ createdTimes.set(key, ts);
208
+ }
209
+ const createdAt = createdTimes.get(key);
210
+ const info = durations.get(key) ?? {};
211
+ if (createdAt !== undefined) {
212
+ info.queued = ts - createdAt;
213
+ }
214
+ durations.set(key, info);
187
215
  }
188
- durations.set(key, info);
189
216
  }
190
217
 
191
218
  // Compute ran duration on terminal events
@@ -705,6 +732,12 @@ interface EventsListProps {
705
732
  isDecrypting?: boolean;
706
733
  /** Run-level hint: the run contains encrypted data (from probe). */
707
734
  hasEncryptedData?: boolean;
735
+ /** Fetch events for an exact correlation or event ID. */
736
+ onExactIdSearch?: (
737
+ id: string,
738
+ kind: ExactWorkflowSearchIdKind,
739
+ signal?: AbortSignal
740
+ ) => Promise<ExactIdSearchResult>;
708
741
  }
709
742
 
710
743
  function EventRow({
@@ -727,6 +760,7 @@ function EventRow({
727
760
  onCacheEventData,
728
761
  encryptionKey,
729
762
  onEncryptedDataDetected,
763
+ suppressGroupDimming = false,
730
764
  }: {
731
765
  event: Event;
732
766
  index: number;
@@ -747,6 +781,8 @@ function EventRow({
747
781
  onCacheEventData: (eventId: string, data: unknown) => void;
748
782
  encryptionKey?: Uint8Array;
749
783
  onEncryptedDataDetected?: () => void;
784
+ /** Exact-ID search results should not dim unrelated rows. */
785
+ suppressGroupDimming?: boolean;
750
786
  }) {
751
787
  const [isLoading, setIsLoading] = useState(false);
752
788
  const [loadedEventData, setLoadedEventData] = useState<unknown | null>(
@@ -788,7 +824,7 @@ function EventRow({
788
824
 
789
825
  const hasActive = activeGroupKey !== undefined;
790
826
  const isRelated = rowGroupKey !== undefined && rowGroupKey === activeGroupKey;
791
- const isDimmed = hasActive && !isRelated;
827
+ const isDimmed = hasActive && !isRelated && !suppressGroupDimming;
792
828
  const isPulsing = hasActive && isRelated;
793
829
 
794
830
  // Gutter state derived from selectedGroupRange
@@ -1139,7 +1175,9 @@ export function EventListView({
1139
1175
  onDecrypt,
1140
1176
  isDecrypting = false,
1141
1177
  hasEncryptedData: hasEncryptedDataProp = false,
1178
+ onExactIdSearch,
1142
1179
  }: EventsListProps) {
1180
+ const toast = useToast();
1143
1181
  const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>(
1144
1182
  'asc'
1145
1183
  );
@@ -1155,25 +1193,42 @@ export function EventListView({
1155
1193
  [onSortOrderChange]
1156
1194
  );
1157
1195
 
1196
+ const [searchQuery, setSearchQuery] = useState('');
1197
+ const [searchResults, setSearchResults] = useState<Event[] | null>(null);
1198
+ const [searchResultsTruncated, setSearchResultsTruncated] = useState(false);
1199
+ const [searchError, setSearchError] = useState<string | null>(null);
1200
+ const [searchLoading, setSearchLoading] = useState(false);
1201
+ const [searchNotFound, setSearchNotFound] = useState(false);
1202
+ const searchRequestRef = useRef(0);
1203
+ const virtuosoRef = useRef<VirtuosoHandle>(null);
1204
+
1205
+ const parsedSearchId = useMemo(
1206
+ () => parseExactWorkflowSearchId(searchQuery),
1207
+ [searchQuery]
1208
+ );
1209
+ const isExactSearchActive = searchResults !== null;
1210
+
1158
1211
  const sortedEvents = useMemo(() => {
1159
- if (!events || events.length === 0) return [];
1212
+ const sourceEvents = isExactSearchActive ? searchResults : (events ?? []);
1213
+ if (sourceEvents.length === 0) return [];
1160
1214
  const dir = effectiveSortOrder === 'desc' ? -1 : 1;
1161
- return [...events].sort(
1215
+ return [...sourceEvents].sort(
1162
1216
  (a, b) =>
1163
1217
  dir *
1164
1218
  (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
1165
1219
  );
1166
- }, [events, effectiveSortOrder]);
1220
+ }, [events, effectiveSortOrder, isExactSearchActive, searchResults]);
1167
1221
 
1168
1222
  // Detect encrypted fields across all loaded events (inline eventData).
1169
1223
  const hasEncryptedInlineData = useMemo(() => {
1170
- if (!events) return false;
1171
- for (const event of events) {
1224
+ const sourceEvents = isExactSearchActive ? searchResults : events;
1225
+ if (!sourceEvents) return false;
1226
+ for (const event of sourceEvents) {
1172
1227
  const ed = (event as Record<string, unknown>).eventData;
1173
1228
  if (hasEncryptedValues(ed)) return true;
1174
1229
  }
1175
1230
  return false;
1176
- }, [events]);
1231
+ }, [events, isExactSearchActive, searchResults]);
1177
1232
 
1178
1233
  // Tracks whether any expanded row's lazy-loaded data contained encrypted markers.
1179
1234
  // Set to true by EventRow via onEncryptedDataDetected; never reset (sticky).
@@ -1187,8 +1242,12 @@ export function EventListView({
1187
1242
  hasEncryptedDataProp || hasEncryptedInlineData || foundEncryptedInLazyData;
1188
1243
 
1189
1244
  const { correlationNameMap, workflowName } = useMemo(
1190
- () => buildNameMaps(events ?? null, run ?? null),
1191
- [events, run]
1245
+ () =>
1246
+ buildNameMaps(
1247
+ isExactSearchActive ? searchResults : (events ?? null),
1248
+ run ?? null
1249
+ ),
1250
+ [events, isExactSearchActive, run, searchResults]
1192
1251
  );
1193
1252
 
1194
1253
  const durationMap = useMemo(
@@ -1278,68 +1337,139 @@ export function EventListView({
1278
1337
  return first >= 0 ? { first, last } : null;
1279
1338
  }, [activeGroupKey, sortedEvents]);
1280
1339
 
1281
- const [searchQuery, setSearchQuery] = useState('');
1282
- const virtuosoRef = useRef<VirtuosoHandle>(null);
1283
-
1284
- const searchIndex = useMemo(() => {
1285
- const entries: {
1286
- fields: string[];
1287
- groupKey?: string;
1288
- eventId: string;
1289
- index: number;
1290
- }[] = [];
1291
- for (let i = 0; i < sortedEvents.length; i++) {
1292
- const ev = sortedEvents[i];
1293
- const isRun = isRunLevel(ev.eventType);
1294
- const name = isRun
1295
- ? (workflowName ?? '')
1296
- : ev.correlationId
1297
- ? (correlationNameMap.get(ev.correlationId) ?? '')
1298
- : '';
1299
- entries.push({
1300
- fields: [
1301
- ev.eventId,
1302
- ev.correlationId ?? '',
1303
- ev.eventType,
1304
- formatEventType(ev.eventType),
1305
- name,
1306
- ].map((f) => f.toLowerCase()),
1307
- groupKey: ev.correlationId ?? (isRun ? '__run__' : undefined),
1308
- eventId: ev.eventId,
1309
- index: i,
1310
- });
1311
- }
1312
- return entries;
1313
- }, [sortedEvents, correlationNameMap, workflowName]);
1314
-
1315
1340
  useEffect(() => {
1316
- const q = searchQuery.trim().toLowerCase();
1317
- if (!q) {
1341
+ const trimmed = searchQuery.trim();
1342
+ if (!trimmed) {
1343
+ searchRequestRef.current += 1;
1344
+ setSearchResults(null);
1345
+ setSearchResultsTruncated(false);
1346
+ setSearchError(null);
1347
+ setSearchLoading(false);
1348
+ setSearchNotFound(false);
1318
1349
  setSelectedGroupKey(undefined);
1319
1350
  return;
1320
1351
  }
1321
- let bestMatch: (typeof searchIndex)[number] | null = null;
1322
- let bestScore = 0;
1323
- for (const entry of searchIndex) {
1324
- for (const field of entry.fields) {
1325
- if (field && field.includes(q)) {
1326
- const score = q.length / field.length;
1327
- if (score > bestScore) {
1328
- bestScore = score;
1329
- bestMatch = entry;
1352
+
1353
+ const parsed = parseExactWorkflowSearchId(trimmed);
1354
+ if (!parsed || !onExactIdSearch) {
1355
+ setSearchResults(null);
1356
+ setSearchLoading(false);
1357
+ setSearchNotFound(false);
1358
+ return;
1359
+ }
1360
+
1361
+ const requestId = ++searchRequestRef.current;
1362
+ setSearchLoading(true);
1363
+ setSearchNotFound(false);
1364
+ setSearchError(null);
1365
+
1366
+ const abortController = new AbortController();
1367
+
1368
+ const timer = setTimeout(() => {
1369
+ void (async () => {
1370
+ try {
1371
+ const results = await onExactIdSearch(
1372
+ parsed.id,
1373
+ parsed.kind,
1374
+ abortController.signal
1375
+ );
1376
+ if (
1377
+ abortController.signal.aborted ||
1378
+ searchRequestRef.current !== requestId
1379
+ ) {
1380
+ return;
1381
+ }
1382
+
1383
+ if (results.status === 'error') {
1384
+ setSearchResults([]);
1385
+ setSearchResultsTruncated(false);
1386
+ setSearchNotFound(false);
1387
+ setSearchError(results.message);
1388
+ setSelectedGroupKey(undefined);
1389
+ return;
1390
+ }
1391
+
1392
+ if (
1393
+ results.status === 'not_found' ||
1394
+ (results.status === 'ok' && results.events.length === 0)
1395
+ ) {
1396
+ setSearchResults([]);
1397
+ setSearchResultsTruncated(false);
1398
+ setSearchNotFound(true);
1399
+ setSearchError(null);
1400
+ setSelectedGroupKey(undefined);
1401
+ return;
1402
+ }
1403
+
1404
+ setSearchResults(results.events);
1405
+ setSearchResultsTruncated(Boolean(results.truncated));
1406
+ setSearchNotFound(false);
1407
+ setSearchError(null);
1408
+ setSelectedGroupKey(
1409
+ parsed.kind === 'event'
1410
+ ? (() => {
1411
+ const first = results.events[0];
1412
+ if (!first) return undefined;
1413
+ return isRunLevel(first.eventType)
1414
+ ? '__run__'
1415
+ : (first.correlationId ?? undefined);
1416
+ })()
1417
+ : parsed.id
1418
+ );
1419
+ virtuosoRef.current?.scrollToIndex({
1420
+ index: 0,
1421
+ align: 'start',
1422
+ behavior: 'smooth',
1423
+ });
1424
+ } catch {
1425
+ if (
1426
+ abortController.signal.aborted ||
1427
+ searchRequestRef.current !== requestId
1428
+ ) {
1429
+ return;
1430
+ }
1431
+ setSearchResults([]);
1432
+ setSearchResultsTruncated(false);
1433
+ setSearchNotFound(false);
1434
+ setSearchError('Failed to search events. Try again.');
1435
+ setSelectedGroupKey(undefined);
1436
+ } finally {
1437
+ if (
1438
+ searchRequestRef.current === requestId &&
1439
+ !abortController.signal.aborted
1440
+ ) {
1441
+ setSearchLoading(false);
1330
1442
  }
1331
1443
  }
1444
+ })();
1445
+ }, 300);
1446
+
1447
+ return () => {
1448
+ clearTimeout(timer);
1449
+ abortController.abort();
1450
+ };
1451
+ }, [searchQuery, onExactIdSearch]);
1452
+
1453
+ const handleSearchKeyDown = useCallback(
1454
+ (event: ReactKeyboardEvent<HTMLInputElement>) => {
1455
+ if (event.key !== 'Enter') {
1456
+ return;
1332
1457
  }
1333
- }
1334
- if (bestMatch) {
1335
- setSelectedGroupKey(bestMatch.groupKey);
1336
- virtuosoRef.current?.scrollToIndex({
1337
- index: bestMatch.index,
1338
- align: 'center',
1339
- behavior: 'smooth',
1340
- });
1341
- }
1342
- }, [searchQuery, searchIndex]);
1458
+
1459
+ const trimmed = searchQuery.trim();
1460
+ if (
1461
+ !trimmed ||
1462
+ parseExactWorkflowSearchId(trimmed) ||
1463
+ !onExactIdSearch ||
1464
+ !looksLikeWorkflowIdSearchInput(trimmed)
1465
+ ) {
1466
+ return;
1467
+ }
1468
+
1469
+ toast.info('Enter a full step ID, wait ID, hook ID, or event ID');
1470
+ },
1471
+ [searchQuery, onExactIdSearch, toast]
1472
+ );
1343
1473
 
1344
1474
  // Track whether we've ever had events to distinguish initial load from refetch
1345
1475
  const hasHadEventsRef = useRef(false);
@@ -1385,17 +1515,6 @@ export function EventListView({
1385
1515
  );
1386
1516
  }
1387
1517
 
1388
- if (!isLoading && (!events || events.length === 0)) {
1389
- return (
1390
- <div
1391
- className="flex items-center justify-center h-full text-sm"
1392
- style={{ color: 'var(--ds-gray-700)' }}
1393
- >
1394
- No events found
1395
- </div>
1396
- );
1397
- }
1398
-
1399
1518
  return (
1400
1519
  <DecryptClickContext.Provider
1401
1520
  value={onDecrypt ? { onDecrypt, isDecrypting } : undefined}
@@ -1460,9 +1579,16 @@ export function EventListView({
1460
1579
  </div>
1461
1580
  <input
1462
1581
  type="search"
1463
- placeholder="Search by name, event type, or ID…"
1582
+ placeholder="Search by step ID, wait ID, hook ID, or event ID…"
1464
1583
  value={searchQuery}
1465
1584
  onChange={(e) => setSearchQuery(e.target.value)}
1585
+ onKeyDown={handleSearchKeyDown}
1586
+ disabled={!onExactIdSearch}
1587
+ title={
1588
+ onExactIdSearch
1589
+ ? undefined
1590
+ : 'Exact ID search is unavailable in this view.'
1591
+ }
1466
1592
  style={{
1467
1593
  marginLeft: -16,
1468
1594
  paddingInline: 12,
@@ -1473,6 +1599,8 @@ export function EventListView({
1473
1599
  outline: 'none',
1474
1600
  height: 40,
1475
1601
  width: '100%',
1602
+ opacity: onExactIdSearch ? 1 : 0.5,
1603
+ cursor: onExactIdSearch ? 'text' : 'not-allowed',
1476
1604
  }}
1477
1605
  />
1478
1606
  </label>
@@ -1519,8 +1647,21 @@ export function EventListView({
1519
1647
  </div>
1520
1648
 
1521
1649
  {/* Virtualized event rows or refetching skeleton */}
1522
- {isRefetching ? (
1650
+ {isRefetching || searchLoading ? (
1523
1651
  <RowsSkeleton />
1652
+ ) : sortedEvents.length === 0 ? (
1653
+ <div
1654
+ className="flex flex-1 items-center justify-center px-6 text-center text-sm"
1655
+ style={{ color: 'var(--ds-gray-700)' }}
1656
+ >
1657
+ {searchNotFound && searchQuery.trim()
1658
+ ? `No events found for ${searchQuery.trim()}`
1659
+ : searchError
1660
+ ? searchError
1661
+ : parsedSearchId && searchQuery.trim() && !onExactIdSearch
1662
+ ? 'Exact ID search is unavailable in this view.'
1663
+ : 'No events found'}
1664
+ </div>
1524
1665
  ) : (
1525
1666
  <Virtuoso
1526
1667
  ref={virtuosoRef}
@@ -1528,7 +1669,11 @@ export function EventListView({
1528
1669
  overscan={20}
1529
1670
  defaultItemHeight={40}
1530
1671
  endReached={() => {
1531
- if (!hasMoreEvents || isLoadingMoreEvents) {
1672
+ if (
1673
+ isExactSearchActive ||
1674
+ !hasMoreEvents ||
1675
+ isLoadingMoreEvents
1676
+ ) {
1532
1677
  return;
1533
1678
  }
1534
1679
  void onLoadMoreEvents?.();
@@ -1558,6 +1703,7 @@ export function EventListView({
1558
1703
  onCacheEventData={cacheEventData}
1559
1704
  encryptionKey={encryptionKey}
1560
1705
  onEncryptedDataDetected={handleEncryptedDataDetected}
1706
+ suppressGroupDimming={isExactSearchActive}
1561
1707
  />
1562
1708
  );
1563
1709
  }}
@@ -1575,10 +1721,15 @@ export function EventListView({
1575
1721
  }}
1576
1722
  >
1577
1723
  <span>
1578
- {sortedEvents.length} event
1579
- {sortedEvents.length !== 1 ? 's' : ''} loaded
1724
+ {isExactSearchActive
1725
+ ? searchError
1726
+ ? searchError
1727
+ : searchNotFound
1728
+ ? `No events found for ${searchQuery.trim()}`
1729
+ : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} for ${searchQuery.trim()}${searchResultsTruncated ? ' (results may be truncated)' : ''}`
1730
+ : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} loaded`}
1580
1731
  </span>
1581
- {hasMoreEvents && (
1732
+ {!isExactSearchActive && hasMoreEvents && (
1582
1733
  <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
1583
1734
  <div className="pointer-events-auto">
1584
1735
  <LoadMoreButton
@@ -968,7 +968,7 @@ export const WorkflowTraceViewer = ({
968
968
  }
969
969
 
970
970
  return (
971
- <div className="relative w-full h-full flex">
971
+ <div className="relative w-full h-full flex flex-row">
972
972
  {/* Timeline (takes remaining space) */}
973
973
  <div className="flex-1 min-w-0 relative">
974
974
  <TraceViewerContextProvider
@@ -296,7 +296,7 @@ export function hookToSpan(hookEvents: Event[], maxEndTime: Date): Span | null {
296
296
 
297
297
  return {
298
298
  spanId: String(hook.hookId),
299
- name: String(hook.hookId),
299
+ name: hook.token ?? String(hook.hookId),
300
300
  kind: 1, // INTERNAL span kind
301
301
  resource: 'hook',
302
302
  library: WORKFLOW_LIBRARY,
package/src/index.ts CHANGED
@@ -14,6 +14,13 @@ export {
14
14
  waitEventsToWaitEntity,
15
15
  } from './components/workflow-traces/trace-span-construction';
16
16
  export type { EventAnalysis } from './lib/event-analysis';
17
+ export {
18
+ parseExactWorkflowSearchId,
19
+ looksLikeWorkflowIdSearchInput,
20
+ type ExactWorkflowSearchId,
21
+ type ExactWorkflowSearchIdKind,
22
+ type ExactIdSearchResult,
23
+ } from './lib/exact-event-search-id';
17
24
  export {
18
25
  analyzeEvents,
19
26
  hasPendingHooksFromEvents,
@@ -0,0 +1,67 @@
1
+ import type { Event } from '@workflow/world';
2
+
3
+ const WORKFLOW_ULID_BODY = '[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}';
4
+
5
+ const STEP_ID_PATTERN = new RegExp(`^step_(${WORKFLOW_ULID_BODY})$`, 'i');
6
+ const WAIT_ID_PATTERN = new RegExp(`^wait_(${WORKFLOW_ULID_BODY})$`, 'i');
7
+ const HOOK_ID_PATTERN = new RegExp(`^hook_(${WORKFLOW_ULID_BODY})$`, 'i');
8
+ const EVENT_ID_PATTERN = new RegExp(`^evnt_(${WORKFLOW_ULID_BODY})$`, 'i');
9
+
10
+ const WORKFLOW_ID_PREFIX_PATTERN = /^(step_|wait_|hook_|evnt_|wrun_)/i;
11
+
12
+ export type ExactWorkflowSearchIdKind = 'step' | 'wait' | 'hook' | 'event';
13
+
14
+ export type ExactWorkflowSearchId = {
15
+ kind: ExactWorkflowSearchIdKind;
16
+ id: string;
17
+ };
18
+
19
+ export type ExactIdSearchResult =
20
+ | { status: 'ok'; events: Event[]; truncated?: boolean }
21
+ | { status: 'not_found' }
22
+ | { status: 'error'; message: string };
23
+
24
+ function matchPrefixedId(
25
+ pattern: RegExp,
26
+ prefix: 'step' | 'wait' | 'hook' | 'evnt',
27
+ kind: ExactWorkflowSearchIdKind,
28
+ query: string
29
+ ): ExactWorkflowSearchId | null {
30
+ const match = query.match(pattern);
31
+ if (!match) {
32
+ return null;
33
+ }
34
+ return { kind, id: `${prefix}_${match[1].toUpperCase()}` };
35
+ }
36
+
37
+ /**
38
+ * Returns a parsed workflow ID when `query` is a full step, wait, hook, or event ID.
39
+ * Partial IDs and run IDs (`wrun_`) are ignored. ULID bodies are matched case-insensitively
40
+ * and normalized to uppercase in the returned ID.
41
+ */
42
+ export function parseExactWorkflowSearchId(
43
+ query: string
44
+ ): ExactWorkflowSearchId | null {
45
+ const trimmed = query.trim();
46
+ if (!trimmed) {
47
+ return null;
48
+ }
49
+
50
+ return (
51
+ matchPrefixedId(STEP_ID_PATTERN, 'step', 'step', trimmed) ??
52
+ matchPrefixedId(WAIT_ID_PATTERN, 'wait', 'wait', trimmed) ??
53
+ matchPrefixedId(HOOK_ID_PATTERN, 'hook', 'hook', trimmed) ??
54
+ matchPrefixedId(EVENT_ID_PATTERN, 'evnt', 'event', trimmed)
55
+ );
56
+ }
57
+
58
+ /** True when input looks like the user is attempting an ID search (including partial). */
59
+ export function looksLikeWorkflowIdSearchInput(query: string): boolean {
60
+ const trimmed = query.trim();
61
+ if (!WORKFLOW_ID_PREFIX_PATTERN.test(trimmed)) {
62
+ return false;
63
+ }
64
+ // Distinguish IDs (contain digits) from event-type strings like step_started.
65
+ // Assumes workflow event types do not include digits in their names.
66
+ return /\d/.test(trimmed);
67
+ }