@workflow/web-shared 4.1.0-beta.62 → 4.1.0-beta.64

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.
Files changed (70) hide show
  1. package/README.md +4 -0
  2. package/dist/components/event-list-view.d.ts +9 -3
  3. package/dist/components/event-list-view.d.ts.map +1 -1
  4. package/dist/components/event-list-view.js +222 -98
  5. package/dist/components/event-list-view.js.map +1 -1
  6. package/dist/components/index.d.ts +1 -0
  7. package/dist/components/index.d.ts.map +1 -1
  8. package/dist/components/index.js +1 -0
  9. package/dist/components/index.js.map +1 -1
  10. package/dist/components/run-trace-view.d.ts +1 -3
  11. package/dist/components/run-trace-view.d.ts.map +1 -1
  12. package/dist/components/run-trace-view.js +2 -2
  13. package/dist/components/run-trace-view.js.map +1 -1
  14. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  15. package/dist/components/sidebar/attribute-panel.js +11 -1
  16. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  17. package/dist/components/sidebar/detail-card.d.ts.map +1 -1
  18. package/dist/components/sidebar/detail-card.js +4 -2
  19. package/dist/components/sidebar/detail-card.js.map +1 -1
  20. package/dist/components/sidebar/entity-detail-panel.d.ts +3 -3
  21. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  22. package/dist/components/sidebar/entity-detail-panel.js +43 -26
  23. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  24. package/dist/components/trace-viewer/trace-viewer.d.ts +7 -1
  25. package/dist/components/trace-viewer/trace-viewer.d.ts.map +1 -1
  26. package/dist/components/trace-viewer/trace-viewer.js +36 -11
  27. package/dist/components/trace-viewer/trace-viewer.js.map +1 -1
  28. package/dist/components/ui/error-stack-block.d.ts +3 -4
  29. package/dist/components/ui/error-stack-block.d.ts.map +1 -1
  30. package/dist/components/ui/error-stack-block.js +18 -9
  31. package/dist/components/ui/error-stack-block.js.map +1 -1
  32. package/dist/components/ui/menu-dropdown.d.ts +16 -0
  33. package/dist/components/ui/menu-dropdown.d.ts.map +1 -0
  34. package/dist/components/ui/menu-dropdown.js +50 -0
  35. package/dist/components/ui/menu-dropdown.js.map +1 -0
  36. package/dist/components/workflow-trace-view.d.ts +3 -3
  37. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  38. package/dist/components/workflow-trace-view.js +31 -129
  39. package/dist/components/workflow-trace-view.js.map +1 -1
  40. package/dist/components/workflow-traces/trace-span-construction.d.ts +18 -5
  41. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  42. package/dist/components/workflow-traces/trace-span-construction.js +65 -18
  43. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  44. package/dist/index.d.ts +3 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/lib/event-materialization.d.ts +72 -0
  49. package/dist/lib/event-materialization.d.ts.map +1 -0
  50. package/dist/lib/event-materialization.js +171 -0
  51. package/dist/lib/event-materialization.js.map +1 -0
  52. package/dist/lib/trace-builder.d.ts +32 -0
  53. package/dist/lib/trace-builder.d.ts.map +1 -0
  54. package/dist/lib/trace-builder.js +129 -0
  55. package/dist/lib/trace-builder.js.map +1 -0
  56. package/package.json +3 -3
  57. package/src/components/event-list-view.tsx +324 -103
  58. package/src/components/index.ts +1 -0
  59. package/src/components/run-trace-view.tsx +0 -6
  60. package/src/components/sidebar/attribute-panel.tsx +17 -2
  61. package/src/components/sidebar/detail-card.tsx +10 -2
  62. package/src/components/sidebar/entity-detail-panel.tsx +59 -21
  63. package/src/components/trace-viewer/trace-viewer.tsx +47 -2
  64. package/src/components/ui/error-stack-block.tsx +26 -16
  65. package/src/components/ui/menu-dropdown.tsx +114 -0
  66. package/src/components/workflow-trace-view.tsx +95 -195
  67. package/src/components/workflow-traces/trace-span-construction.ts +85 -32
  68. package/src/index.ts +13 -0
  69. package/src/lib/event-materialization.ts +243 -0
  70. package/src/lib/trace-builder.ts +201 -0
