@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
|
@@ -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
|
+
});
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
isWaitEventType,
|
|
18
18
|
type StepStatus,
|
|
19
19
|
} from '@workflow/world';
|
|
20
|
+
import { findDuplicateEventIds } from './duplicate-events';
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
23
|
// Materialized entity types
|
|
@@ -105,9 +106,24 @@ function getEventTimestamp(event: Event | undefined): Date | undefined {
|
|
|
105
106
|
*
|
|
106
107
|
* Handles partial event lists gracefully: a step may only have a
|
|
107
108
|
* step_created event with no completion yet.
|
|
109
|
+
*
|
|
110
|
+
* The derived status and timestamps come from the events the run acted on. A
|
|
111
|
+
* repeat of a class the log already records is read past by every replay, so
|
|
112
|
+
* a second terminal event written by a concurrent replay does not move a step
|
|
113
|
+
* off the outcome the first one recorded. Every event stays on the entity's
|
|
114
|
+
* `events` list.
|
|
115
|
+
*
|
|
116
|
+
* That reduction needs the whole log to be sound, so it only runs when
|
|
117
|
+
* `isCompleteHistory` says `events` is it. See {@link findDuplicateEventIds}.
|
|
108
118
|
*/
|
|
109
|
-
export function materializeSteps(
|
|
119
|
+
export function materializeSteps(
|
|
120
|
+
events: Event[],
|
|
121
|
+
{ isCompleteHistory = false }: { isCompleteHistory?: boolean } = {}
|
|
122
|
+
): MaterializedStep[] {
|
|
110
123
|
const groups = groupByCorrelationId(events, isStepEventType);
|
|
124
|
+
const duplicateEventIds = findDuplicateEventIds(events, {
|
|
125
|
+
isCompleteHistory,
|
|
126
|
+
});
|
|
111
127
|
const steps: MaterializedStep[] = [];
|
|
112
128
|
|
|
113
129
|
for (const [correlationId, stepEvents] of groups) {
|
|
@@ -121,6 +137,7 @@ export function materializeSteps(events: Event[]): MaterializedStep[] {
|
|
|
121
137
|
let updatedAt = getEventTimestamp(created) ?? created.createdAt;
|
|
122
138
|
|
|
123
139
|
for (const e of stepEvents) {
|
|
140
|
+
if (duplicateEventIds.has(e.eventId)) continue;
|
|
124
141
|
switch (e.eventType) {
|
|
125
142
|
case 'step_started':
|
|
126
143
|
status = 'running';
|
|
@@ -248,9 +265,12 @@ export function materializeWaits(events: Event[]): MaterializedWait[] {
|
|
|
248
265
|
* Convenience function that materializes all entity types from a flat
|
|
249
266
|
* event list.
|
|
250
267
|
*/
|
|
251
|
-
export function materializeAll(
|
|
268
|
+
export function materializeAll(
|
|
269
|
+
events: Event[],
|
|
270
|
+
options: { isCompleteHistory?: boolean } = {}
|
|
271
|
+
): MaterializedEntities {
|
|
252
272
|
return {
|
|
253
|
-
steps: materializeSteps(events),
|
|
273
|
+
steps: materializeSteps(events, options),
|
|
254
274
|
hooks: materializeHooks(events),
|
|
255
275
|
waits: materializeWaits(events),
|
|
256
276
|
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Event, EventType, WorkflowRun } from '@workflow/world';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { otelTimeToMs } from '../components/workflow-traces/trace-time-utils';
|
|
4
|
+
import { buildTrace } from './trace-builder';
|
|
5
|
+
|
|
6
|
+
const BASE_TIME = Date.parse('2026-01-01T00:00:00.000Z');
|
|
7
|
+
|
|
8
|
+
let nextId = 0;
|
|
9
|
+
|
|
10
|
+
function event(
|
|
11
|
+
eventType: EventType,
|
|
12
|
+
options: { correlationId?: string; at: number }
|
|
13
|
+
): Event {
|
|
14
|
+
nextId += 1;
|
|
15
|
+
return {
|
|
16
|
+
eventId: `evt_${nextId}`,
|
|
17
|
+
runId: 'run_1',
|
|
18
|
+
eventType,
|
|
19
|
+
correlationId: options.correlationId,
|
|
20
|
+
createdAt: new Date(BASE_TIME + options.at * 1000),
|
|
21
|
+
occurredAt: new Date(BASE_TIME + options.at * 1000),
|
|
22
|
+
eventData: eventType === 'step_created' ? { stepName: 'doWork' } : {},
|
|
23
|
+
} as unknown as Event;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const run = {
|
|
27
|
+
runId: 'run_1',
|
|
28
|
+
workflowName: 'demo',
|
|
29
|
+
status: 'running',
|
|
30
|
+
createdAt: new Date(BASE_TIME),
|
|
31
|
+
} as unknown as WorkflowRun;
|
|
32
|
+
|
|
33
|
+
describe('buildTrace', () => {
|
|
34
|
+
it('ends a step span on the terminal event the run acted on', () => {
|
|
35
|
+
const events = [
|
|
36
|
+
event('run_created', { at: 0 }),
|
|
37
|
+
event('run_started', { at: 0 }),
|
|
38
|
+
event('step_created', { correlationId: 'step_a', at: 1 }),
|
|
39
|
+
event('step_started', { correlationId: 'step_a', at: 1 }),
|
|
40
|
+
event('step_completed', { correlationId: 'step_a', at: 4 }),
|
|
41
|
+
// A concurrent replay commits the same outcome much later. Measuring the
|
|
42
|
+
// span against it would report a 20s step that ran for 3s.
|
|
43
|
+
event('step_completed', { correlationId: 'step_a', at: 20 }),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const trace = buildTrace(run, events, new Date(BASE_TIME + 30_000), {
|
|
47
|
+
isCompleteHistory: true,
|
|
48
|
+
});
|
|
49
|
+
const stepSpan = trace.spans.find((span) => span.resource === 'step');
|
|
50
|
+
|
|
51
|
+
expect(stepSpan).toBeDefined();
|
|
52
|
+
expect(otelTimeToMs(stepSpan?.endTime ?? [0, 0])).toBe(BASE_TIME + 4000);
|
|
53
|
+
expect(trace.knownDurationMs).toBe(4000);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('keeps every event in the geometry when the log may be incomplete', () => {
|
|
57
|
+
const events = [
|
|
58
|
+
event('run_created', { at: 0 }),
|
|
59
|
+
event('run_started', { at: 0 }),
|
|
60
|
+
event('step_created', { correlationId: 'step_a', at: 1 }),
|
|
61
|
+
event('step_started', { correlationId: 'step_a', at: 1 }),
|
|
62
|
+
event('step_completed', { correlationId: 'step_a', at: 4 }),
|
|
63
|
+
event('step_completed', { correlationId: 'step_a', at: 20 }),
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
// Without the whole log there is no telling which of the two completions
|
|
67
|
+
// the run acted on, so neither is dropped and the span covers both.
|
|
68
|
+
const trace = buildTrace(run, events, new Date(BASE_TIME + 30_000));
|
|
69
|
+
const stepSpan = trace.spans.find((span) => span.resource === 'step');
|
|
70
|
+
|
|
71
|
+
expect(otelTimeToMs(stepSpan?.endTime ?? [0, 0])).toBe(BASE_TIME + 20_000);
|
|
72
|
+
expect(trace.duplicateEventIds.size).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/lib/trace-builder.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
waitToSpan,
|
|
23
23
|
} from '../components/workflow-traces/trace-span-construction';
|
|
24
24
|
import { otelTimeToMs } from '../components/workflow-traces/trace-time-utils';
|
|
25
|
+
import { findDuplicateEventIds } from './duplicate-events';
|
|
25
26
|
import type { Span } from './trace-types';
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -188,15 +189,40 @@ export interface TraceWithMeta {
|
|
|
188
189
|
resources: { name: string; attributes: Record<string, string> }[];
|
|
189
190
|
/** Duration in ms from trace start to the latest known event. */
|
|
190
191
|
knownDurationMs: number;
|
|
192
|
+
/**
|
|
193
|
+
* The events left out of the span geometry as repeats. Empty unless the
|
|
194
|
+
* caller vouched for the log being complete. See
|
|
195
|
+
* {@link findDuplicateEventIds}.
|
|
196
|
+
*/
|
|
197
|
+
duplicateEventIds: ReadonlySet<string>;
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
export function buildTrace(
|
|
194
201
|
run: WorkflowRun,
|
|
195
202
|
events: Event[],
|
|
196
|
-
now: Date
|
|
203
|
+
now: Date,
|
|
204
|
+
/**
|
|
205
|
+
* Whether `events` is the run's whole log. Defaults to false, which builds
|
|
206
|
+
* the trace from every event: on a subset there is no way to tell a repeat
|
|
207
|
+
* from the only copy the caller was given, and dropping the wrong one moves
|
|
208
|
+
* a span. See {@link findDuplicateEventIds}.
|
|
209
|
+
*/
|
|
210
|
+
{ isCompleteHistory = false }: { isCompleteHistory?: boolean } = {}
|
|
197
211
|
): TraceWithMeta {
|
|
198
|
-
|
|
199
|
-
|
|
212
|
+
// Span geometry comes from what the run acted on. A repeat of a class the
|
|
213
|
+
// log already records is read past by every replay, and letting one through
|
|
214
|
+
// here would stretch a span to whenever a concurrent replay committed it.
|
|
215
|
+
// The event lists still show them, marked as repeats.
|
|
216
|
+
const duplicateEventIds = findDuplicateEventIds(events, {
|
|
217
|
+
isCompleteHistory,
|
|
218
|
+
});
|
|
219
|
+
const actedOnEvents =
|
|
220
|
+
duplicateEventIds.size === 0
|
|
221
|
+
? events
|
|
222
|
+
: events.filter((event) => !duplicateEventIds.has(event.eventId));
|
|
223
|
+
|
|
224
|
+
const groupedEvents = groupEventsByCorrelation(actedOnEvents);
|
|
225
|
+
const latestKnownTime = computeLatestKnownTime(actedOnEvents, run);
|
|
200
226
|
const { runSpan, spans } = buildSpans(
|
|
201
227
|
run,
|
|
202
228
|
groupedEvents,
|
|
@@ -222,5 +248,6 @@ export function buildTrace(
|
|
|
222
248
|
},
|
|
223
249
|
],
|
|
224
250
|
knownDurationMs: Math.max(0, knownDurationMs),
|
|
251
|
+
duplicateEventIds,
|
|
225
252
|
};
|
|
226
253
|
}
|