@workflow/web-shared 5.0.0-beta.41 → 5.0.0-beta.43

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 (96) hide show
  1. package/dist/components/event-list-view.d.ts +10 -2
  2. package/dist/components/event-list-view.d.ts.map +1 -1
  3. package/dist/components/event-list-view.js +52 -26
  4. package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
  5. package/dist/components/sidebar/attributes-block.js +3 -6
  6. package/dist/components/sidebar/conversation-view.d.ts.map +1 -1
  7. package/dist/components/sidebar/conversation-view.js +19 -36
  8. package/dist/components/sidebar/entity-detail-panel.d.ts +5 -0
  9. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  10. package/dist/components/sidebar/entity-detail-panel.js +2 -2
  11. package/dist/components/sidebar/events-list.d.ts +7 -1
  12. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  13. package/dist/components/sidebar/events-list.js +6 -5
  14. package/dist/components/sidebar/resolve-hook-modal.d.ts.map +1 -1
  15. package/dist/components/sidebar/resolve-hook-modal.js +4 -145
  16. package/dist/components/sidebar/sidebar-data-context.d.ts +6 -0
  17. package/dist/components/sidebar/sidebar-data-context.d.ts.map +1 -1
  18. package/dist/components/sidebar/sidebar-data-context.js +1 -1
  19. package/dist/components/stream-viewer-skeleton.js +6 -6
  20. package/dist/components/stream-viewer.js +2 -2
  21. package/dist/components/trace-viewer/components/detail-panel.d.ts.map +1 -1
  22. package/dist/components/trace-viewer/components/detail-panel.js +2 -1
  23. package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.d.ts +9 -0
  24. package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.d.ts.map +1 -0
  25. package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.js +29 -0
  26. package/dist/components/trace-viewer/components/middle-truncate/middle-truncate.d.ts.map +1 -1
  27. package/dist/components/trace-viewer/components/middle-truncate/middle-truncate.js +16 -8
  28. package/dist/components/trace-viewer/components/middle-truncate/use-middle-truncate.d.ts.map +1 -1
  29. package/dist/components/trace-viewer/components/middle-truncate/use-middle-truncate.js +83 -19
  30. package/dist/components/trace-viewer/components/timeline.d.ts.map +1 -1
  31. package/dist/components/trace-viewer/components/timeline.js +13 -22
  32. package/dist/components/trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
  33. package/dist/components/trace-viewer/components/trace-viewer-skeleton.js +58 -18
  34. package/dist/components/trace-viewer/icons.js +5 -5
  35. package/dist/components/trace-viewer/utils.d.ts +6 -10
  36. package/dist/components/trace-viewer/utils.d.ts.map +1 -1
  37. package/dist/components/trace-viewer/utils.js +14 -24
  38. package/dist/components/trace-viewer.d.ts.map +1 -1
  39. package/dist/components/trace-viewer.js +12 -4
  40. package/dist/components/ui/context-card.d.ts.map +1 -1
  41. package/dist/components/ui/context-card.js +9 -21
  42. package/dist/components/ui/data-inspector.d.ts.map +1 -1
  43. package/dist/components/ui/data-inspector.js +8 -35
  44. package/dist/components/ui/duplicate-event-tooltip.d.ts +15 -0
  45. package/dist/components/ui/duplicate-event-tooltip.d.ts.map +1 -0
  46. package/dist/components/ui/duplicate-event-tooltip.js +19 -0
  47. package/dist/components/ui/menu-dropdown.d.ts.map +1 -1
  48. package/dist/components/ui/menu-dropdown.js +3 -19
  49. package/dist/components/ui/skeleton.js +2 -2
  50. package/dist/components/ui/spinner.d.ts.map +1 -1
  51. package/dist/components/ui/spinner.js +5 -11
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +2 -1
  55. package/dist/lib/duplicate-events.d.ts +27 -0
  56. package/dist/lib/duplicate-events.d.ts.map +1 -0
  57. package/dist/lib/duplicate-events.js +166 -0
  58. package/dist/lib/event-materialization.d.ts +15 -2
  59. package/dist/lib/event-materialization.d.ts.map +1 -1
  60. package/dist/lib/event-materialization.js +19 -4
  61. package/dist/lib/trace-builder.d.ts +16 -1
  62. package/dist/lib/trace-builder.d.ts.map +1 -1
  63. package/dist/lib/trace-builder.js +23 -4
  64. package/package.json +4 -4
  65. package/src/components/event-list-view.tsx +70 -32
  66. package/src/components/sidebar/attributes-block.tsx +4 -11
  67. package/src/components/sidebar/conversation-view.tsx +25 -79
  68. package/src/components/sidebar/entity-detail-panel.tsx +6 -0
  69. package/src/components/sidebar/events-list.tsx +21 -3
  70. package/src/components/sidebar/resolve-hook-modal.tsx +23 -186
  71. package/src/components/sidebar/sidebar-data-context.tsx +6 -0
  72. package/src/components/stream-viewer-skeleton.tsx +5 -5
  73. package/src/components/stream-viewer.tsx +1 -1
  74. package/src/components/trace-viewer/components/detail-panel.tsx +1 -0
  75. package/src/components/trace-viewer/components/middle-truncate/measurement-batch.ts +47 -0
  76. package/src/components/trace-viewer/components/middle-truncate/middle-truncate.tsx +15 -7
  77. package/src/components/trace-viewer/components/middle-truncate/use-middle-truncate.ts +132 -38
  78. package/src/components/trace-viewer/components/timeline.tsx +21 -39
  79. package/src/components/trace-viewer/components/trace-viewer-skeleton.tsx +63 -35
  80. package/src/components/trace-viewer/icons.tsx +4 -4
  81. package/src/components/trace-viewer/utils.test.ts +5 -7
  82. package/src/components/trace-viewer/utils.ts +17 -31
  83. package/src/components/trace-viewer.tsx +15 -3
  84. package/src/components/ui/context-card.tsx +16 -26
  85. package/src/components/ui/data-inspector.tsx +20 -65
  86. package/src/components/ui/duplicate-event-tooltip.tsx +44 -0
  87. package/src/components/ui/menu-dropdown.tsx +8 -22
  88. package/src/components/ui/skeleton.tsx +2 -2
  89. package/src/components/ui/spinner.tsx +4 -8
  90. package/src/index.ts +4 -0
  91. package/src/lib/duplicate-events.test.ts +339 -0
  92. package/src/lib/duplicate-events.ts +188 -0
  93. package/src/lib/event-materialization.test.ts +117 -0
  94. package/src/lib/event-materialization.ts +23 -3
  95. package/src/lib/trace-builder.test.ts +74 -0
  96. package/src/lib/trace-builder.ts +30 -3