@@ -35,14 +35,7 @@ import {
35
35
  getCustomSpanClassName,
36
36
  getCustomSpanEventClassName,
37
37
  } from './workflow-traces/trace-colors';
38
- import {
39
- hookToSpan,
40
- runToSpan,
41
- stepToSpan,
42
- WORKFLOW_LIBRARY,
43
- waitToSpan,
44
- } from './workflow-traces/trace-span-construction';
45
- import { otelTimeToMs } from './workflow-traces/trace-time-utils';
38
+ import { buildTrace, type TraceWithMeta } from '../lib/trace-builder';
46
39
 
47
40
  /**
48
41
  * While a run is live, continuously grow root.duration and rescale so the
@@ -273,7 +266,6 @@ function SpanContextMenu({
273
266
  function TraceViewerWithContextMenu({
274
267
  trace,
275
268
  run,
276
- hooks,
277
269
  isLive,
278
270
  onWakeUpSleep,
279
271
  onCancelRun,
@@ -285,7 +277,6 @@ function TraceViewerWithContextMenu({
285
277
  }: {
286
278
  trace: { spans: Span[] };
287
279
  run: WorkflowRun;
288
- hooks: Hook[];
289
280
  isLive: boolean;
290
281
  onWakeUpSleep?: (
291
282
  runId: string,
@@ -307,7 +298,10 @@ function TraceViewerWithContextMenu({
307
298
  // Drive active span widths at 60fps without React re-renders
308
299
  useLiveTick(isLive);
309
300
  const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
310
- const [resolveHookTarget, setResolveHookTarget] = useState<Hook | null>(null);
301
+ const [resolveHookTarget, setResolveHookTarget] = useState<{
302
+ hookId: string;
303
+ token: string;
304
+ } | null>(null);
311
305
  const [resolvingHook, setResolvingHook] = useState(false);
312
306
  // Track hooks resolved in this session so the context menu item hides immediately
313
307
  const [resolvedHookIds, setResolvedHookIds] = useState<Set<string>>(
@@ -323,15 +317,6 @@ function TraceViewerWithContextMenu({
323
317
  return map;
324
318
  }, [trace.spans]);
325
319
 
326
- // Build a lookup map: hookId -> Hook
327
- const hookLookup = useMemo(() => {
328
- const map = new Map<string, Hook>();
329
- for (const hook of hooks) {
330
- map.set(hook.hookId, hook);
331
- }
332
- return map;
333
- }, [hooks]);
334
-
335
320
  const handleResolveHook = useCallback(
336
321
  async (payload: unknown) => {
337
322
  if (resolvingHook || !resolveHookTarget || !onResolveHook) return;
@@ -344,11 +329,7 @@ function TraceViewerWithContextMenu({
344
329
  }
345
330
  try {
346
331
  setResolvingHook(true);
347
- await onResolveHook(
348
- resolveHookTarget.token,
349
- payload,
350
- resolveHookTarget
351
- );
332
+ await onResolveHook(resolveHookTarget.token, payload);
352
333
  toast.success('Hook resolved', {
353
334
  description: 'The payload has been sent and the hook resolved.',
354
335
  });
@@ -496,22 +477,24 @@ function TraceViewerWithContextMenu({
496
477
 
497
478
  // Hook-specific: Resolve Hook (only on active, unresolved hooks)
498
479
  if (menu.resourceType === 'hook' && isRunActive && onResolveHook) {
499
- const hook = hookLookup.get(menu.spanId);
500
480
  const span = spanLookup.get(menu.spanId);
501
- // Check data-level disposedAt, span events, AND local resolved state
502
481
  const hookData = span?.attributes?.data as
503
- | { disposedAt?: unknown }
482
+ | { token?: string; disposedAt?: unknown }
504
483
  | undefined;
484
+ // Check data-level disposedAt, span events, AND local resolved state
505
485
  const isDisposed =
506
486
  Boolean(hookData?.disposedAt) ||
507
487
  Boolean(span?.events?.some((e) => e.name === 'hook_disposed')) ||
508
488
  resolvedHookIds.has(menu.spanId);
509
- if (hook?.token && !isDisposed) {
489
+ if (hookData?.token && !isDisposed) {
510
490
  items.push({
511
491
  label: 'Resolve Hook',
512
492
  icon: <Send className="h-3.5 w-3.5" />,
513
493
  action: () => {
514
- setResolveHookTarget(hook);
494
+ setResolveHookTarget({
495
+ hookId: menu.spanId,
496
+ token: hookData.token!,
497
+ });
515
498
  },
516
499
  });
517
500
  }
@@ -582,7 +565,6 @@ function TraceViewerWithContextMenu({
582
565
  onWakeUpSleep,
583
566
  onCancelRun,
584
567
  onResolveHook,
585
- hookLookup,
586
568
  spanLookup,
587
569
  resolvedHookIds,
588
570
  run.runId,
@@ -610,159 +592,6 @@ function TraceViewerWithContextMenu({
610
592
  );
611
593
  }
612
594
 
613
- type GroupedEvents = {
614
- eventsByStepId: Map<string, Event[]>;
615
- eventsByHookId: Map<string, Event[]>;
616
- runLevelEvents: Event[];
617
- timerEvents: Map<string, Event[]>;
618
- hookEvents: Map<string, Event[]>;
619
- };
620
-
621
- const isTimerEvent = (eventType: string) =>
622
- eventType === 'wait_created' || eventType === 'wait_completed';
623
-
624
- const isHookLifecycleEvent = (eventType: string) =>
625
- eventType === 'hook_received' ||
626
- eventType === 'hook_created' ||
627
- eventType === 'hook_disposed';
628
-
629
- const pushEvent = (
630
- map: Map<string, Event[]>,
631
- correlationId: string,
632
- event: Event
633
- ) => {
634
- const existing = map.get(correlationId);
635
- if (existing) {
636
- existing.push(event);
637
- return;
638
- }
639
- map.set(correlationId, [event]);
640
- };
641
-
642
- const groupEventsByCorrelation = (
643
- events: Event[],
644
- steps: Step[],
645
- hooks: Hook[]
646
- ): GroupedEvents => {
647
- const eventsByStepId = new Map<string, Event[]>();
648
- const eventsByHookId = new Map<string, Event[]>();
649
- const runLevelEvents: Event[] = [];
650
- const timerEvents = new Map<string, Event[]>();
651
- const hookEvents = new Map<string, Event[]>();
652
- const stepIds = new Set(steps.map((step) => step.stepId));
653
- const hookIds = new Set(hooks.map((hook) => hook.hookId));
654
-
655
- for (const event of events) {
656
- const correlationId = event.correlationId;
657
- if (!correlationId) {
658
- runLevelEvents.push(event);
659
- continue;
660
- }
661
-
662
- if (isTimerEvent(event.eventType)) {
663
- pushEvent(timerEvents, correlationId, event);
664
- continue;
665
- }
666
-
667
- if (isHookLifecycleEvent(event.eventType)) {
668
- pushEvent(hookEvents, correlationId, event);
669
- continue;
670
- }
671
-
672
- if (stepIds.has(correlationId)) {
673
- pushEvent(eventsByStepId, correlationId, event);
674
- continue;
675
- }
676
-
677
- if (hookIds.has(correlationId)) {
678
- pushEvent(eventsByHookId, correlationId, event);
679
- continue;
680
- }
681
-
682
- runLevelEvents.push(event);
683
- }
684
-
685
- return {
686
- eventsByStepId,
687
- eventsByHookId,
688
- runLevelEvents,
689
- timerEvents,
690
- hookEvents,
691
- };
692
- };
693
-
694
- const buildSpans = (
695
- run: WorkflowRun,
696
- steps: Step[],
697
- groupedEvents: GroupedEvents,
698
- now: Date
699
- ) => {
700
- const viewerEndTime = new Date(run.completedAt || now);
701
- const stepSpans = steps.map((step) => {
702
- const stepEvents = groupedEvents.eventsByStepId.get(step.stepId) || [];
703
- return stepToSpan(step, stepEvents, now, viewerEndTime);
704
- });
705
-
706
- const hookSpans = Array.from(groupedEvents.hookEvents.values())
707
- .map((events) => hookToSpan(events, run, now))
708
- .filter((span): span is Span => span !== null);
709
-
710
- const waitSpans = Array.from(groupedEvents.timerEvents.values())
711
- .map((events) => waitToSpan(events, run, now))
712
- .filter((span): span is Span => span !== null);
713
-
714
- return {
715
- runSpan: runToSpan(run, groupedEvents.runLevelEvents, now),
716
- spans: [...stepSpans, ...hookSpans, ...waitSpans],
717
- };
718
- };
719
-
720
- const cascadeSpans = (runSpan: Span, spans: Span[]) => {
721
- const sortedSpans = [
722
- runSpan,
723
- ...spans.slice().sort((a, b) => {
724
- const aStart = otelTimeToMs(a.startTime);
725
- const bStart = otelTimeToMs(b.startTime);
726
- return aStart - bStart;
727
- }),
728
- ];
729
-
730
- return sortedSpans.map((span, index) => {
731
- const parentSpanId =
732
- index === 0 ? undefined : String(sortedSpans[index - 1].spanId);
733
- return {
734
- ...span,
735
- parentSpanId,
736
- };
737
- });
738
- };
739
-
740
- const buildTrace = (
741
- run: WorkflowRun,
742
- steps: Step[],
743
- hooks: Hook[],
744
- events: Event[],
745
- now: Date
746
- ) => {
747
- const groupedEvents = groupEventsByCorrelation(events, steps, hooks);
748
- const { runSpan, spans } = buildSpans(run, steps, groupedEvents, now);
749
- const sortedCascadingSpans = cascadeSpans(runSpan, spans);
750
-
751
- return {
752
- traceId: run.runId,
753
- rootSpanId: run.runId,
754
- spans: sortedCascadingSpans,
755
- resources: [
756
- {
757
- name: 'workflow',
758
- attributes: {
759
- 'service.name': WORKFLOW_LIBRARY.name,
760
- },
761
- },
762
- ],
763
- };
764
- };
765
-
766
595
  /** Re-export SpanSelectionInfo for consumers */
