@workflow/web-shared 5.0.0-beta.40 → 5.0.0-beta.42

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/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/timeline.d.ts.map +1 -1
  24. package/dist/components/trace-viewer/components/timeline.js +13 -22
  25. package/dist/components/trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
  26. package/dist/components/trace-viewer/components/trace-viewer-skeleton.js +58 -18
  27. package/dist/components/trace-viewer/icons.js +5 -5
  28. package/dist/components/trace-viewer/utils.d.ts +6 -10
  29. package/dist/components/trace-viewer/utils.d.ts.map +1 -1
  30. package/dist/components/trace-viewer/utils.js +14 -24
  31. package/dist/components/trace-viewer.d.ts.map +1 -1
  32. package/dist/components/trace-viewer.js +12 -4
  33. package/dist/components/ui/context-card.d.ts.map +1 -1
  34. package/dist/components/ui/context-card.js +9 -21
  35. package/dist/components/ui/data-inspector.d.ts.map +1 -1
  36. package/dist/components/ui/data-inspector.js +8 -35
  37. package/dist/components/ui/duplicate-event-tooltip.d.ts +15 -0
  38. package/dist/components/ui/duplicate-event-tooltip.d.ts.map +1 -0
  39. package/dist/components/ui/duplicate-event-tooltip.js +19 -0
  40. package/dist/components/ui/menu-dropdown.d.ts.map +1 -1
  41. package/dist/components/ui/menu-dropdown.js +3 -19
  42. package/dist/components/ui/skeleton.js +2 -2
  43. package/dist/components/ui/spinner.d.ts.map +1 -1
  44. package/dist/components/ui/spinner.js +5 -11
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -1
  48. package/dist/lib/duplicate-events.d.ts +27 -0
  49. package/dist/lib/duplicate-events.d.ts.map +1 -0
  50. package/dist/lib/duplicate-events.js +166 -0
  51. package/dist/lib/event-materialization.d.ts +15 -2
  52. package/dist/lib/event-materialization.d.ts.map +1 -1
  53. package/dist/lib/event-materialization.js +19 -4
  54. package/dist/lib/trace-builder.d.ts +16 -1
  55. package/dist/lib/trace-builder.d.ts.map +1 -1
  56. package/dist/lib/trace-builder.js +23 -4
  57. package/package.json +4 -4
  58. package/src/components/event-list-view.tsx +70 -32
  59. package/src/components/sidebar/attributes-block.tsx +4 -11
  60. package/src/components/sidebar/conversation-view.tsx +25 -79
  61. package/src/components/sidebar/entity-detail-panel.tsx +6 -0
  62. package/src/components/sidebar/events-list.tsx +21 -3
  63. package/src/components/sidebar/resolve-hook-modal.tsx +23 -186
  64. package/src/components/sidebar/sidebar-data-context.tsx +6 -0
  65. package/src/components/stream-viewer-skeleton.tsx +5 -5
  66. package/src/components/stream-viewer.tsx +1 -1
  67. package/src/components/trace-viewer/components/detail-panel.tsx +1 -0
  68. package/src/components/trace-viewer/components/timeline.tsx +21 -39
  69. package/src/components/trace-viewer/components/trace-viewer-skeleton.tsx +63 -35
  70. package/src/components/trace-viewer/icons.tsx +4 -4
  71. package/src/components/trace-viewer/utils.test.ts +5 -7
  72. package/src/components/trace-viewer/utils.ts +17 -31
  73. package/src/components/trace-viewer.tsx +15 -3
  74. package/src/components/ui/context-card.tsx +16 -26
  75. package/src/components/ui/data-inspector.tsx +20 -65
  76. package/src/components/ui/duplicate-event-tooltip.tsx +44 -0
  77. package/src/components/ui/menu-dropdown.tsx +8 -22
  78. package/src/components/ui/skeleton.tsx +2 -2
  79. package/src/components/ui/spinner.tsx +4 -8
  80. package/src/index.ts +4 -0
  81. package/src/lib/duplicate-events.test.ts +339 -0
  82. package/src/lib/duplicate-events.ts +188 -0
  83. package/src/lib/event-materialization.test.ts +117 -0
  84. package/src/lib/event-materialization.ts +23 -3
  85. package/src/lib/trace-builder.test.ts +74 -0
  86. package/src/lib/trace-builder.ts +30 -3
