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

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 (116) hide show
  1. package/README.md +4 -4
  2. package/dist/components/event-list-view.d.ts +12 -0
  3. package/dist/components/event-list-view.d.ts.map +1 -1
  4. package/dist/components/event-list-view.js +41 -20
  5. package/dist/components/sidebar/attribute-panel.d.ts +6 -2
  6. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  7. package/dist/components/sidebar/attribute-panel.js +10 -8
  8. package/dist/components/sidebar/copyable-data-block.d.ts.map +1 -1
  9. package/dist/components/sidebar/copyable-data-block.js +2 -2
  10. package/dist/components/sidebar/entity-detail-panel.d.ts +6 -2
  11. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  12. package/dist/components/sidebar/entity-detail-panel.js +13 -5
  13. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  14. package/dist/components/sidebar/events-list.js +12 -5
  15. package/dist/components/sidebar/resolve-hook-modal.js +1 -1
  16. package/dist/components/sidebar/sidebar-data-context.d.ts +2 -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.js +2 -2
  20. package/dist/components/trace-viewer/components/detail-panel-width.d.ts +4 -4
  21. package/dist/components/trace-viewer/components/detail-panel-width.d.ts.map +1 -1
  22. package/dist/components/trace-viewer/components/detail-panel-width.js +5 -5
  23. package/dist/components/trace-viewer/components/detail-panel.d.ts +1 -1
  24. package/dist/components/trace-viewer/components/detail-panel.d.ts.map +1 -1
  25. package/dist/components/trace-viewer/components/detail-panel.js +3 -3
  26. package/dist/components/trace-viewer/components/draggable-border.d.ts +1 -1
  27. package/dist/components/trace-viewer/components/draggable-border.js +2 -2
  28. package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.d.ts +9 -0
  29. package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.d.ts.map +1 -0
  30. package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.js +29 -0
  31. package/dist/components/trace-viewer/components/middle-truncate/middle-truncate.d.ts.map +1 -1
  32. package/dist/components/trace-viewer/components/middle-truncate/middle-truncate.js +16 -8
  33. package/dist/components/trace-viewer/components/middle-truncate/use-middle-truncate.d.ts.map +1 -1
  34. package/dist/components/trace-viewer/components/middle-truncate/use-middle-truncate.js +83 -19
  35. package/dist/components/trace-viewer/components/minimap.js +4 -4
  36. package/dist/components/trace-viewer/components/span-markers.d.ts +8 -9
  37. package/dist/components/trace-viewer/components/span-markers.d.ts.map +1 -1
  38. package/dist/components/trace-viewer/components/span-markers.js +13 -10
  39. package/dist/components/trace-viewer/components/timeline.d.ts +1 -1
  40. package/dist/components/trace-viewer/components/timeline.js +4 -4
  41. package/dist/components/trace-viewer/components/use-row-window.d.ts +1 -1
  42. package/dist/components/trace-viewer/components/use-row-window.js +2 -2
  43. package/dist/components/trace-viewer/trace-viewer.js +3 -3
  44. package/dist/components/trace-viewer/utils.d.ts +3 -1
  45. package/dist/components/trace-viewer/utils.d.ts.map +1 -1
  46. package/dist/components/trace-viewer/utils.js +6 -5
  47. package/dist/components/ui/data-inspector.d.ts +7 -1
  48. package/dist/components/ui/data-inspector.d.ts.map +1 -1
  49. package/dist/components/ui/data-inspector.js +10 -5
  50. package/dist/components/ui/decrypt-button.d.ts +5 -1
  51. package/dist/components/ui/decrypt-button.d.ts.map +1 -1
  52. package/dist/components/ui/decrypt-button.js +4 -4
  53. package/dist/components/ui/duplicate-event-tooltip.d.ts +15 -5
  54. package/dist/components/ui/duplicate-event-tooltip.d.ts.map +1 -1
  55. package/dist/components/ui/duplicate-event-tooltip.js +18 -9
  56. package/dist/components/ui/error-stack-block.js +4 -4
  57. package/dist/components/ui/timestamp-tooltip.d.ts +5 -3
  58. package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -1
  59. package/dist/components/ui/timestamp-tooltip.js +9 -9
  60. package/dist/components/workflow-traces/event-colors.d.ts.map +1 -1
  61. package/dist/components/workflow-traces/event-colors.js +11 -1
  62. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  63. package/dist/components/workflow-traces/trace-span-construction.js +5 -4
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +2 -1
  67. package/dist/lib/duplicate-events.d.ts +1 -1
  68. package/dist/lib/duplicate-events.js +2 -2
  69. package/dist/lib/hydration.d.ts.map +1 -1
  70. package/dist/lib/hydration.js +8 -13
  71. package/dist/lib/sealed-events.d.ts +28 -0
  72. package/dist/lib/sealed-events.d.ts.map +1 -0
  73. package/dist/lib/sealed-events.js +30 -0
  74. package/dist/lib/trace-builder.d.ts.map +1 -1
  75. package/dist/lib/trace-builder.js +8 -5
  76. package/dist/lib/utils.d.ts +1 -1
  77. package/dist/lib/utils.js +2 -2
  78. package/dist/lib/zstd-browser-decoder.js +2 -2
  79. package/package.json +5 -5
  80. package/src/components/event-list-view.tsx +57 -22
  81. package/src/components/sidebar/attribute-panel.tsx +15 -7
  82. package/src/components/sidebar/copyable-data-block.tsx +8 -1
  83. package/src/components/sidebar/entity-detail-panel.tsx +18 -2
  84. package/src/components/sidebar/events-list.tsx +16 -6
  85. package/src/components/sidebar/resolve-hook-modal.tsx +2 -2
  86. package/src/components/sidebar/sidebar-data-context.tsx +2 -0
  87. package/src/components/stream-viewer.tsx +1 -1
  88. package/src/components/trace-viewer/components/detail-panel-width.ts +4 -4
  89. package/src/components/trace-viewer/components/detail-panel.tsx +3 -1
  90. package/src/components/trace-viewer/components/draggable-border.tsx +1 -1
  91. package/src/components/trace-viewer/components/middle-truncate/measurement-batch.ts +47 -0
  92. package/src/components/trace-viewer/components/middle-truncate/middle-truncate.tsx +15 -7
  93. package/src/components/trace-viewer/components/middle-truncate/use-middle-truncate.ts +132 -38
  94. package/src/components/trace-viewer/components/minimap.tsx +3 -3
  95. package/src/components/trace-viewer/components/span-markers.tsx +18 -13
  96. package/src/components/trace-viewer/components/timeline.tsx +4 -4
  97. package/src/components/trace-viewer/components/use-row-window.ts +1 -1
  98. package/src/components/trace-viewer/trace-viewer.tsx +2 -2
  99. package/src/components/trace-viewer/utils.test.ts +5 -3
  100. package/src/components/trace-viewer/utils.ts +12 -8
  101. package/src/components/ui/data-inspector.tsx +19 -3
  102. package/src/components/ui/decrypt-button.tsx +9 -2
  103. package/src/components/ui/duplicate-event-tooltip.tsx +32 -10
  104. package/src/components/ui/error-stack-block.tsx +3 -3
  105. package/src/components/ui/timestamp-tooltip.tsx +16 -6
  106. package/src/components/workflow-traces/event-colors.ts +11 -0
  107. package/src/components/workflow-traces/trace-span-construction.test.ts +27 -1
  108. package/src/components/workflow-traces/trace-span-construction.ts +5 -3
  109. package/src/index.ts +4 -0
  110. package/src/lib/duplicate-events.ts +1 -1
  111. package/src/lib/hydration.ts +7 -12
  112. package/src/lib/sealed-events.ts +32 -0
  113. package/src/lib/trace-builder.test.ts +29 -1
  114. package/src/lib/trace-builder.ts +10 -5
  115. package/src/lib/utils.ts +1 -1
  116. package/src/lib/zstd-browser-decoder.ts +1 -1
