"""Pure, client-side graph mutation helpers. Dify's ``sync_draft_workflow`` has no server-side patch primitive: it always replaces the whole draft graph. The adapter (``dify_port.py``) therefore reads the current graph, applies mutations like ``apply_set_node_config`` locally, and writes the whole mutated graph back. These helpers do the local mutation only — no DB, no services, no I/O. """ import copy import hashlib import json from typing import Any from core.dify_builder.models import Graph, MutationIntent MUTATION_ARG_KEYS: dict[str, tuple[str, ...]] = { "set_node_config": ("node_id", "path", "value"), "create_node": ("node_type", "config"), "delete_node": ("node_id",), "connect": ("from_node", "to_node"), "insert_between": ("edge", "node_type", "config"), } # ReactFlow containment, matching what the generator computes for a child of # an iteration/loop container (core/workflow/generator/runner.py:1441-1442). _CHILD_EXTENT = "parent" _CHILD_Z_INDEX = 1002 # Args that name a node. An LLM occasionally emits these as ints or empty # strings; unchecked they reach diff_graphs, which drops falsy/non-str ids # from its node maps, and then crash build_change_set's join (ESQ1-271). _NODE_ID_ARGS = ("node_id", "from_node", "to_node") # `path` reaches _resolve_path's str.split; a non-string raises AttributeError # there, so the rejection reason the repair agent reads is a Python internals # message instead of an actionable one. _STRING_ARGS = ("path",) def validate_intent_args(intent: MutationIntent) -> None: """Raise ``ValueError`` if ``intent.args`` is missing a required key for ``intent.op``, or if ``intent.op`` isn't one of ``MUTATION_ARG_KEYS``'s five recognized verbs. Optional keys (marked ``?`` on ``MutationIntent``) are not checked here -- each ``apply_*`` function defaults them itself. """ required = MUTATION_ARG_KEYS.get(intent.op) if required is None: raise ValueError(f"unknown mutation op: {intent.op!r}") missing = [key for key in required if key not in intent.args] if missing: raise ValueError(f"missing required arg(s) {missing} for op {intent.op!r}") for key in _NODE_ID_ARGS: if key not in intent.args: continue value = intent.args[key] if not isinstance(value, str) or not value: raise ValueError(f"{key} must be a non-empty string for op {intent.op!r}, got {value!r}") for key in _STRING_ARGS: if key not in intent.args: continue value = intent.args[key] if not isinstance(value, str) or not value: raise ValueError(f"{key} must be a non-empty string for op {intent.op!r}, got {value!r}") def _resolve_path(container: dict[str, Any], path: str) -> tuple[Any, str | int]: """Walk ``path``'s parent segments and return ``(parent, last_key)``. Segments are dot-separated; an all-digit segment indexes a list, any other segment is a mapping key. Only the FINAL segment may be absent -- that is the key being written. Every intermediate segment must already resolve, and a segment that cannot be walked raises ``ValueError``. Raising is the point. ``filter_applicable`` dry-runs each intent through this function and turns the exception into a rejection reason the repair agent sees on its next attempt. Before this existed, a dotted path wrote one flat junk key that the workflow engine ignored, so every nested repair silently did nothing (ESQ1-285, ESQ1-290). """ segments = path.split(".") if not path or any(segment == "" for segment in segments): raise ValueError(f"empty segment in path: {path!r}") cursor: Any = container for depth, segment in enumerate(segments[:-1]): walked = ".".join(segments[: depth + 1]) if segment.isdigit(): if not isinstance(cursor, list): raise ValueError(f"path {path!r}: {walked} indexes a {type(cursor).__name__}, not a list") index = int(segment) if index >= len(cursor): raise ValueError(f"path {path!r}: index {index} out of range at {walked} (len {len(cursor)})") cursor = cursor[index] continue if not isinstance(cursor, dict): raise ValueError(f"path {path!r}: {walked} is not a mapping") if segment not in cursor: raise ValueError(f"path {path!r}: no key {segment!r} at {walked}") cursor = cursor[segment] last = segments[-1] if last.isdigit(): if not isinstance(cursor, list): raise ValueError(f"path {path!r}: final segment indexes a {type(cursor).__name__}, not a list") index = int(last) if index >= len(cursor): raise ValueError(f"path {path!r}: index {index} out of range (len {len(cursor)})") return cursor, index if not isinstance(cursor, dict): raise ValueError(f"path {path!r}: final container is a {type(cursor).__name__}, not a mapping") return cursor, last def apply_set_node_config(graph: Graph, node_id: str, path: str, value: Any) -> tuple[Graph, list[str]]: """Set the value at ``path`` inside ``node["data"]`` for the node whose ``id == node_id``. ``path`` is dot-separated (``"code"``, ``"cases.0.conditions.1.value"``); see ``_resolve_path``. The placeholder agent emits intents shaped ``{node_id, path, value}`` (e.g. ``path="code"`` to rewrite a Code node's ``data["code"]``); this is the mutation that consumes them. Returns a ``(new_graph, changed_node_ids)`` tuple. ``graph`` is deep-copied first and never mutated. Raises ``ValueError`` if no node in ``graph["nodes"]`` has a matching ``id``. """ new_graph = copy.deepcopy(graph) for node in new_graph.get("nodes", []): if node.get("id") == node_id: parent, key = _resolve_path(node.setdefault("data", {}), path) parent[key] = value return new_graph, [node_id] raise ValueError(f"node not found: {node_id}") def _next_unique_id(base: str, existing_ids: set) -> str: """Return ``base`` if unused, else ``base_2``, ``base_3``, ... on collision with ``existing_ids``. Shared bump sequence for node ids (``_next_node_id``) and edge ids (``_make_edge``).""" candidate = base suffix = 1 while candidate in existing_ids: suffix += 1 candidate = f"{base}_{suffix}" return candidate def _next_node_id(prefix: str, existing_ids: set) -> str: """Return a short, human-readable node id not present in ``existing_ids``. Mirrors ``core.workflow.generator.runner._next_generated_node_id``'s prefix, then prefix_2, prefix_3, ... collision scheme -- reimplemented locally so this module stays free of any dependency on the generator subsystem. """ return _next_unique_id(prefix, existing_ids) def _default_position(graph: Graph) -> dict[str, float]: """Place a new node to the right of the rightmost existing node. Slice 1 uses simple deterministic rightward placement, not the generator's topology-aware layout (``_layout_top_level_nodes``) -- good enough for one node at a time; a smarter layout is Slice 2/3's concern if the canvas ever needs it. """ nodes = graph.get("nodes", []) if not nodes: return {"x": 100.0, "y": 100.0} max_x = max(float(n.get("position", {}).get("x", 0.0)) for n in nodes) return {"x": max_x + 260.0, "y": 100.0} def _build_node( graph: Graph, node_type: str, config: dict[str, Any], position: dict[str, float] | None, node_id: str | None, parent_id: str | None = None, flow_type: str | None = None, ) -> dict[str, Any]: """Construct one ``GraphNodeDict``-shaped node, generating an id/position when omitted (mirrors the generator's ``_fill_node_defaults``, ``core/workflow/generator/runner.py:2387-2394``). Raises ``ValueError`` if a caller-supplied ``node_id`` already exists in ``graph["nodes"]``. ``flow_type`` is the node-level ReactFlow type. Almost every node is the generic ``"custom"`` (the default), but iteration/loop start markers carry ``"custom-iteration-start"`` / ``"custom-loop-start"`` and the canvas has no component for them under ``"custom"`` (React error #130, ESQ1-288). """ existing_ids = {n.get("id") for n in graph.get("nodes", [])} if node_id is not None: if node_id in existing_ids: raise ValueError(f"node id already exists: {node_id}") new_id = node_id else: new_id = _next_node_id(node_type, existing_ids) data = copy.deepcopy(config) data["type"] = node_type # data.type is the real node type -- never overridden by config data.setdefault("title", new_id) data.setdefault("desc", "") data.setdefault("selected", False) node: dict[str, Any] = { "id": new_id, "type": flow_type or "custom", "position": position if position is not None else _default_position(graph), "data": data, } if parent_id: # Containment lives at node level, NOT inside data. Without these the # child renders outside its container and the container does not run # it -- the data-level isInIteration/iteration_id markers are not # enough on their own. node["parentId"] = parent_id node["extent"] = _CHILD_EXTENT node["zIndex"] = _CHILD_Z_INDEX return node def apply_create_node( graph: Graph, node_type: str, config: dict[str, Any], position: dict[str, float] | None = None, node_id: str | None = None, parent_id: str | None = None, flow_type: str | None = None, ) -> tuple[Graph, list[str]]: """Append a new ``GraphNodeDict``-shaped node to the graph. ``node_id`` is optional (not part of spec Sec 9's terse args list) -- when omitted a short id is generated from ``node_type``; when supplied, a collision with an existing node id raises ``ValueError`` (the Slice 1 duplicate-id validation). ``parent_id``, when truthy, nests the new node inside an existing iteration/loop container and adds the matching ``extent``/``zIndex`` wrapper keys. ``flow_type``, when truthy, overrides the node-level ReactFlow type (defaults to ``"custom"``); container start markers need their own. Returns ``(new_graph, [new_node_id])``. """ new_graph = copy.deepcopy(graph) node = _build_node(new_graph, node_type, config, position, node_id, parent_id, flow_type) new_graph.setdefault("nodes", []).append(node) return new_graph, [node["id"]] def apply_delete_node(graph: Graph, node_id: str) -> tuple[Graph, list[str]]: """Remove the node with ``id == node_id`` and every edge touching it. Raises ``ValueError`` if no node in ``graph["nodes"]`` has a matching ``id`` (mirrors ``apply_set_node_config``'s not-found behavior). Returns ``(new_graph, [node_id])``. """ new_graph = copy.deepcopy(graph) nodes = new_graph.get("nodes", []) if not any(n.get("id") == node_id for n in nodes): raise ValueError(f"node not found: {node_id}") new_graph["nodes"] = [n for n in nodes if n.get("id") != node_id] new_graph["edges"] = [ e for e in new_graph.get("edges", []) if e.get("source") != node_id and e.get("target") != node_id ] return new_graph, [node_id] def _make_edge( source: str, target: str, source_handle: str | None, target_handle: str | None, existing_edge_ids: set ) -> dict: """Build one ``GraphEdgeDict``-shaped edge dict with a fresh collision-free id (``source-target``, then ``source-target_2``, ... on collision with ``existing_edge_ids``). Handles default to "source"/"target" (mirrors the generator's ``_fill_edge_defaults``).""" return { "id": _next_unique_id(f"{source}-{target}", existing_edge_ids), "source": source, "target": target, "type": "custom", "sourceHandle": source_handle or "source", "targetHandle": target_handle or "target", } def apply_connect( graph: Graph, from_node: str, to_node: str, source_handle: str | None = None, target_handle: str | None = None, ) -> tuple[Graph, list[str]]: """Add a ``GraphEdgeDict``-shaped edge between two existing nodes. Raises ``ValueError`` if either ``from_node`` or ``to_node`` is not a node id present in ``graph["nodes"]`` (the Slice 1 dangling-ref validation). Handles default to "source"/"target" (mirrors the generator's ``_fill_edge_defaults``). Returns ``(new_graph, [from_node, to_node])``. """ new_graph = copy.deepcopy(graph) node_ids = {n.get("id") for n in new_graph.get("nodes", [])} if from_node not in node_ids: raise ValueError(f"node not found: {from_node}") if to_node not in node_ids: raise ValueError(f"node not found: {to_node}") existing_edge_ids = {e.get("id") for e in new_graph.get("edges", [])} edge = _make_edge(from_node, to_node, source_handle, target_handle, existing_edge_ids) new_graph.setdefault("edges", []).append(edge) return new_graph, [from_node, to_node] def apply_insert_between( graph: Graph, edge: dict[str, str], node_type: str, config: dict[str, Any], position: dict[str, float] | None = None, node_id: str | None = None, ) -> tuple[Graph, list[str]]: """Split an existing edge with a new node: remove the old edge, add ``old_source -> new_node`` and ``new_node -> old_target``. ``edge`` identifies the edge to split by ``{"source": ..., "target": ...}`` (matching Dify's own edge fields, not a caller-known internal edge id). Raises ``ValueError`` if no edge in ``graph["edges"]`` matches (the Slice 1 dangling-ref validation). Returns ``(new_graph, [new_node_id])``. """ old_source = edge.get("source") old_target = edge.get("target") new_graph = copy.deepcopy(graph) edges = new_graph.get("edges", []) matched = next((e for e in edges if e.get("source") == old_source and e.get("target") == old_target), None) if matched is None: raise ValueError(f"edge not found: {old_source} -> {old_target}") node = _build_node(new_graph, node_type, config, position, node_id) new_id = node["id"] new_graph.setdefault("nodes", []).append(node) remaining_edges = [e for e in edges if e is not matched] existing_edge_ids = {e.get("id") for e in remaining_edges} incoming = _make_edge(old_source, new_id, matched.get("sourceHandle"), "target", existing_edge_ids) existing_edge_ids.add(incoming["id"]) outgoing = _make_edge(new_id, old_target, "source", matched.get("targetHandle"), existing_edge_ids) new_graph["edges"] = [*remaining_edges, incoming, outgoing] return new_graph, [new_id] def diff_graphs(before: Graph, after: Graph) -> tuple[list[str], str]: """Compare two graphs and return ``(changes, scope)``. ``scope`` is ``"structure"`` if any node or edge was added or removed, else ``"configuration"`` if any surviving node's ``data`` changed, else ``"configuration"`` with an empty ``changes`` list if nothing differs. ``changes`` is human-readable lines (e.g. ``"added node knowledge-1"``, ``"added knowledge-1 → llm-1"``, ``"llm-1: prompt_template updated"``) -- not bare ids. """ before_nodes = {n.get("id"): n for n in before.get("nodes", []) if n.get("id")} after_nodes = {n.get("id"): n for n in after.get("nodes", []) if n.get("id")} def _edge_key(e: dict[str, Any]) -> tuple[str, str, str, str]: return ( str(e.get("source")), str(e.get("target")), str(e.get("sourceHandle") or "source"), str(e.get("targetHandle") or "target"), ) before_edges = {_edge_key(e) for e in before.get("edges", [])} after_edges = {_edge_key(e) for e in after.get("edges", [])} added_nodes = sorted(after_nodes.keys() - before_nodes.keys()) removed_nodes = sorted(before_nodes.keys() - after_nodes.keys()) added_edges = sorted(after_edges - before_edges) removed_edges = sorted(before_edges - after_edges) changes: list[str] = [] changes.extend(f"added node {node_id}" for node_id in added_nodes) changes.extend(f"removed node {node_id}" for node_id in removed_nodes) changes.extend(f"added {source} → {target}" for source, target, _sh, _th in added_edges) changes.extend(f"removed {source} → {target}" for source, target, _sh, _th in removed_edges) for node_id in sorted(before_nodes.keys() & after_nodes.keys()): before_data = before_nodes[node_id].get("data", {}) after_data = after_nodes[node_id].get("data", {}) changed_keys = sorted( key for key in set(before_data) | set(after_data) if before_data.get(key) != after_data.get(key) ) changes.extend(f"{node_id}: {key} updated" for key in changed_keys) structural = bool(added_nodes or removed_nodes or added_edges or removed_edges) return changes, "structure" if structural else "configuration" def structural_fingerprint(graph: Graph) -> str: """Stable hash of a graph's STRUCTURE only -- node identity+type and edge topology -- EXCLUDING node ``data`` config. Two graphs with the same nodes/edges but different node configs share a fingerprint; adding, removing, or reconnecting a node changes it. Used by recovery (C-1) to tell a config-only hand-edit from a structural one. Order-independent (nodes and edges are sorted before hashing).""" nodes = sorted((str(n.get("id", "")), str((n.get("data") or {}).get("type", ""))) for n in graph.get("nodes", [])) edges = sorted( ( str(e.get("source", "")), str(e.get("target", "")), str(e.get("sourceHandle", "")), str(e.get("targetHandle", "")), ) for e in graph.get("edges", []) ) canonical = json.dumps({"nodes": nodes, "edges": edges}, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() def node_ids(graph: Graph) -> list[str]: """The ids of every node in ``graph``, in document order (missing-id nodes skipped). Used by recovery's target-presence check.""" return [str(n["id"]) for n in graph.get("nodes", []) if n.get("id") is not None] STRUCTURAL_OPS: frozenset[str] = frozenset({"create_node", "delete_node", "connect", "insert_between"}) APPLY_FNS: dict[str, Any] = { "set_node_config": apply_set_node_config, "create_node": apply_create_node, "delete_node": apply_delete_node, "connect": apply_connect, "insert_between": apply_insert_between, } def filter_applicable( graph: Graph, intents: list[MutationIntent], allowed_node_types: set[str] | None = None, ) -> tuple[list[MutationIntent], list[tuple[MutationIntent, str]]]: """Dry-run each intent through the real validate_intent_args + apply_* on a working deep copy, in order (so a connect sees a node an earlier create_node added). Returns (applicable, rejected) where each rejected entry is (intent, reason). Catches every failure the live apply_repair would hit -- unknown op, missing required arg / extra arg key, dangling node/edge ref, duplicate id -- and additionally rejects a create_node / insert_between whose node_type is not in allowed_node_types (a check apply_* does not do). Applicable intents advance the working copy; a rejected intent does not. """ working = copy.deepcopy(graph) applicable: list[MutationIntent] = [] rejected: list[tuple[MutationIntent, str]] = [] for intent in intents: try: validate_intent_args(intent) if allowed_node_types is not None and intent.op in ("create_node", "insert_between"): node_type = intent.args.get("node_type") if node_type not in allowed_node_types: rejected.append((intent, f"node_type not allowed: {node_type!r}")) continue working, _changed = APPLY_FNS[intent.op](working, **intent.args) except Exception as exc: rejected.append((intent, str(exc))) continue applicable.append(intent) return applicable, rejected