Files
dify/api/services/workflow/node_defaults.py
T
GareArc f22362a892 fix: harden agent login, check more of the DSL, and trim the difyctl skill
Server:
- describe, check and fill human-input nodes with Dify's own model, delivery
  methods included; human-input action ids and __timeout are valid branches
- refuse End nodes that share an output name, as the console's publish does
- warn on a branch with no edge
- the release check counts a run as tested when its graph runs the same as
  the draft, so editor-only fields no longer reset it

CLI:
- login needs --server; there is no default server
- not_logged_in names the config folder it checked
- on Linux without a DBus session, keep the token in tokens.yml, since the
  keyring there is lost when the session ends

Skill:
- log in with --no-wait only, then --resume after the user approves
- report problems instead of substituting, and get a yes before building any
  change from the spec or plan
- trim every file under 500 words
2026-10-09 22:43:37 -07:00

142 lines
5.6 KiB
Python

"""Defaults every node needs so the workflow editor can open it; shared by DSL import and describe.node_type."""
from __future__ import annotations
import copy
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
from types import UnionType
from typing import Any, Final, Union, get_args, get_origin
from pydantic import BaseModel, Field
from core.workflow.human_input_adapter import DeliveryChannelConfig
from core.workflow.node_factory import LATEST_VERSION, get_node_type_classes_mapping
from core.workflow.nodes.human_input.entities import HumanInputNodeData
from graphon.enums import BuiltinNodeTypes
from graphon.nodes.base.node import Node
# Only empty containers: values the editor reads without a guard and that mean "none yet".
EDITOR_EMPTY: Final[Mapping[str, Mapping[str, object]]] = {
BuiltinNodeTypes.START: {"variables": []},
BuiltinNodeTypes.END: {"outputs": []},
BuiltinNodeTypes.IF_ELSE: {"cases": []},
BuiltinNodeTypes.CODE: {"variables": []},
BuiltinNodeTypes.TEMPLATE_TRANSFORM: {"variables": []},
BuiltinNodeTypes.HTTP_REQUEST: {"body": {"type": "none", "data": []}},
BuiltinNodeTypes.TOOL: {"tool_parameters": {}, "tool_configurations": {}},
BuiltinNodeTypes.LOOP: {"break_conditions": []},
BuiltinNodeTypes.HUMAN_INPUT: {"delivery_methods": [], "user_actions": [], "inputs": []},
BuiltinNodeTypes.DATASOURCE: {"datasource_parameters": {}},
}
class HumanInputNodeSpec(HumanInputNodeData):
"""Human-input data as the editor saves it; the run parses delivery methods beside the node data."""
delivery_methods: list[DeliveryChannelConfig] = Field(default_factory=list)
# Graphon keeps these node data opaque; Dify parses them with its own model when the node runs.
_DIFY_NODE_DATA: Final[Mapping[str, type[BaseModel]]] = {
BuiltinNodeTypes.HUMAN_INPUT: HumanInputNodeSpec,
}
def node_data_type(node_type: str, node_class: type[Node]) -> type[BaseModel]:
return _DIFY_NODE_DATA.get(node_type) or node_class._get_node_data_type()
_LEGACY_IF_CASE_ID: Final = "true"
_DEFAULT_LOGICAL_OPERATOR: Final = "and"
def _cases_from_legacy_conditions(data: Mapping[str, Any]) -> Mapping[str, Any]:
"""Old if-else nodes keep one condition list and branch on "true"; the editor reads them as one case."""
if "cases" in data or "conditions" not in data:
return data
case = {
"case_id": _LEGACY_IF_CASE_ID,
"logical_operator": data.get("logical_operator") or _DEFAULT_LOGICAL_OPERATOR,
"conditions": data["conditions"],
}
return {**data, "cases": [case]}
# Rewrites of old shapes to the one the editor reads, applied before the empty defaults.
_NORMALIZERS: Final[Mapping[str, Callable[[Mapping[str, Any]], Mapping[str, Any]]]] = {
BuiltinNodeTypes.IF_ELSE: _cases_from_legacy_conditions,
}
def _single_model(candidates: Iterable[object]) -> type[BaseModel] | None:
models = [c for c in candidates if isinstance(c, type) and issubclass(c, BaseModel)]
return models[0] if len(models) == 1 else None
@dataclass(frozen=True)
class _SubModels:
"""The model of a field's mapping value (direct or optional), and of each mapping in its list value."""
value: type[BaseModel] | None
item: type[BaseModel] | None
def _sub_models(annotation: object) -> _SubModels:
arms = get_args(annotation) if get_origin(annotation) in (Union, UnionType) else (annotation,)
is_sequence = get_origin(annotation) in (list, Sequence)
return _SubModels(value=_single_model(arms), item=_single_model(get_args(annotation)) if is_sequence else None)
def _fill(model: type[BaseModel], value: Mapping[str, Any]) -> dict[str, Any]:
# Some validators raise errors other than ValidationError on bad or newer values; filling must never fail an import.
try:
filled = model.model_validate(value).model_dump(mode="json")
except Exception:
return dict(value)
return {**filled, **value}
def complete_sub_models(model: type[BaseModel], data: Mapping[str, Any]) -> dict[str, Any]:
"""Add the keys each sub-object's model defaults; keys already present win."""
fields = model.model_fields
completed = dict(data)
for key, value in data.items():
field = fields.get(key)
if field is None:
continue
models = _sub_models(field.annotation)
if isinstance(value, Mapping) and models.value:
completed[key] = _fill(models.value, value)
elif isinstance(value, list) and models.item:
item_model = models.item
completed[key] = [_fill(item_model, item) if isinstance(item, Mapping) else item for item in value]
return completed
def fill_node_data(data: Mapping[str, Any]) -> dict[str, Any]:
node_type = data.get("type")
if not isinstance(node_type, str):
return dict(data)
normalize = _NORMALIZERS.get(node_type)
if normalize is not None:
data = normalize(data)
filled = {**copy.deepcopy(dict(EDITOR_EMPTY.get(node_type, {}))), **data}
node_class = get_node_type_classes_mapping().get(node_type, {}).get(LATEST_VERSION)
return complete_sub_models(node_data_type(node_type, node_class), filled) if node_class else filled
def fill_graph(graph: Mapping[str, Any]) -> dict[str, Any]:
nodes = graph.get("nodes")
if not isinstance(nodes, list):
return dict(graph)
return {
**graph,
"nodes": [
{**node, "data": fill_node_data(node["data"])}
if isinstance(node, Mapping) and isinstance(node.get("data"), Mapping)
else node
for node in nodes
],
}