@@ -2,13 +2,17 @@
2
2
 
3
3
  import { Spinner } from './spinner';
4
4
 
5
- const STYLES = `.wf-decrypt-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;cursor:pointer;white-space:nowrap;gap:6px;transition:background 150ms}.wf-decrypt-idle{color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400)}.wf-decrypt-idle:hover{background:var(--ds-gray-alpha-200)}.wf-decrypt-done{color:var(--ds-green-900);background:var(--ds-green-100);box-shadow:0 0 0 1px var(--ds-green-400);cursor:default}`;
5
+ const STYLES = `.wf-decrypt-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;cursor:pointer;white-space:nowrap;gap:6px;transition:background 150ms}.wf-decrypt-idle{color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400)}.wf-decrypt-idle:hover{background:var(--ds-gray-alpha-200)}.wf-decrypt-btn:disabled{cursor:not-allowed;opacity:.5}.wf-decrypt-done{color:var(--ds-green-900);background:var(--ds-green-100);box-shadow:0 0 0 1px var(--ds-green-400);cursor:default}`;
6
6
 
7
7
  interface DecryptButtonProps {
8
8
  /** Whether an encryption key has been obtained (decryption is active). */
9
9
  decrypted?: boolean;
10
10
  /** Whether the key is currently being fetched. */
11
11
  loading?: boolean;
12
+ /** Whether decryption is unavailable. */
13
+ disabled?: boolean;
14
+ /** Explains why decryption is unavailable. */
15
+ disabledReason?: string;
12
16
  /** Called when the user clicks to initiate decryption. */
13
17
  onClick?: () => void;
14
18
  }
