How Workflow Layout Reconciliation Works After LLM-Authored Edits in Dograh
When an LLM edits a workflow, the parser drops all node coordinates, and the backend runs a three-phase layout reconciliation algorithm in api/services/workflow/layout.py to restore positions from the previous version before persisting.
The Dograh platform uses TypeScript-based workflow definitions that can be authored or modified by LLMs. Because generated diagrams often contain cramped or unreliable positioning data, the system deliberately strips coordinates during parsing and relies on workflow layout reconciliation to reconstruct sensible visual layouts. This process ensures visual continuity for users while preventing the "stacked at origin" problem common in AI-generated graphs.
Why Node Coordinates Are Discarded
When an LLM generates or edits workflow code, the resulting JSON typically places nodes at arbitrary or overlapping coordinates. Rather than attempting to sanitize these potentially broken layouts, Dograh's parser removes positioning entirely. The responsibility for layout restoration falls to the backend's reconciliation service, which runs after parsing but before persistence in save_workflow.py or create_workflow.py.
The Three-Phase Reconciliation Algorithm
The reconciliation engine is implemented in api/services/workflow/layout.py within the reconcile_positions function (lines 31-77). This function accepts the newly parsed workflow and the previous workflow JSON (draft or last released version), then executes a matching algorithm in three distinct phases.
Phase 1: Build Lookup Tables from Previous State
First, the algorithm indexes all nodes from the previous workflow to create lookup tables for position restoration. Named nodes are indexed by a composite key of their type and data.name, while anonymous nodes are collected into type-ordered lists of positions.
# From api/services/workflow/layout.py (lines 44-56)
for n in prev_nodes:
t = n.get("type") or ""
name = ((n.get("data") or {}).get("name") or "").strip()
pos = n.get("position") or dict(_DEFAULT_POSITION)
if name:
named_positions[(t, name)] = pos
else:
unnamed_positions.setdefault(t, []).append(pos)
This dual-indexing strategy handles both explicitly named user nodes and system-generated nodes (such as automatic "start" or "end" nodes) that lack display names.
Phase 2: Match New Nodes to Old Positions
The algorithm iterates through each node in the new workflow, attempting to restore its position using a prioritized matching strategy. It first attempts named matching using the (type, name) key; if the node lacks a name, it falls back to nth-occurrence matching using a per-type cursor.
# From api/services/workflow/layout.py (lines 59-73)
for node in new_wf.get("nodes") or []:
t = node.get("type") or ""
name = ((node.get("data") or {}).get("name") or "").strip()
pos = named_positions.get((t, name)) if name else None
if pos is None:
idx = unnamed_cursor.get(t, 0)
positions = unnamed_positions.get(t, [])
if idx < len(positions):
pos = positions[idx]
unnamed_cursor[t] = idx + 1
if pos is not None:
node["position"] = dict(pos)
This ensures that unchanged nodes retain their exact previous coordinates, preserving the user's carefully arranged layout even when the LLM modifies other parts of the workflow.
Phase 3: Place Truly New Nodes
After the matching pass, any node still positioned at the origin (0,0) is considered new. The _place_new_nodes helper function (lines 78-104) positions these nodes relative to their first incoming edge using constants _NEW_NODE_DX = 400 and _NEW_NODE_DY = 200, chosen to mimic the UI's dagre layout algorithm.
# From api/services/workflow/layout.py (lines 78-104)
def _place_new_nodes(wf: dict[str, Any]) -> None:
id_to_node = {n["id"]: n for n in wf.get("nodes") or []}
edges = wf.get("edges") or []
for node in wf.get("nodes") or []:
pos = node.get("position") or {}
if pos.get("x") or pos.get("y"):
continue # already positioned
src_id = next((e["source"] for e in edges if e.get("target") == node["id"]), None)
if src_id and src_id in id_to_node:
src_pos = id_to_node[src_id].get("position") or dict(_DEFAULT_POSITION)
node["position"] = {
"x": float(src_pos.get("x", 0.0)) + _NEW_NODE_DX,
"y": float(src_pos.get("y", 0.0)) + _NEW_NODE_DY,
}
Orphan nodes with no incoming edges remain at (0,0), where the UI will auto-layout them later using the dagre algorithm defined in ui/src/app/workflow/[workflowId]/utils/layoutNodes.ts.
Integration with Persistence Flows
The reconciliation step is invoked in both draft saving and workflow creation paths. In api/mcp_server/tools/save_workflow.py (lines 42-56), the _previous_workflow_json helper loads the existing draft or released version, then passes it along with the parsed payload to reconcile_positions.
# From api/mcp_server/tools/save_workflow.py
payload = parsed["workflow"]
payload = reconcile_positions(
payload,
await _previous_workflow_json(workflow) # previous version or draft
)
The same pattern appears in create_workflow.py, ensuring that even newly created workflows benefit from position reconciliation if they are derived from templates or existing drafts.
Validation and Persistence
After reconciliation, the modified payload undergoes Pydantic validation and graph rule checking. The resulting JSON contains a hybrid layout: restored positions for unchanged nodes and sensible defaults for added nodes. This reconciled structure is then persisted either as a draft via save_workflow or as a published version via create_workflow.
Summary
- workflow layout reconciliation runs automatically after LLM-authored edits to restore visual continuity
- The algorithm uses named keys
(type, data.name)and type-ordered lists to match nodes between versions - New nodes receive offsets of 400px horizontally and 200px vertically from their first incoming neighbor
- The implementation resides in
api/services/workflow/layout.pyand is invoked bysave_workflow.pyandcreate_workflow.py - Orphan nodes remain at
(0,0)for UI-side auto-layout, while matched nodes preserve their exact previous coordinates
Frequently Asked Questions
What happens if a node is renamed during an LLM edit?
If a node's name changes, the named matching phase fails to find the old position. The algorithm then attempts nth-occurrence matching based on node type. If the node was the third node of its type in the previous version, it receives the third stored position for that type, preserving approximate location even when identifiers change.
Why strip coordinates instead of sanitizing the LLM output?
LLM-generated coordinates often overlap or place nodes outside the visible canvas. Sanitizing these arbitrary values requires complex heuristics that may still produce poor layouts. By stripping coordinates and running deterministic reconciliation, Dograh guarantees predictable, user-friendly positioning that respects the existing visual structure.
How does the system handle entirely new workflows with no previous version?
When no previous workflow exists, _previous_workflow_json returns None, and reconcile_positions skips the lookup table construction. All nodes remain at (0,0) until the placement phase positions them relative to their edges, or the UI applies auto-layout when the user views the workflow.
What are the _NEW_NODE_DX and _NEW_NODE_DY constants used for?
These constants define the offset distances (400px on the X-axis, 200px on the Y-axis) used when positioning brand-new nodes adjacent to their source nodes. These values approximate the spacing used by Dograh's dagre-based UI layout engine, ensuring that backend-placed nodes align visually with UI-generated layouts.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →