@workflow/web-shared 5.0.0-beta.46 → 5.0.0-beta.47

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 (30) hide show
  1. package/dist/components/event-list-view.js +3 -3
  2. package/dist/components/trace-viewer/components/use-alt-held.d.ts +11 -0
  3. package/dist/components/trace-viewer/components/use-alt-held.d.ts.map +1 -0
  4. package/dist/components/trace-viewer/components/use-alt-held.js +48 -0
  5. package/dist/components/trace-viewer/trace-viewer.d.ts.map +1 -1
  6. package/dist/components/trace-viewer/trace-viewer.js +4 -17
  7. package/dist/components/trace-viewer.d.ts +4 -1
  8. package/dist/components/trace-viewer.d.ts.map +1 -1
  9. package/dist/components/trace-viewer.js +4 -3
  10. package/dist/components/ui/data-inspector.d.ts +3 -2
  11. package/dist/components/ui/data-inspector.d.ts.map +1 -1
  12. package/dist/components/ui/data-inspector.js +85 -8
  13. package/dist/components/workflow-traces/trace-span-construction.d.ts +2 -1
  14. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  15. package/dist/components/workflow-traces/trace-span-construction.js +7 -3
  16. package/dist/lib/duplicate-events.d.ts.map +1 -1
  17. package/dist/lib/duplicate-events.js +14 -25
  18. package/dist/lib/trace-builder.d.ts +3 -1
  19. package/dist/lib/trace-builder.d.ts.map +1 -1
  20. package/dist/lib/trace-builder.js +5 -5
  21. package/package.json +5 -5
  22. package/src/components/event-list-view.tsx +2 -2
  23. package/src/components/trace-viewer/components/use-alt-held.ts +54 -0
  24. package/src/components/trace-viewer/trace-viewer.tsx +3 -15
  25. package/src/components/trace-viewer.tsx +6 -1
  26. package/src/components/ui/data-inspector.tsx +105 -9
  27. package/src/components/workflow-traces/trace-span-construction.ts +14 -2
  28. package/src/lib/duplicate-events.ts +14 -26
  29. package/src/lib/trace-builder.test.ts +42 -1
  30. package/src/lib/trace-builder.ts +13 -4
@@ -8,6 +8,7 @@ import {
8
8
  } from './sidebar/sidebar-data-context';
9
9
  import { TraceViewerSkeleton } from './trace-viewer/components/trace-viewer-skeleton';
10
10
  import { TraceViewer as TraceViewerComponent } from './trace-viewer/trace-viewer';
11
+ import type { GetStepAttributes } from './workflow-traces/trace-span-construction';
11
12
 
