@workflow/web-shared 5.0.0-beta.3 → 5.0.0-beta.5

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 (86) hide show
  1. package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
  2. package/dist/components/new-trace-viewer/components/event-list.js +7 -4
  3. package/dist/components/new-trace-viewer/components/event-list.js.map +1 -1
  4. package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.d.ts +18 -0
  5. package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.d.ts.map +1 -0
  6. package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.js +54 -0
  7. package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.js.map +1 -0
  8. package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.d.ts +20 -0
  9. package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.d.ts.map +1 -0
  10. package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.js +81 -0
  11. package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.js.map +1 -0
  12. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts +25 -0
  13. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts.map +1 -0
  14. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js +101 -0
  15. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js.map +1 -0
  16. package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.d.ts +20 -0
  17. package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.d.ts.map +1 -0
  18. package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.js +114 -0
  19. package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.js.map +1 -0
  20. package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
  21. package/dist/components/new-trace-viewer/components/split-pane.js +2 -3
  22. package/dist/components/new-trace-viewer/components/split-pane.js.map +1 -1
  23. package/dist/components/new-trace-viewer/components/timeline.d.ts +10 -11
  24. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  25. package/dist/components/new-trace-viewer/components/timeline.js +57 -53
  26. package/dist/components/new-trace-viewer/components/timeline.js.map +1 -1
  27. package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
  28. package/dist/components/new-trace-viewer/context.js +5 -7
  29. package/dist/components/new-trace-viewer/context.js.map +1 -1
  30. package/dist/components/new-trace-viewer/detail-panel.d.ts.map +1 -1
  31. package/dist/components/new-trace-viewer/detail-panel.js +2 -1
  32. package/dist/components/new-trace-viewer/detail-panel.js.map +1 -1
  33. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  34. package/dist/components/new-trace-viewer/trace-viewer.js +44 -18
  35. package/dist/components/new-trace-viewer/trace-viewer.js.map +1 -1
  36. package/dist/components/new-trace-viewer/utils.d.ts +3 -8
  37. package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
  38. package/dist/components/new-trace-viewer/utils.js +12 -65
  39. package/dist/components/new-trace-viewer/utils.js.map +1 -1
  40. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  41. package/dist/components/sidebar/attribute-panel.js +6 -0
  42. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  43. package/dist/components/trace-viewer/util/timing.d.ts +1 -1
  44. package/dist/components/trace-viewer/util/timing.d.ts.map +1 -1
  45. package/dist/components/trace-viewer/util/timing.js +5 -8
  46. package/dist/components/trace-viewer/util/timing.js.map +1 -1
  47. package/dist/components/ui/error-stack-block.d.ts.map +1 -1
  48. package/dist/components/ui/error-stack-block.js +24 -2
  49. package/dist/components/ui/error-stack-block.js.map +1 -1
  50. package/dist/components/workflow-trace-view.js +1 -1
  51. package/dist/components/workflow-trace-view.js.map +1 -1
  52. package/dist/components/workflow-traces/trace-span-construction.d.ts +2 -2
  53. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  54. package/dist/components/workflow-traces/trace-span-construction.js +17 -4
  55. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  56. package/dist/lib/hydration.d.ts +6 -0
  57. package/dist/lib/hydration.d.ts.map +1 -1
  58. package/dist/lib/hydration.js +124 -1
  59. package/dist/lib/hydration.js.map +1 -1
  60. package/dist/lib/trace-builder.d.ts.map +1 -1
  61. package/dist/lib/trace-builder.js +2 -1
  62. package/dist/lib/trace-builder.js.map +1 -1
  63. package/dist/lib/utils.d.ts +9 -7
  64. package/dist/lib/utils.d.ts.map +1 -1
  65. package/dist/lib/utils.js +27 -27
  66. package/dist/lib/utils.js.map +1 -1
  67. package/package.json +5 -4
  68. package/src/components/new-trace-viewer/components/event-list.tsx +26 -19
  69. package/src/components/new-trace-viewer/components/middle-truncate/copy-selection.ts +114 -0
  70. package/src/components/new-trace-viewer/components/middle-truncate/middle-truncate.tsx +157 -0
  71. package/src/components/new-trace-viewer/components/middle-truncate/truncate.ts +132 -0
  72. package/src/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.ts +167 -0
  73. package/src/components/new-trace-viewer/components/split-pane.tsx +2 -3
  74. package/src/components/new-trace-viewer/components/timeline.tsx +119 -115
  75. package/src/components/new-trace-viewer/context.tsx +7 -9
  76. package/src/components/new-trace-viewer/detail-panel.tsx +2 -1
  77. package/src/components/new-trace-viewer/trace-viewer.tsx +100 -32
  78. package/src/components/new-trace-viewer/utils.ts +19 -93
  79. package/src/components/sidebar/attribute-panel.tsx +5 -0
  80. package/src/components/trace-viewer/util/timing.ts +5 -9
  81. package/src/components/ui/error-stack-block.tsx +32 -4
  82. package/src/components/workflow-trace-view.tsx +1 -1
  83. package/src/components/workflow-traces/trace-span-construction.ts +23 -5
  84. package/src/lib/hydration.ts +132 -1
  85. package/src/lib/trace-builder.ts +2 -1
  86. package/src/lib/utils.ts +29 -29
