mirror of
https://github.com/langgenius/dify.git
synced 2026-11-12 14:21:29 +08:00
Track backend integration, Ruff cleanup and migration reconciliation in ENG-1271. Retain required copy contracts without implementing frontend interactions.
231 lines
9.8 KiB
Python
231 lines
9.8 KiB
Python
"""Execution revisions for Builder, separate from the editor's full graph hash."""
|
|
|
|
import hashlib
|
|
import json
|
|
from collections.abc import Mapping
|
|
from copy import deepcopy
|
|
from operator import itemgetter
|
|
from typing import TYPE_CHECKING
|
|
|
|
from core.dify_builder.models import Graph
|
|
from core.trigger.constants import TRIGGER_NODE_TYPES
|
|
from graphon.enums import BuiltinNodeTypes
|
|
from runtime_contracts.builder.execution import canonical_digest
|
|
|
|
if TYPE_CHECKING:
|
|
from models.workflow import Workflow
|
|
|
|
|
|
# Only strip presentation fields at their defined canvas paths. A node's
|
|
# configuration may legitimately contain keys such as "position" or "width".
|
|
_NODE_PRESENTATION_FIELDS = frozenset(
|
|
{
|
|
"type", # ReactFlow renderer; the executable node type is data.type.
|
|
"position",
|
|
"positionAbsolute",
|
|
"width",
|
|
"height",
|
|
"selected",
|
|
"dragging",
|
|
"resizing",
|
|
"measured",
|
|
"zIndex",
|
|
"style",
|
|
"sourcePosition",
|
|
"targetPosition",
|
|
}
|
|
)
|
|
_EDGE_PRESENTATION_FIELDS = frozenset({"type", "selected", "animated", "style", "zIndex"})
|
|
# initialEdges derives these rendering hints from the connected nodes. Builder
|
|
# creates edges without them, so the first canvas save adds them even when the
|
|
# user only moves a node. Container membership in NODE data remains executable.
|
|
_EDGE_DATA_PRESENTATION_FIELDS = frozenset(
|
|
{"sourceType", "targetType", "isInIteration", "iteration_id", "isInLoop", "loop_id"}
|
|
)
|
|
|
|
|
|
_DEFAULT_ENTRY_NODE_TYPES = frozenset({BuiltinNodeTypes.START, BuiltinNodeTypes.DATASOURCE, *TRIGGER_NODE_TYPES})
|
|
|
|
_MISSING = object()
|
|
|
|
|
|
def _merge_changed_fields(target: dict, before: dict, latest: dict, fields: frozenset[str]) -> None:
|
|
for field in fields:
|
|
if before.get(field, _MISSING) == latest.get(field, _MISSING):
|
|
continue
|
|
if field in latest:
|
|
target[field] = deepcopy(latest[field])
|
|
else:
|
|
target.pop(field, None)
|
|
|
|
|
|
def merge_canvas_presentation(before: Graph, after: Graph, latest: Graph) -> Graph:
|
|
"""Keep concurrent canvas-only edits when committing an executable patch.
|
|
|
|
Call only after ``execution_revision`` confirms that ``before`` and
|
|
``latest`` have the same execution configuration. Builder owns the changes
|
|
in ``after``; the editor owns the fields excluded from that revision. This
|
|
includes viewport, custom notes, renderer fields and transient node/edge
|
|
data. Newly created Builder nodes and edges retain their generated layout.
|
|
"""
|
|
merged = deepcopy(after)
|
|
before_nodes = {node.get("id"): node for node in before.get("nodes", [])}
|
|
latest_nodes = {node.get("id"): node for node in latest.get("nodes", [])}
|
|
after_node_ids = {node.get("id") for node in after.get("nodes", [])}
|
|
nodes = []
|
|
for node in merged.get("nodes", []):
|
|
node_id = node.get("id")
|
|
if node.get("type") == "custom-note":
|
|
before_note = before_nodes.get(node_id)
|
|
latest_note = latest_nodes.get(node_id)
|
|
if before_note is not None and latest_note is None:
|
|
continue
|
|
if before_note is not None and latest_note != before_note:
|
|
node = deepcopy(latest_note)
|
|
nodes.append(node)
|
|
continue
|
|
if node_id in before_nodes and node_id in latest_nodes:
|
|
before_node = before_nodes[node_id]
|
|
latest_node = latest_nodes[node_id]
|
|
_merge_changed_fields(node, before_node, latest_node, _NODE_PRESENTATION_FIELDS)
|
|
latest_data = latest_node.get("data", {})
|
|
before_data = before_node.get("data", {})
|
|
node_data = node.get("data", {})
|
|
transient_fields = frozenset(
|
|
key for key in node_data.keys() | latest_data.keys() if key == "selected" or key.startswith("_")
|
|
)
|
|
if node_data.get("type") in {BuiltinNodeTypes.ITERATION, BuiltinNodeTypes.LOOP}:
|
|
transient_fields |= frozenset({"width", "height"})
|
|
_merge_changed_fields(node_data, before_data, latest_data, transient_fields)
|
|
nodes.append(node)
|
|
for note in latest.get("nodes", []):
|
|
if (
|
|
note.get("type") == "custom-note"
|
|
and note.get("id") not in before_nodes
|
|
and note.get("id") not in after_node_ids
|
|
):
|
|
nodes.append(deepcopy(note))
|
|
merged["nodes"] = nodes
|
|
|
|
before_edges = {edge.get("id"): edge for edge in before.get("edges", [])}
|
|
latest_edges = {edge.get("id"): edge for edge in latest.get("edges", [])}
|
|
for edge in merged.get("edges", []):
|
|
edge_id = edge.get("id")
|
|
if edge_id not in before_edges or edge_id not in latest_edges:
|
|
continue
|
|
before_edge = before_edges[edge_id]
|
|
latest_edge = latest_edges[edge_id]
|
|
_merge_changed_fields(
|
|
edge, before_edge, latest_edge, _EDGE_PRESENTATION_FIELDS | frozenset({"sourceHandle", "targetHandle"})
|
|
)
|
|
edge_data = edge.get("data", {})
|
|
before_data = before_edge.get("data", {})
|
|
latest_data = latest_edge.get("data", {})
|
|
transient_fields = _EDGE_DATA_PRESENTATION_FIELDS | frozenset(
|
|
key for key in edge_data.keys() | latest_data.keys() if key.startswith("_")
|
|
)
|
|
_merge_changed_fields(edge_data, before_data, latest_data, transient_fields)
|
|
if edge_data and "data" not in edge:
|
|
edge["data"] = edge_data
|
|
|
|
_merge_changed_fields(merged, before, latest, frozenset({"viewport"}))
|
|
return merged
|
|
|
|
|
|
def execution_revision(workflow: "Workflow") -> str:
|
|
"""Hash execution inputs while retaining unknown configuration fields.
|
|
|
|
Canvas hydration and the editor's transient data keys (prefixed with ``_``)
|
|
do not affect execution. Unambiguous graph nodes are ordered by identity;
|
|
edge and configuration array order is preserved because it can affect
|
|
traversal, branch, prompt, or variable semantics. Secret variables
|
|
use their resolved values so re-encryption alone cannot invalidate a plan.
|
|
Only the digest leaves the adapter; plaintext values are neither returned
|
|
nor persisted here.
|
|
|
|
Mutation callers resolve secrets while holding the workflow row lock.
|
|
Key-provider access is required there to compare the same configuration
|
|
that will be mutated, including any concurrent secret changes.
|
|
"""
|
|
graph = normalize_execution_graph(dict(workflow.graph_dict))
|
|
content = {
|
|
"graph": graph,
|
|
"features": workflow.features_dict,
|
|
"environment_variables": [variable.model_dump(mode="json") for variable in workflow.environment_variables],
|
|
"conversation_variables": [variable.model_dump(mode="json") for variable in workflow.conversation_variables],
|
|
}
|
|
return hashlib.sha256(json.dumps(content, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
|
|
|
|
def _normalized_node_data(data: Mapping[str, object]) -> dict[str, object]:
|
|
return {
|
|
key: value
|
|
for key, value in data.items()
|
|
if key != "selected"
|
|
and not key.startswith("_")
|
|
and not (data.get("type") in {BuiltinNodeTypes.ITERATION, BuiltinNodeTypes.LOOP} and key in {"width", "height"})
|
|
}
|
|
|
|
|
|
def node_config_digest(data: Mapping[str, object]) -> str:
|
|
"""Bind admitted native node configuration using the execution revision's exclusions.
|
|
|
|
Callers still validate the original raw configuration and implementation.
|
|
Canvas selection and transient data must not reintroduce a revision conflict
|
|
when native node construction rechecks the admitted binding.
|
|
"""
|
|
config = _normalized_node_data(data)
|
|
config.setdefault("version", "1")
|
|
return canonical_digest(config)
|
|
|
|
|
|
def normalize_execution_graph(graph: Graph) -> Graph:
|
|
"""Retain execution configuration while excluding known canvas presentation paths."""
|
|
graph = dict(graph)
|
|
graph.pop("viewport", None)
|
|
graph["nodes"] = [
|
|
{
|
|
**{key: value for key, value in node.items() if key not in _NODE_PRESENTATION_FIELDS},
|
|
"data": _normalized_node_data(node.get("data", {})),
|
|
}
|
|
for node in graph.get("nodes", [])
|
|
if node.get("type") != "custom-note"
|
|
]
|
|
nodes = graph["nodes"]
|
|
node_ids = [node.get("id") for node in nodes]
|
|
# Graphon resolves unique nodes by ID. The canvas may reorder them during
|
|
# autosave, without changing execution. Keep ambiguous identities ordered:
|
|
# Graphon overwrites duplicate IDs, and Dify chooses the first default entry
|
|
# node when several start/datasource/trigger nodes are present.
|
|
if (
|
|
all(isinstance(node_id, str) and node_id for node_id in node_ids)
|
|
and len(set(node_ids)) == len(node_ids)
|
|
and sum(node["data"].get("type") in _DEFAULT_ENTRY_NODE_TYPES for node in nodes) <= 1
|
|
):
|
|
graph["nodes"] = sorted(nodes, key=itemgetter("id"))
|
|
# Edge order remains significant: Graphon assigns positional IDs and uses
|
|
# the outgoing edge order when scheduling downstream nodes.
|
|
graph["edges"] = [
|
|
{
|
|
**{key: value for key, value in edge.items() if key not in _EDGE_PRESENTATION_FIELDS},
|
|
# The canvas fills missing handles with these defaults. Preserve
|
|
# explicit values, especially source handles that select branches.
|
|
"sourceHandle": edge.get("sourceHandle", "source"),
|
|
"targetHandle": edge.get("targetHandle", "target"),
|
|
"data": {
|
|
key: value
|
|
for key, value in edge.get("data", {}).items()
|
|
if key not in _EDGE_DATA_PRESENTATION_FIELDS and not key.startswith("_")
|
|
},
|
|
}
|
|
for edge in graph.get("edges", [])
|
|
]
|
|
return graph
|
|
|
|
|
|
def executable_graph_revision(graph: Graph) -> str:
|
|
"""Opaque graph digest with the same execution normalization as workflow revisions."""
|
|
return hashlib.sha256(
|
|
json.dumps(normalize_execution_graph(graph), sort_keys=True, separators=(",", ":")).encode()
|
|
).hexdigest()
|