12
13
  const TraceViewer = ({
13
14
  run,
@@ -17,6 +18,7 @@ const TraceViewer = ({
17
18
  hasMore,
18
19
  isLoadingMore,
19
20
  loading = false,
21
+ getStepAttributes,
20
22
  }: {
21
23
  run: WorkflowRun;
22
24
  events: Event[];
@@ -25,6 +27,8 @@ const TraceViewer = ({
25
27
  hasMore?: boolean;
26
28
  isLoadingMore?: boolean;
27
29
  loading?: boolean;
30
+ /** Adds product-specific attributes to event-derived step span data. */
31
+ getStepAttributes?: GetStepAttributes;
28
32
  }) => {
29
33
  const trace: TraceWithMeta | undefined = useMemo(() => {
30
34
  if (!run?.runId) {
@@ -35,9 +39,10 @@ const TraceViewer = ({
35
39
  // repeats with the whole log in hand.
36
40
  return buildTrace(run, events, new Date(), {
37
41
  isCompleteHistory: !hasMore,
42
+ getStepAttributes,
38
43
  });
39
44
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep
40
- }, [run, events, hasMore]);
45
+ }, [run, events, hasMore, getStepAttributes]);
41
46
 
42
47
  // The sidebar shows one entity's slice of the log, so it takes the trace's
43
48
  // answer rather than recomputing one from the slice.
@@ -344,7 +344,7 @@ function BytesDisplayValue({ display }: { display: BytesDisplay }) {
344
344
  // Tree renderer
345
345
  // ---------------------------------------------------------------------------
346
346
 
347
- type Entry = [field: string | undefined, value: unknown];
347
+ type Entry = [field: string | undefined, value: unknown, key?: string | number];
348
348
 
349
349
  interface NodeContext {
350
350
  level: number;
@@ -357,10 +357,51 @@ function formatField(field: string): string {
357
357
  return field === '' ? '""' : field;
358
358
  }
359
359
 
360
+ function isGenericIterable(
361
+ value: unknown
362
+ ): value is object & Iterable<unknown> {
363
+ if (
364
+ value === null ||
365
+ (typeof value !== 'object' && typeof value !== 'function') ||
366
+ Array.isArray(value) ||
367
+ value instanceof Map ||
368
+ value instanceof Set
369
+ ) {
370
+ return false;
371
+ }
372
+ return (
373
+ typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] ===
374
+ 'function'
375
+ );
376
+ }
377
+
378
+ function isEntryIterable(
379
+ value: object & Iterable<unknown>
380
+ ): value is object & Iterable<unknown> & { entries(): Iterable<unknown> } {
381
+ return typeof (value as { entries?: unknown }).entries === 'function';
382
+ }
383
+
384
+ function collectEntries(
385
+ iterable: Iterable<unknown>,
386
+ asPairs: boolean
387
+ ): Entry[] {
388
+ return Array.from(iterable, (item, index) => {
389
+ if (asPairs && Array.isArray(item) && item.length >= 2) {
390
+ return [String(item[0]), collapseRefs(item[1]), index];
391
+ }
392
+ return [undefined, collapseRefs(item), index];
393
+ });
394
+ }
395
+
396
+ function isSelfIterableIterator(value: object & Iterable<unknown>): boolean {
397
+ return Object.is(value[Symbol.iterator](), value);
398
+ }
399
+
360
400
  /**
361
- * Describe an object/array/map/set as an expandable container. Returns null for
362
- * values that should render as a primitive. `prefix` carries a class name shown
363
- * before the opening bracket (Map/Set and named class instances).
401
+ * Describe an object/array/iterable as an expandable container. Returns null
402
+ * for values that should render as a primitive. `prefix` carries a class name
403
+ * shown before the opening bracket (Map/Set, generic iterables, and named class
404
+ * instances).
364
405
  */
365
406
  function describeContainer(
366
407
  value: unknown
@@ -391,6 +432,25 @@ function describeContainer(
391
432
  prefix: 'Set',
392
433
  };
393
434
  }
435
+ if (isGenericIterable(value)) {
436
+ const name = (value as { constructor?: { name?: string } }).constructor
437
+ ?.name;
438
+ const prefix = name && name !== 'Object' ? name : undefined;
439
+ if (isEntryIterable(value)) {
440
+ return {
441
+ entries: collectEntries(value.entries(), true),
442
+ open: '{',
443
+ close: '}',
444
+ prefix,
445
+ };
446
+ }
447
+ return {
448
+ entries: collectEntries(value, false),
449
+ open: '[',
450
+ close: ']',
451
+ prefix,
452
+ };
453
+ }
394
454
  if (value !== null && typeof value === 'object') {
395
455
  const name = (value as { constructor?: { name?: string } }).constructor
396
456
  ?.name;
@@ -616,9 +676,9 @@ function ExpandableContainer({
616
676
  {expanded ? (
617
677
  // biome-ignore lint/a11y/useSemanticElements: ARIA tree group is the correct role here
618
678
  <ul id={contentsId} className={CLS.childFields} role="group">
619
- {entries.map(([childField, childValue], index) => (
679
+ {entries.map(([childField, childValue, entryKey], index) => (
620
680
  <DataRender
621
- key={childField ?? index}
681
+ key={entryKey ?? childField ?? index}
622
682
  field={childField}
623
683
  value={childValue}
624
684
  isLast={index === lastIndex}
@@ -820,8 +880,8 @@ function makeBytesDisplay(display: FormattedStreamChunkDisplay): unknown {
820
880
  * non-expandable versions so the renderer doesn't show their internals.
821
881
  * Only recurses into plain objects and arrays to avoid stripping class
822
882
  * instances (Date, Error, URL, Headers, etc.) that have their own rendering.
823
- * Map and Set containers are preserved while their contents are prepared for
824
- * display.
883
+ * Map and Set contents are prepared here; other iterable contents are prepared
884
+ * when the renderer traverses them.
825
885
  *
826
886
  * Exported for testing the typed-array detection path used by hydrated
827
887
  * AI agent stream chunks (e.g. `{ delta: new Uint8Array(...) }`).
@@ -962,7 +1022,28 @@ function isSameBytesDisplay(a: BytesDisplay, b: BytesDisplay): boolean {
962
1022
  );
963
1023
  }
964
1024
 
965
- function isDeepEqual(a: unknown, b: unknown, seen = new WeakMap()): boolean {
1025
+ function haveSameIterableValues(
1026
+ a: Iterable<unknown>,
1027
+ b: Iterable<unknown>,
1028
+ seen: WeakMap<object, object>
1029
+ ): boolean {
1030
+ const aIterator = a[Symbol.iterator]();
1031
+ const bIterator = b[Symbol.iterator]();
1032
+ while (true) {
1033
+ const aResult = aIterator.next();
1034
+ const bResult = bIterator.next();
1035
+ if (aResult.done || bResult.done) {
1036
+ return aResult.done === bResult.done;
1037
+ }
1038
+ if (!isDeepEqual(aResult.value, bResult.value, seen)) return false;
1039
+ }
1040
+ }
1041
+
1042
+ export function isDeepEqual(
1043
+ a: unknown,
1044
+ b: unknown,
1045
+ seen = new WeakMap()
1046
+ ): boolean {
966
1047
  if (Object.is(a, b)) return true;
967
1048
 
968
1049
  if (isBytesDisplay(a) || isBytesDisplay(b)) {
@@ -977,6 +1058,21 @@ function isDeepEqual(a: unknown, b: unknown, seen = new WeakMap()): boolean {
977
1058
  return a.source === b.source && a.flags === b.flags;
978
1059
  }
979
1060
 
1061
+ if (isGenericIterable(a) || isGenericIterable(b)) {
1062
+ if (!isGenericIterable(a) || !isGenericIterable(b)) return false;
1063
+ if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
1064
+ if (isSelfIterableIterator(a) || isSelfIterableIterator(b)) return false;
1065
+ if (seen.get(a) === b) return true;
1066
+ seen.set(a, b);
1067
+ const aHasEntries = isEntryIterable(a);
1068
+ const bHasEntries = isEntryIterable(b);
1069
+ if (aHasEntries !== bHasEntries) return false;
1070
+ if (aHasEntries && bHasEntries) {
1071
+ return haveSameIterableValues(a.entries(), b.entries(), seen);
1072
+ }
1073
+ return haveSameIterableValues(a, b, seen);
1074
+ }
1075
+
980
1076
  if (a instanceof Map && b instanceof Map) {
981
1077
  if (a.size !== b.size) return false;
982
1078
  for (const [key, value] of a.entries()) {
@@ -150,6 +150,10 @@ export function waitToSpan(
150
150
  };
151
151
  }
152
152
 
153
+ export type GetStepAttributes = (
154
+ events: Event[]
155
+ ) => Record<string, unknown> | undefined;
156
+
153
157
  export const stepEventsToStepEntity = (
154
158
  events: Event[]
155
159
  ): {
@@ -231,7 +235,11 @@ export const stepEventsToStepEntity = (
231
235
  /**
232
236
  * Converts step events to an OpenTelemetry Span
233
237
  */
234
- export function stepToSpan(stepEvents: Event[], maxEndTime: Date): Span | null {
238
+ export function stepToSpan(
239
+ stepEvents: Event[],
240
+ maxEndTime: Date,
241
+ getStepAttributes?: GetStepAttributes
242
+ ): Span | null {
235
243
  const step = stepEventsToStepEntity(stepEvents);
236
244
  if (!step) {
237
245
  return null;
@@ -242,7 +250,11 @@ export function stepToSpan(stepEvents: Event[], maxEndTime: Date): Span | null {
242
250
 
243
251
  const attributes = {
244
252
  resource: 'step' as const,
245
- data: step,
253
+ data: {
254
+ ...getStepAttributes?.(stepEvents),
255
+ // Canonical event-derived fields cannot be overridden by extensions.
256
+ ...step,
257
+ },
246
258
  };
247
259
 
248
260
  const resource = 'step';
@@ -1,8 +1,9 @@
1
1
  import {
2
+ classifyEntityEvent,
2
3
  type EntityEventClass,
3
4
  type Event,
4
- entityEventClass,
5
5
  isSlotEventId,
6
+ TERMINAL_EVENT_CLASSES,
6
7
  } from '@workflow/world';
7
8
 
8
9
  /**
@@ -10,13 +11,13 @@ import {
10
11
  *
11
12
  * Concurrent replays of one run write to a shared log, so a replay working
12
13
  * 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.
14
+ * `wait_created` / `attr_set` for an entity the log already records one of.
15
+ * Every replay reads the first event of that class at the same position, so a
16
+ * later one cannot change what the workflow observes.
16
17
  *
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
18
+ * The classification comes from `classifyEntityEvent` in `@workflow/world`,
19
+ * the same function the runtime keys its own duplicate detection on. What it
20
+ * cannot share is consumer state: the runtime passes over an event only after every
20
21
  * registered callback has declined it, and a callback registered for a
21
22
  * still-open entity legitimately claims a repeat (each retry of a step writes
22
23
  * another `step_started`, and a live step consumer absorbs a second
@@ -25,27 +26,11 @@ import {
25
26
  * consumer remains.
26
27
  */
27
28
 
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
29
  /** Classes with no entity to close first: the log records one per run. */
42
30
  const SINGLETON_EVENT_CLASSES: ReadonlySet<EntityEventClass> = new Set([
43
31
  'run_started',
44
32
  ]);
45
33
 
46
- /** Entity key for events that carry no correlation ID (the run itself). */
47
- const RUN_ENTITY_KEY = '';
48
-
49
34
  /**
50
35
  * Shown against an event this module reports. Deliberately says what the log
51
36
  * shows rather than what the runtime did with it: tolerating these repeats is
@@ -122,10 +107,13 @@ function foldDuplicates(ordered: readonly Event[]): Set<string> {
122
107
  const closedEntities = new Set<string>();
123
108
 
124
109
  for (const event of ordered) {
125
- const eventClass = entityEventClass(event.eventType);
126
- if (eventClass === undefined) continue;
110
+ // Shared with the runtime's own duplicate detection, deliberately: an
111
+ // event it tracks under no class is one it never reads past, so naming it
112
+ // here would grey out an event the run acted on.
113
+ const classification = classifyEntityEvent(event);
114
+ if (classification === undefined) continue;
127
115
 
128
- const entity = event.correlationId ?? RUN_ENTITY_KEY;
116
+ const { eventClass, entity } = classification;
129
117
  const classKey = `${eventClass}:${entity}`;
130
118
  const repeatsClass = seenClasses.has(classKey);
131
119
  const entityWasClosed = closedEntities.has(entity);
@@ -9,7 +9,11 @@ let nextId = 0;
9
9
 
10
10
  function event(
11
11
  eventType: EventType,
12
- options: { correlationId?: string; at: number }
12
+ options: {
13
+ correlationId?: string;
14
+ at: number;
15
+ externalAttemptId?: string;
16
+ }
13
17
  ): Event {
14
18
  nextId += 1;
15
19
  return {
@@ -20,6 +24,7 @@ function event(
20
24
  createdAt: new Date(BASE_TIME + options.at * 1000),
21
25
  occurredAt: new Date(BASE_TIME + options.at * 1000),
22
26
  eventData: eventType === 'step_created' ? { stepName: 'doWork' } : {},
27
+ externalAttemptId: options.externalAttemptId,
23
28
  } as unknown as Event;
24
29
  }
25
30
 
@@ -31,6 +36,42 @@ const run = {
31
36
  } as unknown as WorkflowRun;
32
37
 
33
38
  describe('buildTrace', () => {
39
+ it('adds caller-derived attributes to step span data', () => {
40
+ const events = [
41
+ event('run_created', { at: 0 }),
42
+ event('run_started', { at: 0 }),
43
+ event('step_created', { correlationId: 'step_a', at: 1 }),
44
+ event('step_started', {
45
+ correlationId: 'step_a',
46
+ at: 2,
47
+ externalAttemptId: 'attempt_first',
48
+ }),
49
+ event('step_retrying', { correlationId: 'step_a', at: 3 }),
50
+ event('step_started', {
51
+ correlationId: 'step_a',
52
+ at: 4,
53
+ externalAttemptId: 'attempt_latest',
54
+ }),
55
+ ];
56
+
57
+ const trace = buildTrace(run, events, new Date(BASE_TIME + 5000), {
58
+ getStepAttributes(stepEvents) {
59
+ const latestStart = stepEvents
60
+ .slice()
61
+ .reverse()
62
+ .find((candidate) => candidate.eventType === 'step_started') as
63
+ | (Event & { externalAttemptId?: string })
64
+ | undefined;
65
+ return { externalAttemptId: latestStart?.externalAttemptId };
66
+ },
67
+ });
68
+ const stepSpan = trace.spans.find((span) => span.resource === 'step');
69
+
70
+ expect(stepSpan?.attributes.data).toMatchObject({
71
+ externalAttemptId: 'attempt_latest',
72
+ });
73
+ });
74
+
34
75
  it('ends a step span on the terminal event the run acted on', () => {
35
76
  const events = [
36
77
  event('run_created', { at: 0 }),
@@ -14,6 +14,7 @@ import {
14
14
  type WorkflowRun,
15
15
  } from '@workflow/world';
16
16
  import {
17
+ type GetStepAttributes,
17
18
  getEventTimestamp,
18
19
  hookToSpan,
19
20
  runToSpan,
@@ -137,7 +138,8 @@ function buildSpans(
137
138
  run: WorkflowRun,
138
139
  groupedEvents: GroupedEvents,
139
140
  now: Date,
140
- latestKnownTime: Date
141
+ latestKnownTime: Date,
142
+ getStepAttributes?: GetStepAttributes
141
143
  ) {
142
144
  // Active child spans cap at latestKnownTime so they don't extend into
143
145
  // unknown territory. Even when the run is completed, we may not have loaded
@@ -146,7 +148,7 @@ function buildSpans(
146
148
  const runMaxEnd = run.completedAt ?? now;
147
149
 
148
150
  const stepSpans = Array.from(groupedEvents.eventsByStepId.values())
149
- .map((events) => stepToSpan(events, childMaxEnd))
151
+ .map((events) => stepToSpan(events, childMaxEnd, getStepAttributes))
150
152
  .filter((span): span is Span => span !== null);
151
153
 
152
154
  const hookSpans = Array.from(groupedEvents.hookEvents.values())
@@ -208,7 +210,13 @@ export function buildTrace(
208
210
  * from the only copy the caller was given, and dropping the wrong one moves
209
211
  * a span. See {@link findDuplicateEventIds}.
210
212
  */
211
- { isCompleteHistory = false }: { isCompleteHistory?: boolean } = {}
213
+ {
214
+ isCompleteHistory = false,
215
+ getStepAttributes,
216
+ }: {
217
+ isCompleteHistory?: boolean;
218
+ getStepAttributes?: GetStepAttributes;
219
+ } = {}
212
220
  ): TraceWithMeta {
213
221
  // Span geometry comes from what the run acted on. A repeat of a class the
214
222
  // log already records is read past by every replay, and letting one through
@@ -232,7 +240,8 @@ export function buildTrace(
232
240
  run,
233
241
  groupedEvents,
234
242
  now,
235
- latestKnownTime
243
+ latestKnownTime,
244
+ getStepAttributes
236
245
  );
237
246
  const sortedCascadingSpans = cascadeSpans(runSpan, spans);
238
247