-
Notifications
You must be signed in to change notification settings - Fork 13
Fix disjointed dashboard layout for workflows with human_gate options and loop-backs #153
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -230,13 +230,83 @@ export function buildGraphElements( | |
| }); | ||
| } | ||
|
|
||
| // Classify edges as forward or back-edges using a DFS from $start. | ||
| // Back-edges are loop-back routes (e.g. plan_reviewer → planner when | ||
| // approved=false). Feeding them to Dagre as-is causes it to greedily | ||
| // reverse arbitrary edges to break cycles, which scrambles the ranking | ||
| // and produces disjointed layouts. Pre-classifying lets us pass each | ||
| // back-edge to Dagre in REVERSED direction so the layout reflects the | ||
| // true forward DAG, while we still render the edge in its original | ||
| // direction. | ||
| const backEdgeIds = findBackEdges(flowNodes, flowEdges, '$start'); | ||
|
|
||
| // Apply dagre layout to top-level nodes only (non-children) | ||
| applyDagreLayout(flowNodes, flowEdges); | ||
| applyDagreLayout(flowNodes, flowEdges, backEdgeIds); | ||
|
|
||
| return { nodes: flowNodes, edges: flowEdges }; | ||
| } | ||
|
|
||
| function applyDagreLayout(flowNodes: Node<GraphNodeData>[], flowEdges: Edge[]): void { | ||
| /** | ||
| * Identify back-edges via DFS from the entry node. An edge u→v is a back-edge | ||
| * iff v is an ancestor of u in the DFS tree (i.e. v is currently on the DFS | ||
| * stack when we visit u→v). Operates on top-level node IDs only, since edges | ||
| * have already been remapped from group children to group parents. | ||
| * | ||
| * Traversal order is deterministic: outgoing edges are visited in sorted | ||
| * target-ID order, and unreachable subgraphs are entered in sorted source-ID | ||
| * order, so layout is stable across renders. | ||
| */ | ||
| function findBackEdges( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: |
||
| flowNodes: Node<GraphNodeData>[], | ||
| flowEdges: Edge[], | ||
| startId: string, | ||
| ): Set<string> { | ||
| const topLevelIds = new Set(flowNodes.filter((n) => !n.parentId).map((n) => n.id)); | ||
|
|
||
| // Build adjacency from top-level edges. Sort targets for stability. | ||
| const adj = new Map<string, { target: string; edgeId: string }[]>(); | ||
| for (const e of flowEdges) { | ||
| if (!topLevelIds.has(e.source) || !topLevelIds.has(e.target)) continue; | ||
| if (!adj.has(e.source)) adj.set(e.source, []); | ||
| adj.get(e.source)!.push({ target: e.target, edgeId: e.id }); | ||
| } | ||
| for (const list of adj.values()) { | ||
| list.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0)); | ||
| } | ||
|
|
||
| const backEdges = new Set<string>(); | ||
| const onStack = new Set<string>(); | ||
| const visited = new Set<string>(); | ||
|
|
||
| const dfs = (node: string): void => { | ||
| visited.add(node); | ||
| onStack.add(node); | ||
| for (const { target, edgeId } of adj.get(node) ?? []) { | ||
| if (onStack.has(target)) { | ||
| backEdges.add(edgeId); | ||
| } else if (!visited.has(target)) { | ||
| dfs(target); | ||
| } | ||
| } | ||
| onStack.delete(node); | ||
| }; | ||
|
|
||
| if (topLevelIds.has(startId)) dfs(startId); | ||
|
|
||
| // Also DFS from any unvisited nodes that have outgoing edges, so back-edges | ||
| // in unreachable subgraphs are still classified deterministically. | ||
| for (const id of [...adj.keys()].sort()) { | ||
| if (!visited.has(id)) dfs(id); | ||
| } | ||
|
|
||
| return backEdges; | ||
| } | ||
|
|
||
| function applyDagreLayout( | ||
| flowNodes: Node<GraphNodeData>[], | ||
| flowEdges: Edge[], | ||
| backEdgeIds: Set<string>, | ||
| ): void { | ||
| // Use a NON-compound dagre graph — compound mode causes crashes | ||
| // when edges cross compound boundaries | ||
| const g = new dagre.graphlib.Graph(); | ||
|
|
@@ -253,10 +323,14 @@ function applyDagreLayout(flowNodes: Node<GraphNodeData>[], flowEdges: Edge[]): | |
| g.setNode(node.id, { width: w, height: h }); | ||
| } | ||
|
|
||
| // Add edges (dagre needs valid source/target nodes) | ||
| // Add edges (dagre needs valid source/target nodes). Back-edges are passed | ||
| // in REVERSED direction so dagre ranks the underlying DAG correctly; the | ||
| // visible flowEdges entries keep their original direction. | ||
| for (const edge of flowEdges) { | ||
| // Only add edge if both source and target are in dagre graph | ||
| if (g.hasNode(edge.source) && g.hasNode(edge.target)) { | ||
| if (!g.hasNode(edge.source) || !g.hasNode(edge.target)) continue; | ||
| if (backEdgeIds.has(edge.id)) { | ||
| g.setEdge(edge.target, edge.source); | ||
| } else { | ||
| g.setEdge(edge.source, edge.target); | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: add a regression test alongside
test_workflow_started_includes_routes(tests/test_engine/test_event_emission.py:163) that asserts ahuman_gate's option routes appear inevent.data["routes"]withwhen == "selection == '<value>'". Cheap insurance that the silent regression motivating this PR can't sneak back in.