@@ -20,6 +24,8 @@ interface DecryptButtonProps {
20
24
  export function DecryptButton({
21
25
  decrypted = false,
22
26
  loading = false,
27
+ disabled = false,
28
+ disabledReason,
23
29
  onClick,
24
30
  }: DecryptButtonProps) {
25
31
  return (
@@ -28,7 +34,8 @@ export function DecryptButton({
28
34
  <button
29
35
  type="button"
30
36
  onClick={decrypted ? undefined : onClick}
31
- disabled={decrypted || loading}
37
+ disabled={decrypted || loading || disabled}
38
+ title={disabled ? disabledReason : undefined}
32
39
  className={`wf-decrypt-btn ${decrypted ? 'wf-decrypt-done' : 'wf-decrypt-idle'}`}
33
40
  >
34
41
  {loading ? (
@@ -10,22 +10,24 @@ import {
10
10
  } from './tooltip';
11
11
 
12
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.
13
+ * Explains why an event row is shown greyed out, whether it is a repeat the
14
+ * runtime read past ({@link DUPLICATE_EVENT_MESSAGE}), a backend seal for an
15
+ * abandoned position (`SEALED_EVENT_MESSAGE`), or any other notice a list
16
+ * attaches.
15
17
  *
16
- * Renders `children` untouched when `isDuplicate` is false, so a call site can
18
+ * Renders `children` untouched when `notice` is absent, so a call site can
17
19
  * 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
+ * so it works in the sidebar and the events table alike; nesting one inside
21
+ * an existing provider is harmless.
20
22
  */
21
- export function DuplicateEventTooltip({
22
- isDuplicate = false,
23
+ export function EventNoticeTooltip({
24
+ notice,
23
25
  children,
24
26
  }: {
25
- isDuplicate?: boolean;
27
+ notice?: string;
26
28
  children: ReactNode;
27
29
  }): ReactNode {
28
- if (!isDuplicate) return children;
30
+ if (!notice) return children;
29
31
 
30
32
  return (
31
33
  <TooltipProvider delayDuration={300}>
@@ -36,9 +38,29 @@ export function DuplicateEventTooltip({
36
38
  collisionPadding={8}
37
39
  side="top"
38
40
  >
39
- {DUPLICATE_EVENT_MESSAGE}
41
+ {notice}
40
42
  </TooltipContent>
41
43
  </Tooltip>
42
44
  </TooltipProvider>
43
45
  );
44
46
  }
47
+
48
+ /**
49
+ * The duplicate-specific wrapper kept for existing call sites: greys the
50
+ * event out as a repeat the runtime read past.
51
+ */
52
+ export function DuplicateEventTooltip({
53
+ isDuplicate = false,
54
+ children,
55
+ }: {
56
+ isDuplicate?: boolean;
57
+ children: ReactNode;
58
+ }): ReactNode {
59
+ return (
60
+ <EventNoticeTooltip
61
+ notice={isDuplicate ? DUPLICATE_EVENT_MESSAGE : undefined}
62
+ >
63
+ {children}
64
+ </EventNoticeTooltip>
65
+ );
66
+ }
@@ -39,10 +39,10 @@ export function isStructuredErrorWithStack(
39
39
  /**
40
40
  * Pull a short, single-line title out of an error message.
41
41
  *
42
- * Workflow's structured error messages are multi-line the first line is
42
+ * Workflow's structured error messages are multi-line: the first line is
43
43
  * the headline (`Failed to serialize step return value`) and the rest are
44
44
  * `╰▶ hint:` / `╰▶ docs:` framed details. The full message belongs in the
45
- * body of the error block; the title should just be the headline so the
45
+ * body of the error block; the title should be the headline so the
46
46
  * card stays scannable.
47
47
  */
48
48
  function deriveTitle(message: string): string {
@@ -94,7 +94,7 @@ export function ErrorStackBlock({ value }: { value: StructuredErrorRecord }) {
94
94
  <AlertCircle className="h-4 w-4 shrink-0" />
95
95
  <p
96
96
  className="text-label-12 font-semibold m-0 truncate"
97
- // The full message or stack is in the body below; the header just
97
+ // The full message or stack is in the body below; the header
98
98
  // shows the first line, single-line, with overflow
99
99
  // ellipsised so a long title doesn't push the copy button or
100
100
  // wrap into the framed hint/docs lines.
@@ -156,7 +156,13 @@ function ZoneDateTimeRow({
156
156
  );
157
157
  }
158
158
 
159
- function RelativeTimeContextCardContent({ date }: { date: number }): ReactNode {
159
+ function RelativeTimeContextCardContent({
160
+ date,
161
+ prefix,
162
+ }: {
163
+ date: number;
164
+ prefix?: string;
165
+ }): ReactNode {
160
166
  const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
161
167
  const timeAgo = useTimeAgo(date);
162
168
 
@@ -164,7 +170,7 @@ function RelativeTimeContextCardContent({ date }: { date: number }): ReactNode {
164
170
  <div className="flex flex-col gap-3 min-w-[300px]">
165
171
  <div className="flex flex-col gap-3">
166
172
  <span className="tabular-nums text-label-13 text-gray-900">
167
- {timeAgo}
173
+ {prefix ? `${prefix} ${timeAgo}` : timeAgo}
168
174
  </span>
169
175
  </div>
170
176
  <div className="flex flex-col gap-2">
@@ -196,17 +202,19 @@ type RelativeTimeCardProps = Omit<
196
202
  date?: number | null;
197
203
  /** Custom content to render instead of the default relative time text. */
198
204
  children?: ReactNode;
205
+ prefix?: string;
199
206
  };
200
207
 
201
208
  /**
202
209
  * Relative time label that reveals a context card with detailed UTC and local
203
210
  * timestamps on hover. Renders a default short relative time label (e.g.
204
- * "3 days ago") when `children` is omitted; renders just the children without
211
+ * "3 days ago") when `children` is omitted; renders only the children without
205
212
  * the hover card when `date` is nullish.
206
213
  */
207
214
  export function RelativeTimeCard({
208
215
  date,
209
216
  children: _children,
217
+ prefix,
210
218
  ...props
211
219
  }: RelativeTimeCardProps): ReactNode {
212
220
  const children =
@@ -216,7 +224,7 @@ export function RelativeTimeCard({
216
224
 
217
225
  return (
218
226
  <ContextCardTrigger
219
- content={<RelativeTimeContextCardContent date={date} />}
227
+ content={<RelativeTimeContextCardContent date={date} prefix={prefix} />}
220
228
  {...props}
221
229
  >
222
230
  {children}
@@ -225,7 +233,7 @@ export function RelativeTimeCard({
225
233
  }
226
234
 
227
235
  // ---------------------------------------------------------------------------
228
- // TimestampTooltip convenience wrapper used across the observability UI
236
+ // TimestampTooltip: convenience wrapper used across the observability UI
229
237
  // ---------------------------------------------------------------------------
230
238
 
231
239
  /**
@@ -238,10 +246,12 @@ export function TimestampTooltip({
238
246
  date,
239
247
  children,
240
248
  side = 'top',
249
+ prefix,
241
250
  }: {
242
251
  date: number | Date | string | null | undefined;
243
252
  children: ReactNode;
244
253
  side?: ContextCardTriggerProps['side'];
254
+ prefix?: string;
245
255
  }): ReactNode {
246
256
  const hasProvider = useHasContextCardProvider();
247
257
 
@@ -255,7 +265,7 @@ export function TimestampTooltip({
255
265
  if (ts == null || Number.isNaN(ts)) return <>{children}</>;
256
266
 
257
267
  const card = (
258
- <RelativeTimeCard date={ts} side={side} asChild>
268
+ <RelativeTimeCard date={ts} side={side} prefix={prefix} asChild>
259
269
  {children}
260
270
  </RelativeTimeCard>
261
271
  );
@@ -72,6 +72,17 @@ export function getEventColor(
72
72
  };
73
73
  }
74
74
 
75
+ // Sealed positions - neutral gray: backend log filler, not run activity
76
+ if (eventType === 'noop') {
77
+ return {
78
+ color: 'var(--ds-gray-500)',
79
+ background: 'var(--ds-gray-100)',
80
+ border: 'var(--ds-gray-400)',
81
+ text: 'var(--ds-gray-900)',
82
+ secondary: 'var(--ds-gray-700)',
83
+ };
84
+ }
85
+
75
86
  // Default - Blue
76
87
  return {
77
88
  color: 'var(--ds-blue-600)',
@@ -1,6 +1,6 @@
1
1
  import type { Event, WorkflowRun } from '@workflow/world';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { runToSpan } from './trace-span-construction';
3
+ import { runToSpan, stepToSpan } from './trace-span-construction';
4
4
  import { otelTimeToMs } from './trace-time-utils';
5
5
 
6
6
  const date = (ms: number) => new Date(ms);
@@ -83,3 +83,29 @@ describe('runToSpan', () => {
83
83
  expect(data).not.toHaveProperty('occurredAt');
84
84
  });
85
85
  });
86
+
87
+ describe('stepToSpan', () => {
88
+ it('uses the short name for a workflow-prefixed step name', () => {
89
+ const span = stepToSpan(
90
+ [
91
+ event({
92
+ eventType: 'step_created',
93
+ correlationId: 'step_1',
94
+ eventData: {
95
+ stepName: 'workflow//src/billing.ts//fetchInvoices',
96
+ input: null,
97
+ },
98
+ }),
99
+ event({
100
+ eventType: 'step_completed',
101
+ correlationId: 'step_1',
102
+ createdAt: date(2_000),
103
+ eventData: { result: null },
104
+ }),
105
+ ],
106
+ date(2_000)
107
+ );
108
+
109
+ expect(span?.name).toBe('fetchInvoices');
110
+ });
111
+ });
@@ -175,7 +175,7 @@ export const stepEventsToStepEntity = (
175
175
  return null;
176
176
  }
177
177
 
178
- // Walk events in order to derive status, attempt count, and timestamps.
178
+ // Walk events to derive status, attempt count, and timestamps.
179
179
  // Handles both step_retrying and consecutive step_started as retry signals.
180
180
  let status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' =
181
181
  'pending';
@@ -236,7 +236,9 @@ export function stepToSpan(stepEvents: Event[], maxEndTime: Date): Span | null {
236
236
  if (!step) {
237
237
  return null;
238
238
  }
239
- const parsedName = parseStepName(String(step.stepName));
239
+ const parsedName =
240
+ parseStepName(String(step.stepName)) ??
241
+ parseWorkflowName(String(step.stepName));
240
242
 
241
243
  const attributes = {
242
244
  resource: 'step' as const,
@@ -404,7 +406,7 @@ export function runToSpan(
404
406
  getEventTimestamp(terminalEvent) ?? run.completedAt ?? undefined;
405
407
  const endTime = completedAt ?? now;
406
408
 
407
- // Only embed identification fields not the full object with
409
+ // Only embed identification fields, not the full object with
408
410
  // input/output/error which may contain non-cloneable types. Lifecycle
409
411
  // timestamps are event-derived so detail rows align with the span timeline.
410
412
  const { input: _i, output: _o, error: _e, ...runIdentity } = run;
package/src/index.ts CHANGED
@@ -59,6 +59,10 @@ export {
59
59
  STREAM_REF_TYPE,
60
60
  truncateId,
61
61
  } from './lib/hydration';
62
+ export {
63
+ isSealedNoopEvent,
64
+ SEALED_EVENT_MESSAGE,
65
+ } from './lib/sealed-events';
62
66
  export type { DecodedStreamChunkSource } from './lib/stream-display';
63
67
  export type { ToastAdapter } from './lib/toast';
64
68
  export { ToastProvider, useToast } from './lib/toast';
@@ -162,7 +162,7 @@ function foldDuplicates(ordered: readonly Event[]): Set<string> {
162
162
  * `isCompleteHistory` must be false whenever the caller holds a subset of the
163
163
  * run's log: one page of a paginated list, or the result of a search. Which
164
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
165
+ * subset the earlier event may be missing, and the fold would report
166
166
  * the surviving one. Nothing is classified in that case.
167
167
  *
168
168
  * Two other things make the answer unknowable and yield the same empty result:
@@ -137,8 +137,8 @@ export function getWebRevivers(): Revivers {
137
137
  // entry for each built-in Error subclass plus the workflow-specific
138
138
  // `FatalError` / `RetryableError` / `HookConflictError` /
139
139
  // `RuntimeDecryptionError` and `AggregateError`. Without
140
- // matching revivers here, `devalue.unflatten` throws "Unknown type X"
141
- // which surfaces in the web o11y UI as "Failed to load resource
140
+ // matching revivers here, `devalue.unflatten` throws "Unknown type X",
141
+ // which surfaces in the web o11y UI as "Failed to load resource
142
142
  // details: Unknown type FatalError".
143
143
  Error: (value) => {
144
144
  const opts = 'cause' in value ? { cause: value.cause } : undefined;
@@ -171,7 +171,7 @@ export function getWebRevivers(): Revivers {
171
171
  // `FatalError` and `RetryableError` are not built-in browser globals,
172
172
  // so we can't resolve a constructor from globalThis. The web o11y UI
173
173
  // doesn't need `instanceof FatalError` to pass (no user code runs
174
- // here) it just needs `name`, `message`, `stack`, and any extra
174
+ // here). The web o11y UI needs `name`, `message`, `stack`, and any extra
175
175
  // enumerable fields to render. Construct a plain `Error` with `name`
176
176
  // set; ObjectInspector reads `constructor.name` for the displayed
177
177
  // class label, but we don't have the real class, so we emit a tagged
@@ -209,7 +209,7 @@ export function getWebRevivers(): Revivers {
209
209
  // RetryableError reducer for the rationale around realm-safety).
210
210
  // Rehydrate as a Date so o11y consumers can render it directly.
211
211
  // Guard against payloads from older runtime versions that predate
212
- // the field without this check, `new Date(undefined)` would
212
+ // the field: without this check, `new Date(undefined)` would
213
213
  // produce an Invalid Date rather than omitting the property.
214
214
  if (value.retryAfter != null) {
215
215
  error.retryAfter = new Date(value.retryAfter);
@@ -492,17 +492,12 @@ export async function hydrateResourceIOAsync<T>(
492
492
  );
493
493
  // Payloads may be zstd-compressed (the Web DecompressionStream has no zstd);
494
494
  // register the WASM-backed browser decoder before hydrating. Idempotent and
495
- // lazy the WASM is only compiled when a zstd payload is actually decoded.
495
+ // lazy: the WASM is only compiled when a zstd payload is actually decoded.
496
496
  const { ensureZstdDecoderRegistered } = await import(
497
497
  './zstd-browser-decoder.js'
498
498
  );
499
499
  ensureZstdDecoderRegistered();
500
- // Resolve the *full* key capability, not just the symmetric key: a run's
501
- // event log can contain sealed ('encp') payloads that another run wrote to
502
- // it (a cross-deployment hook resumption, say), and opening those needs the
503
- // run's X25519 scalar in addition to its AES key. Both are derived from the
504
- // same 32 bytes the key-retrieval endpoint returns.
505
- // Resolve the *full* key capability, not just the symmetric key: a run's
500
+ // Resolve the *full* key capability: a run's
506
501
  // event log can contain sealed ('encp') payloads that another run wrote to
507
502
  // it (a cross-deployment hook resumption, say), and opening those needs the
508
503
  // run's X25519 scalar in addition to its AES key. Both derive from the same
@@ -520,7 +515,7 @@ export async function hydrateResourceIOAsync<T>(
520
515
  if (value instanceof Uint8Array) {
521
516
  return hydrateDataWithKey(value, revivers, cryptoKey);
522
517
  }
523
- // Not serialized return as-is.
518
+ // Not serialized, so return as-is.
524
519
  return value;
525
520
  }
526
521
 
@@ -0,0 +1,32 @@
1
+ import { type Event, isSealedNoopEvent as isSealedNoop } from '@workflow/world';
2
+
3
+ /**
4
+ * Sealed-position `noop` events (specVersion 7).
5
+ *
6
+ * A sealed-log backend hands each write its position before the write
7
+ * commits, so a writer that dies after claiming a position leaves a hole.
8
+ * The backend closes a provably abandoned hole by writing a `noop` event
9
+ * into it, creating a log-only row the run itself never observes. Replay steps
10
+ * over it without offering it to any consumer and without advancing the
11
+ * deterministic clock.
12
+ *
13
+ * The observability UI mirrors that treatment. A `noop` appears in event
14
+ * lists (greyed, with {@link SEALED_EVENT_MESSAGE} on hover) because it is a
15
+ * real row of the log, but it is excluded from span geometry and from
16
+ * trace-duration bounds: its `createdAt` is the *sealer's* wall clock, which
17
+ * can postdate every real event around it, and letting it stretch a span or
18
+ * the trace's known duration would chart the sealer's schedule rather than
19
+ * the run's.
20
+ */
21
+ export const SEALED_EVENT_MESSAGE =
22
+ 'No-op events are written by the backend to seal an abandoned log position';
23
+
24
+ /**
25
+ * Whether `event` is a backend-written seal for an abandoned position.
26
+ *
27
+ * Delegates to `@workflow/world` so the UI, the `node:vm` engine and the
28
+ * QuickJS engine cannot drift on what a seal is.
29
+ */
30
+ export function isSealedNoopEvent(event: Pick<Event, 'eventType'>): boolean {
31
+ return isSealedNoop(event);
32
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Event, EventType, WorkflowRun } from '@workflow/world';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import { otelTimeToMs } from '../components/workflow-traces/trace-time-utils';
4
- import { buildTrace } from './trace-builder';
4
+ import { buildTrace, filterSpanRawEvents } from './trace-builder';
5
5
 
6
6
  const BASE_TIME = Date.parse('2026-01-01T00:00:00.000Z');
7
7
 
@@ -71,4 +71,32 @@ describe('buildTrace', () => {
71
71
  expect(otelTimeToMs(stepSpan?.endTime ?? [0, 0])).toBe(BASE_TIME + 20_000);
72
72
  expect(trace.duplicateEventIds.size).toBe(0);
73
73
  });
74
+
75
+ it('keeps sealed-position noops out of the geometry and its time bounds', () => {
76
+ const events = [
77
+ event('run_created', { at: 0 }),
78
+ event('run_started', { at: 0 }),
79
+ event('step_created', { correlationId: 'step_a', at: 1 }),
80
+ event('step_started', { correlationId: 'step_a', at: 1 }),
81
+ event('step_completed', { correlationId: 'step_a', at: 4 }),
82
+ // A hole sealed by a reader long after the run went quiet. Its
83
+ // createdAt is the SEALER's clock — letting it into the geometry would
84
+ // stretch the trace to the sealer's schedule.
85
+ event('noop', { correlationId: 'noop_5', at: 300 }),
86
+ ];
87
+
88
+ const trace = buildTrace(run, events, new Date(BASE_TIME + 400_000), {
89
+ isCompleteHistory: true,
90
+ });
91
+
92
+ // No span for the noop's correlationId, and the trace's known duration
93
+ // ends at the last real event, not at the seal.
94
+ expect(trace.spans.some((span) => span.spanId === 'noop_5')).toBe(false);
95
+ expect(trace.knownDurationMs).toBe(4000);
96
+
97
+ // The run span's raw event list still shows the seal (greyed in the UI),
98
+ // exactly like duplicate rows: real log rows, marked with the reason.
99
+ const runRaw = filterSpanRawEvents(events, 'run', 'run_1');
100
+ expect(runRaw.some((e) => e.eventType === 'noop')).toBe(true);
101
+ });
74
102
  });
@@ -23,6 +23,7 @@ import {
23
23
  } from '../components/workflow-traces/trace-span-construction';
24
24
  import { otelTimeToMs } from '../components/workflow-traces/trace-time-utils';
25
25
  import { findDuplicateEventIds } from './duplicate-events';
26
+ import { isSealedNoopEvent } from './sealed-events';
26
27
  import type { Span } from './trace-types';
27
28
 
28
29
  /**
@@ -212,14 +213,18 @@ export function buildTrace(
212
213
  // Span geometry comes from what the run acted on. A repeat of a class the
213
214
  // log already records is read past by every replay, and letting one through
214
215
  // here would stretch a span to whenever a concurrent replay committed it.
215
- // The event lists still show them, marked as repeats.
216
+ // Sealed-position noops are excluded for the same reason with a different
217
+ // clock: a noop's createdAt is the sealer's wall time, which can postdate
218
+ // every real event around it, so feeding it into span grouping or the
219
+ // latest-known-time bound would chart the sealer's schedule instead of the
220
+ // run's. The event lists still show both, marked with the reason.
216
221
  const duplicateEventIds = findDuplicateEventIds(events, {
217
222
  isCompleteHistory,
218
223
  });
219
- const actedOnEvents =
220
- duplicateEventIds.size === 0
221
- ? events
222
- : events.filter((event) => !duplicateEventIds.has(event.eventId));
224
+ const actedOnEvents = events.filter(
225
+ (event) =>
226
+ !duplicateEventIds.has(event.eventId) && !isSealedNoopEvent(event)
227
+ );
223
228
 
224
229
  const groupedEvents = groupEventsByCorrelation(actedOnEvents);
225
230
  const latestKnownTime = computeLatestKnownTime(actedOnEvents, run);
package/src/lib/utils.ts CHANGED
@@ -80,7 +80,7 @@ export function formatDuration(ms: number, compact = false): string {
80
80
  * Unlike {@link formatDuration}, this keeps sub-second detail, so 1626ms
81
81
  * renders as "1.63s" rather than "2s". `fractionDigits` sets both the number of
82
82
  * decimals on the seconds component (trailing zeros trimmed, so 2000ms is "2s"
83
- * and 1500ms is "1.5s") and the rounding granularity the default of 2 rounds
83
+ * and 1500ms is "1.5s") and the rounding granularity: the default of 2 rounds
84
84
  * to 10ms; the timeline ruler passes fewer digits to match its tick step.
85
85
  *
86
86
  * - < 1s: shows whole milliseconds (e.g. "626ms")
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * The package leaves WASM sourcing to the caller; we resolve the shipped
10
10
  * `zstd.wasm` as a bundler asset (`new URL(..., import.meta.url)`, the same
11
- * pattern the trace-viewer Worker uses) and compile it once, lazily the
11
+ * pattern the trace-viewer Worker uses) and compile it once, lazily: the
12
12
  * WASM is fetched only the first time a zstd payload is actually decoded.
13
13
  */
14
14
  import { registerZstdDecoder } from '@workflow/core/serialization-format';