767
596
  export type { SpanSelectionInfo };
768
597
 
@@ -870,14 +699,75 @@ function PanelResizeHandle({
870
699
  );
871
700
  }
872
701
 
702
+ // ---------------------------------------------------------------------------
703
+ // Trace viewer footer — loading / waiting indicator
704
+ // ---------------------------------------------------------------------------
705
+
706
+ function TraceViewerFooter({
707
+ hasMore,
708
+ isLive,
709
+ isInitialLoading,
710
+ }: {
711
+ hasMore: boolean;
712
+ isLive: boolean;
713
+ isInitialLoading: boolean;
714
+ }): ReactNode {
715
+ const style = { color: 'var(--ds-gray-900)' };
716
+ if (hasMore || isInitialLoading) {
717
+ return (
718
+ <div
719
+ className="flex items-center justify-center gap-2 py-3 text-xs"
720
+ style={style}
721
+ >
722
+ <svg
723
+ className="h-3.5 w-3.5 animate-spin"
724
+ viewBox="0 0 24 24"
725
+ fill="none"
726
+ >
727
+ <circle
728
+ className="opacity-25"
729
+ cx="12"
730
+ cy="12"
731
+ r="10"
732
+ stroke="currentColor"
733
+ strokeWidth="4"
734
+ />
735
+ <path
736
+ className="opacity-75"
737
+ fill="currentColor"
738
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
739
+ />
740
+ </svg>
741
+ Loading more events…
742
+ </div>
743
+ );
744
+ }
745
+ if (isLive) {
746
+ return (
747
+ <div
748
+ className="flex items-center justify-center py-3 text-xs"
749
+ style={style}
750
+ >
751
+ Waiting for more events…
752
+ </div>
753
+ );
754
+ }
755
+ return (
756
+ <div
757
+ className="flex items-center justify-center py-3 text-xs"
758
+ style={style}
759
+ >
760
+ End of run
761
+ </div>
762
+ );
763
+ }
764
+
873
765
  // ---------------------------------------------------------------------------
