@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.
- package/dist/components/event-list-view.d.ts +10 -2
- package/dist/components/event-list-view.d.ts.map +1 -1
- package/dist/components/event-list-view.js +52 -26
- package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
- package/dist/components/sidebar/attributes-block.js +3 -6
- package/dist/components/sidebar/conversation-view.d.ts.map +1 -1
- package/dist/components/sidebar/conversation-view.js +19 -36
- package/dist/components/sidebar/entity-detail-panel.d.ts +5 -0
- package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.js +2 -2
- package/dist/components/sidebar/events-list.d.ts +7 -1
- package/dist/components/sidebar/events-list.d.ts.map +1 -1
- package/dist/components/sidebar/events-list.js +6 -5
- package/dist/components/sidebar/resolve-hook-modal.d.ts.map +1 -1
- package/dist/components/sidebar/resolve-hook-modal.js +4 -145
- package/dist/components/sidebar/sidebar-data-context.d.ts +6 -0
- package/dist/components/sidebar/sidebar-data-context.d.ts.map +1 -1
- package/dist/components/sidebar/sidebar-data-context.js +1 -1
- package/dist/components/stream-viewer-skeleton.js +6 -6
- package/dist/components/stream-viewer.js +2 -2
- package/dist/components/trace-viewer/components/detail-panel.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/detail-panel.js +2 -1
- package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.d.ts +9 -0
- package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.d.ts.map +1 -0
- package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.js +29 -0
- package/dist/components/trace-viewer/components/middle-truncate/middle-truncate.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/middle-truncate/middle-truncate.js +16 -8
- package/dist/components/trace-viewer/components/middle-truncate/use-middle-truncate.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/middle-truncate/use-middle-truncate.js +83 -19
- package/dist/components/trace-viewer/components/timeline.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/timeline.js +13 -22
- package/dist/components/trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/trace-viewer-skeleton.js +58 -18
- package/dist/components/trace-viewer/icons.js +5 -5
- package/dist/components/trace-viewer/utils.d.ts +6 -10
- package/dist/components/trace-viewer/utils.d.ts.map +1 -1
- package/dist/components/trace-viewer/utils.js +14 -24
- package/dist/components/trace-viewer.d.ts.map +1 -1
- package/dist/components/trace-viewer.js +12 -4
- package/dist/components/ui/context-card.d.ts.map +1 -1
- package/dist/components/ui/context-card.js +9 -21
- package/dist/components/ui/data-inspector.d.ts.map +1 -1
- package/dist/components/ui/data-inspector.js +8 -35
- package/dist/components/ui/duplicate-event-tooltip.d.ts +15 -0
- package/dist/components/ui/duplicate-event-tooltip.d.ts.map +1 -0
- package/dist/components/ui/duplicate-event-tooltip.js +19 -0
- package/dist/components/ui/menu-dropdown.d.ts.map +1 -1
- package/dist/components/ui/menu-dropdown.js +3 -19
- package/dist/components/ui/skeleton.js +2 -2
- package/dist/components/ui/spinner.d.ts.map +1 -1
- package/dist/components/ui/spinner.js +5 -11
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/duplicate-events.d.ts +27 -0
- package/dist/lib/duplicate-events.d.ts.map +1 -0
- package/dist/lib/duplicate-events.js +166 -0
- package/dist/lib/event-materialization.d.ts +15 -2
- package/dist/lib/event-materialization.d.ts.map +1 -1
- package/dist/lib/event-materialization.js +19 -4
- package/dist/lib/trace-builder.d.ts +16 -1
- package/dist/lib/trace-builder.d.ts.map +1 -1
- package/dist/lib/trace-builder.js +23 -4
- package/package.json +4 -4
- package/src/components/event-list-view.tsx +70 -32
- package/src/components/sidebar/attributes-block.tsx +4 -11
- package/src/components/sidebar/conversation-view.tsx +25 -79
- package/src/components/sidebar/entity-detail-panel.tsx +6 -0
- package/src/components/sidebar/events-list.tsx +21 -3
- package/src/components/sidebar/resolve-hook-modal.tsx +23 -186
- package/src/components/sidebar/sidebar-data-context.tsx +6 -0
- package/src/components/stream-viewer-skeleton.tsx +5 -5
- package/src/components/stream-viewer.tsx +1 -1
- package/src/components/trace-viewer/components/detail-panel.tsx +1 -0
- package/src/components/trace-viewer/components/middle-truncate/measurement-batch.ts +47 -0
- package/src/components/trace-viewer/components/middle-truncate/middle-truncate.tsx +15 -7
- package/src/components/trace-viewer/components/middle-truncate/use-middle-truncate.ts +132 -38
- package/src/components/trace-viewer/components/timeline.tsx +21 -39
- package/src/components/trace-viewer/components/trace-viewer-skeleton.tsx +63 -35
- package/src/components/trace-viewer/icons.tsx +4 -4
- package/src/components/trace-viewer/utils.test.ts +5 -7
- package/src/components/trace-viewer/utils.ts +17 -31
- package/src/components/trace-viewer.tsx +15 -3
- package/src/components/ui/context-card.tsx +16 -26
- package/src/components/ui/data-inspector.tsx +20 -65
- package/src/components/ui/duplicate-event-tooltip.tsx +44 -0
- package/src/components/ui/menu-dropdown.tsx +8 -22
- package/src/components/ui/skeleton.tsx +2 -2
- package/src/components/ui/spinner.tsx +4 -8
- package/src/index.ts +4 -0
- package/src/lib/duplicate-events.test.ts +339 -0
- package/src/lib/duplicate-events.ts +188 -0
- package/src/lib/event-materialization.test.ts +117 -0
- package/src/lib/event-materialization.ts +23 -3
- package/src/lib/trace-builder.test.ts +74 -0
- 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
|
|
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
|
|
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
|
|
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=
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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=
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
96
|
-
|
|
97
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
67
|
-
backgroundColor: color ?? 'var(--ds-gray-700)',
|
|
63
|
+
backgroundColor: color,
|
|
68
64
|
transform: `rotate(${i * config.angle}deg) translate(${size * 0.36}px)`,
|
|
69
|
-
|
|
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
|
+
});
|