@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.
- package/README.md +4 -4
- package/dist/components/event-list-view.d.ts +12 -0
- package/dist/components/event-list-view.d.ts.map +1 -1
- package/dist/components/event-list-view.js +41 -20
- package/dist/components/sidebar/attribute-panel.d.ts +6 -2
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +10 -8
- package/dist/components/sidebar/copyable-data-block.d.ts.map +1 -1
- package/dist/components/sidebar/copyable-data-block.js +2 -2
- package/dist/components/sidebar/entity-detail-panel.d.ts +6 -2
- package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.js +13 -5
- package/dist/components/sidebar/events-list.d.ts.map +1 -1
- package/dist/components/sidebar/events-list.js +12 -5
- package/dist/components/sidebar/resolve-hook-modal.js +1 -1
- package/dist/components/sidebar/sidebar-data-context.d.ts +2 -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.js +2 -2
- package/dist/components/trace-viewer/components/detail-panel-width.d.ts +4 -4
- package/dist/components/trace-viewer/components/detail-panel-width.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/detail-panel-width.js +5 -5
- package/dist/components/trace-viewer/components/detail-panel.d.ts +1 -1
- package/dist/components/trace-viewer/components/detail-panel.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/detail-panel.js +3 -3
- package/dist/components/trace-viewer/components/draggable-border.d.ts +1 -1
- package/dist/components/trace-viewer/components/draggable-border.js +2 -2
- 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/minimap.js +4 -4
- package/dist/components/trace-viewer/components/span-markers.d.ts +8 -9
- package/dist/components/trace-viewer/components/span-markers.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/span-markers.js +13 -10
- package/dist/components/trace-viewer/components/timeline.d.ts +1 -1
- package/dist/components/trace-viewer/components/timeline.js +4 -4
- package/dist/components/trace-viewer/components/use-row-window.d.ts +1 -1
- package/dist/components/trace-viewer/components/use-row-window.js +2 -2
- package/dist/components/trace-viewer/trace-viewer.js +3 -3
- package/dist/components/trace-viewer/utils.d.ts +3 -1
- package/dist/components/trace-viewer/utils.d.ts.map +1 -1
- package/dist/components/trace-viewer/utils.js +6 -5
- package/dist/components/ui/data-inspector.d.ts +7 -1
- package/dist/components/ui/data-inspector.d.ts.map +1 -1
- package/dist/components/ui/data-inspector.js +10 -5
- package/dist/components/ui/decrypt-button.d.ts +5 -1
- package/dist/components/ui/decrypt-button.d.ts.map +1 -1
- package/dist/components/ui/decrypt-button.js +4 -4
- package/dist/components/ui/duplicate-event-tooltip.d.ts +15 -5
- package/dist/components/ui/duplicate-event-tooltip.d.ts.map +1 -1
- package/dist/components/ui/duplicate-event-tooltip.js +18 -9
- package/dist/components/ui/error-stack-block.js +4 -4
- package/dist/components/ui/timestamp-tooltip.d.ts +5 -3
- package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -1
- package/dist/components/ui/timestamp-tooltip.js +9 -9
- package/dist/components/workflow-traces/event-colors.d.ts.map +1 -1
- package/dist/components/workflow-traces/event-colors.js +11 -1
- package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
- package/dist/components/workflow-traces/trace-span-construction.js +5 -4
- 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 +1 -1
- package/dist/lib/duplicate-events.js +2 -2
- package/dist/lib/hydration.d.ts.map +1 -1
- package/dist/lib/hydration.js +8 -13
- package/dist/lib/sealed-events.d.ts +28 -0
- package/dist/lib/sealed-events.d.ts.map +1 -0
- package/dist/lib/sealed-events.js +30 -0
- package/dist/lib/trace-builder.d.ts.map +1 -1
- package/dist/lib/trace-builder.js +8 -5
- package/dist/lib/utils.d.ts +1 -1
- package/dist/lib/utils.js +2 -2
- package/dist/lib/zstd-browser-decoder.js +2 -2
- package/package.json +5 -5
- package/src/components/event-list-view.tsx +57 -22
- package/src/components/sidebar/attribute-panel.tsx +15 -7
- package/src/components/sidebar/copyable-data-block.tsx +8 -1
- package/src/components/sidebar/entity-detail-panel.tsx +18 -2
- package/src/components/sidebar/events-list.tsx +16 -6
- package/src/components/sidebar/resolve-hook-modal.tsx +2 -2
- package/src/components/sidebar/sidebar-data-context.tsx +2 -0
- package/src/components/stream-viewer.tsx +1 -1
- package/src/components/trace-viewer/components/detail-panel-width.ts +4 -4
- package/src/components/trace-viewer/components/detail-panel.tsx +3 -1
- package/src/components/trace-viewer/components/draggable-border.tsx +1 -1
- 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/minimap.tsx +3 -3
- package/src/components/trace-viewer/components/span-markers.tsx +18 -13
- package/src/components/trace-viewer/components/timeline.tsx +4 -4
- package/src/components/trace-viewer/components/use-row-window.ts +1 -1
- package/src/components/trace-viewer/trace-viewer.tsx +2 -2
- package/src/components/trace-viewer/utils.test.ts +5 -3
- package/src/components/trace-viewer/utils.ts +12 -8
- package/src/components/ui/data-inspector.tsx +19 -3
- package/src/components/ui/decrypt-button.tsx +9 -2
- package/src/components/ui/duplicate-event-tooltip.tsx +32 -10
- package/src/components/ui/error-stack-block.tsx +3 -3
- package/src/components/ui/timestamp-tooltip.tsx +16 -6
- package/src/components/workflow-traces/event-colors.ts +11 -0
- package/src/components/workflow-traces/trace-span-construction.test.ts +27 -1
- package/src/components/workflow-traces/trace-span-construction.ts +5 -3
- package/src/index.ts +4 -0
- package/src/lib/duplicate-events.ts +1 -1
- package/src/lib/hydration.ts +7 -12
- package/src/lib/sealed-events.ts +32 -0
- package/src/lib/trace-builder.test.ts +29 -1
- package/src/lib/trace-builder.ts +10 -5
- package/src/lib/utils.ts +1 -1
- 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
|
|
14
|
-
*
|
|
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 `
|
|
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
|
|
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
|
|
22
|
-
|
|
23
|
+
export function EventNoticeTooltip({
|
|
24
|
+
notice,
|
|
23
25
|
children,
|
|
24
26
|
}: {
|
|
25
|
-
|
|
27
|
+
notice?: string;
|
|
26
28
|
children: ReactNode;
|
|
27
29
|
}): ReactNode {
|
|
28
|
-
if (!
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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:
|
package/src/lib/hydration.ts
CHANGED
|
@@ -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
|
-
//
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|
package/src/lib/trace-builder.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
|
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';
|