@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.
Files changed (39) hide show
  1. package/dist/components/event-list-view.d.ts +3 -1
  2. package/dist/components/event-list-view.d.ts.map +1 -1
  3. package/dist/components/event-list-view.js +27 -3
  4. package/dist/components/event-list-view.js.map +1 -1
  5. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  6. package/dist/components/sidebar/attribute-panel.js +63 -27
  7. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  8. package/dist/components/sidebar/entity-detail-panel.d.ts +3 -1
  9. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  10. package/dist/components/sidebar/entity-detail-panel.js +2 -2
  11. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  12. package/dist/components/sidebar/events-list.d.ts +3 -1
  13. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  14. package/dist/components/sidebar/events-list.js +22 -15
  15. package/dist/components/sidebar/events-list.js.map +1 -1
  16. package/dist/components/ui/data-inspector.d.ts.map +1 -1
  17. package/dist/components/ui/data-inspector.js +18 -1
  18. package/dist/components/ui/data-inspector.js.map +1 -1
  19. package/dist/components/workflow-trace-view.d.ts +3 -1
  20. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  21. package/dist/components/workflow-trace-view.js +2 -2
  22. package/dist/components/workflow-trace-view.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/lib/hydration.d.ts +14 -1
  28. package/dist/lib/hydration.d.ts.map +1 -1
  29. package/dist/lib/hydration.js +116 -3
  30. package/dist/lib/hydration.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/components/event-list-view.tsx +31 -0
  33. package/src/components/sidebar/attribute-panel.tsx +78 -27
  34. package/src/components/sidebar/entity-detail-panel.tsx +4 -0
  35. package/src/components/sidebar/events-list.tsx +27 -11
  36. package/src/components/ui/data-inspector.tsx +35 -1
  37. package/src/components/workflow-trace-view.tsx +4 -0
  38. package/src/index.ts +3 -0
  39. 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.57",
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.1.0-beta.62",
55
+ "@workflow/core": "4.2.0-beta.64",
56
56
  "@workflow/utils": "4.1.0-beta.13",
57
- "@workflow/world": "4.1.0-beta.8"
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
- export const localMillisecondTime = (value: unknown): string => {
266
- let date: Date;
287
+ const parseDateValue = (value: unknown): Date | null => {
288
+ if (value == null) {
289
+ return null;
290
+ }
267
291
  if (value instanceof Date) {
268
- date = value;
269
- } else if (typeof value === 'number') {
270
- date = new Date(value);
271
- } else if (typeof value === 'string') {
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
- // e.g. 12/17/2025, 9:08:55.182 AM
278
- return date.toLocaleString(undefined, {
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: localMillisecondTime,
327
- startedAt: localMillisecondTime,
328
- updatedAt: localMillisecondTime,
329
- completedAt: localMillisecondTime,
330
- expiredAt: localMillisecondTime,
331
- retryAfter: localMillisecondTime,
332
- resumeAt: localMillisecondTime,
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
- resolvedAttributes.map((attribute) => (
785
- <AttributeBlock
786
- isLoading={isLoading}
787
- key={attribute}
788
- attribute={attribute}
789
- value={displayData[attribute as keyof typeof displayData]}
790
- context={displayContext}
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 handleExpand = useCallback(async () => {
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
- existingData,
71
- loadedData,
72
- isLoading,
73
- onLoadEventData,
74
- event.correlationId,
75
- event.eventId,
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.prototype.hasOwnProperty.call(b, key)) return false;
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,
@@ -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
- return hydrateResourceIOGeneric(resource as any, getRevivers()) as T;
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
  }