From 80a197b8f375d32c0522f6817253a4df484716e1 Mon Sep 17 00:00:00 2001 From: zzheng Date: Sun, 6 Sep 2026 20:28:55 -0700 Subject: [PATCH 1/3] Derive reader-facing labels from the graph, without new Dex concepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additions to the Flow Definition renderer, each derived from vocabulary Dex already has, so a Flow gets them without adopting a convention or changing the analyser: stepRole() gate | batch | work. A Step whose WaitFor holds a Channel condition is a gate: in Dex that already means an actor outside the Flow must publish before it proceeds. Naming it adds no concept, it promotes one that was in the graph all along. A SubFlow condition makes it a batch; Timers alone do not. waitSentence() the wait as one plain sentence. Channel and Timer names stay verbatim because they are the operator's handles; what goes is the scaffolding around them — anyOf, skipWaitImmediately, and the `.for 1` suffix a reader has no use for. displayName() reads the FDG schema's existing per-node `metadata`, falling back to the type name. A Step type is part of the durable contract of an open execution, so a display name sits in front of it and never replaces it: the card keeps showing the type whenever a display name covers it. Gates are coloured and carry a SOMEONE MUST ACT badge, so the reader can see where a human is required without reading any label. On a real 12-Step Flow this finds all four gates and both fan-out Steps from the graph alone. Deliberately absent: whether a model or a script does the work. No Dex concept answers that, and inventing one here would put an application fact into the renderer's vocabulary — `metadata` is the place for it. --- .../src/FlowDefinitionGraph.tsx | 107 ++++- .../src/definitionLayout.ts | 375 ++++++++++++++++-- .../flow-definition-renderer/src/index.ts | 9 + .../flow-definition-renderer/src/styles.css | 109 +++++ web/app/rendering/definitionLayout.test.ts | 176 ++++++++ 5 files changed, 747 insertions(+), 29 deletions(-) diff --git a/packages/flow-definition-renderer/src/FlowDefinitionGraph.tsx b/packages/flow-definition-renderer/src/FlowDefinitionGraph.tsx index 471e3ef4e..0bbe56a81 100644 --- a/packages/flow-definition-renderer/src/FlowDefinitionGraph.tsx +++ b/packages/flow-definition-renderer/src/FlowDefinitionGraph.tsx @@ -27,6 +27,7 @@ import { import type { FlowDefinitionGraph, FlowDefinitionNode, SourceSpan } from './types'; import { buildDefinitionScene, + recoveryHubSteps, filterDefinitionEdgesForSelection, isResourceRelation, type DefinitionEdgeData, @@ -34,11 +35,14 @@ import { type DefinitionNodeData, type DefinitionSelectionDetail, type DefinitionVisibility, + type RecoveryLayout, } from './definitionLayout'; const layerLabels: Array<[DefinitionLayer | 'diagnostics', string]> = [ ['control', 'Control flow'], + ['recovery', 'Recovery paths'], ['waits', 'WaitFor'], + ['decisions', 'Decisions'], ['rpcs', 'RPC'], ['channels', 'Channels'], ['attributes', 'Attributes'], @@ -49,7 +53,9 @@ const layerLabels: Array<[DefinitionLayer | 'diagnostics', string]> = [ const defaultVisibility: DefinitionVisibility & { diagnostics: boolean } = { control: true, + recovery: true, waits: true, + decisions: true, rpcs: true, attributes: true, channels: true, @@ -68,6 +74,7 @@ const nodeTypes: NodeTypes = { definitionStep: StepNode, definitionStream: StreamNode, definitionSubFlow: SubFlowNode, + definitionRecovery: RecoveryNode, definitionTimeout: TimeoutNode, definitionUnknown: UnknownNode, definitionWait: WaitNode, @@ -83,17 +90,32 @@ export function FlowDefinitionGraphView({ graph: FlowDefinitionGraph; }) { const [visibility, setVisibility] = useState(defaultVisibility); + // ?recovery=steps|rail|table|traced — see RecoveryLayout. Review scaffolding: it lets + // the three candidate placements be compared side by side on one build. + const recoveryLayout = useMemo(() => { + // Guarded: this component is also rendered to static markup in tests, where there is + // no window to read a query string from. + if (typeof window === 'undefined') return 'steps'; + const requested = new URLSearchParams(window.location.search).get('recovery'); + return requested === 'rail' || requested === 'table' || requested === 'traced' + ? requested + : 'steps'; + }, []); const [selectedNodeID, setSelectedNodeID] = useState(''); const [selectedEdgeID, setSelectedEdgeID] = useState(''); const [isMiniMapExpanded, setIsMiniMapExpanded] = useState(false); const [flowInstance, setFlowInstance] = useState(null); + const tracedHubs = useMemo( + () => (recoveryLayout === 'traced' ? recoveryHubSteps(graph) : new Set()), + [graph, recoveryLayout], + ); const scene = useMemo( - () => buildDefinitionScene(graph, visibility), - [graph, visibility], + () => buildDefinitionScene(graph, visibility, { recoveryLayout }), + [graph, visibility, recoveryLayout], ); const selectedNode = scene.nodes.find((node) => node.id === selectedNodeID); const visibleEdges = useMemo( - () => filterDefinitionEdgesForSelection(scene.edges, graph.nodes, selectedNodeID), + () => filterDefinitionEdgesForSelection(scene.edges, graph.nodes, selectedNodeID, tracedHubs), [graph.nodes, scene.edges, selectedNodeID], ); const selectedEdge = visibleEdges.find((edge) => edge.id === selectedEdgeID); @@ -132,6 +154,9 @@ export function FlowDefinitionGraphView({

