Files
dify/api/services/dify_builder/agent/graph_translate.py
T
Charles YaoandClaude Opus 5 674669f7a6 fix(dify-builder): carry the container start marker's ReactFlow type
The previous ESQ1-288 fix dropped the iteration/loop start marker, stripped
start_node_id from the container config and dropped the generator's container
entry edge. All three are required by the ENGINE, not just the canvas:
IterationNodeData.start_node_id / LoopNodeData.start_node_id have no default,
node data is validated eagerly at graph construction, and the body frame is
rooted at start_node_id. Every container workflow the Builder produced was
therefore non-runnable -- worse than the canvas crash it replaced.

The real root cause was never the marker node. The generator already emits it
with the correct node-level ReactFlow type ("custom-iteration-start" /
"custom-loop-start", runner.py:1350-1362); _build_node hardcoded "type":
"custom" and threw it away, leaving a node the frontend has no component for
(React error #130).

Carry it through instead, the same way the previous commit carried
parentId/position: a defaulted `flow_type` on _build_node / apply_create_node
(absent -> "custom", so every existing caller is byte-identical), set from the
source node's type in to_intents. The marker, start_node_id and the entry edge
are all persisted again.

Tests: the three tests that asserted the ABSENCE of the marker, start_node_id
and the entry edge are replaced by ones asserting their presence, plus the
round-trip test that would have caught this -- to_intents -> filter_applicable
-> APPLY_FNS -> IterationNodeData/LoopNodeData.model_validate on the persisted
container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:26 -07:00

73 lines
3.0 KiB
Python

"""Translate a WorkflowGenerator graph into Dify Builder MutationIntents.
The generator emits ReactFlow nodes ({id, type, data:{type:<real>,...}}) and
edges. The node-level "type" is the ReactFlow component key -- "custom" for
ordinary nodes, but "custom-iteration-start" / "custom-loop-start" for the
container start markers -- and it is carried through as `flow_type`, because
a marker persisted as a generic "custom" node has no canvas component and
crashes the editor (React error #130, ESQ1-288). We emit one create_node per
node (config = node.data minus the redundant "type" key -- apply_create_node
re-sets it) followed by one connect per edge, so every connect endpoint
already exists at apply time. Node ids are preserved verbatim -- including
the generator's start node id -- so intra-node references (variable
selectors, prompt {{#id.var#}}) stay valid; reconciling the generator's start
node with the draft's existing start is the handler's job, not this
translation step."""
from typing import Any
from core.dify_builder.models import MutationIntent
# The node-level ReactFlow type every ordinary node carries. Only a
# non-generic value is worth putting on an intent -- `_build_node` already
# defaults to this one.
_GENERIC_FLOW_TYPE = "custom"
def to_intents(graph: dict[str, Any]) -> list[MutationIntent]:
nodes = graph.get("nodes") or []
edges = graph.get("edges") or []
creates: list[MutationIntent] = []
for node in nodes:
data = node.get("data") or {}
node_type = data.get("type")
node_id = node.get("id")
if not node_type or not node_id:
continue
config = {k: v for k, v in data.items() if k != "type"}
args: dict[str, Any] = {
"node_type": str(node_type),
"config": config,
"node_id": str(node_id),
}
# The ReactFlow wrapper carries nesting and layout OUTSIDE `data`, so
# reading only id+data dropped them on every build: children landed
# un-nested and the generator's layout was replaced by defaults.
parent_id = node.get("parentId")
if parent_id:
args["parent_id"] = str(parent_id)
position = node.get("position")
if isinstance(position, dict):
args["position"] = dict(position)
flow_type = node.get("type")
if flow_type and flow_type != _GENERIC_FLOW_TYPE:
args["flow_type"] = str(flow_type)
creates.append(MutationIntent(op="create_node", args=args))
connects: list[MutationIntent] = []
for edge in edges:
src, tgt = edge.get("source"), edge.get("target")
if not src or not tgt:
continue
args: dict[str, Any] = {"from_node": str(src), "to_node": str(tgt)}
source_handle = edge.get("sourceHandle")
if source_handle:
args["source_handle"] = source_handle
target_handle = edge.get("targetHandle")
if target_handle:
args["target_handle"] = target_handle
connects.append(MutationIntent(op="connect", args=args))
return creates + connects