@@ -30,33 +30,11 @@ export function computeRootBounds(spans: Span[]): RootBounds {
30
30
  return { startTime: minStart, endTime: maxEnd, duration };
31
31
  }
32
32
 
33
- // ---------------------------------------------------------------------------
34
- // Time compression
35
- // ---------------------------------------------------------------------------
36
-
37
- export interface TimeCompression {
38
- toVisual(time: number): number;
39
- fromVisual(fraction: number): number;
40
- isCompressed: boolean;
41
- }
42
-
43
- export function buildTimeCompression(
44
- _spans: Span[],
45
- viewStart: number,
46
- viewEnd: number
47
- ): TimeCompression {
48
- const range = viewEnd - viewStart;
49
-
50
- return {
51
- isCompressed: false,
52
- toVisual(time: number): number {
53
- if (range <= 0) return 0;
54
- return Math.min(Math.max((time - viewStart) / range, 0), 1);
55
- },
56
- fromVisual(fraction: number): number {
57
- return viewStart + fraction * range;
58
- },
59
- };
33
+ export function getSpanDurationMs(span: Span): number {
34
+ return Math.max(
35
+ 0,
36
+ getHighResInMs(span.endTime) - getHighResInMs(span.startTime)
37
+ );
60
38
  }
61
39
 
62
40
  // ---------------------------------------------------------------------------
@@ -66,6 +44,7 @@ export function buildTimeCompression(
66
44
  export interface TimeMarker {
67
45
  position: number;
68
46
  label: string;
47
+ value: number;
69
48
  }
70
49
 
71
50
  const NICE_INTERVALS = [
@@ -102,36 +81,7 @@ export function computeTimeMarkers(
102
81
  markers.push({
103
82
  position: Math.min(Math.max(position, 0), 1),
104
83
  label: formatDuration(Math.abs(t), true),
105
- });
106
- if (markers.length >= MAX_MARKERS) break;
107
- }
108
-
109
- return markers;
110
- }
111
-
112
- export function computeCompressedTimeMarkers(
113
- compression: TimeCompression,
114
- viewStart: number,
115
- viewEnd: number,
116
- rootStart: number
117
- ): TimeMarker[] {
118
- const viewDuration = viewEnd - viewStart;
119
- if (viewDuration <= 0) return [];
120
-
121
- const maxTicks = 6;
122
- const interval = pickInterval(viewDuration, maxTicks);
123
- const offset = viewStart - rootStart;
124
-
125
- const firstTick = Math.ceil(offset / interval) * interval;
126
- const markers: TimeMarker[] = [];
127
-
128
- for (let t = firstTick; t <= offset + viewDuration; t += interval) {
129
- const absTime = rootStart + t;
130
- const position = compression.toVisual(absTime);
131
- if (position < -0.01 || position > 1.01) continue;
132
- markers.push({
133
- position: Math.min(Math.max(position, 0), 1),
134
- label: formatDuration(Math.abs(t), true),
84
+ value: t,
135
85
  });
136
86
  if (markers.length >= MAX_MARKERS) break;
137
87
  }
@@ -152,8 +102,12 @@ export interface SpanGap {
152
102
 
153
103
  export function computeSpanGaps(
154
104
  spans: Span[],
155
- compression: TimeCompression
105
+ viewStart: number,
106
+ viewEnd: number
156
107
  ): SpanGap[] {
108
+ const range = viewEnd - viewStart;
109
+ if (range <= 0) return [];
110
+
157
111
  const gaps: SpanGap[] = [];
158
112
  for (let i = 0; i < spans.length - 1; i++) {
159
113
  const endTime = getHighResInMs(spans[i].endTime);
@@ -161,8 +115,8 @@ export function computeSpanGaps(
161
115
  const gapMs = startTime - endTime;
162
116
  if (gapMs <= 0) continue;
163
117
 
164
- const leftFrac = compression.toVisual(endTime);
165
- const rightFrac = compression.toVisual(startTime);
118
+ const leftFrac = Math.min(Math.max((endTime - viewStart) / range, 0), 1);
119
+ const rightFrac = Math.min(Math.max((startTime - viewStart) / range, 0), 1);
166
120
  if (rightFrac - leftFrac < 0.001) continue;
167
121
 
168
122
  gaps.push({ gapMs, leftFrac, rightFrac, rowIndex: i });
@@ -379,39 +333,12 @@ function computeHookSegmentsFromSpan(
379
333
  }
380
334
 
381
335
  function computeSleepSegmentsFromSpan(
382
- startMs: number,
336
+ _startMs: number,
383
337
  duration: number,
384
- events: SpanEvent[]
338
+ _events: SpanEvent[]
385
339
  ): Segment[] {
386
- const segments: Segment[] = [];
387
- if (duration <= 0) return segments;
388
-
389
- const completedTime = events
390
- .filter((e) => e.name === 'wait_completed')
391
- .map((e) => getHighResInMs(e.timestamp))
392
- .sort((a, b) => a - b)[0];
393
-
394
- if (completedTime === undefined) {
395
- segments.push({ startFraction: 0, endFraction: 1, status: 'sleeping' });
396
- return segments;
397
- }
398
-
399
- const completedFrac = timeToFraction(completedTime, startMs, duration);
400
- if (completedFrac > 0.001) {
401
- segments.push({
402
- startFraction: 0,
403
- endFraction: completedFrac,
404
- status: 'sleeping',
405
- });
406
- }
407
-
408
- segments.push({
409
- startFraction: completedFrac,
410
- endFraction: 1,
411
- status: 'succeeded',
412
- });
413
-
414
- return segments;
340
+ if (duration <= 0) return [];
341
+ return [{ startFraction: 0, endFraction: 1, status: 'sleeping' }];
415
342
  }
416
343
 
417
344
  function computeRunSegmentsFromSpan(
@@ -497,8 +424,7 @@ function computeV1RunSegments(
497
424
  */
498
425
  export function computeSpanSegments(span: Span): Segment[] {
499
426
  const startMs = getHighResInMs(span.startTime);
500
- const endMs = getHighResInMs(span.endTime);
501
- const duration = endMs - startMs;
427
+ const duration = getSpanDurationMs(span);
502
428
  const activeStartMs = span.activeStartTime
503
429
  ? getHighResInMs(span.activeStartTime)
504
430
  : undefined;
@@ -441,6 +441,7 @@ const attributeToDisplayFn: Record<
441
441
  // Hook details
442
442
  token: (value: unknown) => String(value),
443
443
  isWebhook: (value: unknown) => String(value),
444
+ isSystem: (value: unknown) => String(value),
444
445
  receivedCount: (value: unknown) => String(value),
445
446
  lastReceivedAt: localMillisecondTimeOrNull,
446
447
  disposedAt: localMillisecondTimeOrNull,
@@ -624,6 +625,10 @@ const attributeToDisplayFn: Record<
624
625
  if (!hasDisplayContent(value)) return null;
625
626
  return <DetailCard summary="Event Data">{JsonBlock(value)}</DetailCard>;
626
627
  },
628
+ errorCode: (value: unknown) => {
629
+ if (typeof value !== 'string' || value.length === 0) return null;
630
+ return String(value);
631
+ },
627
632
  };
628
633
 
629
634
  const resolvableAttributes = [
@@ -14,22 +14,18 @@ export function getMsInHighRes(ms: number): [number, number] {
14
14
  }
15
15
 
16
16
  /**
17
- * Formats a duration for timeline display (compact single-unit format).
17
+ * Formats a duration for timeline display (compact format).
18
18
  * @deprecated Use formatDuration(ms, true) instead.
19
19
  */
20
20
  export const formatDurationForTimeline = (ms: number): string =>
21
21
  formatDuration(ms, true);
22
22
 
23
- const timeSelectionFormatter = new Intl.NumberFormat(undefined, {
24
- minimumFractionDigits: 2,
25
- maximumFractionDigits: 2,
26
- });
27
-
28
23
  export function formatTimeSelection(ms: number): string {
29
- if (ms >= 1000) {
30
- return `${timeSelectionFormatter.format(ms / 1000)}s`;
24
+ const roundedMs = Math.round(ms);
25
+ if (roundedMs < 1000) {
26
+ return `${roundedMs}ms`;
31
27
  }
32
- return `${timeSelectionFormatter.format(ms)}ms`;
28
+ return `${Math.round(ms / 1000)}s`;
33
29
  }
34
30
 
35
31
  /**
@@ -18,6 +18,21 @@ export function isStructuredErrorWithStack(
18
18
  );
19
19
  }
20
20
 
21
+ /**
22
+ * Pull a short, single-line title out of an error message.
23
+ *
24
+ * Workflow's structured error messages are multi-line — the first line is
25
+ * the headline (`Failed to serialize step return value`) and the rest are
26
+ * `╰▶ hint:` / `╰▶ docs:` framed details. The full message belongs in the
27
+ * body of the error block; the title should just be the headline so the
28
+ * card stays scannable.
29
+ */
30
+ function deriveTitle(message: string): string {
31
+ const firstLine =
32
+ message.split('\n').find((line) => line.trim().length > 0) ?? message;
33
+ return firstLine.trim();
34
+ }
35
+
21
36
  /**
22
37
  * Renders an error with a `stack` field as a visually distinct error block.
23
38
  * Shows the error message with an alert icon at the top, separated from
@@ -31,6 +46,10 @@ export function ErrorStackBlock({
31
46
  const toast = useToast();
32
47
  const stack = value.stack;
33
48
  const message = typeof value.message === 'string' ? value.message : undefined;
49
+ // V8's `Error.stack` already starts with `Name: message`, so when the
50
+ // body shows the stack it includes the full multi-line message anyway.
51
+ // The header just needs the first line.
52
+ const title = message ? deriveTitle(message) : undefined;
34
53
  const copyText = message ? `${message}\n\n${stack}` : stack;
35
54
 
36
55
  return (
@@ -65,16 +84,25 @@ export function ErrorStackBlock({
65
84
  <Copy size={12} />
66
85
  </button>
67
86
 
68
- {message && (
87
+ {title && (
69
88
  <div
70
- className="flex items-start gap-2 px-3 py-2.5 pr-10"
89
+ className="flex items-center gap-2 px-3 py-2.5 pr-10"
71
90
  style={{
72
91
  color: 'var(--ds-red-900)',
73
92
  borderBottom: '1px solid var(--ds-red-400)',
74
93
  }}
75
94
  >
76
- <AlertCircle className="h-4 w-4 shrink-0" style={{ marginTop: 1 }} />
77
- <p className="text-xs font-semibold m-0 break-words">{message}</p>
95
+ <AlertCircle className="h-4 w-4 shrink-0" />
96
+ <p
97
+ className="text-xs font-semibold m-0 truncate"
98
+ // The full multi-line message is in the stack body below; the
99
+ // header just shows the first line, single-line, with overflow
100
+ // ellipsised so a long title doesn't push the copy button or
101
+ // wrap into the framed hint/docs lines.
102
+ title={message}
103
+ >
104
+ {title}
105
+ </p>
78
106
  </div>
79
107
  )}
80
108
  <pre
@@ -974,7 +974,7 @@ export const WorkflowTraceViewer = ({
974
974
  }
975
975
 
976
976
  return (
977
- <div className="relative w-full h-full flex flex-col">
977
+ <div className="relative w-full h-full flex flex-row">
978
978
  {/* Timeline (takes remaining space) */}
979
979
  <div className="flex-1 min-w-0 relative">
980
980
  <TraceViewerContextProvider
@@ -59,7 +59,7 @@ export const waitEventsToWaitEntity = (
59
59
  waitId: string;
60
60
  runId: string;
61
61
  createdAt: Date;
62
- resumeAt: Date;
62
+ resumeAt?: Date;
63
63
  completedAt?: Date;
64
64
  } | null => {
65
65
  const startEvent = events.find((event) => event.eventType === 'wait_created');
@@ -73,7 +73,9 @@ export const waitEventsToWaitEntity = (
73
73
  waitId: startEvent.correlationId,
74
74
  runId: startEvent.runId,
75
75
  createdAt: startEvent.createdAt,
76
- resumeAt: startEvent.eventData?.resumeAt,
76
+ resumeAt: startEvent.eventData?.resumeAt
77
+ ? new Date(startEvent.eventData.resumeAt)
78
+ : undefined,
77
79
  completedAt: completedEvent?.createdAt,
78
80
  };
79
81
  };
@@ -81,13 +83,29 @@ export const waitEventsToWaitEntity = (
81
83
  /**
82
84
  * Converts a workflow Wait to an OpenTelemetry Span
83
85
  */
84
- export function waitToSpan(events: Event[], maxEndTime: Date): Span | null {
86
+ export function waitToSpan(
87
+ events: Event[],
88
+ maxEndTime: Date,
89
+ fallbackEndTime = maxEndTime
90
+ ): Span | null {
85
91
  const wait = waitEventsToWaitEntity(events);
86
92
  if (!wait) {
87
93
  return null;
88
94
  }
89
95
  const startTime = wait.createdAt;
90
- const endTime = wait.completedAt ?? maxEndTime;
96
+ const startMs = startTime.getTime();
97
+ let endTime = wait.completedAt;
98
+ if (!endTime) {
99
+ const fallbackCap =
100
+ wait.resumeAt && wait.resumeAt.getTime() < fallbackEndTime.getTime()
101
+ ? wait.resumeAt
102
+ : fallbackEndTime;
103
+ endTime =
104
+ maxEndTime.getTime() > startMs &&
105
+ maxEndTime.getTime() < fallbackCap.getTime()
106
+ ? maxEndTime
107
+ : fallbackCap;
108
+ }
91
109
  const start = dateToOtelTime(startTime);
92
110
  const end = dateToOtelTime(endTime);
93
111
  const duration = calculateDuration(startTime, endTime);
@@ -296,7 +314,7 @@ export function hookToSpan(hookEvents: Event[], maxEndTime: Date): Span | null {
296
314
 
297
315
  return {
298
316
  spanId: String(hook.hookId),
299
- name: String(hook.hookId),
317
+ name: hook.token ?? String(hook.hookId),
300
318
  kind: 1, // INTERNAL span kind
301
319
  resource: 'hook',
302
320
  library: WORKFLOW_LIBRARY,
@@ -58,12 +58,60 @@ function base64ToArrayBuffer(base64: string): ArrayBuffer {
58
58
  // Web revivers (browser-safe, no Buffer dependency)
59
59
  // ---------------------------------------------------------------------------
60
60
 
61
+ /**
62
+ * Build a reviver for one of the built-in `Error` subclasses (e.g.
63
+ * `TypeError`, `RangeError`). The constructor for the named subclass is
64
+ * resolved off `globalThis` at call time so the produced instance has the
65
+ * correct prototype chain in the consumer realm. Falls back to a generic
66
+ * `Error` (with `name` set) if the global isn't available, which keeps the
67
+ * o11y UI rendering even on exotic browsers.
68
+ *
69
+ * `cause` is passed through `ErrorOptions` to the constructor when present,
70
+ * matching `getCommonRevivers` in `@workflow/core` so the resulting `cause`
71
+ * property has the same semantics (non-enumerable, set by the engine) as a
72
+ * freshly thrown Error in the consumer realm. The `'cause' in value` check
73
+ * preserves the distinction between "no cause" and "cause is undefined".
74
+ */
75
+ function makeWebErrorSubclassReviver(
76
+ name:
77
+ | 'EvalError'
78
+ | 'RangeError'
79
+ | 'ReferenceError'
80
+ | 'SyntaxError'
81
+ | 'TypeError'
82
+ | 'URIError'
83
+ ) {
84
+ return (value: { message: string; stack?: string; cause?: unknown }) => {
85
+ const opts = 'cause' in value ? { cause: value.cause } : undefined;
86
+ const Ctor = (globalThis as Record<string, any>)[name] as
87
+ | ErrorConstructor
88
+ | undefined;
89
+ let error: Error;
90
+ if (typeof Ctor === 'function') {
91
+ error = new Ctor(value.message, opts);
92
+ } else {
93
+ // Fallback path: no built-in subclass available (exotic env). Construct
94
+ // a plain Error with the right `name` and copy `cause` manually since
95
+ // the base Error constructor is what we actually called.
96
+ error = Object.assign(new Error(value.message, opts), { name });
97
+ }
98
+ if (value.stack !== undefined) error.stack = value.stack;
99
+ return error;
100
+ };
101
+ }
102
+
61
103
  /**
62
104
  * Get the web-specific revivers for hydrating serialized data.
63
105
  *
64
106
  * Uses `atob()` for base64 decoding (no Node.js Buffer dependency).
65
107
  * All types are revived as real instances (Date, Map, Set, URL,
66
108
  * URLSearchParams, Headers, Error, etc.).
109
+ *
110
+ * NOTE: this set must mirror the keys in `SerializableSpecial` (see
111
+ * `@workflow/core/serialization/types`). Any reducer key added on the
112
+ * serialization side that isn't covered here will cause `devalue.unflatten`
113
+ * to throw `Unknown type X`, which `hydrateResourceIO` swallows and
114
+ * surfaces as a "Failed to load resource details" banner in the o11y UI.
67
115
  */
68
116
  export function getWebRevivers(): Revivers {
69
117
  function reviveArrayBuffer(value: string): ArrayBuffer {
@@ -83,10 +131,93 @@ export function getWebRevivers(): Revivers {
83
131
  BigUint64Array: (value: string) =>
84
132
  new BigUint64Array(reviveArrayBuffer(value)),
85
133
  Date: (value) => new Date(value),
134
+
135
+ // Error family. The reducer side (see
136
+ // `packages/core/src/serialization/reducers/common.ts`) emits a tagged
137
+ // entry for each built-in Error subclass plus the workflow-specific
138
+ // `FatalError` / `RetryableError` and `AggregateError`. Without
139
+ // matching revivers here, `devalue.unflatten` throws "Unknown type X"
140
+ // — which surfaces in the web o11y UI as "Failed to load resource
141
+ // details: Unknown type FatalError".
86
142
  Error: (value) => {
143
+ const opts = 'cause' in value ? { cause: value.cause } : undefined;
144
+ const error = new Error(value.message, opts);
145
+ error.name = value.name;
146
+ if (value.stack !== undefined) error.stack = value.stack;
147
+ return error;
148
+ },
149
+ EvalError: makeWebErrorSubclassReviver('EvalError'),
150
+ RangeError: makeWebErrorSubclassReviver('RangeError'),
151
+ ReferenceError: makeWebErrorSubclassReviver('ReferenceError'),
152
+ SyntaxError: makeWebErrorSubclassReviver('SyntaxError'),
153
+ TypeError: makeWebErrorSubclassReviver('TypeError'),
154
+ URIError: makeWebErrorSubclassReviver('URIError'),
155
+ AggregateError: (value) => {
156
+ const opts = 'cause' in value ? { cause: value.cause } : undefined;
157
+ const Ctor = (
158
+ globalThis as { AggregateError?: AggregateErrorConstructor }
159
+ ).AggregateError;
160
+ const error =
161
+ typeof Ctor === 'function'
162
+ ? new Ctor(value.errors, value.message, opts)
163
+ : Object.assign(new Error(value.message, opts), {
164
+ name: 'AggregateError',
165
+ errors: value.errors,
166
+ });
167
+ if (value.stack !== undefined) error.stack = value.stack;
168
+ return error;
169
+ },
170
+ // `FatalError` and `RetryableError` are not built-in browser globals,
171
+ // so we can't resolve a constructor from globalThis. The web o11y UI
172
+ // doesn't need `instanceof FatalError` to pass (no user code runs
173
+ // here) — it just needs `name`, `message`, `stack`, and any extra
174
+ // enumerable fields to render. Construct a plain `Error` with `name`
175
+ // set; ObjectInspector reads `constructor.name` for the displayed
176
+ // class label, but we don't have the real class, so we emit a tagged
177
+ // Error whose `name` field carries the class identity. This matches
178
+ // how the existing base `Error` reviver presents unknown subclasses.
179
+ FatalError: (value) => {
180
+ const opts = 'cause' in value ? { cause: value.cause } : undefined;
181
+ const error = new Error(value.message, opts);
182
+ error.name = 'FatalError';
183
+ if (value.stack !== undefined) error.stack = value.stack;
184
+ return error;
185
+ },
186
+ RetryableError: (value) => {
187
+ const opts = 'cause' in value ? { cause: value.cause } : undefined;
188
+ const error = new Error(value.message, opts) as Error & {
189
+ retryAfter?: Date;
190
+ };
191
+ error.name = 'RetryableError';
192
+ if (value.stack !== undefined) error.stack = value.stack;
193
+ // `retryAfter` is serialized as an epoch ms number (see the runtime
194
+ // RetryableError reducer for the rationale around realm-safety).
195
+ // Rehydrate as a Date so o11y consumers can render it directly.
196
+ // Guard against payloads from older runtime versions that predate
197
+ // the field — without this check, `new Date(undefined)` would
198
+ // produce an Invalid Date rather than omitting the property.
199
+ if (value.retryAfter != null) {
200
+ error.retryAfter = new Date(value.retryAfter);
201
+ }
202
+ return error;
203
+ },
204
+ DOMException: (value) => {
205
+ // Modern browsers and Node 18+ expose `DOMException` on globalThis.
206
+ // `AbortController.abort()` with no argument synthesizes one as the
207
+ // signal's reason, so this is a common payload for any aborted step.
208
+ const G = globalThis as { DOMException?: typeof DOMException };
209
+ if (typeof G.DOMException === 'function') {
210
+ const e = new G.DOMException(value.message, value.name);
211
+ if (value.stack !== undefined) e.stack = value.stack;
212
+ if ('cause' in value) (e as { cause?: unknown }).cause = value.cause;
213
+ return e;
214
+ }
87
215
  const error = new Error(value.message);
88
216
  error.name = value.name;
89
- error.stack = value.stack;
217
+ if (value.stack !== undefined) error.stack = value.stack;
218
+ if ('cause' in value) {
219
+ (error as Error & { cause?: unknown }).cause = value.cause;
220
+ }
90
221
  return error;
91
222
  },
92
223
  Float32Array: (value: string) => new Float32Array(reviveArrayBuffer(value)),
@@ -117,6 +117,7 @@ function buildSpans(
117
117
  // unknown territory. Even when the run is completed, we may not have loaded
118
118
  // all events yet, so always use the latest event we've actually seen.
119
119
  const childMaxEnd = latestKnownTime;
120
+ const runMaxEnd = run.completedAt ?? now;
120
121
 
121
122
  const stepSpans = Array.from(groupedEvents.eventsByStepId.values())
122
123
  .map((events) => stepToSpan(events, childMaxEnd))
@@ -127,7 +128,7 @@ function buildSpans(
127
128
  .filter((span): span is Span => span !== null);
128
129
 
129
130
  const waitSpans = Array.from(groupedEvents.timerEvents.values())
130
- .map((events) => waitToSpan(events, childMaxEnd))
131
+ .map((events) => waitToSpan(events, childMaxEnd, runMaxEnd))
131
132
  .filter((span): span is Span => span !== null);
132
133
 
133
134
  return {
package/src/lib/utils.ts CHANGED
@@ -7,15 +7,6 @@ export function cn(...inputs: ClassValue[]) {
7
7
  return twMerge(clsx(inputs));
8
8
  }
9
9
 
10
- // const compactFormatter = new Intl.NumberFormat(undefined, {
11
- // maximumFractionDigits: 2,
12
- // });
13
-
14
- const secondsFormatter = new Intl.NumberFormat(undefined, {
15
- minimumFractionDigits: 2,
16
- maximumFractionDigits: 2,
17
- });
18
-
19
10
  const MS_IN_SECOND = 1000;
20
11
  const MS_IN_MINUTE = 60 * MS_IN_SECOND;
21
12
  const MS_IN_HOUR = 60 * MS_IN_MINUTE;
@@ -25,44 +16,53 @@ const MS_IN_DAY = 24 * MS_IN_HOUR;
25
16
  * Formats a duration in milliseconds to a human-readable string.
26
17
  *
27
18
  * @param ms - Duration in milliseconds
28
- * @param compact - If true, returns a single-unit format (e.g., "0.38s", "2.5m").
29
- * If false (default), returns multi-part format (e.g., "1m 13.21s", "2d 5h 3m 12.00s").
19
+ * @param compact - If true, returns a compact format (e.g., "380ms", "2m 30s").
20
+ * If false (default), returns multi-part format (e.g., "1m 13s", "2d 5h 3m 12s").
30
21
  *
31
22
  * Compact format (timeline markers):
32
- * - < 1m: shows seconds (e.g., "0.38s", "45s")
33
- * - < 1h: shows minutes (e.g., "2.5m")
34
- * - >= 1h: shows hours (e.g., "2.5h")
23
+ * - < 1s: shows milliseconds (e.g., "380ms")
24
+ * - < 1m: shows seconds (e.g., "45s")
25
+ * - < 1h: shows minutes and seconds (e.g., "2m 30s")
26
+ * - >= 1h: shows hours and minutes (e.g., "2h 30m")
35
27
  *
36
28
  * Full format:
37
- * - < 1m: shows seconds (e.g., "0.38s", "2.71s")
38
- * - >= 1m: shows decomposed format with fractional seconds (e.g., "1m 13.21s", "2m 13.04s")
29
+ * - < 1s: shows milliseconds (e.g., "380ms")
30
+ * - < 1m: shows seconds (e.g., "45s")
31
+ * - >= 1m: shows decomposed format with whole seconds (e.g., "1m 13s", "2m 13s")
39
32
  */
40
33
  export function formatDuration(ms: number, compact = false): string {
41
34
  if (ms === 0) {
42
35
  return '0s';
43
36
  }
44
37
 
45
- if (ms < MS_IN_MINUTE) {
46
- return `${secondsFormatter.format(ms / MS_IN_SECOND)}s`;
38
+ if (ms < MS_IN_SECOND) {
39
+ const roundedMs = Math.round(ms);
40
+ return roundedMs < MS_IN_SECOND ? `${roundedMs}ms` : '1s';
41
+ }
42
+
43
+ const roundedMs = Math.round(ms / MS_IN_SECOND) * MS_IN_SECOND;
44
+
45
+ if (roundedMs < MS_IN_MINUTE) {
46
+ return `${Math.floor(roundedMs / MS_IN_SECOND)}s`;
47
47
  }
48
48
 
49
49
  // Compact format: multi-unit without decimals (e.g. "8m 20s", "2h 30m")
50
50
  if (compact) {
51
- if (ms < MS_IN_HOUR) {
52
- const m = Math.floor(ms / MS_IN_MINUTE);
53
- const s = Math.floor((ms % MS_IN_MINUTE) / MS_IN_SECOND);
51
+ if (roundedMs < MS_IN_HOUR) {
52
+ const m = Math.floor(roundedMs / MS_IN_MINUTE);
53
+ const s = Math.floor((roundedMs % MS_IN_MINUTE) / MS_IN_SECOND);
54
54
  return s > 0 ? `${m}m ${s}s` : `${m}m`;
55
55
  }
56
- const h = Math.floor(ms / MS_IN_HOUR);
57
- const m = Math.floor((ms % MS_IN_HOUR) / MS_IN_MINUTE);
56
+ const h = Math.floor(roundedMs / MS_IN_HOUR);
57
+ const m = Math.floor((roundedMs % MS_IN_HOUR) / MS_IN_MINUTE);
58
58
  return m > 0 ? `${h}h ${m}m` : `${h}h`;
59
59
  }
60
60
 
61
- // Full format: decompose into larger units + fractional seconds
62
- const days = Math.floor(ms / MS_IN_DAY);
63
- const hours = Math.floor((ms % MS_IN_DAY) / MS_IN_HOUR);
64
- const minutes = Math.floor((ms % MS_IN_HOUR) / MS_IN_MINUTE);
65
- const seconds = (ms % MS_IN_MINUTE) / MS_IN_SECOND;
61
+ // Full format: decompose into larger units + whole seconds.
62
+ const days = Math.floor(roundedMs / MS_IN_DAY);
63
+ const hours = Math.floor((roundedMs % MS_IN_DAY) / MS_IN_HOUR);
64
+ const minutes = Math.floor((roundedMs % MS_IN_HOUR) / MS_IN_MINUTE);
65
+ const seconds = Math.floor((roundedMs % MS_IN_MINUTE) / MS_IN_SECOND);
66
66
 
67
67
  const parts: string[] = [];
68
68
 
@@ -75,7 +75,7 @@ export function formatDuration(ms: number, compact = false): string {
75
75
  if (minutes > 0) {
76
76
  parts.push(`${minutes}m`);
77
77
  }
78
- parts.push(`${secondsFormatter.format(seconds)}s`);
78
+ parts.push(`${seconds}s`);
79
79
 
80
80
  return parts.join(' ');
81
81
  }