diff --git a/.changeset/o11y-run-ref-rendering.md b/.changeset/o11y-run-ref-rendering.md new file mode 100644 index 0000000000..5fe3ec63fa --- /dev/null +++ b/.changeset/o11y-run-ref-rendering.md @@ -0,0 +1,7 @@ +--- +"@workflow/core": patch +"@workflow/web-shared": patch +"@workflow/web": patch +--- + +Add clickable Run reference rendering in observability UI diff --git a/packages/cli/src/lib/inspect/hydration.ts b/packages/cli/src/lib/inspect/hydration.ts index ccae3b3ece..93d4528ffd 100644 --- a/packages/cli/src/lib/inspect/hydration.ts +++ b/packages/cli/src/lib/inspect/hydration.ts @@ -13,8 +13,10 @@ import { hydrateResourceIO as hydrateResourceIOGeneric, isEncryptedData, isExpiredStub, + isRunRef, observabilityRevivers, type Revivers, + serializedInstanceToRef, } from '@workflow/core/serialization-format'; import { parseClassName } from '@workflow/utils/parse-name'; import chalk from 'chalk'; @@ -206,12 +208,18 @@ export function getCLIRevivers(): Revivers { ...observabilityRevivers, // CLI-specific overrides for class instances with inspect.custom Class: (value) => ``, - Instance: (value) => - new CLIClassInstanceRef( + Instance: (value) => { + // Run instances are rendered as RunRef for clickable rendering + const runRef = serializedInstanceToRef(value); + if (isRunRef(runRef)) { + return runRef; + } + return new CLIClassInstanceRef( extractClassName(value.classId), value.classId, value.data - ), + ); + }, Set: (value) => new Set(value), URL: (value) => new URL(value), URLSearchParams: (value) => new URLSearchParams(value === '.' ? '' : value), diff --git a/packages/core/src/serialization-format.ts b/packages/core/src/serialization-format.ts index f74b13eccb..23de35f17e 100644 --- a/packages/core/src/serialization-format.ts +++ b/packages/core/src/serialization-format.ts @@ -243,6 +243,32 @@ export interface StreamRef { streamId: string; } +/** Marker for Run reference objects rendered as links in the UI */ +export const RUN_REF_TYPE = '__workflow_run_ref__'; + +/** A Run reference for UI display */ +export interface RunRef { + __type: typeof RUN_REF_TYPE; + runId: string; +} + +/** Check if a value is a RunRef object */ +export const isRunRef = (value: unknown): value is RunRef => { + return ( + value !== null && + typeof value === 'object' && + '__type' in value && + value.__type === RUN_REF_TYPE && + 'runId' in value && + typeof value.runId === 'string' + ); +}; + +/** Convert a serialized Run value to a RunRef for display */ +export const serializedRunToRunRef = (value: { runId: string }): RunRef => { + return { __type: RUN_REF_TYPE, runId: value.runId }; +}; + /** Marker for custom class instance references */ export const CLASS_INSTANCE_REF_TYPE = '__workflow_class_instance_ref__'; @@ -347,16 +373,23 @@ export const extractClassName = (classId: string): string => { return parts[parts.length - 1] || classId; }; -/** Convert a serialized class instance to a ClassInstanceRef for display */ +/** Convert a serialized class instance to a ClassInstanceRef for display. + * Run instances are special-cased to a RunRef for clickable rendering. */ export const serializedInstanceToRef = (value: { classId: string; data: unknown; -}): ClassInstanceRef => { - return new ClassInstanceRef( - extractClassName(value.classId), - value.classId, - value.data - ); +}): ClassInstanceRef | RunRef => { + const className = extractClassName(value.classId); + if ( + className === 'Run' && + value.data !== null && + typeof value.data === 'object' && + 'runId' in value.data && + typeof (value.data as { runId: unknown }).runId === 'string' + ) { + return serializedRunToRunRef(value.data as { runId: string }); + } + return new ClassInstanceRef(className, value.classId, value.data); }; /** Convert a serialized class reference to a display string */ diff --git a/packages/web-shared/src/components/run-trace-view.tsx b/packages/web-shared/src/components/run-trace-view.tsx index 1ebfcf2ad9..be5d21380d 100644 --- a/packages/web-shared/src/components/run-trace-view.tsx +++ b/packages/web-shared/src/components/run-trace-view.tsx @@ -24,6 +24,7 @@ interface RunTraceViewProps { ) => Promise; onCancelRun?: (runId: string) => Promise; onStreamClick?: (streamId: string) => void; + onRunClick?: (runId: string) => void; onSpanSelect?: (info: SpanSelectionInfo) => void; onLoadMoreSpans?: () => void | Promise; hasMoreSpans?: boolean; @@ -42,6 +43,7 @@ export function RunTraceView({ onResolveHook, onCancelRun, onStreamClick, + onRunClick, onSpanSelect, onLoadMoreSpans, hasMoreSpans, @@ -71,6 +73,7 @@ export function RunTraceView({ onResolveHook={onResolveHook} onCancelRun={onCancelRun} onStreamClick={onStreamClick} + onRunClick={onRunClick} onSpanSelect={onSpanSelect} onLoadMoreSpans={onLoadMoreSpans} hasMoreSpans={hasMoreSpans} diff --git a/packages/web-shared/src/components/sidebar/attribute-panel.tsx b/packages/web-shared/src/components/sidebar/attribute-panel.tsx index 57ae04d2f5..5a728c53c2 100644 --- a/packages/web-shared/src/components/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/components/sidebar/attribute-panel.tsx @@ -4,19 +4,23 @@ import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name'; import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; import type { ModelMessage } from 'ai'; import { Lock } from 'lucide-react'; -import { Spinner } from '../ui/spinner'; import type { KeyboardEvent, ReactNode } from 'react'; import { useCallback, useContext, useMemo, useState } from 'react'; import { isEncryptedMarker, isExpiredMarker } from '../../lib/hydration'; import { useToast } from '../../lib/toast'; import { extractConversation, isDoStreamStep } from '../../lib/utils'; -import { DecryptClickContext, StreamClickContext } from '../ui/data-inspector'; +import { + DecryptClickContext, + RunClickContext, + StreamClickContext, +} from '../ui/data-inspector'; import { ErrorCard } from '../ui/error-card'; import { ErrorStackBlock, isStructuredErrorWithStack, } from '../ui/error-stack-block'; import { Skeleton } from '../ui/skeleton'; +import { Spinner } from '../ui/spinner'; import { TimestampTooltip } from '../ui/timestamp-tooltip'; import { ConversationView } from './conversation-view'; import { CopyableDataBlock } from './copyable-data-block'; @@ -405,7 +409,8 @@ const attributeToDisplayFn: Record< workflowName: (value: unknown) => parseWorkflowName(String(value))?.shortName ?? '?', moduleSpecifier: (value: unknown) => getModuleSpecifierFromName(value), - stepName: (value: unknown) => parseStepName(String(value))?.shortName ?? '?', + stepName: (value: unknown) => + parseStepName(String(value))?.shortName ?? String(value), // IDs runId: (value: unknown) => String(value), stepId: (value: unknown) => String(value), @@ -717,6 +722,7 @@ export const AttributePanel = ({ error, expiredAt, onStreamClick, + onRunClick, onDecrypt, isDecrypting = false, resource, @@ -728,6 +734,8 @@ export const AttributePanel = ({ expiredAt?: string | Date; /** Callback when a stream reference is clicked */ onStreamClick?: (streamId: string) => void; + /** Callback when a run reference is clicked */ + onRunClick?: (runId: string) => void; /** Callback when an encrypted marker is clicked (triggers decryption) */ onDecrypt?: () => void; /** Whether decryption is currently in progress */ @@ -831,122 +839,126 @@ export const AttributePanel = ({ }, []); return ( - - -
- {/* Basic attributes in a vertical layout with border */} - {visibleBasicAttributes.length > 0 && ( -
- {orderedBasicAttributes.map((attribute, index) => { - const displayValue = attributeToDisplayFn[ - attribute as keyof typeof attributeToDisplayFn - ]?.(displayData[attribute as keyof typeof displayData]); - const isModuleSpecifier = attribute === 'moduleSpecifier'; - const moduleSpecifierValue = - typeof displayValue === 'string' - ? displayValue - : String(displayValue ?? displayData.moduleSpecifier ?? ''); - const shouldCapitalizeLabel = - attribute !== 'workflowCoreVersion'; - const showResumeAtSkeleton = - isLoading && resource === 'sleep' && !displayData.resumeAt; - const showDivider = - index < orderedBasicAttributes.length - 1 || - showResumeAtSkeleton; - - return ( -
+ + + +
+ {/* Basic attributes in a vertical layout with border */} + {visibleBasicAttributes.length > 0 && ( +
+ {orderedBasicAttributes.map((attribute, index) => { + const displayValue = attributeToDisplayFn[ + attribute as keyof typeof attributeToDisplayFn + ]?.(displayData[attribute as keyof typeof displayData]); + const isModuleSpecifier = attribute === 'moduleSpecifier'; + const moduleSpecifierValue = + typeof displayValue === 'string' + ? displayValue + : String( + displayValue ?? displayData.moduleSpecifier ?? '' + ); + const shouldCapitalizeLabel = + attribute !== 'workflowCoreVersion'; + const showResumeAtSkeleton = + isLoading && resource === 'sleep' && !displayData.resumeAt; + const showDivider = + index < orderedBasicAttributes.length - 1 || + showResumeAtSkeleton; + + return ( +
+
+ + {getAttributeDisplayName(attribute)} + + {isModuleSpecifier ? ( + + ) : ( + + {displayValue} + + )} +
+ {showDivider ? ( +
+ ) : null} +
+ ); + })} + {isLoading && resource === 'sleep' && !displayData.resumeAt && ( +
- {getAttributeDisplayName(attribute)} + resumeAt - {isModuleSpecifier ? ( - - ) : ( - - {displayValue} - - )} +
- {showDivider ? ( -
- ) : null}
- ); - })} - {isLoading && resource === 'sleep' && !displayData.resumeAt && ( -
-
- - resumeAt - - -
-
- )} -
- )} - {error ? ( - - ) : hasExpired ? ( - - ) : ( - <> - {resolvedAttributes.map((attribute) => ( - - ))} - - )} -
- - + )} +
+ )} + {error ? ( + + ) : hasExpired ? ( + + ) : ( + <> + {resolvedAttributes.map((attribute) => ( + + ))} + + )} +
+
+
+
); }; diff --git a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx index 833d8b342b..704fb46a0b 100644 --- a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx +++ b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx @@ -4,8 +4,8 @@ import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; import clsx from 'clsx'; import { Send, Zap } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useToast } from '../../lib/toast'; import { isEncryptedMarker } from '../../lib/hydration'; +import { useToast } from '../../lib/toast'; import { DecryptClickContext } from '../ui/data-inspector'; import { DecryptButton } from '../ui/decrypt-button'; import { AttributePanel } from './attribute-panel'; @@ -57,6 +57,7 @@ export interface SelectedSpanInfo { export function EntityDetailPanel({ run, onStreamClick, + onRunClick, spanDetailData, spanDetailError, spanDetailLoading, @@ -73,6 +74,8 @@ export function EntityDetailPanel({ run: WorkflowRun; /** Callback when a stream reference is clicked */ onStreamClick?: (streamId: string) => void; + /** Callback when a run reference is clicked */ + onRunClick?: (runId: string) => void; /** Pre-fetched span detail data for the selected span. */ spanDetailData: WorkflowRun | Step | Hook | Event | null; /** Error from external span detail fetch. */ @@ -489,6 +492,7 @@ export function EntityDetailPanel({ isLoading={loading} error={error ?? undefined} onStreamClick={onStreamClick} + onRunClick={onRunClick} onDecrypt={onDecrypt} isDecrypting={isDecrypting} resource={resource} diff --git a/packages/web-shared/src/components/ui/data-inspector.tsx b/packages/web-shared/src/components/ui/data-inspector.tsx index 4fc236aaf1..b40ea91a27 100644 --- a/packages/web-shared/src/components/ui/data-inspector.tsx +++ b/packages/web-shared/src/components/ui/data-inspector.tsx @@ -9,8 +9,14 @@ */ import { Lock } from 'lucide-react'; -import { createContext, useContext, useEffect, useRef, useState } from 'react'; -import { Spinner } from './spinner'; +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { ObjectInspector, ObjectLabel, @@ -27,6 +33,7 @@ import { inspectorThemeExtendedLight, inspectorThemeLight, } from './inspector-theme'; +import { Spinner } from './spinner'; // --------------------------------------------------------------------------- // StreamRef / ClassInstanceRef type detection @@ -35,19 +42,29 @@ import { const STREAM_REF_TYPE = '__workflow_stream_ref__'; const CLASS_INSTANCE_REF_TYPE = '__workflow_class_instance_ref__'; +const RUN_REF_TYPE = '__workflow_run_ref__'; interface StreamRef { __type: typeof STREAM_REF_TYPE; streamId: string; } +interface RunRef { + __type: typeof RUN_REF_TYPE; + runId: string; +} + function isStreamRef(value: unknown): value is StreamRef { - return ( - value !== null && - typeof value === 'object' && - '__type' in value && - (value as Record).__type === STREAM_REF_TYPE - ); + if (value === null || typeof value !== 'object') return false; + // Check both enumerable and non-enumerable __type (opaque refs use non-enumerable) + const desc = Object.getOwnPropertyDescriptor(value, '__type'); + return desc?.value === STREAM_REF_TYPE; +} + +function isRunRef(value: unknown): value is RunRef { + if (value === null || typeof value !== 'object') return false; + const desc = Object.getOwnPropertyDescriptor(value, '__type'); + return desc?.value === RUN_REF_TYPE; } function isClassInstanceRef(value: unknown): value is { @@ -89,6 +106,10 @@ export const DecryptClickContext = createContext< DecryptClickContextValue | undefined >(undefined); +export const RunClickContext = createContext< + ((runId: string) => void) | undefined +>(undefined); + function EncryptedInlineLabel() { const ctx = useContext(DecryptClickContext); if (ctx) { @@ -137,19 +158,24 @@ function EncryptedInlineLabel() { ); } - function StreamRefInline({ streamRef }: { streamRef: StreamRef }) { const onStreamClick = useContext(StreamClickContext); + const [hovered, setHovered] = useState(false); return ( + ); +} + // --------------------------------------------------------------------------- // Extended theme context (for colors react-inspector doesn't support natively) // --------------------------------------------------------------------------- @@ -225,6 +278,17 @@ function NodeRenderer({ ); } + // RunRef → inline clickable badge linking to the target run + if (isRunRef(data)) { + return ( + + {name != null && } + {name != null && : } + + + ); + } + // ClassInstanceRef → show className as type, data as the inspectable value if (isClassInstanceRef(data)) { if (depth === 0) { @@ -275,6 +339,41 @@ function NodeRenderer({ // Public component // --------------------------------------------------------------------------- +/** + * Create a non-expandable wrapper that carries ref data as non-enumerable + * properties. ObjectInspector won't render children for objects with no + * enumerable keys, but our NodeRenderer can still detect them. + */ +function makeOpaqueRef(ref: Record): unknown { + const opaque = Object.create(null); + for (const [key, value] of Object.entries(ref)) { + Object.defineProperty(opaque, key, { value, enumerable: false }); + } + return opaque; +} + +/** + * Recursively walk data and replace RunRef/StreamRef objects with + * non-expandable versions so ObjectInspector doesn't show their internals. + * Only recurses into plain objects and arrays to avoid stripping class + * instances (Date, Error, Map, Set, URL, Headers, etc.) that have their + * own rendering in NodeRenderer. + */ +function collapseRefs(data: unknown): unknown { + if (data === null || typeof data !== 'object') return data; + if (isRunRef(data) || isStreamRef(data)) + return makeOpaqueRef(data as unknown as Record); + if (Array.isArray(data)) return data.map(collapseRefs); + // Only recurse into plain objects — leave class instances untouched + const proto = Object.getPrototypeOf(data); + if (proto !== Object.prototype && proto !== null) return data; + const result: Record = {}; + for (const [key, value] of Object.entries(data)) { + result[key] = collapseRefs(value); + } + return result; +} + export interface DataInspectorProps { /** The data to inspect */ data: unknown; @@ -284,6 +383,8 @@ export interface DataInspectorProps { name?: string; /** Callback when a stream reference is clicked */ onStreamClick?: (streamId: string) => void; + /** Callback when a run reference is clicked */ + onRunClick?: (runId: string) => void; /** Callback when an encrypted marker is clicked (triggers decryption) */ onDecrypt?: () => void; /** Whether decryption is currently in progress */ @@ -295,10 +396,12 @@ export function DataInspector({ expandLevel = 2, name, onStreamClick, + onRunClick, onDecrypt, isDecrypting = false, }: DataInspectorProps) { - const stableData = useStableInspectorData(data); + const collapsedData = useMemo(() => collapseRefs(data), [data]); + const stableData = useStableInspectorData(collapsedData); const [initialExpandLevel, setInitialExpandLevel] = useState(expandLevel); const isDark = useDarkMode(); const extendedTheme = isDark @@ -334,7 +437,13 @@ export function DataInspector({ ); } - + if (onRunClick) { + wrapped = ( + + {wrapped} + + ); + } if (onDecrypt) { wrapped = ( diff --git a/packages/web-shared/src/components/workflow-trace-view.tsx b/packages/web-shared/src/components/workflow-trace-view.tsx index 34e6647032..e7046aad02 100644 --- a/packages/web-shared/src/components/workflow-trace-view.tsx +++ b/packages/web-shared/src/components/workflow-trace-view.tsx @@ -767,6 +767,7 @@ export const WorkflowTraceViewer = ({ onResolveHook, onCancelRun, onStreamClick, + onRunClick, onSpanSelect, onLoadEventData, onLoadMoreSpans, @@ -797,6 +798,8 @@ export const WorkflowTraceViewer = ({ onCancelRun?: (runId: string) => Promise; /** Callback when a stream reference is clicked in the detail panel */ onStreamClick?: (streamId: string) => void; + /** Callback when a run reference is clicked in the detail panel */ + onRunClick?: (runId: string) => void; /** Callback when a span is selected. */ onSpanSelect?: (info: SpanSelectionInfo) => void; /** Callback to load event data for a specific event (lazy loading in sidebar) */ @@ -906,6 +909,12 @@ export const WorkflowTraceViewer = ({ }); }, [events, selectedSpan?.spanId]); + // Reset selected span when navigating to a different run + useEffect(() => { + setSelectedSpan(null); + setDeselectTrigger((n) => n + 1); + }, [run?.runId]); + const handleClose = useCallback(() => { setSelectedSpan(null); setDeselectTrigger((n) => n + 1); @@ -1143,6 +1152,7 @@ export const WorkflowTraceViewer = ({ ``, Instance: (value) => { + // Run instances are rendered as clickable RunRef badges + const runRef = serializedInstanceToRef(value); + if (isRunRef(runRef)) { + return runRef; + } const className = extractClassName(value.classId); const data = value.data; const props = diff --git a/packages/web/app/components/run-detail-view.tsx b/packages/web/app/components/run-detail-view.tsx index 15039e0552..0493e6138f 100644 --- a/packages/web/app/components/run-detail-view.tsx +++ b/packages/web/app/components/run-detail-view.tsx @@ -269,6 +269,15 @@ export function RunDetailView({ [updateSearchParams] ); + const handleRunRefClick = useCallback( + (targetRunId: string) => { + // Navigate to the target run with a clean URL (no search params) + // so the sidebar panel resets + navigate(`/run/${encodeURIComponent(targetRunId)}`); + }, + [navigate] + ); + const handleWakeUpSleep = useCallback( async (runId: string, correlationId: string) => { return wakeUpRun(env, runId, { correlationIds: [correlationId] }); @@ -752,6 +761,7 @@ export function RunDetailView({ spanDetailError={spanDetailError} onSpanSelect={handleSpanSelect} onStreamClick={handleStreamClick} + onRunClick={handleRunRefClick} onWakeUpSleep={handleWakeUpSleep} onResolveHook={handleResolveHook} onLoadEventData={handleLoadSidebarEventData}