@workflow/web-shared 4.1.0-beta.57 → 4.1.0-beta.59
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 +3 -1
- package/dist/components/event-list-view.d.ts.map +1 -1
- package/dist/components/event-list-view.js +27 -3
- package/dist/components/event-list-view.js.map +1 -1
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +63 -27
- package/dist/components/sidebar/attribute-panel.js.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.d.ts +3 -1
- 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/entity-detail-panel.js.map +1 -1
- package/dist/components/sidebar/events-list.d.ts +3 -1
- package/dist/components/sidebar/events-list.d.ts.map +1 -1
- package/dist/components/sidebar/events-list.js +22 -15
- package/dist/components/sidebar/events-list.js.map +1 -1
- package/dist/components/ui/data-inspector.d.ts.map +1 -1
- package/dist/components/ui/data-inspector.js +18 -1
- package/dist/components/ui/data-inspector.js.map +1 -1
- package/dist/components/workflow-trace-view.d.ts +3 -1
- package/dist/components/workflow-trace-view.d.ts.map +1 -1
- package/dist/components/workflow-trace-view.js +2 -2
- package/dist/components/workflow-trace-view.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/hydration.d.ts +14 -1
- package/dist/lib/hydration.d.ts.map +1 -1
- package/dist/lib/hydration.js +116 -3
- package/dist/lib/hydration.js.map +1 -1
- package/package.json +3 -3
- package/src/components/event-list-view.tsx +31 -0
- package/src/components/sidebar/attribute-panel.tsx +78 -27
- package/src/components/sidebar/entity-detail-panel.tsx +4 -0
- package/src/components/sidebar/events-list.tsx +27 -11
- package/src/components/ui/data-inspector.tsx +35 -1
- package/src/components/workflow-trace-view.tsx +4 -0
- package/src/index.ts +3 -0
- package/src/lib/hydration.ts +151 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workflow/web-shared",
|
|
3
3
|
"description": "Shared components for Workflow Observability UI",
|
|
4
|
-
"version": "4.1.0-beta.
|
|
4
|
+
"version": "4.1.0-beta.59",
|
|
5
5
|
"private": false,
|
|
6
6
|
"files": [
|
|
7
7
|
"dist",
|
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
"streamdown": "2.3.0",
|
|
53
53
|
"tailwind-merge": "3.5.0",
|
|
54
54
|
"tailwindcss": "4",
|
|
55
|
-
"@workflow/core": "4.
|
|
55
|
+
"@workflow/core": "4.2.0-beta.64",
|
|
56
56
|
"@workflow/utils": "4.1.0-beta.13",
|
|
57
|
-
"@workflow/world": "4.1.0-beta.
|
|
57
|
+
"@workflow/world": "4.1.0-beta.9"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@biomejs/biome": "^2.4.4",
|
|
@@ -454,6 +454,11 @@ function deepParseJson(value: unknown): unknown {
|
|
|
454
454
|
return value.map(deepParseJson);
|
|
455
455
|
}
|
|
456
456
|
if (value !== null && typeof value === 'object') {
|
|
457
|
+
// Preserve objects with custom constructors (e.g., encrypted markers,
|
|
458
|
+
// class instance refs) — don't destructure them into plain objects
|
|
459
|
+
if (value.constructor !== Object) {
|
|
460
|
+
return value;
|
|
461
|
+
}
|
|
457
462
|
const result: Record<string, unknown> = {};
|
|
458
463
|
for (const [k, v] of Object.entries(value)) {
|
|
459
464
|
result[k] = deepParseJson(v);
|
|
@@ -584,6 +589,8 @@ interface EventsListProps {
|
|
|
584
589
|
hasMoreEvents?: boolean;
|
|
585
590
|
isLoadingMoreEvents?: boolean;
|
|
586
591
|
onLoadMoreEvents?: () => Promise<void> | void;
|
|
592
|
+
/** When provided, signals that decryption is active (triggers re-load of expanded events) */
|
|
593
|
+
encryptionKey?: Uint8Array;
|
|
587
594
|
}
|
|
588
595
|
|
|
589
596
|
function EventRow({
|
|
@@ -600,6 +607,7 @@ function EventRow({
|
|
|
600
607
|
onSelectGroup,
|
|
601
608
|
onHoverGroup,
|
|
602
609
|
onLoadEventData,
|
|
610
|
+
encryptionKey,
|
|
603
611
|
}: {
|
|
604
612
|
event: Event;
|
|
605
613
|
index: number;
|
|
@@ -614,6 +622,7 @@ function EventRow({
|
|
|
614
622
|
onSelectGroup: (groupKey: string | undefined) => void;
|
|
615
623
|
onHoverGroup: (groupKey: string | undefined) => void;
|
|
616
624
|
onLoadEventData?: (event: Event) => Promise<unknown | null>;
|
|
625
|
+
encryptionKey?: Uint8Array;
|
|
617
626
|
}) {
|
|
618
627
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
619
628
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -686,6 +695,26 @@ function EventRow({
|
|
|
686
695
|
}
|
|
687
696
|
}, [event, loadedEventData, hasExistingEventData, onLoadEventData]);
|
|
688
697
|
|
|
698
|
+
// When encryption key changes and this event was previously loaded,
|
|
699
|
+
// re-load to get decrypted data
|
|
700
|
+
useEffect(() => {
|
|
701
|
+
if (encryptionKey && hasAttemptedLoad && onLoadEventData) {
|
|
702
|
+
setLoadedEventData(null);
|
|
703
|
+
setHasAttemptedLoad(false);
|
|
704
|
+
onLoadEventData(event)
|
|
705
|
+
.then((data) => {
|
|
706
|
+
if (data !== null && data !== undefined) {
|
|
707
|
+
setLoadedEventData(data);
|
|
708
|
+
}
|
|
709
|
+
setHasAttemptedLoad(true);
|
|
710
|
+
})
|
|
711
|
+
.catch(() => {
|
|
712
|
+
setHasAttemptedLoad(true);
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
716
|
+
}, [encryptionKey]);
|
|
717
|
+
|
|
689
718
|
const handleExpandToggle = useCallback(
|
|
690
719
|
(e: ReactMouseEvent) => {
|
|
691
720
|
e.stopPropagation();
|
|
@@ -937,6 +966,7 @@ export function EventListView({
|
|
|
937
966
|
hasMoreEvents = false,
|
|
938
967
|
isLoadingMoreEvents = false,
|
|
939
968
|
onLoadMoreEvents,
|
|
969
|
+
encryptionKey,
|
|
940
970
|
}: EventsListProps) {
|
|
941
971
|
const sortedEvents = useMemo(() => {
|
|
942
972
|
if (!events || events.length === 0) return [];
|
|
@@ -1154,6 +1184,7 @@ export function EventListView({
|
|
|
1154
1184
|
onSelectGroup={onSelectGroup}
|
|
1155
1185
|
onHoverGroup={onHoverGroup}
|
|
1156
1186
|
onLoadEventData={onLoadEventData}
|
|
1187
|
+
encryptionKey={encryptionKey}
|
|
1157
1188
|
/>
|
|
1158
1189
|
);
|
|
1159
1190
|
}}
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
|
|
4
4
|
import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
|
|
5
5
|
import type { ModelMessage } from 'ai';
|
|
6
|
+
import { Lock } from 'lucide-react';
|
|
6
7
|
import type { KeyboardEvent, ReactNode } from 'react';
|
|
7
8
|
import { useCallback, useMemo, useState } from 'react';
|
|
8
9
|
import { toast } from 'sonner';
|
|
10
|
+
import { isEncryptedMarker } from '../../lib/hydration';
|
|
9
11
|
import { extractConversation, isDoStreamStep } from '../../lib/utils';
|
|
10
12
|
import { StreamClickContext } from '../ui/data-inspector';
|
|
11
13
|
import { ErrorCard } from '../ui/error-card';
|
|
@@ -172,6 +174,26 @@ function ConversationWithTabs({
|
|
|
172
174
|
* Render a value with the shared DataInspector (ObjectInspector with
|
|
173
175
|
* custom theming, nodeRenderer for StreamRef/ClassInstanceRef, etc.)
|
|
174
176
|
*/
|
|
177
|
+
/**
|
|
178
|
+
* Inline display for an encrypted field — no expand, just a flat label
|
|
179
|
+
* with the lucide Lock icon matching the title bar Decrypt button.
|
|
180
|
+
*/
|
|
181
|
+
function EncryptedFieldBlock() {
|
|
182
|
+
return (
|
|
183
|
+
<div
|
|
184
|
+
className="flex items-center gap-1.5 rounded-md border px-3 py-2 text-xs"
|
|
185
|
+
style={{
|
|
186
|
+
borderColor: 'var(--ds-gray-300)',
|
|
187
|
+
backgroundColor: 'var(--ds-gray-100)',
|
|
188
|
+
color: 'var(--ds-gray-700)',
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
<Lock className="h-3 w-3" />
|
|
192
|
+
<span className="font-medium">Encrypted</span>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
175
197
|
function JsonBlock(value: unknown) {
|
|
176
198
|
return <CopyableDataBlock data={value} />;
|
|
177
199
|
}
|
|
@@ -262,20 +284,24 @@ const getModuleSpecifierFromName = (value: unknown): string => {
|
|
|
262
284
|
return raw;
|
|
263
285
|
};
|
|
264
286
|
|
|
265
|
-
|
|
266
|
-
|
|
287
|
+
const parseDateValue = (value: unknown): Date | null => {
|
|
288
|
+
if (value == null) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
267
291
|
if (value instanceof Date) {
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
date = new Date(value);
|
|
273
|
-
} else {
|
|
274
|
-
date = new Date(String(value));
|
|
292
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
293
|
+
}
|
|
294
|
+
if (typeof value === 'string' && value.trim().length === 0) {
|
|
295
|
+
return null;
|
|
275
296
|
}
|
|
276
297
|
|
|
277
|
-
|
|
278
|
-
|
|
298
|
+
const date =
|
|
299
|
+
typeof value === 'number' ? new Date(value) : new Date(String(value));
|
|
300
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const formatLocalMillisecondTime = (date: Date): string =>
|
|
304
|
+
date.toLocaleString(undefined, {
|
|
279
305
|
year: 'numeric',
|
|
280
306
|
month: 'numeric',
|
|
281
307
|
day: 'numeric',
|
|
@@ -284,6 +310,23 @@ export const localMillisecondTime = (value: unknown): string => {
|
|
|
284
310
|
second: 'numeric',
|
|
285
311
|
fractionalSecondDigits: 3,
|
|
286
312
|
});
|
|
313
|
+
|
|
314
|
+
export const localMillisecondTime = (value: unknown): string => {
|
|
315
|
+
const date = parseDateValue(value);
|
|
316
|
+
if (!date) {
|
|
317
|
+
return '-';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// e.g. 12/17/2025, 9:08:55.182 AM
|
|
321
|
+
return formatLocalMillisecondTime(date);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const localMillisecondTimeOrNull = (value: unknown): string | null => {
|
|
325
|
+
const date = parseDateValue(value);
|
|
326
|
+
if (!date) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
return formatLocalMillisecondTime(date);
|
|
287
330
|
};
|
|
288
331
|
|
|
289
332
|
interface DisplayContext {
|
|
@@ -309,6 +352,7 @@ const attributeToDisplayFn: Record<
|
|
|
309
352
|
attempt: (value: unknown) => String(value),
|
|
310
353
|
// Hook details
|
|
311
354
|
token: (value: unknown) => String(value),
|
|
355
|
+
isWebhook: (value: unknown) => String(value),
|
|
312
356
|
// Event details
|
|
313
357
|
eventType: (value: unknown) => String(value),
|
|
314
358
|
correlationId: (value: unknown) => String(value),
|
|
@@ -323,19 +367,21 @@ const attributeToDisplayFn: Record<
|
|
|
323
367
|
executionContext: (_value: unknown) => null,
|
|
324
368
|
// Dates
|
|
325
369
|
// TODO: relative time with tooltips for ISO times
|
|
326
|
-
createdAt:
|
|
327
|
-
startedAt:
|
|
328
|
-
updatedAt:
|
|
329
|
-
completedAt:
|
|
330
|
-
expiredAt:
|
|
331
|
-
retryAfter:
|
|
332
|
-
resumeAt:
|
|
370
|
+
createdAt: localMillisecondTimeOrNull,
|
|
371
|
+
startedAt: localMillisecondTimeOrNull,
|
|
372
|
+
updatedAt: localMillisecondTimeOrNull,
|
|
373
|
+
completedAt: localMillisecondTimeOrNull,
|
|
374
|
+
expiredAt: localMillisecondTimeOrNull,
|
|
375
|
+
retryAfter: localMillisecondTimeOrNull,
|
|
376
|
+
resumeAt: localMillisecondTimeOrNull,
|
|
333
377
|
// Resolved attributes, won't actually use this function
|
|
334
378
|
metadata: (value: unknown) => {
|
|
335
379
|
if (!hasDisplayContent(value)) return null;
|
|
380
|
+
if (isEncryptedMarker(value)) return <EncryptedFieldBlock />;
|
|
336
381
|
return JsonBlock(value);
|
|
337
382
|
},
|
|
338
383
|
input: (value: unknown, context?: DisplayContext) => {
|
|
384
|
+
if (isEncryptedMarker(value)) return <EncryptedFieldBlock />;
|
|
339
385
|
// Check if input has args + closure vars structure
|
|
340
386
|
if (value && typeof value === 'object' && 'args' in value) {
|
|
341
387
|
const { args, closureVars, thisVal } = value as {
|
|
@@ -439,6 +485,7 @@ const attributeToDisplayFn: Record<
|
|
|
439
485
|
},
|
|
440
486
|
output: (value: unknown) => {
|
|
441
487
|
if (!hasDisplayContent(value)) return null;
|
|
488
|
+
if (isEncryptedMarker(value)) return <EncryptedFieldBlock />;
|
|
442
489
|
return (
|
|
443
490
|
<DetailCard
|
|
444
491
|
summary="Output"
|
|
@@ -450,6 +497,7 @@ const attributeToDisplayFn: Record<
|
|
|
450
497
|
);
|
|
451
498
|
},
|
|
452
499
|
error: (value: unknown) => {
|
|
500
|
+
if (isEncryptedMarker(value)) return <EncryptedFieldBlock />;
|
|
453
501
|
if (!hasDisplayContent(value)) return null;
|
|
454
502
|
|
|
455
503
|
// If the error object has a `stack` field, render it as readable
|
|
@@ -477,6 +525,7 @@ const attributeToDisplayFn: Record<
|
|
|
477
525
|
);
|
|
478
526
|
},
|
|
479
527
|
eventData: (value: unknown) => {
|
|
528
|
+
if (isEncryptedMarker(value)) return <EncryptedFieldBlock />;
|
|
480
529
|
if (!hasDisplayContent(value)) return null;
|
|
481
530
|
return <DetailCard summary="Event Data">{JsonBlock(value)}</DetailCard>;
|
|
482
531
|
},
|
|
@@ -781,15 +830,17 @@ export const AttributePanel = ({
|
|
|
781
830
|
) : hasExpired ? (
|
|
782
831
|
<ExpiredDataMessage />
|
|
783
832
|
) : (
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
833
|
+
<>
|
|
834
|
+
{resolvedAttributes.map((attribute) => (
|
|
835
|
+
<AttributeBlock
|
|
836
|
+
isLoading={isLoading}
|
|
837
|
+
key={attribute}
|
|
838
|
+
attribute={attribute}
|
|
839
|
+
value={displayData[attribute as keyof typeof displayData]}
|
|
840
|
+
context={displayContext}
|
|
841
|
+
/>
|
|
842
|
+
))}
|
|
843
|
+
</>
|
|
793
844
|
)}
|
|
794
845
|
</div>
|
|
795
846
|
</StreamClickContext.Provider>
|
|
@@ -62,6 +62,7 @@ export function EntityDetailPanel({
|
|
|
62
62
|
onWakeUpSleep,
|
|
63
63
|
onLoadEventData,
|
|
64
64
|
onResolveHook,
|
|
65
|
+
encryptionKey,
|
|
65
66
|
selectedSpan,
|
|
66
67
|
}: {
|
|
67
68
|
run: WorkflowRun;
|
|
@@ -93,6 +94,8 @@ export function EntityDetailPanel({
|
|
|
93
94
|
payload: unknown,
|
|
94
95
|
hook?: Hook
|
|
95
96
|
) => Promise<void>;
|
|
97
|
+
/** Encryption key (available after Decrypt is clicked), used to re-load event data */
|
|
98
|
+
encryptionKey?: Uint8Array;
|
|
96
99
|
/** Info about the currently selected span from the trace viewer */
|
|
97
100
|
selectedSpan: SelectedSpanInfo | null;
|
|
98
101
|
}): React.JSX.Element | null {
|
|
@@ -457,6 +460,7 @@ export function EntityDetailPanel({
|
|
|
457
460
|
<EventsList
|
|
458
461
|
events={rawEvents}
|
|
459
462
|
onLoadEventData={onLoadEventData}
|
|
463
|
+
encryptionKey={encryptionKey}
|
|
460
464
|
/>
|
|
461
465
|
</section>
|
|
462
466
|
)}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import type { Event } from '@workflow/world';
|
|
4
|
-
import { useCallback, useMemo, useState } from 'react';
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
5
5
|
import {
|
|
6
6
|
ErrorStackBlock,
|
|
7
7
|
isStructuredErrorWithStack,
|
|
@@ -35,16 +35,20 @@ const DATA_EVENT_TYPES = new Set([
|
|
|
35
35
|
function EventItem({
|
|
36
36
|
event,
|
|
37
37
|
onLoadEventData,
|
|
38
|
+
encryptionKey,
|
|
38
39
|
}: {
|
|
39
40
|
event: Event;
|
|
40
41
|
onLoadEventData?: (
|
|
41
42
|
correlationId: string,
|
|
42
43
|
eventId: string
|
|
43
44
|
) => Promise<unknown | null>;
|
|
45
|
+
/** When this changes (e.g., Decrypt was clicked), invalidate cached data */
|
|
46
|
+
encryptionKey?: Uint8Array;
|
|
44
47
|
}) {
|
|
45
48
|
const [loadedData, setLoadedData] = useState<unknown | null>(null);
|
|
46
49
|
const [isLoading, setIsLoading] = useState(false);
|
|
47
50
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
51
|
+
const wasExpandedRef = useRef(false);
|
|
48
52
|
|
|
49
53
|
// Check if the event already has eventData from the store
|
|
50
54
|
const existingData =
|
|
@@ -52,8 +56,7 @@ function EventItem({
|
|
|
52
56
|
const displayData = existingData ?? loadedData;
|
|
53
57
|
const canHaveData = DATA_EVENT_TYPES.has(event.eventType);
|
|
54
58
|
|
|
55
|
-
const
|
|
56
|
-
if (existingData || loadedData !== null || isLoading) return;
|
|
59
|
+
const loadEventData = useCallback(async () => {
|
|
57
60
|
if (!onLoadEventData || !event.correlationId || !event.eventId) return;
|
|
58
61
|
|
|
59
62
|
try {
|
|
@@ -66,14 +69,23 @@ function EventItem({
|
|
|
66
69
|
} finally {
|
|
67
70
|
setIsLoading(false);
|
|
68
71
|
}
|
|
69
|
-
}, [
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
isLoading
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
}, [onLoadEventData, event.correlationId, event.eventId]);
|
|
73
|
+
|
|
74
|
+
const handleExpand = useCallback(async () => {
|
|
75
|
+
if (existingData || loadedData !== null || isLoading) return;
|
|
76
|
+
wasExpandedRef.current = true;
|
|
77
|
+
await loadEventData();
|
|
78
|
+
}, [existingData, loadedData, isLoading, loadEventData]);
|
|
79
|
+
|
|
80
|
+
// When the encryption key changes and this event was previously expanded,
|
|
81
|
+
// re-load the data so it gets decrypted
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (encryptionKey && wasExpandedRef.current && loadedData !== null) {
|
|
84
|
+
setLoadedData(null); // clear stale data
|
|
85
|
+
loadEventData();
|
|
86
|
+
}
|
|
87
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
|
+
}, [encryptionKey]);
|
|
77
89
|
|
|
78
90
|
const createdAt = new Date(event.createdAt);
|
|
79
91
|
|
|
@@ -226,6 +238,7 @@ export function EventsList({
|
|
|
226
238
|
isLoading = false,
|
|
227
239
|
error,
|
|
228
240
|
onLoadEventData,
|
|
241
|
+
encryptionKey,
|
|
229
242
|
}: {
|
|
230
243
|
events: Event[];
|
|
231
244
|
isLoading?: boolean;
|
|
@@ -234,6 +247,8 @@ export function EventsList({
|
|
|
234
247
|
correlationId: string,
|
|
235
248
|
eventId: string
|
|
236
249
|
) => Promise<unknown | null>;
|
|
250
|
+
/** When provided, signals that decryption is active (triggers re-load of expanded events) */
|
|
251
|
+
encryptionKey?: Uint8Array;
|
|
237
252
|
}) {
|
|
238
253
|
// Sort by createdAt
|
|
239
254
|
const sortedEvents = useMemo(
|
|
@@ -270,6 +285,7 @@ export function EventsList({
|
|
|
270
285
|
key={event.eventId}
|
|
271
286
|
event={event}
|
|
272
287
|
onLoadEventData={onLoadEventData}
|
|
288
|
+
encryptionKey={encryptionKey}
|
|
273
289
|
/>
|
|
274
290
|
))}
|
|
275
291
|
</div>
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* and expand behavior.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { Lock } from 'lucide-react';
|
|
11
12
|
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
|
12
13
|
import {
|
|
13
14
|
ObjectInspector,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
ObjectValue,
|
|
18
19
|
} from 'react-inspector';
|
|
19
20
|
import { useDarkMode } from '../../hooks/use-dark-mode';
|
|
21
|
+
import { ENCRYPTED_DISPLAY_NAME } from '../../lib/hydration';
|
|
20
22
|
import {
|
|
21
23
|
type InspectorThemeExtended,
|
|
22
24
|
inspectorThemeDark,
|
|
@@ -130,6 +132,38 @@ function NodeRenderer({
|
|
|
130
132
|
}) {
|
|
131
133
|
const extendedTheme = useContext(ExtendedThemeContext);
|
|
132
134
|
|
|
135
|
+
// Encrypted marker → flat label with Lock icon, non-expandable
|
|
136
|
+
if (
|
|
137
|
+
data !== null &&
|
|
138
|
+
typeof data === 'object' &&
|
|
139
|
+
data.constructor?.name === ENCRYPTED_DISPLAY_NAME
|
|
140
|
+
) {
|
|
141
|
+
const label = (
|
|
142
|
+
<span style={{ color: 'var(--ds-gray-600)', fontStyle: 'italic' }}>
|
|
143
|
+
<Lock
|
|
144
|
+
className="h-3 w-3"
|
|
145
|
+
style={{
|
|
146
|
+
display: 'inline',
|
|
147
|
+
verticalAlign: 'middle',
|
|
148
|
+
marginRight: '3px',
|
|
149
|
+
marginTop: '-1px',
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
152
|
+
Encrypted
|
|
153
|
+
</span>
|
|
154
|
+
);
|
|
155
|
+
if (depth === 0) {
|
|
156
|
+
return label;
|
|
157
|
+
}
|
|
158
|
+
return (
|
|
159
|
+
<span>
|
|
160
|
+
{name != null && <ObjectName name={name} />}
|
|
161
|
+
{name != null && <span>: </span>}
|
|
162
|
+
{label}
|
|
163
|
+
</span>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
133
167
|
// StreamRef → inline clickable badge
|
|
134
168
|
if (isStreamRef(data)) {
|
|
135
169
|
return (
|
|
@@ -310,7 +344,7 @@ function isDeepEqual(a: unknown, b: unknown, seen = new WeakMap()): boolean {
|
|
|
310
344
|
if (aKeys.length !== bKeys.length) return false;
|
|
311
345
|
|
|
312
346
|
for (const key of aKeys) {
|
|
313
|
-
if (!Object.
|
|
347
|
+
if (!Object.hasOwn(b, key)) return false;
|
|
314
348
|
if (!isDeepEqual(a[key], b[key], seen)) return false;
|
|
315
349
|
}
|
|
316
350
|
|
|
@@ -893,6 +893,7 @@ export const WorkflowTraceViewer = ({
|
|
|
893
893
|
onLoadMoreSpans,
|
|
894
894
|
hasMoreSpans = false,
|
|
895
895
|
isLoadingMoreSpans = false,
|
|
896
|
+
encryptionKey,
|
|
896
897
|
}: {
|
|
897
898
|
run: WorkflowRun;
|
|
898
899
|
steps: Step[];
|
|
@@ -929,6 +930,8 @@ export const WorkflowTraceViewer = ({
|
|
|
929
930
|
hasMoreSpans?: boolean;
|
|
930
931
|
/** Whether trace pagination is currently fetching another page. */
|
|
931
932
|
isLoadingMoreSpans?: boolean;
|
|
933
|
+
/** Encryption key (available after Decrypt), threaded to event list for re-loading */
|
|
934
|
+
encryptionKey?: Uint8Array;
|
|
932
935
|
}) => {
|
|
933
936
|
const [selectedSpan, setSelectedSpan] = useState<SelectedSpanInfo | null>(
|
|
934
937
|
null
|
|
@@ -1252,6 +1255,7 @@ export const WorkflowTraceViewer = ({
|
|
|
1252
1255
|
onWakeUpSleep={onWakeUpSleep}
|
|
1253
1256
|
onLoadEventData={onLoadEventData}
|
|
1254
1257
|
onResolveHook={onResolveHook}
|
|
1258
|
+
encryptionKey={encryptionKey}
|
|
1255
1259
|
selectedSpan={selectedSpan}
|
|
1256
1260
|
/>
|
|
1257
1261
|
</ErrorBoundary>
|
package/src/index.ts
CHANGED
|
@@ -24,10 +24,13 @@ export type { Revivers, StreamRef } from './lib/hydration';
|
|
|
24
24
|
export {
|
|
25
25
|
CLASS_INSTANCE_REF_TYPE,
|
|
26
26
|
ClassInstanceRef,
|
|
27
|
+
ENCRYPTED_PLACEHOLDER,
|
|
27
28
|
extractStreamIds,
|
|
28
29
|
getWebRevivers,
|
|
29
30
|
hydrateResourceIO,
|
|
31
|
+
hydrateResourceIOWithKey,
|
|
30
32
|
isClassInstanceRef,
|
|
33
|
+
isEncryptedMarker,
|
|
31
34
|
isStreamId,
|
|
32
35
|
isStreamRef,
|
|
33
36
|
STREAM_REF_TYPE,
|
package/src/lib/hydration.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import {
|
|
10
10
|
extractClassName,
|
|
11
11
|
hydrateResourceIO as hydrateResourceIOGeneric,
|
|
12
|
+
isEncryptedData,
|
|
12
13
|
observabilityRevivers,
|
|
13
14
|
type Revivers,
|
|
14
15
|
} from '@workflow/core/serialization-format';
|
|
@@ -17,8 +18,10 @@ import {
|
|
|
17
18
|
export {
|
|
18
19
|
CLASS_INSTANCE_REF_TYPE,
|
|
19
20
|
ClassInstanceRef,
|
|
21
|
+
ENCRYPTED_PLACEHOLDER,
|
|
20
22
|
extractStreamIds,
|
|
21
23
|
isClassInstanceRef,
|
|
24
|
+
isEncryptedData,
|
|
22
25
|
isStreamId,
|
|
23
26
|
isStreamRef,
|
|
24
27
|
type Revivers,
|
|
@@ -142,5 +145,152 @@ function getRevivers(): Revivers {
|
|
|
142
145
|
* from the server before passing it to UI components.
|
|
143
146
|
*/
|
|
144
147
|
export function hydrateResourceIO<T>(resource: T): T {
|
|
145
|
-
|
|
148
|
+
const hydrated = hydrateResourceIOGeneric(
|
|
149
|
+
resource as any,
|
|
150
|
+
getRevivers()
|
|
151
|
+
) as T;
|
|
152
|
+
return replaceEncryptedWithMarkers(hydrated);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Encrypted data display markers
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export const ENCRYPTED_DISPLAY_NAME = 'Encrypted';
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create a display-friendly object for encrypted data.
|
|
163
|
+
*
|
|
164
|
+
* Uses the same named-constructor trick as the Instance reviver so that
|
|
165
|
+
* ObjectInspector renders the constructor name ("🔒 Encrypted") with no
|
|
166
|
+
* expandable children. The original encrypted bytes are stored in a
|
|
167
|
+
* non-enumerable property for later decryption.
|
|
168
|
+
*/
|
|
169
|
+
function createEncryptedMarker(data: Uint8Array): object {
|
|
170
|
+
// biome-ignore lint/complexity/useArrowFunction: arrow functions have no .prototype
|
|
171
|
+
const ctor = { [ENCRYPTED_DISPLAY_NAME]: function () {} }[
|
|
172
|
+
ENCRYPTED_DISPLAY_NAME
|
|
173
|
+
]!;
|
|
174
|
+
const obj = Object.create(ctor.prototype);
|
|
175
|
+
// Store original bytes for decryption, but non-enumerable so
|
|
176
|
+
// ObjectInspector doesn't show them as children
|
|
177
|
+
Object.defineProperty(obj, '__encryptedData', {
|
|
178
|
+
value: data,
|
|
179
|
+
enumerable: false,
|
|
180
|
+
configurable: false,
|
|
181
|
+
});
|
|
182
|
+
return obj;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Check if a value is an encrypted display marker */
|
|
186
|
+
export function isEncryptedMarker(value: unknown): boolean {
|
|
187
|
+
return (
|
|
188
|
+
value !== null &&
|
|
189
|
+
typeof value === 'object' &&
|
|
190
|
+
value.constructor?.name === ENCRYPTED_DISPLAY_NAME
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Post-process hydrated resource data: replace encrypted Uint8Array values
|
|
196
|
+
* with display-friendly marker objects in known data fields.
|
|
197
|
+
*/
|
|
198
|
+
function replaceEncryptedWithMarkers<T>(resource: T): T {
|
|
199
|
+
if (!resource || typeof resource !== 'object') return resource;
|
|
200
|
+
const r = resource as Record<string, unknown>;
|
|
201
|
+
const result = { ...r };
|
|
202
|
+
|
|
203
|
+
for (const key of ['input', 'output', 'metadata', 'error']) {
|
|
204
|
+
if (isEncryptedData(result[key])) {
|
|
205
|
+
result[key] = createEncryptedMarker(result[key] as Uint8Array);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (result.eventData && typeof result.eventData === 'object') {
|
|
210
|
+
const ed = { ...(result.eventData as Record<string, unknown>) };
|
|
211
|
+
for (const key of EVENT_DATA_SERIALIZED_FIELDS) {
|
|
212
|
+
if (isEncryptedData(ed[key])) {
|
|
213
|
+
ed[key] = createEncryptedMarker(ed[key] as Uint8Array);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
result.eventData = ed;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return result as T;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Known serialized subfields within eventData, matching hydrateEventData in core */
|
|
223
|
+
const EVENT_DATA_SERIALIZED_FIELDS = [
|
|
224
|
+
'result',
|
|
225
|
+
'input',
|
|
226
|
+
'output',
|
|
227
|
+
'metadata',
|
|
228
|
+
'payload',
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Hydrate resource data with decryption support.
|
|
233
|
+
*
|
|
234
|
+
* When a key is provided, encrypted fields are decrypted before hydration.
|
|
235
|
+
* This is the async version used when the user clicks "Decrypt" in the web UI.
|
|
236
|
+
*
|
|
237
|
+
* Handles both top-level fields (input, output, metadata) and nested
|
|
238
|
+
* eventData subfields (result, input, output, metadata, payload).
|
|
239
|
+
*/
|
|
240
|
+
export async function hydrateResourceIOWithKey<T>(
|
|
241
|
+
resource: T,
|
|
242
|
+
key: Uint8Array
|
|
243
|
+
): Promise<T> {
|
|
244
|
+
const { hydrateDataWithKey } = await import(
|
|
245
|
+
'@workflow/core/serialization-format'
|
|
246
|
+
);
|
|
247
|
+
const { importKey } = await import('@workflow/core/encryption');
|
|
248
|
+
const cryptoKey = await importKey(key);
|
|
249
|
+
const revivers = getRevivers();
|
|
250
|
+
|
|
251
|
+
/** Extract original encrypted bytes from a marker or raw Uint8Array, then decrypt + hydrate */
|
|
252
|
+
async function decryptField(
|
|
253
|
+
value: unknown,
|
|
254
|
+
rev: Revivers,
|
|
255
|
+
k: Awaited<ReturnType<typeof importKey>>
|
|
256
|
+
): Promise<unknown> {
|
|
257
|
+
// Already-hydrated: encrypted marker with stored bytes
|
|
258
|
+
if (isEncryptedMarker(value)) {
|
|
259
|
+
const raw = (value as any).__encryptedData as Uint8Array;
|
|
260
|
+
return hydrateDataWithKey(raw, rev, k);
|
|
261
|
+
}
|
|
262
|
+
// Raw encrypted Uint8Array (not yet hydrated)
|
|
263
|
+
if (value instanceof Uint8Array) {
|
|
264
|
+
return hydrateDataWithKey(value, rev, k);
|
|
265
|
+
}
|
|
266
|
+
// Not encrypted — return as-is
|
|
267
|
+
return value;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const r = resource as Record<string, unknown>;
|
|
271
|
+
const result = { ...r };
|
|
272
|
+
|
|
273
|
+
// Decrypt + hydrate top-level serialized fields (runs, steps, hooks)
|
|
274
|
+
for (const field of ['input', 'output', 'metadata', 'error']) {
|
|
275
|
+
if (field in result) {
|
|
276
|
+
result[field] = await decryptField(result[field], revivers, cryptoKey);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Decrypt + hydrate eventData subfields (events)
|
|
281
|
+
if (result.eventData && typeof result.eventData === 'object') {
|
|
282
|
+
const eventData = { ...(result.eventData as Record<string, unknown>) };
|
|
283
|
+
for (const field of EVENT_DATA_SERIALIZED_FIELDS) {
|
|
284
|
+
if (field in eventData) {
|
|
285
|
+
eventData[field] = await decryptField(
|
|
286
|
+
eventData[field],
|
|
287
|
+
revivers,
|
|
288
|
+
cryptoKey
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
result.eventData = eventData;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return result as T;
|
|
146
296
|
}
|