{graph.source.language} · {graph.source.path}

+ {recoveryLayout !== 'steps' ? ( + recovery: {recoveryLayout} + ) : null}
{layerLabels.map(([layer, label]) => (
{recoveryLayout !== 'steps' ? ( recovery: {recoveryLayout} @@ -243,6 +286,9 @@ export function FlowDefinitionGraphView({ {selectionKind(selectedNode.data)} {selectionName(selectedNode.data)} + {selectionTypeName(selectedNode.data) && ( + {selectedNode.data.kind === 'step' ? 'Step type ' : ''}{selectionTypeName(selectedNode.data)} + )} {selectedNode.id} {selectedNode.data.sourceTitle && {selectedNode.data.sourceTitle}} @@ -301,12 +347,13 @@ function StepNode({ data }: NodeProps) {
{shown} {definition.start && START} - {role === 'gate' && SOMEONE MUST ACT}
- {/* The type name stays on the card whenever a display name covers it: it is the - durable identity, the thing you pass to dexcli and name in a resume. */} - {shown !== definition.name && ( -
{definition.name}
+ {role === 'gate' && SOMEONE MUST ACT} + {(nodeData.inputLabel || nodeData.outputLabel) && ( +
+ {nodeData.inputLabel && in {nodeData.inputLabel}} + {nodeData.outputLabel && out {nodeData.outputLabel}} +
)} {nodeData.waitSentence ? (
{nodeData.waitSentence}
@@ -673,7 +720,14 @@ function selectionKind(data: DefinitionNodeData): string { function selectionName(data: DefinitionNodeData): string { if (data.kind === 'attributes') return `${data.definitions?.length ?? 0} definitions`; - return data.definition?.name ?? data.displayName ?? data.kind; + return data.displayName ?? data.definition?.name ?? data.kind; +} + +/** The Step type name, only when a display name is standing in front of it. */ +function selectionTypeName(data: DefinitionNodeData): string | undefined { + const name = data.definition?.name; + if (!name || name === data.displayName) return undefined; + return name; } function edgeKindLabel(edge: Edge): string { diff --git a/packages/flow-definition-renderer/src/definitionLayout.ts b/packages/flow-definition-renderer/src/definitionLayout.ts index 7f5d72ef1..4fe2b061f 100644 --- a/packages/flow-definition-renderer/src/definitionLayout.ts +++ b/packages/flow-definition-renderer/src/definitionLayout.ts @@ -36,6 +36,8 @@ export interface DefinitionNodeData extends Record { displayName?: string; role?: StepRole; waitSentence?: string; + inputLabel?: string; + outputLabel?: string; relatedEdges?: FlowDefinitionEdge[]; nameByID?: Record; selectionDetails?: DefinitionSelectionDetail[]; @@ -88,6 +90,10 @@ const branchGap = 42; const cardWidth = 288; const dispatchSize = 58; const stepTopologyRankSeparation = 168; +// Collapsed cards are a fraction of the height of a card holding WaitFor shapes and +// decision grids, so the gap sized for those leaves the column mostly empty and forces +// the viewport to zoom the text away. +const collapsedTopologyRankSeparation = 76; /** * Steps that exist only to absorb a failure, derived from the graph rather than named. @@ -222,6 +228,13 @@ export function stepRole(graph: FlowDefinitionGraph, stepID: string): StepRole { * suffix a reader has no use for. */ export function waitSentence(graph: FlowDefinitionGraph, stepID: string): string { + const step = graph.nodes.find((node) => node.id === stepID); + const authored = step ? metadataText(step, 'waitLabel') : ''; + if (authored) return authored; + return derivedWaitSentence(graph, stepID); +} + +function derivedWaitSentence(graph: FlowDefinitionGraph, stepID: string): string { const waits = graph.nodes.filter((node) => node.kind === 'wait' && node.parentId === stepID); const real = waits.filter((node) => node.wait?.type !== 'skipWaitImmediately'); if (real.length === 0) return ''; @@ -257,6 +270,12 @@ export function waitSentence(graph: FlowDefinitionGraph, stepID: string): string * contract of an open execution, so a display name must never replace it — only sit in * front of it. */ +/** One string out of the schema's per-node `metadata`, or empty. */ +export function metadataText(node: FlowDefinitionNode, key: string): string { + const value = (node.metadata as Record | undefined)?.[key]; + return typeof value === 'string' ? value.trim() : ''; +} + export function displayName(node: FlowDefinitionNode): string { const provided = (node.metadata as { displayName?: unknown } | undefined)?.displayName; return typeof provided === 'string' && provided.trim() !== '' ? provided : node.name; @@ -338,6 +357,8 @@ export function buildDefinitionScene( displayName: displayName(step), role: stepRole(graph, step.id), waitSentence: waitSentence(graph, step.id), + inputLabel: metadataText(step, 'inputLabel'), + outputLabel: metadataText(step, 'outputLabel'), sourceTitle: sourceTitle(step.span), }, } satisfies Node; @@ -537,9 +558,9 @@ function layoutStep( (group) => decisionDimensions(group, graph.edges, nameByID), ); const unknownSection = { height: unknownDefinitions.length * 82, width: 224 }; - const contentWidth = Math.max(268, waitSection.width, decisionSection.width, unknownSection.width); + const contentWidth = Math.max(336, waitSection.width, decisionSection.width, unknownSection.width); const width = contentWidth + stepGap * 2; - let cursorTop = stepHeaderHeight(step.name, width); + let cursorTop = stepHeaderHeight(step, width, waitSentence(graph, step.id), stepRole(graph, step.id)); const children: Array> = []; if (waitDefinitions.length > 0) { const placed = placeSection(step.id, waitGroups, cursorTop, contentWidth, 'wait'); @@ -564,7 +585,7 @@ function layoutStep( } return { children, - dimensions: { height: Math.max(154, cursorTop), width }, + dimensions: { height: Math.max(72, cursorTop), width }, }; } @@ -759,11 +780,14 @@ function layoutStepTopology( definitionsByID: Map, ): Map { if (steps.length === 0) return new Map(); + const rankSeparation = [...stepLayouts.values()].some((layout) => layout.children.length > 0) + ? stepTopologyRankSeparation + : collapsedTopologyRankSeparation; const dagreGraph = new dagre.graphlib.Graph(); dagreGraph.setDefaultEdgeLabel(() => ({})); dagreGraph.setGraph({ rankdir: 'TB', - ranksep: stepTopologyRankSeparation, + ranksep: rankSeparation, nodesep: 104, marginx: 0, marginy: 0, @@ -795,7 +819,7 @@ function layoutStepTopology( x: position.x - minimumX, y: position.y - minimumY, }])); - return placeStartStepFirst(graph.flow.startStepId, steps, stepLayouts, positions); + return placeStartStepFirst(graph.flow.startStepId, steps, stepLayouts, positions, rankSeparation); } function placeStartStepFirst( @@ -803,6 +827,7 @@ function placeStartStepFirst( steps: FlowDefinitionNode[], stepLayouts: Map, positions: Map, + rankSeparation: number, ): Map { if (!startStepID) return positions; const startPosition = positions.get(startStepID); @@ -814,7 +839,7 @@ function placeStartStepFirst( const position = positions.get(step.id)!; return position.x + stepLayouts.get(step.id)!.dimensions.width; })); - const verticalOffset = startDimensions.height + stepTopologyRankSeparation; + const verticalOffset = startDimensions.height + rankSeparation; const reordered = new Map([...positions].map(([stepID, position]) => [stepID, stepID === startStepID ? { x: Math.max(0, (topologyWidth - startDimensions.width) / 2), y: 0 } : { x: position.x, y: position.y + verticalOffset }])); @@ -1263,9 +1288,41 @@ function isDashed(kind: string): boolean { return kind.startsWith('resource_') || kind === 'wait_condition' || kind === 'subflow'; } -function stepHeaderHeight(name: string, width: number): number { - const availableCharacters = Math.max(24, Math.floor((width - 112) / 8)); - return Math.max(52, 28 + wrappedLineCount(name, availableCharacters) * 20); +/** + * Height of everything the Step card draws above its children: the shown name, the in/out + * pair, and the wait sentence. The per-line figures track styles.css; when a rule there + * changes font size or line height, change the matching number here. + */ +function stepHeaderHeight( + node: FlowDefinitionNode, + width: number, + sentence: string, + role: StepRole, +): number { + // .definition-step-frame padding-top. + let height = 12; + // .definition-step-title strong: 17px on a 21px line, max-width calc(100% - 60px). + const titleCharacters = Math.max(18, Math.floor((width - 88) / 9)); + height += wrappedLineCount(displayName(node), titleCharacters) * 21; + // .definition-step-actor, which only a gate draws. + if (role === 'gate') height += 21; + // .definition-step-io: 9px monospace on a 12px line, one line each for `in` and `out`. + const ioCharacters = Math.max(24, Math.floor((width - 56) / 5.4)); + const inputLines = wrappedLineCountOrZero(metadataText(node, 'inputLabel'), ioCharacters); + const outputLines = wrappedLineCountOrZero(metadataText(node, 'outputLabel'), ioCharacters); + if (inputLines + outputLines > 0) height += 4 + (inputLines + outputLines) * 12; + // .definition-step-wait: 9.5px, line-height 1.4, under a dashed rule. + if (sentence) { + const waitCharacters = Math.max(28, Math.floor((width - 56) / 4.8)); + height += 11 + wrappedLineCount(sentence, waitCharacters) * 14; + } + // .definition-step-frame padding-bottom. + return height + 14; +} + +/** Like wrappedLineCount, but an absent string occupies no lines at all. */ +function wrappedLineCountOrZero(value: string, charactersPerLine: number): number { + return value ? wrappedLineCount(value, charactersPerLine) : 0; } function wrappedLineCount(value: string, charactersPerLine: number): number { diff --git a/packages/flow-definition-renderer/src/styles.css b/packages/flow-definition-renderer/src/styles.css index c030b9895..603908714 100644 --- a/packages/flow-definition-renderer/src/styles.css +++ b/packages/flow-definition-renderer/src/styles.css @@ -247,6 +247,9 @@ } .definition-step-frame { + display: flex; + padding: 12px 14px 14px; + flex-direction: column; border: 2px solid #6b97ca; border-radius: 9px; background: #eaf3fd; @@ -254,13 +257,7 @@ } .definition-step-title { - position: absolute; - top: 0; - left: 0; display: flex; - width: 100%; - min-height: 46px; - padding: 7px 14px 5px; align-items: center; justify-content: center; } @@ -270,7 +267,7 @@ color: #1e2c3f; font-size: 17px; font-weight: 650; - line-height: 1.2; + line-height: 21px; overflow-wrap: anywhere; text-align: center; } @@ -524,7 +521,8 @@ } .definition-step-actor { - margin-left: 8px; + align-self: flex-start; + margin-top: 6px; padding: 1px 6px; border-radius: 999px; background: #c43b62; @@ -534,12 +532,60 @@ letter-spacing: 0.07em; } -.definition-step-type { - margin-top: 2px; - color: #93a2b8; +.definition-role-key { + display: grid; + margin: 9px 0 0; + gap: 3px; +} + +.definition-role-key > div { + display: flex; + align-items: center; + gap: 7px; +} + +.definition-role-key dd { + margin: 0; + color: var(--flow-definition-muted); + font-size: 11px; +} + +.definition-role-key-swatch { + width: 15px; + height: 11px; + flex: 0 0 15px; + border: 2px solid #6b97ca; + border-radius: 3px; + background: #eaf3fd; +} + +.definition-role-key-swatch--gate { + border-color: #c43b62; + background: #fdf1f4; +} + +.definition-role-key-swatch--batch { + border-color: #b3760f; + background: #fffaf0; +} + +.definition-step-io { + display: grid; + gap: 1px; + margin-top: 4px; + color: #4f6178; font: 9px ui-monospace, Menlo, monospace; } +.definition-step-io em { + display: inline-block; + width: 22px; + color: #93a2b8; + font-style: normal; +} + +.definition-step-io .out { color: #1f6f3f; } + .definition-step-wait { margin-top: 5px; padding-top: 5px; diff --git a/web/app/rendering/FlowDefinitionGraph.test.tsx b/web/app/rendering/FlowDefinitionGraph.test.tsx index 8688bf9a7..994243705 100644 --- a/web/app/rendering/FlowDefinitionGraph.test.tsx +++ b/web/app/rendering/FlowDefinitionGraph.test.tsx @@ -16,7 +16,14 @@ import { describe('Flow Definition Graph renderer', () => { it('renders semantic shapes, icons, and failure diagnostics', () => { - const markup = renderToStaticMarkup(); + // Internals are hidden on open, so this asks for every layer explicitly. + const markup = renderToStaticMarkup( + , + ); expect(markup).toContain('definition-flow-frame'); expect(markup).toContain('definition-step-frame'); @@ -41,11 +48,35 @@ describe('Flow Definition Graph renderer', () => { expect(markup).not.toContain('title="lines '); }); - it('keeps Streams hidden by default while resources remain visible', () => { + it('opens on the Steps alone, with every internal layer one click away', () => { const markup = renderToStaticMarkup(); + // What a reader sees first: the Steps and the transitions between them. + expect(markup).toContain('definition-step-frame'); + // Not the machinery inside them. + expect(markup).not.toContain('definition-wait-shape'); + expect(markup).not.toContain('definition-decision-card'); + expect(markup).not.toContain('definition-attributes-box'); + expect(markup).not.toContain('definition-channel-pipe'); + expect(markup).not.toContain('definition-rpc-hexagon'); + // Hidden, not removed: each one is still a legend button. + for (const label of ['WaitFor', 'Decisions', 'Attributes', 'Channels', 'RPC']) { + expect(markup).toContain(`>${label}`); + } + }); + + it('keeps Streams hidden even when the other internal layers are asked for', () => { + const markup = renderToStaticMarkup( + , + ); + + // Streams carry best-effort progress, not structure, so they stay off until asked for. expect(markup).not.toContain('definition-stream-node'); - expect(markup).toContain('aria-pressed="true"'); + expect(markup).toContain('definition-attributes-box'); expect(markup).toContain('aria-pressed="false"'); }); diff --git a/web/vite.config.ts b/web/vite.config.ts index 18657246a..a57babfa0 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -18,6 +18,14 @@ export default defineConfig({ '@': fileURLToPath(new URL('.', import.meta.url)), }, }, + optimizeDeps: { + // preserveSymlinks resolves the linked workspace packages through web/node_modules, so + // Vite would otherwise pre-bundle them and serve that bundle for the life of the + // process, hiding every edit to their source. Excluding them serves the source instead. + // Their files still sit under node_modules, which the watcher ignores, so restart the + // dev server after editing one. + exclude: ['@superdurable/flow-definition-renderer'], + }, server: { proxy: { '/api': process.env.DEX_WEB_PROXY ?? 'http://127.0.0.1:8902', From 80bb235d0b561a96fc58944203861830f5957873 Mon Sep 17 00:00:00 2001 From: zzheng Date: Mon, 7 Sep 2026 08:48:40 -0700 Subject: [PATCH 3/3] Reattach the displayName doc comment and shorten two blocks The displayName block was left above metadataText when that function was inserted between them, so it documented the wrong function. Both blocks also exceeded the repository's comment-length rule. --- .../src/definitionLayout.ts | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/packages/flow-definition-renderer/src/definitionLayout.ts b/packages/flow-definition-renderer/src/definitionLayout.ts index 4fe2b061f..656239a4d 100644 --- a/packages/flow-definition-renderer/src/definitionLayout.ts +++ b/packages/flow-definition-renderer/src/definitionLayout.ts @@ -194,17 +194,11 @@ export function withoutRecoveryPaths(graph: FlowDefinitionGraph): FlowDefinition * traced the rail card, with its edges revealed only while it is selected */ /** - * What a Step is, in the reader's terms, derived only from Dex's own vocabulary. + * What a Step is, read from its WaitFor conditions alone. * - * gate its WaitFor has a Channel condition, so an actor outside the Flow must publish - * before it proceeds. Dex already models human-in-the-loop this way; naming it - * adds no concept, it promotes one that was already there. - * batch its WaitFor has a SubFlow condition: it fans out and waits for children. - * work pure Execute, or a wait on Timers alone. - * - * What a graph cannot answer — whether a model or a script does the work — is deliberately - * absent. That is an application fact, not a Dex one, and belongs in the schema's own - * `metadata` extension point rather than in this vocabulary. + * gate a Channel condition: an actor outside the Flow must publish first + * batch a SubFlow condition: it fans out and waits for children + * work pure Execute, or a wait on Timers alone */ export type StepRole = 'gate' | 'batch' | 'work'; @@ -262,20 +256,13 @@ function derivedWaitSentence(graph: FlowDefinitionGraph, stepID: string): string return clauses.join(', ') + skippable; } -/** - * The name to show on a card. - * - * Uses the FDG schema's existing per-node `metadata` rather than a new field, and falls - * back to the durable type name, which is always present. A Step type name is part of the - * contract of an open execution, so a display name must never replace it — only sit in - * front of it. - */ /** One string out of the schema's per-node `metadata`, or empty. */ export function metadataText(node: FlowDefinitionNode, key: string): string { const value = (node.metadata as Record | undefined)?.[key]; return typeof value === 'string' ? value.trim() : ''; } +/** The card's name: authored `metadata.displayName`, else the durable type name. */ export function displayName(node: FlowDefinitionNode): string { const provided = (node.metadata as { displayName?: unknown } | undefined)?.displayName; return typeof provided === 'string' && provided.trim() !== '' ? provided : node.name;