@@ -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
+ });
@@ -0,0 +1,188 @@
1
+ import {
2
+ type EntityEventClass,
3
+ type Event,
4
+ entityEventClass,
5
+ isSlotEventId,
6
+ } from '@workflow/world';
7
+
8
+ /**
9
+ * Identifies events a replay reads past.
10
+ *
11
+ * Concurrent replays of one run write to a shared log, so a replay working
12
+ * from a stale prefix can commit a second `step_created` / `step_started` /
13
+ * `wait_created` for an entity the log already records one of. Every replay
14
+ * reads the first event of that class at the same position, so a later one
15
+ * cannot change what the workflow observes.
16
+ *
17
+ * The classification mirrors `entityEventClass` in `@workflow/world`, which is
18
+ * what the runtime keys its own duplicate detection on. What it cannot mirror
19
+ * is consumer state: the runtime passes over an event only after every
20
+ * registered callback has declined it, and a callback registered for a
21
+ * still-open entity legitimately claims a repeat (each retry of a step writes
22
+ * another `step_started`, and a live step consumer absorbs a second
23
+ * `step_created`). So a repeat counts here only once a terminal event for the
24
+ * same entity sits earlier in the log, which is the point past which no
25
+ * consumer remains.
26
+ */
27
+
28
+ /**
29
+ * Classes whose event closes its entity: no consumer is left for it after.
30
+ *
31
+ * The run's own terminal events are absent because `entityEventClass` gives
32
+ * them no class. The runtime exits rather than replaying the body once the log
33
+ * holds one, so nothing ever consumes them and nothing can repeat them.
34
+ */
35
+ const TERMINAL_EVENT_CLASSES: ReadonlySet<EntityEventClass> = new Set([
36
+ 'step_terminal',
37
+ 'wait_completed',
38
+ 'hook_disposed',
39
+ ]);
40
+
41
+ /** Classes with no entity to close first: the log records one per run. */
42
+ const SINGLETON_EVENT_CLASSES: ReadonlySet<EntityEventClass> = new Set([
43
+ 'run_started',
44
+ ]);
45
+
46
+ /** Entity key for events that carry no correlation ID (the run itself). */
47
+ const RUN_ENTITY_KEY = '';
48
+
49
+ /**
50
+ * Shown against an event this module reports. Deliberately says what the log
51
+ * shows rather than what the runtime did with it: tolerating these repeats is
52
+ * recent, and on a run recorded before it a repeat no consumer claimed failed
53
+ * the replay instead of being passed over.
54
+ */
55
+ export const DUPLICATE_EVENT_MESSAGE =
56
+ 'Written by a concurrent replay after an event of the same kind was already recorded and acted on. The run follows the earlier one.';
57
+
58
+ /**
59
+ * Candidate log order, by event ID.
60
+ *
61
+ * Event IDs are fixed-width and monotonic within a run under both the ULID and
62
+ * the slot scheme. Length is compared first so a shorter ID never sorts after
63
+ * a longer one on a log that mixes widths.
64
+ *
65
+ * Whether this *is* the log order depends on the ID scheme, which is why
66
+ * {@link hasKnowableLogOrder} gates the fold. See its doc.
67
+ */
68
+ function compareEventId(a: Event, b: Event): number {
69
+ if (a.eventId.length !== b.eventId.length) {
70
+ return a.eventId.length - b.eventId.length;
71
+ }
72
+ return a.eventId < b.eventId ? -1 : a.eventId > b.eventId ? 1 : 0;
73
+ }
74
+
75
+ function createdAtMs(event: Event): number {
76
+ const createdAt = event.createdAt;
77
+ return createdAt instanceof Date
78
+ ? createdAt.getTime()
79
+ : new Date(createdAt as unknown as string).getTime();
80
+ }
81
+
82
+ /**
83
+ * Whether the order the run consumed its log in can be recovered from the
84
+ * events alone.
85
+ *
86
+ * Which occurrence of a class came first is the whole question here, so the
87
+ * fold needs the log's order, not an order. The backends do not agree on how
88
+ * to recover it for every ID scheme:
89
+ *
90
+ * - A **slot-numbered** run carries its position in the ID. The slot is drawn
91
+ * at the publish, which is the linearization point, so slot order is log
92
+ * order everywhere and the ID alone settles it.
93
+ * - A **ULID-numbered** run does not. One backend returns such a log in
94
+ * `(createdAt, eventId)` order while another returns it keyed on the ID, and
95
+ * `createdAt` is stamped when the write request arrives rather than when it
96
+ * commits. Concurrent writers can therefore produce opposite timestamp and
97
+ * ID orders, and the run consumed whichever its own backend served.
98
+ *
99
+ * So a ULID log is only knowable where the two orders agree. Where they
100
+ * contradict, the fold could name the surviving event and pass over the one
101
+ * the run acted on, which is worse than saying nothing.
102
+ */
103
+ function hasKnowableLogOrder(orderedById: readonly Event[]): boolean {
104
+ if (orderedById.every((event) => isSlotEventId(event.eventId))) return true;
105
+
106
+ for (let index = 1; index < orderedById.length; index++) {
107
+ const previous = createdAtMs(orderedById[index - 1]);
108
+ const current = createdAtMs(orderedById[index]);
109
+ // A missing or unparseable timestamp leaves nothing to corroborate the ID
110
+ // order with, which is the same position as a contradiction.
111
+ if (Number.isNaN(previous) || Number.isNaN(current)) return false;
112
+ if (current < previous) return false;
113
+ }
114
+
115
+ return true;
116
+ }
117
+
118
+ /** The fold itself, over a log whose order is known. */
119
+ function foldDuplicates(ordered: readonly Event[]): Set<string> {
120
+ const duplicates = new Set<string>();
121
+ const seenClasses = new Set<string>();
122
+ const closedEntities = new Set<string>();
123
+
124
+ for (const event of ordered) {
125
+ const eventClass = entityEventClass(event.eventType);
126
+ if (eventClass === undefined) continue;
127
+
128
+ const entity = event.correlationId ?? RUN_ENTITY_KEY;
129
+ const classKey = `${eventClass}:${entity}`;
130
+ const repeatsClass = seenClasses.has(classKey);
131
+ const entityWasClosed = closedEntities.has(entity);
132
+
133
+ if (TERMINAL_EVENT_CLASSES.has(eventClass)) {
134
+ closedEntities.add(entity);
135
+ }
136
+
137
+ if (!repeatsClass) {
138
+ // First of its class, but the entity already finished: no consumer is
139
+ // left to take it and it repeats nothing, so the runtime reports
140
+ // divergence here and exits. Everything past this point went unread, so
141
+ // the fold stops with it rather than recording the class and presenting
142
+ // a later event of it as a repeat the run passed over.
143
+ if (entityWasClosed) break;
144
+ seenClasses.add(classKey);
145
+ continue;
146
+ }
147
+
148
+ // The entity is still open, so a consumer is registered for it and takes
149
+ // this event: another attempt, not a repeat read past.
150
+ if (entityWasClosed || SINGLETON_EVENT_CLASSES.has(eventClass)) {
151
+ duplicates.add(event.eventId);
152
+ }
153
+ }
154
+
155
+ return duplicates;
156
+ }
157
+
158
+ /**
159
+ * The IDs of the events in `events` that repeat a class the log already
160
+ * records for the same entity, after that entity finished.
161
+ *
162
+ * `isCompleteHistory` must be false whenever the caller holds a subset of the
163
+ * run's log: one page of a paginated list, or the result of a search. Which
164
+ * occurrence of a class came first is a property of the whole log, so on a
165
+ * subset the earlier event may simply be missing, and the fold would report
166
+ * the surviving one. Nothing is classified in that case.
167
+ *
168
+ * Two other things make the answer unknowable and yield the same empty result:
169
+ * a log whose order cannot be recovered from the events (see
170
+ * {@link hasKnowableLogOrder}), and everything past the point the run
171
+ * diverged, since the run exited there and read no further.
172
+ */
173
+ export function findDuplicateEventIds(
174
+ events: readonly Event[],
175
+ { isCompleteHistory }: { isCompleteHistory: boolean }
176
+ ): Set<string> {
177
+ if (!isCompleteHistory || events.length < 2) return new Set();
178
+
179
+ // Dropped before the sort, not during the fold: an event with no ID has no
180
+ // log position to order on, and the caller could not match it either.
181
+ const ordered = events
182
+ .filter((event) => Boolean(event.eventId))
183
+ .sort(compareEventId);
184
+
185
+ if (!hasKnowableLogOrder(ordered)) return new Set();
186
+
187
+ return foldDuplicates(ordered);
188
+ }
@@ -0,0 +1,117 @@
1
+ import type { Event, EventType } from '@workflow/world';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { materializeSteps } from './event-materialization';
4
+
5
+ const BASE_TIME = Date.parse('2026-01-01T00:00:00.000Z');
6
+
7
+ let nextId = 0;
8
+
9
+ function event(
10
+ eventType: EventType,
11
+ options: { correlationId?: string; at?: number } = {}
12
+ ): Event {
13
+ nextId += 1;
14
+ const offsetSeconds = options.at ?? nextId;
15
+ return {
16
+ eventId: `evt_${nextId}`,
17
+ runId: 'run_1',
18
+ eventType,
19
+ correlationId: options.correlationId,
20
+ createdAt: new Date(BASE_TIME + offsetSeconds * 1000),
21
+ occurredAt: new Date(BASE_TIME + offsetSeconds * 1000),
22
+ eventData: eventType === 'step_created' ? { stepName: 'doWork' } : {},
23
+ } as unknown as Event;
24
+ }
25
+
26
+ describe('materializeSteps', () => {
27
+ it('derives status and timings from the run of a well-formed step', () => {
28
+ const events = [
29
+ event('step_created', { correlationId: 'step_a', at: 1 }),
30
+ event('step_started', { correlationId: 'step_a', at: 2 }),
31
+ event('step_completed', { correlationId: 'step_a', at: 5 }),
32
+ ];
33
+
34
+ const [step] = materializeSteps(events);
35
+
36
+ expect(step.status).toBe('completed');
37
+ expect(step.attempt).toBe(1);
38
+ expect(step.startedAt?.getTime()).toBe(BASE_TIME + 2000);
39
+ expect(step.completedAt?.getTime()).toBe(BASE_TIME + 5000);
40
+ });
41
+
42
+ it('counts one attempt per start across retries', () => {
43
+ const events = [
44
+ event('step_created', { correlationId: 'step_a', at: 1 }),
45
+ event('step_started', { correlationId: 'step_a', at: 2 }),
46
+ event('step_retrying', { correlationId: 'step_a', at: 3 }),
47
+ event('step_started', { correlationId: 'step_a', at: 4 }),
48
+ event('step_completed', { correlationId: 'step_a', at: 6 }),
49
+ ];
50
+
51
+ const [step] = materializeSteps(events);
52
+
53
+ expect(step.attempt).toBe(2);
54
+ expect(step.status).toBe('completed');
55
+ // The first start is when the step went from queued to running.
56
+ expect(step.startedAt?.getTime()).toBe(BASE_TIME + 2000);
57
+ });
58
+
59
+ it('keeps the outcome the run acted on when a replay writes another one', () => {
60
+ const events = [
61
+ event('step_created', { correlationId: 'step_a', at: 1 }),
62
+ event('step_started', { correlationId: 'step_a', at: 2 }),
63
+ event('step_failed', { correlationId: 'step_a', at: 3 }),
64
+ // Written by a concurrent replay working from a stale prefix.
65
+ event('step_completed', { correlationId: 'step_a', at: 9 }),
66
+ ];
67
+
68
+ const [step] = materializeSteps(events, { isCompleteHistory: true });
69
+
70
+ expect(step.status).toBe('failed');
71
+ expect(step.completedAt?.getTime()).toBe(BASE_TIME + 3000);
72
+ expect(step.updatedAt.getTime()).toBe(BASE_TIME + 3000);
73
+ });
74
+
75
+ it('still lists the passed-over event on the entity', () => {
76
+ const events = [
77
+ event('step_created', { correlationId: 'step_a', at: 1 }),
78
+ event('step_started', { correlationId: 'step_a', at: 2 }),
79
+ event('step_failed', { correlationId: 'step_a', at: 3 }),
80
+ event('step_completed', { correlationId: 'step_a', at: 9 }),
81
+ ];
82
+
83
+ const [step] = materializeSteps(events, { isCompleteHistory: true });
84
+
85
+ expect(step.events).toHaveLength(4);
86
+ });
87
+
88
+ it('takes the last outcome when the log may be incomplete', () => {
89
+ const events = [
90
+ event('step_created', { correlationId: 'step_a', at: 1 }),
91
+ event('step_started', { correlationId: 'step_a', at: 2 }),
92
+ event('step_failed', { correlationId: 'step_a', at: 3 }),
93
+ event('step_completed', { correlationId: 'step_a', at: 9 }),
94
+ ];
95
+
96
+ // On a page of the log there is no telling which failure the run acted on,
97
+ // so nothing is passed over and the fold reports what it was given.
98
+ const [step] = materializeSteps(events);
99
+
100
+ expect(step.status).toBe('completed');
101
+ });
102
+
103
+ it('counts a repeated creation as one attempt while the step is open', () => {
104
+ const events = [
105
+ event('step_created', { correlationId: 'step_a', at: 1 }),
106
+ // A live step consumer claims this, so it is an attempt, not a repeat.
107
+ event('step_created', { correlationId: 'step_a', at: 2 }),
108
+ event('step_started', { correlationId: 'step_a', at: 3 }),
109
+ ];
110
+
111
+ const [step] = materializeSteps(events, { isCompleteHistory: true });
112
+
113
+ expect(step.events).toHaveLength(3);
114
+ expect(step.status).toBe('running');
115
+ expect(step.attempt).toBe(1);
116
+ });
117
+ });