Files
dify/api/services/dify_builder/graph_ops.py

926 lines
43 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 collections.abc import Callable
from contextlib import suppress
from typing import Any, NamedTuple
from core.dify_builder.models import Graph, MutationIntent
from core.dify_builder.node_defaults import default_config_or_empty
from core.trigger.constants import TRIGGER_NODE_TYPES
from services.dify_builder import credentials
from services.workflow_generation.graph_normalizers import declared_branch_handles, heal_nodes_for_preflight
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 (services/workflow_generation/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",)
# The args that carry a node's data into the draft, and so the args through
# which a redaction placeholder could overwrite a real credential:
# set_node_config's `value`, create_node's and insert_between's `config`.
_REDACTABLE_ARGS = ("value", "config")
# Inside a node's config, the one key that REINTERPRETS its siblings rather
# than sitting beside them: an http-request `authorization` is `{type, config}`
# and a `body` is `{type, data}`, and `type` decides whether the sibling is read
# at all. `_build_node`'s deeper defaults merge never fills it in behind a
# caller who wrote the siblings without it -- `{"config": {"type": "bearer",
# "api_key": ...}}` merged under `{"type": "no-auth", ...}` is a node that
# LOOKS authorized, sends no credential, and was written in silence. This is
# `node_defaults`' "a default may fill a blank, never invent a purpose" rule,
# one level down.
_MODE_KEY = "type"
class OperationValidationError(ValueError):
"""A structural refusal with a source-owned, value-free observation code.
Message and ValueError compatibility remain the mutation caller's contract;
the code is observation only and never controls applicability.
"""
def __init__(self, message: str, reason_code: str) -> None:
"""Preserve the original refusal text and its fixed source classification."""
super().__init__(message)
self.reason_code = reason_code
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.
Also refuses any ``value`` / ``config`` carrying ``credentials.REDACTED``.
The agents show a node's config with its secrets replaced by that sentinel;
a model that then rewrites the surrounding field hands the placeholder
straight back, and writing it would destroy a live credential. Raising
here is the whole guard: this function is the one chokepoint both the
``filter_applicable`` dry run and ``dify_port.apply_repair``'s write loop
pass every intent through, so a refused value is rejected with a reason
the corrective re-prompt can read and the stored credential stands
untouched. Keyed on the exact generated string, never on model prose.
"""
required = MUTATION_ARG_KEYS.get(intent.op)
if required is None:
raise OperationValidationError(f"unknown mutation op: {intent.op!r}", "unknown_operation")
missing = [key for key in required if key not in intent.args]
if missing:
raise OperationValidationError(f"missing required arg(s) {missing} for op {intent.op!r}", "missing_argument")
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 OperationValidationError(
f"{key} must be a non-empty string for op {intent.op!r}, got {value!r}", "invalid_argument"
)
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 OperationValidationError(
f"{key} must be a non-empty string for op {intent.op!r}, got {value!r}", "invalid_argument"
)
# KNOWN AND ACCEPTED, deliberately not closed here: this stops the model
# handing the PLACEHOLDER back, not a rewrite that simply OMITS the
# Authorization line. That write carries no sentinel, passes, and clobbers
# the credential. The difference is that its failure is LOUD -- the next
# verify run fails authentication and lands in the repair loop with a real
# error -- where the leak this guard closes was silent and unrecoverable.
# Closing it would need merge semantics for a free-text `headers` blob,
# which is a larger design change and would break legitimate deletion.
for key in _REDACTABLE_ARGS:
if key in intent.args and credentials.carries_redaction(intent.args[key]):
raise OperationValidationError(
f"{key} for op {intent.op!r} contains the redaction placeholder "
f"{credentials.REDACTED!r}: that is not the real secret. Leave the field "
"unchanged, or set it to a real value.",
"redacted_value",
)
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 OperationValidationError(f"empty segment in path: {path!r}", "invalid_path")
cursor: Any = container
for depth, segment in enumerate(segments[:-1]):
walked = ".".join(segments[: depth + 1])
if segment.isdigit():
if not isinstance(cursor, list):
raise OperationValidationError(
f"path {path!r}: {walked} indexes a {type(cursor).__name__}, not a list", "invalid_path"
)
index = int(segment)
if index >= len(cursor):
raise OperationValidationError(
f"path {path!r}: index {index} out of range at {walked} (len {len(cursor)})", "invalid_path"
)
cursor = cursor[index]
continue
if not isinstance(cursor, dict):
raise OperationValidationError(f"path {path!r}: {walked} is not a mapping", "invalid_path")
if segment not in cursor:
raise OperationValidationError(f"path {path!r}: no key {segment!r} at {walked}", "invalid_path")
cursor = cursor[segment]
last = segments[-1]
if last.isdigit():
if not isinstance(cursor, list):
raise OperationValidationError(
f"path {path!r}: final segment indexes a {type(cursor).__name__}, not a list", "invalid_path"
)
index = int(last)
if index >= len(cursor):
raise OperationValidationError(
f"path {path!r}: index {index} out of range (len {len(cursor)})", "invalid_path"
)
return cursor, index
if not isinstance(cursor, dict):
raise OperationValidationError(
f"path {path!r}: final container is a {type(cursor).__name__}, not a mapping", "invalid_path"
)
return cursor, last
def value_at_path(data: dict[str, Any], path: str) -> tuple[bool, Any]:
"""``(True, value)`` for the value ``path`` addresses inside a node's
``data``, or ``(False, None)`` when nothing is there.
The read half of ``apply_set_node_config``, through the same
``_resolve_path`` walk, so a caller comparing what a path held BEFORE a
write against what it holds after cannot disagree with the write about
which slot the path names. ``_resolve_path`` returns the parent for a final
segment that does not exist yet -- that is the key being created -- so the
read is attempted and its absence reported rather than raised.
"""
try:
parent, key = _resolve_path(data, path)
return True, parent[key]
except (ValueError, KeyError, IndexError, TypeError):
return False, None
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``.
``value`` is deep-copied too, so the new graph shares no structure with the
caller's argument. It is normally ``intent.args["value"]``, and the graph it
lands in is then healed in place (``heal_nodes_for_preflight`` rewrites
``">="`` to ``"≥"`` and ``60`` to ``"60"``): without the copy the caller's
own intent would change under it, so a dry run would silently rewrite the
very intents it was asked to judge.
"""
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] = copy.deepcopy(value)
return new_graph, [node_id]
raise OperationValidationError(f"node not found: {node_id}", "node_not_found")
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 ``services.workflow_generation.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``,
``services/workflow_generation/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).
``node_type``'s registered defaults (``core.dify_builder.node_defaults``)
are merged UNDER ``config``: a key the caller supplied always wins, and a
key it omitted is filled with the value Dify's own editor would have
created the node with. Several node types have engine-required fields with
no default -- a ``template-transform`` without ``variables`` makes the draft
preflight refuse the whole repair batch -- and an LLM writes only the fields
it was thinking about. This is the one chokepoint every created node passes
through, so it covers Build, Edit and Fix, ``create_node`` and
``insert_between``, and the ``filter_applicable`` dry run alike.
The merge goes ONE level into a default whose value is a dict, for the same
reason: an LLM writing ``model: {provider, name}`` was thinking about the
provider and the model name, not about ``mode`` or ``completion_params``,
and a shallow merge dropped those with the rest of the block -- so the
engine refused the node for ``model.mode`` and the one corrective re-prompt
was spent on a field the default was already carrying. One level, not a
recursive merge: a caller value that is not a dict replaces the default
outright, and a dict nested deeper is still the caller's word entirely.
With one exclusion, and it is the reason this is not a blanket deep merge
(see ``_MODE_KEY``): a default block whose ``type`` the caller did not write
keeps today's shallow behaviour, because ``type`` reinterprets its siblings
instead of sitting beside them. Merging it in turns a partial
``authorization`` -- the caller's ``config`` with a bearer token in it --
into a ``no-auth`` node that sends no credential and is written in silence,
which is exactly the failure a default is forbidden from creating.
"""
if flow_type == "custom-note":
if node_type:
raise ValueError("Canvas notes cannot carry executable node configuration")
text = config.get("text", "")
if not isinstance(text, str) or len(text) > 2000:
raise ValueError("Simple canvas note text must be at most 2000 characters")
# Native note widgets store a Lexical editor state, not plain text or
# executable configuration. Build only simple paragraphs/text runs.
config = {
"theme": "blue",
"author": "",
"showAuthor": False,
"text": json.dumps(
{
"root": {
"type": "root",
"version": 1,
"format": "",
"indent": 0,
"direction": None,
"children": [
{
"type": "paragraph",
"version": 1,
"format": "",
"indent": 0,
"direction": None,
"children": [
{
"type": "text",
"version": 1,
"text": text,
"format": 0,
"detail": 0,
"mode": "normal",
"style": "",
}
],
}
],
}
},
ensure_ascii=False,
),
}
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)
defaults = default_config_or_empty(node_type)
data = {**defaults, **copy.deepcopy(config)}
for key, default_value in defaults.items():
supplied = data.get(key)
if not isinstance(default_value, dict) or not isinstance(supplied, dict):
continue
if _MODE_KEY in default_value and _MODE_KEY not in supplied:
continue
data[key] = {**default_value, **supplied}
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": {
"iteration-start": "custom-iteration-start",
"loop-start": "custom-loop-start",
}.get(node_type)
or 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 OperationValidationError(f"node not found: {node_id}", "node_not_found")
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), or if ``from_node`` is a branch node (if-else /
question-classifier / human-input / fail-branch) and ``source_handle``
(or the default "source") is not one of its declared handles. Handles
default to "source"/"target" (mirrors the generator's
``_fill_edge_defaults``). Returns ``(new_graph, [from_node, to_node])``.
"""
new_graph = copy.deepcopy(graph)
nodes_by_id = {n.get("id"): n for n in new_graph.get("nodes", [])}
if from_node not in nodes_by_id:
raise OperationValidationError(f"node not found: {from_node}", "node_not_found")
if to_node not in nodes_by_id:
raise OperationValidationError(f"node not found: {to_node}", "node_not_found")
# A branch node (if-else / question-classifier / human-input / fail-branch)
# only routes along the handles it declares. An edge on any other handle
# -- including the default "source" -- hangs off nothing: its arm never
# runs and the run still reports "succeeded" (ESQ1-303). Reject it here
# like a dangling node ref; the caller's ``except ValueError`` shows it.
declared = declared_branch_handles(nodes_by_id[from_node])
handle = source_handle or "source"
if declared and handle not in declared:
raise OperationValidationError(
f"branch node {from_node!r} has no handle {handle!r}; it declares {declared}", "invalid_branch_handle"
)
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 _reused_insertion_node(
graph: Graph,
edge: dict[str, str],
node_type: str,
config: dict[str, Any],
position: dict[str, float] | None,
node_id: str | None,
) -> dict[str, Any]:
"""Validate opt-in wiring of one existing, ordinary native canvas node."""
matches = [node for node in graph.get("nodes", []) if node.get("id") == node_id]
if not node_id or len(matches) != 1:
raise ValueError("reuse_existing requires one exact existing node_id")
node = matches[0]
data = node.get("data", {})
if node_id in {edge["source"], edge["target"]}:
raise ValueError("reuse_existing node cannot be the source or target of the replaced edge")
if config != {} or position is not None:
raise ValueError("reuse_existing requires config={} and no position override; node data is preserved")
if data.get("type") != node_type:
raise ValueError("reuse_existing requires the existing node's exact native type")
if node_type in {
"start",
"datasource",
"end",
"answer",
"iteration",
"loop",
"iteration-start",
"loop-start",
*TRIGGER_NODE_TYPES,
} or (
node.get("parentId")
or data.get("iteration_id")
or data.get("loop_id")
or data.get("isInIteration")
or data.get("isInLoop")
):
raise ValueError("reuse_existing does not support entry, terminal or container nodes/children")
declared = declared_branch_handles(node)
if declared and "source" not in declared:
raise ValueError("reuse_existing node does not declare the required outgoing source handle")
source = next((n for n in graph.get("nodes", []) if n.get("id") == edge["source"]), None)
target = next((n for n in graph.get("nodes", []) if n.get("id") == edge["target"]), None)
if source is None or target is None or edge["source"] == edge["target"]:
raise ValueError("reuse_existing requires distinct existing edge endpoints")
if any(
endpoint.get("parentId")
or any(endpoint.get("data", {}).get(key) for key in ("iteration_id", "loop_id", "isInIteration", "isInLoop"))
for endpoint in (source, target)
):
raise ValueError("reuse_existing does not support edges inside containers")
declared_source = declared_branch_handles(source)
if declared_source and edge.get("sourceHandle", "source") not in declared_source:
raise ValueError("reuse_existing edge does not name a declared source branch handle")
return 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,
reuse_existing: bool = False,
) -> 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 matches. Opt-in ``reuse_existing=True``
retains the complete existing node object: exact ID/type, config={}, no
position override, isolated node only, no container/terminal/invalid branch insertion. Optional
edge handles disambiguate parallel edges. Default creation is unchanged.
Returns ``(new_graph, [node_id])``; reuse writes edges only.
"""
if type(reuse_existing) is not bool:
raise ValueError("reuse_existing must be a boolean")
old_source = edge["source"]
old_target = edge["target"]
new_graph = copy.deepcopy(graph)
edges = new_graph.get("edges", [])
matches = [e for e in edges if e.get("source") == old_source and e.get("target") == old_target]
if reuse_existing:
matches = [
e
for e in matches
if all(
e.get(k, default) == edge[k]
for k, default in (("sourceHandle", "source"), ("targetHandle", "target"))
if k in edge
)
]
if len(matches) != 1:
raise ValueError("reuse_existing requires one exact existing edge, including handles when ambiguous")
matched = matches[0] if matches else None
if matched is None:
raise ValueError(f"edge not found: {old_source} -> {old_target}")
if reuse_existing:
if any(
matched.get(key, default) != default and key not in edge
for key, default in (("sourceHandle", "source"), ("targetHandle", "target"))
):
raise ValueError("reuse_existing requires explicit nondefault edge handles")
exact_edge = {
**edge,
"sourceHandle": matched.get("sourceHandle", "source"),
"targetHandle": matched.get("targetHandle", "target"),
}
node = _reused_insertion_node(new_graph, exact_edge, node_type, config, position, node_id)
if any(node_id in {e.get("source"), e.get("target")} for e in edges):
raise ValueError("reuse_existing requires an isolated node; existing dependencies need manual wiring")
else:
node = _build_node(new_graph, node_type, config, position, node_id)
new_graph.setdefault("nodes", []).append(node)
new_id = node["id"]
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`` uses language-neutral diff notation: ``+ knowledge-1``,
``− knowledge-1 → llm-1``, ``llm-1: prompt_template``. Actual resource
IDs and configuration field names are preserved; the UI localizes the
surrounding change heading and scope.
"""
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"+ {node_id}" for node_id in added_nodes)
changes.extend(f"− {node_id}" for node_id in removed_nodes)
changes.extend(f"+ {source} → {target}" for source, target, _sh, _th in added_edges)
changes.extend(f"− {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}" 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 already_present_predicate(graph: Graph, intents: list[MutationIntent]) -> Callable[[MutationIntent], bool]:
"""The idempotent-re-entry rule: ``True`` for an intent ``graph`` already
satisfies.
An interrupted step's Retry re-sends the same batch against an
already-mutated draft, so a ``create_node`` for an id that is now present and
a ``connect`` for an edge that is now present are clean no-ops rather than
errors. Reused insertions are no-ops only when their exact replacement
edges are present and the original edge is absent.
``dify_port.apply_repair`` drops them before its apply loop, and
``filter_applicable`` skips them in its dry run for the same reason: without
this the dry run rejects a duplicate ``create_node`` ("node id already
exists") that the port would have quietly dropped, and burns the one
corrective re-prompt on a batch that was going to apply cleanly.
Ids targeted by a ``delete_node`` in THIS SAME batch do not count as present:
a from-scratch build sends ``delete_node(placeholder_start)`` +
``create_node(same id)`` in one batch, and without the exclusion the create
would be dropped as already-present while the delete still ran.
An edge is keyed on ``(source, target, sourceHandle)``, not on the pair
alone. Two edges between the same nodes on DIFFERENT handles are different
edges -- they leave different arms of a branch -- so a repair that re-routes
an arm is not something the draft already satisfies. Keyed on the pair, it
matched the edge already there and was dropped as a no-op here AND at
``dify_port.apply_repair``, which runs this same predicate: a legitimate
repair silently discarded. Both sides default a missing handle to
``"source"``, which is how graphon reads one (``edge_config.get
("sourceHandle", "source")``), so an intent that omits it still matches an
edge persisted with it.
"""
deleted_ids = {i.args.get("node_id") for i in intents if i.op == "delete_node"}
present_node_ids = {n.get("id") for n in graph.get("nodes") or []} - deleted_ids
present_edges = {
(e.get("source"), e.get("target"), e.get("sourceHandle") or "source")
for e in graph.get("edges") or []
if e.get("source") not in deleted_ids and e.get("target") not in deleted_ids
}
def already_present(intent: MutationIntent) -> bool:
if intent.op == "create_node":
return intent.args.get("node_id") in present_node_ids
if intent.op == "insert_between" and intent.args.get("reuse_existing") is True:
try:
validate_intent_args(intent)
args = intent.args
edge = args["edge"]
node_id = args.get("node_id")
_reused_insertion_node(graph, edge, args["node_type"], args["config"], args.get("position"), node_id)
if {node_id, edge["source"], edge["target"]} & deleted_ids:
return False
keys = {
(e.get("source"), e.get("target"), e.get("sourceHandle", "source"), e.get("targetHandle", "target"))
for e in graph.get("edges", [])
}
return (
(
edge["source"],
edge["target"],
edge.get("sourceHandle", "source"),
edge.get("targetHandle", "target"),
)
not in keys
and (edge["source"], node_id, edge.get("sourceHandle", "source"), "target") in keys
and (node_id, edge["target"], "source", edge.get("targetHandle", "target")) in keys
and {key for key in keys if node_id in key[:2]}
== {
(edge["source"], node_id, edge.get("sourceHandle", "source"), "target"),
(node_id, edge["target"], "source", edge.get("targetHandle", "target")),
}
)
except (ValueError, KeyError, TypeError):
return False
if intent.op == "connect":
return (
intent.args.get("from_node"),
intent.args.get("to_node"),
intent.args.get("source_handle") or "source",
) in present_edges
return False
return already_present
class DryRun(NamedTuple):
"""What ``filter_applicable`` learned from dry-running a batch of intents.
``applicable`` and ``rejected`` are the pair this function has always
returned. ``graph`` is the working copy those applicable intents produced --
the graph ``dify_port.apply_repair`` would be about to write, healed the same
way. It is returned rather than discarded so ``preflight.vet_intents`` can
put it through the node-data validation the port's own preflight performs,
while there is still a corrective re-prompt left to spend on the answer.
``changed_nodes`` is the ids those applicable intents wrote NODE DATA to, in
order, which is what ``preflight.new_preflight_problems`` needs to tell a
node this batch is answerable for from one it merely left alone. It is
narrower than a change set in two deliberate ways, and both matter because
a node in it loses its pre-existing-defect exemption entirely:
* ``connect`` and ``insert_between(reuse_existing=True)`` are EXCLUDED. ``apply_connect`` reports both endpoints as
changed -- correct for a diff, wrong here: adding an edge cannot change
either endpoint's ``validate_node_config`` verdict, so counting them would
let merely WIRING an already-broken node make that node's pre-existing
defect veto the whole batch. Nothing is given up by leaving it out.
* the heal is not folded in. ``heal_nodes_for_preflight`` scans EVERY node,
not only the ones the intents named, so its ids would make an unrelated
node the healer normalized count as written by this batch. Same pre-heal
point in the sequence as ``dify_port.apply_repair``'s own copy.
"""
applicable: list[MutationIntent]
rejected: list[tuple[MutationIntent, str]]
graph: Graph
changed_nodes: list[str]
def filter_applicable(
graph: Graph,
intents: list[MutationIntent],
allowed_node_types: set[str] | None = None,
*,
on_rejection: Callable[[MutationIntent, str], None] | None = None,
) -> DryRun:
"""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 a ``DryRun``: ``(applicable, rejected, graph, changed_nodes)``,
where each rejected entry is ``(intent, reason)``, ``graph`` is the mutated
working copy and ``changed_nodes`` is the ids whose node DATA those intents
wrote (see ``DryRun``).
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.
An intent the graph already satisfies is neither applied nor rejected, and
stays in ``applicable`` for the port to drop the same way
(``already_present_predicate``, which the port runs before its own apply
loop). The working copy already reflects it -- that is what "already
present" means -- so the graph returned here is still what the port would
write.
``on_rejection`` receives only source-owned codes. Observation failures are
suppressed and cannot change any existing mutation verdict or error text.
The working copy then goes through the shared deterministic heal set
(``services.workflow_generation.graph_normalizers.heal_nodes_for_preflight``), exactly as
``dify_port.apply_repair`` runs it over the real draft after its own apply
loop. Same normalizers, same place in the sequence, one definition: a dry run
that healed less than the write chokepoint would report a defect the write
was going to fix, and one that healed more would pass a batch the write would
refuse. Both make the answer this function gives about the port a guess.
"""
working = copy.deepcopy(graph)
is_already_present = already_present_predicate(graph, intents)
applicable: list[MutationIntent] = []
rejected: list[tuple[MutationIntent, str]] = []
changed_nodes: list[str] = []
for intent in intents:
# Before validate_intent_args, exactly where the port drops it.
if is_already_present(intent):
applicable.append(intent)
continue
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 and not (
intent.op == "create_node" and node_type == "" and intent.args.get("flow_type") == "custom-note"
):
rejected.append((intent, f"node_type not allowed: {node_type!r}"))
if on_rejection is not None:
with suppress(Exception):
on_rejection(intent, "unsupported_node_type")
continue
working, changed = APPLY_FNS[intent.op](working, **intent.args)
except Exception as exc:
rejected.append((intent, str(exc)))
if on_rejection is not None:
with suppress(Exception):
on_rejection(
intent, exc.reason_code if isinstance(exc, OperationValidationError) else "operation_invalid"
)
continue
# A connect writes an EDGE; its endpoints' node data is untouched (see
# ``DryRun.changed_nodes``). Mirrored in ``dify_port.apply_repair``.
if intent.op != "connect" and not (intent.op == "insert_between" and intent.args.get("reuse_existing") is True):
changed_nodes.extend(changed)
applicable.append(intent)
heal_nodes_for_preflight(working.get("nodes") or [])
return DryRun(applicable, rejected, working, changed_nodes)