@@ -25,6 +25,7 @@ import {
25
25
  useRef,
26
26
  useState,
27
27
  } from 'react';
28
+ import { cn } from '../../lib/cn';
28
29
  import { ENCRYPTED_DISPLAY_NAME } from '../../lib/hydration';
29
30
  import {
30
31
  type DecodedStreamChunkSource,
@@ -182,16 +183,8 @@ function EncryptedInlineLabel() {
182
183
  );
183
184
  }
184
185
  return (
185
- <span style={{ color: 'var(--ds-gray-600)', fontStyle: 'italic' }}>
186
- <Lock
187
- className="h-3 w-3"
188
- style={{
189
- display: 'inline',
190
- verticalAlign: 'middle',
191
- marginRight: '3px',
192
- marginTop: '-1px',
193
- }}
194
- />
186
+ <span className="text-gray-600 italic">
187
+ <Lock className="-mt-px mr-[3px] inline h-3 w-3 align-middle" />
195
188
  Encrypted
196
189
  </span>
197
190
  );
@@ -199,18 +192,10 @@ function EncryptedInlineLabel() {
199
192
 
200
193
  function StreamRefInline({ streamRef }: { streamRef: StreamRef }) {
201
194
  const onStreamClick = useContext(StreamClickContext);
202
- const [hovered, setHovered] = useState(false);
203
195
  return (
204
196
  <button
205
197
  type="button"
206
- className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-mono cursor-pointer underline decoration-transparent transition-colors"
207
- style={{
208
- backgroundColor: hovered ? 'var(--ds-blue-200)' : 'var(--ds-blue-100)',
209
- color: 'var(--ds-blue-900)',
210
- border: '1px solid var(--ds-blue-300)',
211
- }}
212
- onMouseEnter={() => setHovered(true)}
213
- onMouseLeave={() => setHovered(false)}
198
+ className="inline-flex cursor-pointer items-center gap-1 rounded border border-blue-300 bg-blue-100 px-1.5 py-0.5 font-mono text-[10px] text-blue-900 underline decoration-transparent transition-colors hover:bg-blue-200"
214
199
  onClick={(e) => {
215
200
  e.stopPropagation();
216
201
  onStreamClick?.(streamRef.streamId);
@@ -225,20 +210,10 @@ function StreamRefInline({ streamRef }: { streamRef: StreamRef }) {
225
210
 
226
211
  function RunRefInline({ runRef }: { runRef: RunRef }) {
227
212
  const onRunClick = useContext(RunClickContext);
228
- const [hovered, setHovered] = useState(false);
229
213
  return (
230
214
  <button
231
215
  type="button"
232
- className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-mono cursor-pointer underline decoration-transparent transition-colors"
233
- style={{
234
- backgroundColor: hovered
235
- ? 'var(--ds-purple-200)'
236
- : 'var(--ds-purple-100)',
237
- color: 'var(--ds-purple-900)',
238
- border: '1px solid var(--ds-purple-300)',
239
- }}
240
- onMouseEnter={() => setHovered(true)}
241
- onMouseLeave={() => setHovered(false)}
216
+ className="inline-flex cursor-pointer items-center gap-1 rounded border border-purple-300 bg-purple-100 px-1.5 py-0.5 font-mono text-[10px] text-purple-900 underline decoration-transparent transition-colors hover:bg-purple-200"
242
217
  onClick={(e) => {
243
218
  e.stopPropagation();
244
219
  onRunClick?.(runRef.runId);
@@ -267,10 +242,7 @@ function DecodedBytesChunk({
267
242
  {selectedView === 'decoded' ? (
268
243
  <div className="min-w-0">
269
244
  {typeof parsed === 'string' ? (
270
- <span
271
- className="whitespace-pre-wrap break-words"
272
- style={{ color: 'var(--ds-gray-1000)' }}
273
- >
245
+ <span className="whitespace-pre-wrap break-words text-gray-1000">
274
246
  {deserializeChunkText(parsed)}
275
247
  </span>
276
248
  ) : (
@@ -282,20 +254,15 @@ function DecodedBytesChunk({
282
254
  )}
283
255
  <div className="mt-2 flex">
284
256
  <div
285
- className="inline-flex overflow-hidden rounded border"
286
- style={{ borderColor: 'var(--ds-gray-400)' }}
257
+ className="inline-flex overflow-hidden rounded border border-gray-400"
287
258
  title={`${source.type} decoded as ${source.encoding.toUpperCase()} text. Switch to Bytes to inspect the summarized raw value.`}
288
259
  >
289
260
  <button
290
261
  type="button"
291
- className="h-5 px-1.5 text-[10px] font-medium"
292
- style={{
293
- backgroundColor:
294
- selectedView === 'decoded'
295
- ? 'var(--ds-gray-200)'
296
- : 'var(--ds-gray-100)',
297
- color: 'var(--ds-gray-900)',
298
- }}
262
+ className={cn(
263
+ 'h-5 px-1.5 font-medium text-[10px] text-gray-900',
264
+ selectedView === 'decoded' ? 'bg-gray-200' : 'bg-gray-100'
265
+ )}
299
266
  onClick={() => setSelectedView('decoded')}
300
267
  aria-pressed={selectedView === 'decoded'}
301
268
  aria-label="Show decoded text"
@@ -304,15 +271,10 @@ function DecodedBytesChunk({
304
271
  </button>
305
272
  <button
306
273
  type="button"
307
- className="h-5 border-l px-1.5 text-[10px] font-medium"
308
- style={{
309
- borderColor: 'var(--ds-gray-400)',
310
- backgroundColor:
311
- selectedView === 'bytes'
312
- ? 'var(--ds-gray-200)'
313
- : 'var(--ds-gray-100)',
314
- color: 'var(--ds-gray-900)',
315
- }}
274
+ className={cn(
275
+ 'h-5 border-gray-400 border-l px-1.5 font-medium text-[10px] text-gray-900',
276
+ selectedView === 'bytes' ? 'bg-gray-200' : 'bg-gray-100'
277
+ )}
316
278
  onClick={() => setSelectedView('bytes')}
317
279
  aria-pressed={selectedView === 'bytes'}
318
280
  aria-label="Show raw bytes summary"
@@ -338,23 +300,19 @@ function DecodedBytesInspector({
338
300
  <div className="font-mono">
339
301
  <button
340
302
  type="button"
341
- className="flex max-w-full items-start gap-1 text-left"
342
- style={{ color: 'var(--ds-gray-1000)' }}
303
+ className="flex max-w-full items-start gap-1 text-left text-gray-1000"
343
304
  onClick={() => setExpanded((value) => !value)}
344
305
  title={`${source.type} decoded as ${source.encoding.toUpperCase()} text`}
345
306
  >
346
- <span className="select-none" style={{ color: 'var(--ds-gray-700)' }}>
307
+ <span className="select-none text-gray-700">
347
308
  {expanded ? '▼' : '▶'}
348
309
  </span>
349
310
  <span className="min-w-0 break-words">{source.rawSummary}</span>
350
311
  </button>
351
312
  {expanded && (
352
313
  <div className="mt-1 pl-5">
353
- <span style={{ color: 'var(--ds-gray-700)' }}>decoded: </span>
354
- <span
355
- className="whitespace-pre-wrap break-words"
356
- style={{ color: 'var(--ds-green-900)' }}
357
- >
314
+ <span className="text-gray-700">decoded: </span>
315
+ <span className="whitespace-pre-wrap break-words text-green-900">
358
316
  {JSON.stringify(decodedText)}
359
317
  </span>
360
318
  </div>
@@ -373,10 +331,7 @@ function BytesDisplayValue({ display }: { display: BytesDisplay }) {
373
331
  );
374
332
  }
375
333
  return (
376
- <span
377
- className="whitespace-pre-wrap break-words"
378
- style={{ color: 'var(--ds-gray-1000)' }}
379
- >
334
+ <span className="whitespace-pre-wrap break-words text-gray-1000">
380
335
  {display.text}
381
336
  </span>
382
337
  );
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { DUPLICATE_EVENT_MESSAGE } from '../../lib/duplicate-events';
5
+ import {
6
+ Tooltip,
7
+ TooltipContent,
8
+ TooltipProvider,
9
+ TooltipTrigger,
10
+ } from './tooltip';
11
+
12
+ /**
13
+ * Explains why an event is shown greyed out: it repeats a class the log
14
+ * already records for the same entity, after that entity finished.
15
+ *
16
+ * Renders `children` untouched when `isDuplicate` is false, so a call site can
17
+ * wrap an event label unconditionally. Mounts its own {@link TooltipProvider}
18
+ * so it works in the sidebar and the events table alike; nesting one inside an
19
+ * existing provider is harmless.
20
+ */
21
+ export function DuplicateEventTooltip({
22
+ isDuplicate = false,
23
+ children,
24
+ }: {
25
+ isDuplicate?: boolean;
26
+ children: ReactNode;
27
+ }): ReactNode {
28
+ if (!isDuplicate) return children;
29
+
30
+ return (
31
+ <TooltipProvider delayDuration={300}>
32
+ <Tooltip>
33
+ <TooltipTrigger asChild>{children}</TooltipTrigger>
34
+ <TooltipContent
35
+ className="max-w-[264px] leading-snug"
36
+ collisionPadding={8}
37
+ side="top"
38
+ >
39
+ {DUPLICATE_EVENT_MESSAGE}
40
+ </TooltipContent>
41
+ </Tooltip>
42
+ </TooltipProvider>
43
+ );
44
+ }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useRef, useState } from 'react';
4
+ import { cn } from '../../lib/cn';
4
5
 
5
6
  const STYLES = `.wf-menu-btn{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 12px;border-radius:6px;font-size:14px;font-weight:500;line-height:20px;color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400);cursor:pointer;white-space:nowrap;transition:background 150ms}.wf-menu-btn:hover{background:var(--ds-gray-alpha-200)}.wf-menu-item{appearance:none;-webkit-appearance:none;border:none;display:flex;align-items:center;width:100%;height:40px;padding:0 8px;border-radius:6px;font-size:14px;color:var(--ds-gray-1000);background:transparent;cursor:pointer;transition:background 150ms}.wf-menu-item:hover{background:var(--ds-gray-alpha-100)}`;
6
7
 
@@ -41,7 +42,7 @@ export function MenuDropdown<T extends string = string>({
41
42
  }, [open]);
42
43
 
43
44
  return (
44
- <div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
45
+ <div ref={ref} className="relative shrink-0">
45
46
  <style dangerouslySetInnerHTML={{ __html: STYLES }} />
46
47
 
47
48
  <button
@@ -55,11 +56,7 @@ export function MenuDropdown<T extends string = string>({
55
56
  height={16}
56
57
  viewBox="0 0 16 16"
57
58
  fill="none"
58
- style={{
59
- marginLeft: 16,
60
- marginRight: -4,
61
- color: 'var(--ds-gray-900)',
62
- }}
59
+ className="-mr-1 ml-4 text-gray-900"
63
60
  >
64
61
  <path
65
62
  d="M4.5 6L8 9.5L11.5 6"
@@ -73,18 +70,7 @@ export function MenuDropdown<T extends string = string>({
73
70
 
74
71
  {open && (
75
72
  <div
76
- style={{
77
- position: 'absolute',
78
- right: 0,
79
- top: '100%',
80
- marginTop: 4,
81
- minWidth: 140,
82
- padding: 4,
83
- borderRadius: 12,
84
- background: 'var(--ds-background-100)',
85
- boxShadow: 'var(--ds-shadow-menu, var(--ds-shadow-medium))',
86
- zIndex: 2001,
87
- }}
73
+ className="absolute top-full right-0 z-[2001] mt-1 min-w-[140px] bg-background-100 p-1 material-menu"
88
74
  role="menu"
89
75
  >
90
76
  {options.map((option) => (
@@ -92,10 +78,10 @@ export function MenuDropdown<T extends string = string>({
92
78
  key={option.value}
93
79
  type="button"
94
80
  role="menuitem"
95
- className="wf-menu-item"
96
- style={{
97
- fontWeight: option.value === value ? 500 : 400,
98
- }}
81
+ className={cn(
82
+ 'wf-menu-item',
83
+ option.value === value ? 'font-medium' : 'font-normal'
84
+ )}
99
85
  onClick={() => {
100
86
  onChange(option.value);
101
87
  setOpen(false);
@@ -8,8 +8,8 @@ function Skeleton({
8
8
  return (
9
9
  <div
10
10
  {...props}
11
- className={cn('rounded-md', className)}
12
- style={{ backgroundColor: 'var(--ds-gray-200)', ...style }}
11
+ className={cn('rounded-md bg-gray-200', className)}
12
+ style={style}
13
13
  />
14
14
  );
15
15
  }
@@ -44,9 +44,8 @@ export function Spinner({
44
44
 
45
45
  return (
46
46
  <span
47
+ className="relative inline-flex"
47
48
  style={{
48
- display: 'inline-flex',
49
- position: 'relative',
50
49
  width: size,
51
50
  height: size,
52
51
  }}
@@ -55,18 +54,15 @@ export function Spinner({
55
54
  {config.delays.map((delay, i) => (
56
55
  <span
57
56
  key={delay}
57
+ className="absolute top-1/2 left-1/2 rounded-[1px] bg-gray-700 [animation-iteration-count:infinite] [animation-name:wf-spinner-fade] [animation-timing-function:linear]"
58
58
  style={{
59
- position: 'absolute',
60
- left: '50%',
61
- top: '50%',
62
59
  width: config.lineW,
63
60
  height: config.lineH,
64
61
  marginLeft: -config.lineW / 2,
65
62
  marginTop: -config.lineH / 2,
66
- borderRadius: 1,
67
- backgroundColor: color ?? 'var(--ds-gray-700)',
63
+ backgroundColor: color,
68
64
  transform: `rotate(${i * config.angle}deg) translate(${size * 0.36}px)`,
69
- animation: `wf-spinner-fade ${config.duration}ms linear infinite`,
65
+ animationDuration: `${config.duration}ms`,
70
66
  animationDelay: `${delay}ms`,
71
67
  }}
72
68
  />
package/src/index.ts CHANGED
@@ -10,6 +10,10 @@ export {
10
10
  stepEventsToStepEntity,
11
11
  waitEventsToWaitEntity,
12
12
  } from './components/workflow-traces/trace-span-construction';
13
+ export {
14
+ DUPLICATE_EVENT_MESSAGE,
15
+ findDuplicateEventIds,
16
+ } from './lib/duplicate-events';
13
17
  export type { EventAnalysis } from './lib/event-analysis';
14
18
  export {
15
19
  analyzeEvents,
@@ -0,0 +1,339 @@
1
+ import type { Event, EventType } from '@workflow/world';
2
+ import { DUPLICATE_EVENT_FIXTURES } from '@workflow/world/test-support/duplicate-event-fixtures.js';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { findDuplicateEventIds } from './duplicate-events';
5
+
6
+ const BASE_TIME = Date.parse('2026-01-01T00:00:00.000Z');
7
+
8
+ const COMPLETE = { isCompleteHistory: true };
9
+
10
+ let nextSlot = 0;
11
+
12
+ function event(
13
+ eventType: EventType,
14
+ options: {
15
+ correlationId?: string;
16
+ /** Log position. Defaults to the order the fixture created the event in. */
17
+ slot?: number;
18
+ /** `createdAt`/`occurredAt`, in seconds after the fixture epoch. */
19
+ at?: number;
20
+ occurredAt?: number;
21
+ } = {}
22
+ ): Event {
23
+ nextSlot += 1;
24
+ const slot = options.slot ?? nextSlot;
25
+ const createdAt = new Date(BASE_TIME + (options.at ?? slot) * 1000);
26
+ return {
27
+ eventId: `evnt_${String(slot).padStart(26, '0')}`,
28
+ runId: 'run_1',
29
+ eventType,
30
+ correlationId: options.correlationId,
31
+ createdAt,
32
+ occurredAt:
33
+ options.occurredAt === undefined
34
+ ? createdAt
35
+ : new Date(BASE_TIME + options.occurredAt * 1000),
36
+ eventData: {},
37
+ } as unknown as Event;
38
+ }
39
+
40
+ /**
41
+ * The same event under the older ID scheme, whose IDs are ULIDs rather than
42
+ * slots. A backend serving such a log may order it by `(createdAt, eventId)`
43
+ * instead of by ID, so the ID alone does not fix the log position.
44
+ */
45
+ function ulidEvent(...args: Parameters<typeof event>): Event {
46
+ const slotEvent = event(...args);
47
+ const slot = slotEvent.eventId.slice('evnt_'.length).replace(/^0+/, '');
48
+ return {
49
+ ...slotEvent,
50
+ eventId: `evnt_01K${slot.padStart(23, '0')}`,
51
+ } as Event;
52
+ }
53
+
54
+ describe('findDuplicateEventIds', () => {
55
+ it('returns nothing for a log with no repeats', () => {
56
+ const events = [
57
+ event('run_created'),
58
+ event('run_started'),
59
+ event('step_created', { correlationId: 'step_a' }),
60
+ event('step_started', { correlationId: 'step_a' }),
61
+ event('step_completed', { correlationId: 'step_a' }),
62
+ event('run_completed'),
63
+ ];
64
+
65
+ expect(findDuplicateEventIds(events, COMPLETE)).toEqual(new Set());
66
+ });
67
+
68
+ it('flags every repeat a finished entity collects', () => {
69
+ const created = event('step_created', { correlationId: 'step_a' });
70
+ const started = event('step_started', { correlationId: 'step_a' });
71
+ const completed = event('step_completed', { correlationId: 'step_a' });
72
+ const createdAgain = event('step_created', { correlationId: 'step_a' });
73
+ const startedAgain = event('step_started', { correlationId: 'step_a' });
74
+
75
+ expect(
76
+ findDuplicateEventIds(
77
+ [created, started, completed, createdAgain, startedAgain],
78
+ COMPLETE
79
+ )
80
+ ).toEqual(new Set([createdAgain.eventId, startedAgain.eventId]));
81
+ });
82
+
83
+ it('leaves a class the log has not recorded for the entity yet', () => {
84
+ // The step finished without a step_started in the log, so this one repeats
85
+ // nothing. The runtime reports that as divergence rather than passing it
86
+ // over, and the UI must not present it as a settled repeat.
87
+ const events = [
88
+ event('step_created', { correlationId: 'step_a' }),
89
+ event('step_completed', { correlationId: 'step_a' }),
90
+ event('step_started', { correlationId: 'step_a' }),
91
+ ];
92
+
93
+ expect(findDuplicateEventIds(events, COMPLETE)).toEqual(new Set());
94
+ });
95
+
96
+ it('treats completed and failed as one terminal class', () => {
97
+ // A concurrent replay writing the other outcome does not move the step off
98
+ // the outcome the run acted on.
99
+ const failed = event('step_failed', { correlationId: 'step_a' });
100
+ const completed = event('step_completed', { correlationId: 'step_a' });
101
+
102
+ expect(findDuplicateEventIds([failed, completed], COMPLETE)).toEqual(
103
+ new Set([completed.eventId])
104
+ );
105
+ });
106
+
107
+ it('keys on the correlation id, so sibling entities never collide', () => {
108
+ const events = [
109
+ event('step_created', { correlationId: 'step_a' }),
110
+ event('step_created', { correlationId: 'step_b' }),
111
+ event('step_completed', { correlationId: 'step_a' }),
112
+ event('step_completed', { correlationId: 'step_b' }),
113
+ ];
114
+
115
+ expect(findDuplicateEventIds(events, COMPLETE)).toEqual(new Set());
116
+ });
117
+
118
+ it('does not flag the repeated events of a retried step', () => {
119
+ // Each attempt legitimately records its own start, and each retryable
120
+ // failure its own step_retrying. The step's consumer is registered for the
121
+ // whole sequence and takes all of them.
122
+ const events = [
123
+ event('step_created', { correlationId: 'step_a' }),
124
+ event('step_started', { correlationId: 'step_a' }),
125
+ event('step_retrying', { correlationId: 'step_a' }),
126
+ event('step_started', { correlationId: 'step_a' }),
127
+ event('step_retrying', { correlationId: 'step_a' }),
128
+ event('step_started', { correlationId: 'step_a' }),
129
+ event('step_completed', { correlationId: 'step_a' }),
130
+ ];
131
+
132
+ expect(findDuplicateEventIds(events, COMPLETE)).toEqual(new Set());
133
+ });
134
+
135
+ it('does not flag a second step_created while the step is still open', () => {
136
+ // The step's consumer is registered and absorbs it, so the run does not
137
+ // read past this event.
138
+ const events = [
139
+ event('step_created', { correlationId: 'step_a' }),
140
+ event('step_created', { correlationId: 'step_a' }),
141
+ event('step_started', { correlationId: 'step_a' }),
142
+ ];
143
+
144
+ expect(findDuplicateEventIds(events, COMPLETE)).toEqual(new Set());
145
+ });
146
+
147
+ it('does not flag repeated hook deliveries', () => {
148
+ const events = [
149
+ event('hook_created', { correlationId: 'hook_a' }),
150
+ event('hook_received', { correlationId: 'hook_a' }),
151
+ event('hook_received', { correlationId: 'hook_a' }),
152
+ event('hook_disposed', { correlationId: 'hook_a' }),
153
+ ];
154
+
155
+ expect(findDuplicateEventIds(events, COMPLETE)).toEqual(new Set());
156
+ });
157
+
158
+ it('flags a second start of the run, which carries no correlation id', () => {
159
+ const started = event('run_started');
160
+ const startedAgain = event('run_started');
161
+
162
+ expect(findDuplicateEventIds([started, startedAgain], COMPLETE)).toEqual(
163
+ new Set([startedAgain.eventId])
164
+ );
165
+ });
166
+
167
+ it('leaves a second outcome for the run alone', () => {
168
+ // Nothing consumes the run's own terminal events: the runtime exits rather
169
+ // than replaying the body once the log holds one. A second is a fault
170
+ // worth seeing, not a repeat the run passed over.
171
+ const completed = event('run_completed');
172
+ const cancelled = event('run_cancelled');
173
+
174
+ expect(findDuplicateEventIds([completed, cancelled], COMPLETE)).toEqual(
175
+ new Set()
176
+ );
177
+ });
178
+
179
+ it('folds in log order, not in createdAt order', () => {
180
+ const created = event('wait_created', { correlationId: 'wait_a', at: 1 });
181
+ const completed = event('wait_completed', {
182
+ correlationId: 'wait_a',
183
+ at: 3,
184
+ });
185
+ // The repeat entered before the completion it lost to and only took its
186
+ // log position afterwards, so its createdAt is the earliest of the three.
187
+ const createdAgain = event('wait_created', {
188
+ correlationId: 'wait_a',
189
+ at: 0,
190
+ });
191
+
192
+ expect(
193
+ findDuplicateEventIds([created, completed, createdAgain], COMPLETE)
194
+ ).toEqual(new Set([createdAgain.eventId]));
195
+ });
196
+
197
+ it('folds in log order, not in occurredAt order', () => {
198
+ const created = event('wait_created', { correlationId: 'wait_a' });
199
+ const completed = event('wait_completed', { correlationId: 'wait_a' });
200
+ // occurredAt is measured on the writer's clock, which can run behind.
201
+ const createdAgain = event('wait_created', {
202
+ correlationId: 'wait_a',
203
+ occurredAt: -60,
204
+ });
205
+
206
+ expect(
207
+ findDuplicateEventIds([created, completed, createdAgain], COMPLETE)
208
+ ).toEqual(new Set([createdAgain.eventId]));
209
+ });
210
+
211
+ it('gives the same answer whichever way the caller sorted', () => {
212
+ const events = [
213
+ event('wait_created', { correlationId: 'wait_a' }),
214
+ event('wait_completed', { correlationId: 'wait_a' }),
215
+ event('wait_created', { correlationId: 'wait_a' }),
216
+ ];
217
+
218
+ const ascending = findDuplicateEventIds(events, COMPLETE);
219
+ const descending = findDuplicateEventIds([...events].reverse(), COMPLETE);
220
+
221
+ expect(ascending).toEqual(new Set([events[2].eventId]));
222
+ expect(descending).toEqual(ascending);
223
+ });
224
+
225
+ it('gives the same answer on tied timestamps whichever way the caller sorted', () => {
226
+ // Two replays that stamped the same millisecond. Only the log position
227
+ // separates them, so the answer must not depend on the caller's order.
228
+ const events = [
229
+ event('wait_created', { correlationId: 'wait_a', at: 5 }),
230
+ event('wait_completed', { correlationId: 'wait_a', at: 5 }),
231
+ event('wait_created', { correlationId: 'wait_a', at: 5 }),
232
+ ];
233
+
234
+ const ascending = findDuplicateEventIds(events, COMPLETE);
235
+ const descending = findDuplicateEventIds([...events].reverse(), COMPLETE);
236
+
237
+ expect(ascending).toEqual(new Set([events[2].eventId]));
238
+ expect(descending).toEqual(ascending);
239
+ });
240
+
241
+ it('classifies a ULID log whose timestamps corroborate its ids', () => {
242
+ const created = ulidEvent('wait_created', { correlationId: 'wait_a' });
243
+ const completed = ulidEvent('wait_completed', { correlationId: 'wait_a' });
244
+ const createdAgain = ulidEvent('wait_created', { correlationId: 'wait_a' });
245
+
246
+ expect(
247
+ findDuplicateEventIds([created, completed, createdAgain], COMPLETE)
248
+ ).toEqual(new Set([createdAgain.eventId]));
249
+ });
250
+
251
+ it('classifies nothing on a ULID log whose timestamps contradict its ids', () => {
252
+ // A ULID carries no log position: one backend returns such a log in
253
+ // createdAt order and another in id order, and createdAt is stamped when
254
+ // the write arrives rather than when it commits. With the two orders
255
+ // disagreeing, which wait_created the run acted on depends on the backend,
256
+ // so naming either would be a guess.
257
+ const created = ulidEvent('wait_created', { correlationId: 'wait_a' });
258
+ const completed = ulidEvent('wait_completed', { correlationId: 'wait_a' });
259
+ const createdAgain = ulidEvent('wait_created', {
260
+ correlationId: 'wait_a',
261
+ at: -60,
262
+ });
263
+
264
+ expect(
265
+ findDuplicateEventIds([created, completed, createdAgain], COMPLETE)
266
+ ).toEqual(new Set());
267
+ });
268
+
269
+ it('classifies nothing past the point the run diverged', () => {
270
+ // The step finished without a step_started, so the runtime reports
271
+ // divergence on the first trailing start and exits. The second start and
272
+ // the wait's repeat after it went unread, and neither is a repeat the run
273
+ // passed over. The wait's repeat before it still is.
274
+ const events = [
275
+ event('wait_created', { correlationId: 'wait_a' }),
276
+ event('wait_completed', { correlationId: 'wait_a' }),
277
+ event('wait_created', { correlationId: 'wait_a' }),
278
+ event('step_created', { correlationId: 'step_a' }),
279
+ event('step_completed', { correlationId: 'step_a' }),
280
+ event('step_started', { correlationId: 'step_a' }),
281
+ event('step_started', { correlationId: 'step_a' }),
282
+ event('wait_created', { correlationId: 'wait_a' }),
283
+ ];
284
+
285
+ expect(findDuplicateEventIds(events, COMPLETE)).toEqual(
286
+ new Set([events[2].eventId])
287
+ );
288
+ });
289
+
290
+ it('classifies nothing when the caller holds part of the log', () => {
291
+ // A newest-first page can open on the repeat and omit the event it
292
+ // repeats, which would invert the answer.
293
+ const events = [
294
+ event('wait_completed', { correlationId: 'wait_a' }),
295
+ event('wait_created', { correlationId: 'wait_a' }),
296
+ ];
297
+
298
+ expect(findDuplicateEventIds(events, { isCompleteHistory: false })).toEqual(
299
+ new Set()
300
+ );
301
+ });
302
+
303
+ it('skips events with no id, which callers cannot match on', () => {
304
+ const anonymous = (eventType: EventType) =>
305
+ ({
306
+ ...event(eventType, { correlationId: 'wait_a' }),
307
+ eventId: undefined,
308
+ }) as unknown as Event;
309
+
310
+ expect(
311
+ findDuplicateEventIds(
312
+ [
313
+ anonymous('wait_created'),
314
+ anonymous('wait_completed'),
315
+ anonymous('wait_created'),
316
+ ],
317
+ COMPLETE
318
+ )
319
+ ).toEqual(new Set());
320
+ });
321
+ });
322
+
323
+ /**
324
+ * The other half of these runs against `EventsConsumer` in `@workflow/core`,
325
+ * so a fixture whose expectation moves fails on both sides.
326
+ */
327
+ describe('shared duplicate-event fixtures', () => {
328
+ for (const fixture of DUPLICATE_EVENT_FIXTURES) {
329
+ it(`classifies the right events: ${fixture.name}`, () => {
330
+ const events = fixture.events.map((spec, index) =>
331
+ event(spec.eventType, { correlationId: spec.entity, slot: index + 1 })
332
+ );
333
+
334
+ expect(findDuplicateEventIds(events, COMPLETE)).toEqual(
335
+ new Set(fixture.ignoredIndices.map((index) => events[index].eventId))
336
+ );
337
+ });
338
+ }
339
+ });