874
766
  // Main component
875
767
  // ---------------------------------------------------------------------------
876
768
 
877
769
  export const WorkflowTraceViewer = ({
878
770
  run,
879
- steps,
880
- hooks,
881
771
  events,
882
772
  isLoading,
883
773
  error,
@@ -894,10 +784,9 @@ export const WorkflowTraceViewer = ({
894
784
  hasMoreSpans = false,
895
785
  isLoadingMoreSpans = false,
896
786
  encryptionKey,
787
+ onDecrypt,
897
788
  }: {
898
789
  run: WorkflowRun;
899
- steps: Step[];
900
- hooks: Hook[];
901
790
  events: Event[];
902
791
  isLoading?: boolean;
903
792
  error?: Error | null;
@@ -932,6 +821,8 @@ export const WorkflowTraceViewer = ({
932
821
  isLoadingMoreSpans?: boolean;
933
822
  /** Encryption key (available after Decrypt), threaded to event list for re-loading */
934
823
  encryptionKey?: Uint8Array;
824
+ /** Callback to initiate decryption of encrypted run data */
825
+ onDecrypt?: () => void;
935
826
  }) => {
936
827
  const [selectedSpan, setSelectedSpan] = useState<SelectedSpanInfo | null>(
937
828
  null
@@ -947,13 +838,14 @@ export const WorkflowTraceViewer = ({
947
838
 
948
839
  // Build trace only when actual data changes — no timer-driven rebuilds.
949
840
  // Active span widths are animated imperatively by useLiveTick at 60fps.
950
- const trace = useMemo(() => {
951
- if (!run) {
841
+ const traceWithMeta: TraceWithMeta | undefined = useMemo(() => {
842
+ if (!run?.runId) {
952
843
  return undefined;
953
844
  }
954
- return buildTrace(run, steps, hooks, events, new Date());
845
+ return buildTrace(run, events, new Date());
955
846
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep; useLiveTick handles live growth
956
- }, [run, steps, hooks, events]);
847
+ }, [run, events]);
848
+ const trace = traceWithMeta;
957
849
 
958
850
  useEffect(() => {
959
851
  if (error && !isLoading) {
@@ -1064,7 +956,7 @@ export const WorkflowTraceViewer = ({
1064
956
  );
1065
957
  }, [selectedSpan?.data]);
1066
958
 
1067
- if (isLoading || !trace) {
959
+ if (!trace) {
1068
960
  return (
1069
961
  <div className="relative w-full h-full">
1070
962
  <div className="border-b border-gray-alpha-400 w-full" />
@@ -1093,7 +985,6 @@ export const WorkflowTraceViewer = ({
1093
985
  <TraceViewerWithContextMenu
1094
986
  trace={trace}
1095
987
  run={run}
1096
- hooks={hooks}
1097
988
  isLive={isLive}
1098
989
  onWakeUpSleep={onWakeUpSleep}
1099
990
  onCancelRun={onCancelRun}
@@ -1107,6 +998,15 @@ export const WorkflowTraceViewer = ({
1107
998
  height="100%"
1108
999
  isLive={isLive}
1109
1000
  trace={trace}
1001
+ knownDurationMs={traceWithMeta?.knownDurationMs}
1002
+ hasMoreData={hasMoreSpans || Boolean(isLoading)}
1003
+ footer={
1004
+ <TraceViewerFooter
1005
+ hasMore={hasMoreSpans}
1006
+ isLive={isLive}
1007
+ isInitialLoading={Boolean(isLoading)}
1008
+ />
1009
+ }
1110
1010
  />
1111
1011
  </TraceViewerWithContextMenu>
1112
1012
  </TraceViewerContextProvider>
@@ -1246,7 +1146,6 @@ export const WorkflowTraceViewer = ({
1246
1146
  <ErrorBoundary title="Failed to load entity details">
1247
1147
  <EntityDetailPanel
1248
1148
  run={run}
1249
- hooks={hooks}
1250
1149
  onStreamClick={onStreamClick}
1251
1150
  spanDetailData={spanDetailData ?? null}
1252
1151
  spanDetailError={spanDetailError}
@@ -1256,6 +1155,7 @@ export const WorkflowTraceViewer = ({
1256
1155
  onLoadEventData={onLoadEventData}
1257
1156
  onResolveHook={onResolveHook}
1258
1157
  encryptionKey={encryptionKey}
1158
+ onDecrypt={onDecrypt}
1259
1159
  selectedSpan={selectedSpan}
1260
1160
  />
1261
1161
  </ErrorBoundary>
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
6
- import type { Event, Step, WorkflowRun } from '@workflow/world';
6
+ import type { Event, WorkflowRun } from '@workflow/world';
7
7
  import type { Span, SpanEvent } from '../trace-viewer/types';
8
8
  import { shouldShowVerticalLine } from './event-colors';
9
9
  import { calculateDuration, dateToOtelTime } from './trace-time-utils';
@@ -81,18 +81,13 @@ export const waitEventsToWaitEntity = (
81
81
  /**
82
82
  * Converts a workflow Wait to an OpenTelemetry Span
83
83
  */
84
- export function waitToSpan(
85
- events: Event[],
86
- run: WorkflowRun,
87
- nowTime: Date
88
- ): Span | null {
84
+ export function waitToSpan(events: Event[], maxEndTime: Date): Span | null {
89
85
  const wait = waitEventsToWaitEntity(events);
90
86
  if (!wait) {
91
87
  return null;
92
88
  }
93
- const viewerEndTime = new Date(run.completedAt || nowTime) ?? nowTime;
94
- const startTime = wait?.createdAt ?? nowTime;
95
- const endTime = wait?.completedAt ?? viewerEndTime;
89
+ const startTime = wait.createdAt;
90
+ const endTime = wait.completedAt ?? maxEndTime;
96
91
  const start = dateToOtelTime(startTime);
97
92
  const end = dateToOtelTime(endTime);
98
93
  const duration = calculateDuration(startTime, endTime);
@@ -117,29 +112,92 @@ export function waitToSpan(
117
112
  };
118
113
  }
119
114
 
115
+ export const stepEventsToStepEntity = (
116
+ events: Event[]
117
+ ): {
118
+ stepId: string;
119
+ runId: string;
120
+ stepName: string;
121
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
122
+ attempt: number;
123
+ createdAt: Date;
124
+ updatedAt: Date;
125
+ startedAt?: Date;
126
+ completedAt?: Date;
127
+ specVersion?: number;
128
+ } | null => {
129
+ const createdEvent = events.find(
130
+ (event) => event.eventType === 'step_created'
131
+ );
132
+ if (!createdEvent) {
133
+ return null;
134
+ }
135
+ // Walk events in order to derive status, attempt count, and timestamps.
136
+ // Handles both step_retrying and consecutive step_started as retry signals.
137
+ let status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' =
138
+ 'pending';
139
+ let attempt = 0;
140
+ let startedAt: Date | undefined;
141
+ let completedAt: Date | undefined;
142
+
143
+ for (const e of events) {
144
+ switch (e.eventType) {
145
+ case 'step_started':
146
+ status = 'running';
147
+ attempt += 1;
148
+ if (!startedAt) startedAt = e.createdAt;
149
+ completedAt = undefined;
150
+ break;
151
+ case 'step_completed':
152
+ status = 'completed';
153
+ completedAt = e.createdAt;
154
+ break;
155
+ case 'step_failed':
156
+ status = 'failed';
157
+ completedAt = e.createdAt;
158
+ break;
159
+ case 'step_retrying':
160
+ status = 'pending';
161
+ completedAt = undefined;
162
+ break;
163
+ }
164
+ }
165
+
166
+ // Ensure at least attempt 1 if we never saw step_started
167
+ if (attempt === 0) attempt = 1;
168
+
169
+ const lastEvent = events[events.length - 1];
170
+ return {
171
+ stepId: createdEvent.correlationId,
172
+ runId: createdEvent.runId,
173
+ stepName: createdEvent.eventData?.stepName ?? '',
174
+ status,
175
+ attempt,
176
+ createdAt: createdEvent.createdAt,
177
+ updatedAt: lastEvent?.createdAt ?? createdEvent.createdAt,
178
+ startedAt,
179
+ completedAt,
180
+ specVersion: createdEvent.specVersion,
181
+ };
182
+ };
183
+
120
184
  /**
121
- * Converts a workflow Step to an OpenTelemetry Span
185
+ * Converts step events to an OpenTelemetry Span
122
186
  */
123
- export function stepToSpan(
124
- step: Step,
125
- stepEvents: Event[],
126
- nowTime?: Date,
127
- maxEndTime?: Date
128
- ): Span {
129
- const now = nowTime ?? new Date();
187
+ export function stepToSpan(stepEvents: Event[], maxEndTime: Date): Span | null {
188
+ const step = stepEventsToStepEntity(stepEvents);
189
+ if (!step) {
190
+ return null;
191
+ }
130
192
  const parsedName = parseStepName(String(step.stepName));
131
193
 
132
- // Only embed identification fields — not the full object with
133
- // input/output/error which may contain non-cloneable types.
134
- // The detail panel fetches full data separately via spanDetailData.
135
- const { input: _i, output: _o, error: _e, ...stepIdentity } = step;
136
194
  const attributes = {
137
195
  resource: 'step' as const,
138
- data: stepIdentity,
196
+ data: step,
139
197
  };
140
198
 
141
199
  const resource = 'step';
142
- const endTime = new Date(step.completedAt ?? maxEndTime ?? now);
200
+ const endTime = new Date(step.completedAt ?? maxEndTime);
143
201
 
144
202
  // Include ALL correlated events on the span so the sidebar detail view
145
203
  // can display them. The timeline uses the `showVerticalLine` flag to
@@ -187,6 +245,7 @@ export const hookEventsToHookEntity = (
187
245
  ): {
188
246
  hookId: string;
189
247
  runId: string;
248
+ token?: string;
190
249
  createdAt: Date;
191
250
  receivedCount: number;
192
251
  lastReceivedAt?: Date;
@@ -208,6 +267,7 @@ export const hookEventsToHookEntity = (
208
267
  return {
209
268
  hookId: createdEvent.correlationId,
210
269
  runId: createdEvent.runId,
270
+ token: createdEvent.eventData?.token,
211
271
  createdAt: createdEvent.createdAt,
212
272
  receivedCount: receivedEvents.length,
213
273
  lastReceivedAt: lastReceivedEvent?.createdAt || undefined,
@@ -218,11 +278,7 @@ export const hookEventsToHookEntity = (
218
278
  /**
219
279
  * Converts a workflow Hook to an OpenTelemetry Span
220
280
  */
221
- export function hookToSpan(
222
- hookEvents: Event[],
223
- run: WorkflowRun,
224
- nowTime: Date
225
- ): Span | null {
281
+ export function hookToSpan(hookEvents: Event[], maxEndTime: Date): Span | null {
226
282
  const hook = hookEventsToHookEntity(hookEvents);
227
283
  if (!hook) {
228
284
  return null;
@@ -231,10 +287,7 @@ export function hookToSpan(
231
287
  // Convert hook-related events to span events
232
288
  const events = convertEventsToSpanEvents(hookEvents, false);
233
289
 
234
- // We display hooks as a minimum span size of 10 seconds, just to ensure
235
- // it's clickable even if there is no
236
- const viewerEndTime = new Date(run.completedAt || nowTime) ?? nowTime;
237
- const endTime = hook.disposedAt || viewerEndTime;
290
+ const endTime = hook.disposedAt || maxEndTime;
238
291
 
239
292
  return {
240
293
  spanId: String(hook.hookId),
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
10
10
  export * from './components';
11
11
  export {
12
12
  hookEventsToHookEntity,
13
+ stepEventsToStepEntity,
13
14
  waitEventsToWaitEntity,
14
15
  } from './components/workflow-traces/trace-span-construction';
15
16
  export type { EventAnalysis } from './lib/event-analysis';
@@ -20,6 +21,18 @@ export {
20
21
  isTerminalStatus,
21
22
  shouldShowReenqueueButton,
22
23
  } from './lib/event-analysis';
24
+ export type {
25
+ MaterializedEntities,
26
+ MaterializedHook,
27
+ MaterializedStep,
28
+ MaterializedWait,
29
+ } from './lib/event-materialization';
30
+ export {
31
+ materializeAll,
32
+ materializeHooks,
33
+ materializeSteps,
34
+ materializeWaits,
35
+ } from './lib/event-materialization';
23
36
  export type { Revivers, StreamRef } from './lib/hydration';
24
37
  export {
25
38
  CLASS_INSTANCE_REF_TYPE,