mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 23:06:40 +08:00
Track backend integration, Ruff cleanup and migration reconciliation in ENG-1271. Retain required copy contracts without implementing frontend interactions.
1629 lines
66 KiB
Python
1629 lines
66 KiB
Python
"""``preflight_errors``: the dry ``Graph.init`` a draft is put through before
|
|
it is written or run. Exercised on the REAL ESQ1-303 dev draft (app 0f11bedc,
|
|
dumped from dev on 2026-09-21), which the engine refused to start.
|
|
|
|
Plus the two layers built on it: ``new_preflight_problems`` (the new-problems-only
|
|
rule ``apply_repair`` refuses a write on) and ``vet_intents`` (the whole engine
|
|
check a proposed batch passes before it can reach an approval gate)."""
|
|
|
|
import copy
|
|
import json
|
|
from pathlib import Path
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from core.dify_builder.models import Graph, MutationIntent
|
|
from core.workflow.human_input_adapter import parse_human_input_delivery_methods
|
|
from core.workflow.nodes.human_input.entities import HumanInputNodeData
|
|
from services.dify_builder import graph_ops
|
|
from services.dify_builder.preflight import new_preflight_problems, preflight_errors, vet_intents
|
|
from services.workflow_generation.graph_normalizers import heal_nodes_for_preflight
|
|
from services.workflow_runtime.node_factory import validate_node_config
|
|
|
|
_FIXTURES = Path(__file__).parent / "fixtures"
|
|
|
|
|
|
def _esq1_303_draft() -> Graph:
|
|
return json.loads((_FIXTURES / "esq1_303_draft_graph.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
def test_the_esq1_303_dev_draft_would_not_start_and_the_error_names_the_node() -> None:
|
|
errors = preflight_errors(_esq1_303_draft())
|
|
|
|
assert len(errors) == 1
|
|
assert errors[0].startswith("node 'node2' (if-else): ")
|
|
assert "Input should be a valid string" in errors[0] # value 60 is a JSON number
|
|
|
|
|
|
def test_the_same_draft_passes_once_the_value_is_a_string() -> None:
|
|
graph = _esq1_303_draft()
|
|
node2 = next(n for n in graph["nodes"] if n["id"] == "node2")
|
|
node2["data"]["cases"][0]["conditions"][0]["value"] = "60"
|
|
|
|
assert preflight_errors(graph) == []
|
|
|
|
|
|
def test_every_broken_node_is_reported_not_only_the_first() -> None:
|
|
graph = _esq1_303_draft()
|
|
broken_twice = copy.deepcopy(next(n for n in graph["nodes"] if n["id"] == "node2"))
|
|
broken_twice["id"] = "node2b"
|
|
graph["nodes"].append(broken_twice)
|
|
|
|
errors = preflight_errors(graph)
|
|
|
|
assert [e.split(" ")[1] for e in errors] == ["'node2'", "'node2b'"]
|
|
|
|
|
|
def test_note_widgets_are_skipped_exactly_like_graph_init_skips_them() -> None:
|
|
graph: Graph = {
|
|
"nodes": [
|
|
{"id": "note", "type": "custom-note", "data": {"type": "", "text": "remember"}},
|
|
{"id": "s", "type": "custom", "data": {"type": "start", "title": "Start", "variables": []}},
|
|
],
|
|
"edges": [],
|
|
}
|
|
|
|
assert preflight_errors(graph) == []
|
|
|
|
|
|
def test_an_empty_or_shapeless_graph_has_nothing_to_report() -> None:
|
|
assert preflight_errors({}) == []
|
|
assert preflight_errors({"nodes": [None, "junk"], "edges": []}) == []
|
|
|
|
|
|
def _human_input_graph(delivery_methods: object) -> Graph:
|
|
return {
|
|
"nodes": [
|
|
{
|
|
"id": "start",
|
|
"type": "custom",
|
|
"data": {"type": "start", "title": "Start", "variables": []},
|
|
},
|
|
{
|
|
"id": "review",
|
|
"type": "custom",
|
|
"data": {
|
|
"type": "human-input",
|
|
"title": "Review",
|
|
"delivery_methods": delivery_methods,
|
|
"form_content": "Review and approve. {{#$output.comment#}}",
|
|
"inputs": [
|
|
{
|
|
"type": "paragraph",
|
|
"output_variable_name": "comment",
|
|
"default": {"type": "constant", "selector": [], "value": ""},
|
|
}
|
|
],
|
|
"user_actions": [{"id": "approve", "title": "Approve", "button_style": "primary"}],
|
|
"timeout": 3,
|
|
"timeout_unit": "day",
|
|
},
|
|
},
|
|
{"id": "end", "type": "custom", "data": {"type": "end", "title": "End", "outputs": []}},
|
|
],
|
|
"edges": [
|
|
{"id": "start-review", "source": "start", "target": "review", "sourceHandle": "source"},
|
|
{"id": "approved", "source": "review", "target": "end", "sourceHandle": "approve"},
|
|
{"id": "timed-out", "source": "review", "target": "end", "sourceHandle": "__timeout"},
|
|
],
|
|
}
|
|
|
|
|
|
def test_preflight_rejects_a_channel_kind_used_as_the_human_input_delivery_uuid() -> None:
|
|
graph = _human_input_graph([{"id": "webapp", "type": "webapp", "enabled": True}])
|
|
|
|
errors = preflight_errors(graph)
|
|
|
|
assert len(errors) == 1
|
|
assert errors[0].startswith("node 'review' (human-input): ")
|
|
assert "0.webapp.id" in errors[0]
|
|
assert "UUID" in errors[0]
|
|
|
|
|
|
@pytest.mark.parametrize("delivery_id", [None, "b3d2e84f-5629-4f43-9662-ac69ed1b551c"])
|
|
def test_preflight_accepts_native_human_input_delivery_ids_without_changing_the_graph(delivery_id: str | None) -> None:
|
|
method = {"type": "webapp", "enabled": True}
|
|
if delivery_id is not None:
|
|
method["id"] = delivery_id
|
|
graph = _human_input_graph([method])
|
|
before = copy.deepcopy(graph)
|
|
|
|
assert preflight_errors(graph) == []
|
|
node_data = validate_node_config(graph["nodes"][1])
|
|
methods = parse_human_input_delivery_methods(node_data)
|
|
|
|
assert len(methods) == 1
|
|
assert isinstance(methods[0].id, UUID)
|
|
if delivery_id is None:
|
|
assert methods[0].id.version == 4
|
|
assert "id" not in node_data.model_dump()["delivery_methods"][0]
|
|
else:
|
|
assert methods[0].id == UUID("b3d2e84f-5629-4f43-9662-ac69ed1b551c")
|
|
assert node_data.model_dump()["delivery_methods"][0]["id"] == delivery_id
|
|
form_data = HumanInputNodeData.model_validate(node_data.model_dump())
|
|
assert form_data.user_actions[0].id == "approve"
|
|
assert form_data.inputs[0].output_variable_name == "comment"
|
|
assert graph["nodes"][0]["data"]["variables"] == []
|
|
assert [edge["sourceHandle"] for edge in graph["edges"][1:]] == ["approve", "__timeout"]
|
|
assert graph == before
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("delivery_methods", "location", "error_type"),
|
|
[
|
|
([{"id": "webapp", "type": "webapp", "enabled": False}], (0, "webapp", "id"), "uuid_parsing"),
|
|
(["raw-method"], (0,), "model_attributes_type"),
|
|
([{"type": "unknown"}], (0,), "union_tag_invalid"),
|
|
([{"type": "email"}], (0, "email", "config"), "missing"),
|
|
([{"type": "webapp", "config": "raw-config"}], (0, "webapp", "config"), "model_type"),
|
|
],
|
|
)
|
|
def test_preflight_preserves_human_input_delivery_adapter_failures(
|
|
delivery_methods: object, location: tuple[str | int, ...], error_type: str
|
|
) -> None:
|
|
graph = _human_input_graph(delivery_methods)
|
|
before = copy.deepcopy(graph)
|
|
|
|
errors = preflight_errors(graph)
|
|
|
|
assert len(errors) == 1
|
|
assert errors[0].startswith("node 'review' (human-input): ")
|
|
with pytest.raises(ValueError, match=r"node 'review' \(human-input\)") as excinfo:
|
|
validate_node_config(graph["nodes"][1])
|
|
assert isinstance(excinfo.value.__cause__, ValidationError)
|
|
error = excinfo.value.__cause__.errors()[0]
|
|
assert error["loc"] == location
|
|
assert error["type"] == error_type
|
|
assert graph == before
|
|
|
|
|
|
@pytest.mark.parametrize("delivery_methods", [None, "raw-methods", {"type": "webapp"}, []])
|
|
def test_preflight_keeps_the_delivery_adapters_empty_result_for_non_lists(delivery_methods: object) -> None:
|
|
graph = _human_input_graph(delivery_methods)
|
|
|
|
assert preflight_errors(graph) == []
|
|
node_data = validate_node_config(graph["nodes"][1])
|
|
assert parse_human_input_delivery_methods(node_data) == []
|
|
|
|
|
|
def _http_node(node_id: str, authorization: Graph) -> Graph:
|
|
return {
|
|
"id": node_id,
|
|
"type": "custom",
|
|
"data": {
|
|
"type": "http-request",
|
|
"title": "Call",
|
|
"method": "post",
|
|
"url": "https://x.test/a",
|
|
"authorization": authorization,
|
|
"headers": "",
|
|
"params": "",
|
|
"body": {"type": "none", "data": []},
|
|
},
|
|
}
|
|
|
|
|
|
def test_an_authorization_without_type_is_reported_not_raised() -> None:
|
|
"""The live E2E crash: graphon's ``HttpRequestNodeAuthorization.check_config``
|
|
reads ``values.data["type"]``, which is absent once the required ``type``
|
|
failed, so it raises KeyError -- which pydantic does not wrap and
|
|
``validate_node_config`` does not catch. A validation step must report
|
|
it, never crash the Builder session. Built WITHOUT the normalizer."""
|
|
graph: Graph = {"nodes": [_http_node("h", {"config": {"type": "bearer", "api_key": "x"}})], "edges": []}
|
|
|
|
errors = preflight_errors(graph)
|
|
|
|
assert errors == ["node 'h' (http-request): KeyError: 'type'"]
|
|
|
|
|
|
# Exactly which ``authorization`` shapes reach ``_CRASH_LOCATION``. The rule is
|
|
# not "no ``type``" -- graphon's ``check_config`` is a ``mode="before"`` validator
|
|
# on ``config``, so it only runs when a ``config`` KEY is present, and it then
|
|
# reads ``values.data["type"]``, which is absent whenever ``type`` failed its own
|
|
# Literal check. That comment has been written wrong twice; this pins it.
|
|
_CRASH_AUTHORIZATION_SHAPES: list[tuple[Graph, bool]] = [
|
|
({"config": {"type": "bearer", "api_key": "x"}}, True),
|
|
({"type": None, "config": {"type": "bearer", "api_key": "x"}}, True),
|
|
({"type": "bogus", "config": {"type": "bearer", "api_key": "x"}}, True),
|
|
(dict[str, object](), False),
|
|
({"type": None}, False),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(("authorization", "crashes"), _CRASH_AUTHORIZATION_SHAPES)
|
|
def test_only_an_authorization_with_a_config_and_a_bad_type_crashes(authorization: Graph, crashes: bool) -> None:
|
|
errors = preflight_errors({"nodes": [_http_node("h", authorization)], "edges": []})
|
|
|
|
assert len(errors) == 1
|
|
if crashes:
|
|
assert errors[0] == "node 'h' (http-request): KeyError: 'type'"
|
|
else:
|
|
# an ordinary ValidationError, with a real field path
|
|
assert "KeyError" not in errors[0]
|
|
assert "authorization.type" in errors[0]
|
|
|
|
|
|
def test_a_crashing_node_does_not_hide_the_problems_of_the_nodes_after_it() -> None:
|
|
graph = _esq1_303_draft()
|
|
graph["nodes"].insert(0, _http_node("h", {"type": "bearer", "config": {"type": "bearer", "api_key": "x"}}))
|
|
|
|
errors = preflight_errors(graph)
|
|
|
|
assert [e.split(" ")[1] for e in errors] == ["'h'", "'node2'"]
|
|
assert errors[0].startswith("node 'h' (http-request): KeyError: ")
|
|
assert errors[1].startswith("node 'node2' (if-else): ") # ValueError messages unchanged
|
|
|
|
|
|
# ---- new_preflight_problems --------------------------------------------------
|
|
|
|
|
|
def _scoring_graph(operator: str = "≥", value: object = "60") -> Graph:
|
|
"""A start + if-else draft the engine accepts as written."""
|
|
return {
|
|
"nodes": [
|
|
{
|
|
"id": "start",
|
|
"type": "custom",
|
|
"data": {
|
|
"type": "start",
|
|
"title": "Start",
|
|
"variables": [
|
|
{"variable": "score", "type": "number", "label": "Score", "required": True, "options": []}
|
|
],
|
|
},
|
|
},
|
|
{
|
|
"id": "branch",
|
|
"type": "custom",
|
|
"data": {
|
|
"type": "if-else",
|
|
"title": "Branch",
|
|
"cases": [
|
|
{
|
|
"case_id": "true",
|
|
"logical_operator": "and",
|
|
"conditions": [
|
|
{
|
|
"id": "c1",
|
|
"varType": "number",
|
|
"variable_selector": ["start", "score"],
|
|
"comparison_operator": operator,
|
|
"value": value,
|
|
}
|
|
],
|
|
}
|
|
],
|
|
},
|
|
},
|
|
],
|
|
"edges": [
|
|
{"id": "e1", "source": "start", "target": "branch", "sourceHandle": "source", "targetHandle": "target"}
|
|
],
|
|
}
|
|
|
|
|
|
def test_the_base_draft_this_file_edits_starts_clean() -> None:
|
|
assert preflight_errors(_scoring_graph()) == []
|
|
|
|
|
|
def test_a_problem_the_draft_already_had_is_not_new() -> None:
|
|
broken = _scoring_graph(operator="==")
|
|
|
|
assert preflight_errors(broken) # it is a problem
|
|
assert new_preflight_problems(broken, broken, ()) == [] # ...just not a new one
|
|
|
|
|
|
def test_a_problem_the_change_introduced_is_new_and_quotes_the_engine() -> None:
|
|
problems = new_preflight_problems(_scoring_graph(), _scoring_graph(operator="=="), ())
|
|
|
|
assert len(problems) == 1
|
|
assert problems[0].startswith("node 'branch' (if-else): ")
|
|
assert "comparison_operator" in problems[0]
|
|
|
|
|
|
def test_a_change_that_heals_a_pre_existing_problem_has_nothing_to_report() -> None:
|
|
assert new_preflight_problems(_scoring_graph(operator="=="), _scoring_graph(), ()) == []
|
|
|
|
|
|
def test_a_newly_broken_node_is_reported_even_while_another_stays_broken() -> None:
|
|
"""A pre-existing refusal on one node does not buy silence for the rest of
|
|
the draft: the if-else was already refused, the start node was not."""
|
|
before = _scoring_graph(operator="==")
|
|
after = copy.deepcopy(before)
|
|
after["nodes"][0]["data"]["variables"][0]["type"] = "not-a-type"
|
|
|
|
problems = new_preflight_problems(before, after, ())
|
|
|
|
assert [p.split(" ")[1] for p in problems] == ["'start'"]
|
|
|
|
|
|
def _code_node(**data: object) -> Graph:
|
|
return {
|
|
"nodes": [{"id": "code1", "type": "custom", "data": {"type": "code", "title": "Code", **data}}],
|
|
"edges": [],
|
|
}
|
|
|
|
|
|
def test_half_repairing_an_already_broken_node_is_not_a_new_problem() -> None:
|
|
"""Why the rule is not keyed on MESSAGE TEXT: pydantic quotes a truncated
|
|
repr of the input, so filling one of a node's missing fields rewrites the
|
|
messages for the others. Keyed on text, this repair -- which strictly
|
|
improved the node -- would be refused as a new problem."""
|
|
before = _code_node()
|
|
after = _code_node(code="def main():\n return {}")
|
|
|
|
assert preflight_errors(before) != preflight_errors(after) # the text really does change
|
|
assert preflight_errors(after) # the node is still refused...
|
|
assert new_preflight_problems(before, after, ()) == [] # ...but not because of this change
|
|
|
|
|
|
def test_a_node_the_batch_wrote_gets_no_exemption_at_all() -> None:
|
|
"""``touched`` is what closes the silent false success.
|
|
|
|
The same bad field replaced by a DIFFERENT bad value is not a new problem to
|
|
the location key, and no key short of "is it startable now?" could see it --
|
|
pydantic's ``input`` for a ``Field required`` error is the parent dict, so
|
|
keying on the input repr would refuse honest partial repairs instead. A
|
|
repair targets a node that is by definition already invalid, so this is Fix's
|
|
main line: untouched, the batch applies and the card says "Applied the
|
|
changes" over a draft that still cannot start."""
|
|
before = _code_node(code_language="klingon")
|
|
after = _code_node(code_language="martian")
|
|
|
|
assert new_preflight_problems(before, after, ()) == [] # nothing NEW -- and that was the bug
|
|
problems = new_preflight_problems(before, after, ["code1"])
|
|
|
|
assert len(problems) == 1
|
|
assert problems[0].startswith("node 'code1' (code): ")
|
|
assert "code_language" in problems[0]
|
|
|
|
|
|
def test_a_half_repaired_node_the_batch_wrote_is_refused_even_though_it_improved() -> None:
|
|
"""The companion to ``test_half_repairing_an_already_broken_node_is_not_a_new_problem``:
|
|
the exemption that test pins is for a node NOBODY wrote. Fill one of the
|
|
missing fields of a node this batch is answerable for and it is still not
|
|
startable, so the batch is still refused -- with the node's whole remaining
|
|
refusal, which is what the corrective re-prompt needs."""
|
|
before = _code_node()
|
|
after = _code_node(code="def main():\n return {}")
|
|
|
|
assert new_preflight_problems(before, after, ()) == [] # untouched: a strict improvement
|
|
problems = new_preflight_problems(before, after, ["code1"])
|
|
|
|
assert len(problems) == 1
|
|
assert "code_language" in problems[0] # what is STILL missing
|
|
|
|
|
|
def test_a_node_the_batch_wrote_and_actually_fixed_reports_nothing() -> None:
|
|
"""The rule is "startable", not "was touched": a repair that really works
|
|
passes, or every Fix would be refused."""
|
|
before = _code_node()
|
|
after = _code_node(code="def main():\n return {}", code_language="python3", outputs={}, variables=[])
|
|
|
|
assert preflight_errors(after) == []
|
|
assert new_preflight_problems(before, after, ["code1"]) == []
|
|
|
|
|
|
def test_an_untouched_node_keeps_its_exemption_while_a_written_one_does_not() -> None:
|
|
"""Both rules in one graph, so neither can quietly become the other."""
|
|
before: Graph = {"nodes": [*_code_node()["nodes"], *_code_node()["nodes"]], "edges": []}
|
|
before["nodes"][1] = {**before["nodes"][1], "id": "code2"}
|
|
after = copy.deepcopy(before)
|
|
|
|
problems = new_preflight_problems(before, after, ["code2"])
|
|
|
|
assert [p.split(" ")[1] for p in problems] == ["'code2'"]
|
|
|
|
|
|
def test_an_already_invalid_node_broken_further_is_a_new_problem() -> None:
|
|
"""Why the rule is not keyed on NODE IDENTITY either. ``code1`` is refused
|
|
before AND after, and the count of messages does not move -- but the change
|
|
added a refusal at a field path that was fine, and that is a new problem.
|
|
|
|
This is Fix's main path, not an edge case: for the whole
|
|
ESQ1-302 / ESQ1-303 / F4 family the node ``propose_repair`` targets is by
|
|
definition already preflight-invalid, so keying on the node would leave the
|
|
repair with no node-data coverage on its own culprit."""
|
|
before = _code_node(code_language="python3") # already invalid, but this field is fine
|
|
after = _code_node(code_language="klingon") # ...and now it is not
|
|
|
|
assert len(preflight_errors(before)) == len(preflight_errors(after)) == 1
|
|
problems = new_preflight_problems(before, after, ())
|
|
|
|
assert len(problems) == 1
|
|
assert problems[0].startswith("node 'code1' (code): ")
|
|
assert "code_language" in problems[0]
|
|
# ...and the same change in reverse is a repair, not a problem
|
|
assert new_preflight_problems(after, before, ()) == []
|
|
|
|
|
|
def test_a_recreated_id_of_a_different_type_is_a_new_node() -> None:
|
|
before: Graph = {"nodes": [{"id": "n1", "type": "custom", "data": {"type": "code", "title": "Code"}}], "edges": []}
|
|
after: Graph = {"nodes": [{"id": "n1", "type": "custom", "data": {"type": "llm", "title": "LLM"}}], "edges": []}
|
|
|
|
problems = new_preflight_problems(before, after, ())
|
|
|
|
assert len(problems) == 1
|
|
assert problems[0].startswith("node 'n1' (llm): ")
|
|
|
|
|
|
# ---- vet_intents -------------------------------------------------------------
|
|
|
|
_ALLOWED = {"if-else", "start", "llm", "end", "template-transform"}
|
|
|
|
|
|
def _operator_of(graph: Graph) -> str:
|
|
"""The one condition operator ``_scoring_graph`` carries."""
|
|
branch = next(n for n in graph["nodes"] if n["id"] == "branch")
|
|
return branch["data"]["cases"][0]["conditions"][0]["comparison_operator"]
|
|
|
|
|
|
def _set_operator(value: str) -> MutationIntent:
|
|
return MutationIntent(
|
|
op="set_node_config",
|
|
args={"node_id": "branch", "path": "cases.0.conditions.0.comparison_operator", "value": value},
|
|
)
|
|
|
|
|
|
def test_a_batch_the_engine_accepts_has_no_rejections() -> None:
|
|
vetted = vet_intents(_scoring_graph(), [_set_operator("≤")], _ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
assert len(vetted.applicable) == 1
|
|
|
|
|
|
def test_a_structural_refusal_is_quoted_from_the_engines_own_error() -> None:
|
|
intent = MutationIntent(op="set_node_config", args={"node_id": "ghost", "path": "title", "value": "x"})
|
|
|
|
vetted = vet_intents(_scoring_graph(), [intent], _ALLOWED)
|
|
|
|
assert vetted.applicable == []
|
|
assert len(vetted.rejections) == 1
|
|
assert "node not found: ghost" in vetted.rejections[0]
|
|
|
|
|
|
# ---- the rejection text must not carry the node's credentials ----
|
|
|
|
|
|
_SECRET_KEY = "sk-live-9f3c-SECRET"
|
|
_SECRET_HEADER = "tok-999-SECRET"
|
|
|
|
|
|
_AUTHORIZATION = {"type": "api-key", "config": {"type": "bearer", "header": "X-Auth", "api_key": _SECRET_KEY}}
|
|
_HEADERS = f"Authorization: Bearer {_SECRET_HEADER}"
|
|
|
|
|
|
def _secret_http_node(data: Graph) -> Graph:
|
|
return {"id": "h", "type": "custom", "data": {"type": "http-request", **data}}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("data", "leaked"),
|
|
[
|
|
# pydantic truncates the MIDDLE of its input repr and keeps both ends, so
|
|
# which secret survives depends on key order. Both are genuinely
|
|
# reachable, so both are pinned.
|
|
({"authorization": _AUTHORIZATION, "headers": _HEADERS}, _SECRET_HEADER),
|
|
({"headers": _HEADERS, "authorization": _AUTHORIZATION}, _SECRET_KEY),
|
|
],
|
|
)
|
|
def test_the_refusal_text_withholds_the_nodes_credentials(data: Graph, leaked: str) -> None:
|
|
"""pydantic puts ``input_value=<repr of what it was given>`` in its message,
|
|
and for a model-level error (a required field missing) that repr is the
|
|
node's WHOLE ``data`` -- which for an http-request node is where a live
|
|
bearer token sits. This message goes straight into a corrective re-prompt.
|
|
|
|
The raw engine text really does carry a secret in both shapes below, so the
|
|
redaction is load-bearing rather than belt-and-braces."""
|
|
node = _secret_http_node(data)
|
|
with pytest.raises(ValueError, match=r"node 'h' \(http-request\)") as raw:
|
|
validate_node_config(node)
|
|
assert leaked in str(raw.value) # the engine's own text leaks it...
|
|
|
|
errors = preflight_errors({"nodes": [node], "edges": []})
|
|
|
|
assert len(errors) == 1
|
|
assert _SECRET_KEY not in errors[0] # ...and this one leaks neither
|
|
assert _SECRET_HEADER not in errors[0]
|
|
assert errors[0].startswith("node 'h' (http-request): ") # still says which node, and why
|
|
assert "url" in errors[0]
|
|
|
|
|
|
_BODY_SECRET = "BODYSECRET"
|
|
|
|
|
|
def test_a_secret_in_a_request_body_is_outside_redactions_declared_scope() -> None:
|
|
"""Pins the boundary, in the honest direction.
|
|
|
|
``credentials.redact_node_config`` covers ``authorization`` / ``headers`` /
|
|
``params`` -- fields that exist to hold a secret. A ``body`` value does not,
|
|
and it reaches the refusal text verbatim; anything that stores or re-prompts
|
|
this text (``handlers_edit.last_edit_rejection``) inherits that. Asserting it
|
|
rather than letting a docstring claim otherwise -- and see
|
|
``services.dify_builder.credentials`` for why widening the profile would
|
|
make http-request bodies uneditable instead of safer."""
|
|
node = _secret_http_node(
|
|
{
|
|
"method": "post", # no ``url`` -> a MODEL-level error, so pydantic dumps the whole data
|
|
"authorization": _AUTHORIZATION,
|
|
"headers": _HEADERS,
|
|
"body": {"type": "json", "data": [{"key": "", "type": "text", "value": f'{{"token":"{_BODY_SECRET}"}}'}]},
|
|
}
|
|
)
|
|
|
|
errors = preflight_errors({"nodes": [node], "edges": []})
|
|
|
|
assert len(errors) == 1
|
|
assert _SECRET_KEY not in errors[0] # in scope: withheld
|
|
assert _SECRET_HEADER not in errors[0] # in scope: withheld
|
|
assert _BODY_SECRET in errors[0] # OUT of scope: carried, and documented as such
|
|
|
|
|
|
def test_withholding_the_credential_does_not_change_the_verdict() -> None:
|
|
"""The verdict is taken from the REAL node; only the text is rebuilt from a
|
|
redacted copy. A node that starts must not be reported because redaction
|
|
changed it, and one that does not must still be reported."""
|
|
valid = _secret_http_node(
|
|
{
|
|
"title": "Call",
|
|
"method": "post",
|
|
"url": "https://x.test/a",
|
|
"authorization": _AUTHORIZATION,
|
|
"headers": _HEADERS,
|
|
"params": "",
|
|
"body": {"type": "none", "data": []},
|
|
}
|
|
)
|
|
|
|
assert preflight_errors({"nodes": [valid], "edges": []}) == []
|
|
assert new_preflight_problems({"nodes": [], "edges": []}, {"nodes": [valid], "edges": []}, ["h"]) == []
|
|
|
|
|
|
def test_an_operator_no_normalizer_can_heal_is_rejected_by_the_node_data_check() -> None:
|
|
"""The keystone: ``==`` applies cleanly (the structural filter says
|
|
"applicable, 0 rejected") and then kills the write at ``apply_repair``'s
|
|
preflight. That is the live F4 shape -- four applicable intents, a batch that
|
|
died at the port, and a corrective re-prompt that could never fire because
|
|
nothing had been rejected.
|
|
|
|
``==`` is chosen because NO normalizer heals it and none ever will: graphon
|
|
spells string equality ``is`` / ``is not`` and number equality ``=`` / ``≠``,
|
|
so ``==`` is ambiguous between them and guessing would silently change which
|
|
comparison the draft runs (``graph_normalizers._ASCII_COMPARISON_OPERATORS``
|
|
carries the two unambiguous ORDERING forms and nothing else). Step (b) proves
|
|
it rather than assuming it.
|
|
"""
|
|
graph = _scoring_graph()
|
|
intents = [_set_operator("==")]
|
|
|
|
# (a) the structural filter alone is blind to it
|
|
dry_run = graph_ops.filter_applicable(graph, intents, _ALLOWED)
|
|
assert dry_run.rejected == []
|
|
assert dry_run.applicable == intents
|
|
|
|
# (b) run the heal set over an UNHEALED application of the same intent -- the
|
|
# dry run above already healed its own graph, so asserting on that one would
|
|
# only prove the heal set is idempotent. This is the line that fails the day
|
|
# "==" becomes healable: it reports nothing changed, the operator is still
|
|
# there, and the engine still refuses the result.
|
|
unhealed, _changed = graph_ops.apply_set_node_config(graph, **intents[0].args)
|
|
assert heal_nodes_for_preflight(unhealed["nodes"]) == []
|
|
assert _operator_of(unhealed) == "=="
|
|
assert preflight_errors(unhealed)
|
|
|
|
# (c) ...so vet_intents rejects it, in the engine's own words
|
|
vetted = vet_intents(graph, intents, _ALLOWED)
|
|
assert len(vetted.rejections) == 1
|
|
assert vetted.rejections[0].startswith("- the draft would then not start: node 'branch' (if-else): ")
|
|
assert "comparison_operator" in vetted.rejections[0]
|
|
|
|
|
|
def test_the_unhealability_check_really_would_catch_a_healable_operator() -> None:
|
|
"""The contrast that gives the test above its teeth: run step (b) on ``>=``
|
|
-- which IS healable -- and every one of its assertions flips."""
|
|
graph = _scoring_graph()
|
|
|
|
unhealed, _changed = graph_ops.apply_set_node_config(graph, **_set_operator(">=").args)
|
|
|
|
assert heal_nodes_for_preflight(unhealed["nodes"]) == ["branch"]
|
|
assert _operator_of(unhealed) == "≥"
|
|
assert preflight_errors(unhealed) == []
|
|
|
|
|
|
def test_an_ascii_ordering_operator_is_healed_and_so_is_never_rejected() -> None:
|
|
"""The mirror of the test above, and the reason the brief's ``>=`` case is
|
|
dead: ``>=`` can only mean ``≥``, the shared heal set rewrites it before the
|
|
preflight, and the write chokepoint does the same -- so the dry run must NOT
|
|
report it. A dry run stricter than the write burns the re-prompt on nothing."""
|
|
graph = _scoring_graph()
|
|
|
|
vetted = vet_intents(graph, [_set_operator(">=")], _ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
assert vetted.applicable
|
|
dry = graph_ops.filter_applicable(graph, [_set_operator(">=")])
|
|
assert new_preflight_problems(graph, dry.graph, dry.changed_nodes) == []
|
|
|
|
|
|
def test_a_draft_the_user_already_broke_does_not_veto_an_unrelated_edit() -> None:
|
|
"""New problems only. The if-else was refused before the edit and is refused
|
|
after it; the edit renamed a different node and must go through."""
|
|
broken = _scoring_graph(operator="==")
|
|
rename = MutationIntent(op="set_node_config", args={"node_id": "start", "path": "title", "value": "Begin"})
|
|
|
|
vetted = vet_intents(broken, [rename], _ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
assert vetted.applicable == [rename]
|
|
|
|
|
|
def test_a_batch_that_leaves_its_own_culprit_invalid_is_refused_before_the_gate() -> None:
|
|
"""The dry run has to predict the port's refusal, or the corrective
|
|
re-prompt cannot fire and the handler learns nothing to carry forward.
|
|
|
|
``branch`` is already refused at the operator; the batch rewrites that exact
|
|
field to another value the engine also refuses. Nothing is NEW, so before the
|
|
``touched`` rule this reached the approval gate looking healthy and died at
|
|
the write."""
|
|
already_broken = _scoring_graph(operator="==")
|
|
|
|
vetted = vet_intents(already_broken, [_set_operator("klingon")], _ALLOWED)
|
|
|
|
assert len(vetted.rejections) == 1
|
|
assert "node 'branch' (if-else)" in vetted.rejections[0]
|
|
assert "comparison_operator" in vetted.rejections[0]
|
|
|
|
|
|
def test_wiring_an_already_broken_node_does_not_make_the_batch_answerable_for_it() -> None:
|
|
"""``touched`` is "wrote this node's DATA", not "appears in the change set".
|
|
|
|
``apply_connect`` reports BOTH endpoints as changed -- right for a diff,
|
|
wrong here: an edge cannot change either endpoint's
|
|
``validate_node_config`` verdict. Counting them would mean that merely
|
|
wiring a node the user already broke makes its pre-existing defect veto the
|
|
whole batch, which is the invariant the untouched rule exists to keep."""
|
|
already_broken: Graph = {
|
|
"nodes": [
|
|
{"id": "llm1", "type": "custom", "data": {"type": "llm", "title": "LLM"}},
|
|
{"id": "end1", "type": "custom", "data": {"type": "end", "title": "End"}},
|
|
],
|
|
"edges": [],
|
|
}
|
|
assert len(preflight_errors(already_broken)) == 2 # both endpoints are refused as they stand
|
|
|
|
wire = MutationIntent(op="connect", args={"from_node": "llm1", "to_node": "end1"})
|
|
vetted = vet_intents(already_broken, [wire], {"llm", "end"})
|
|
|
|
assert vetted.rejections == []
|
|
assert vetted.applicable == [wire]
|
|
|
|
|
|
def test_a_batch_that_really_fixes_its_culprit_still_passes() -> None:
|
|
"""The other half: the same already-broken node, repaired properly, is not
|
|
refused -- otherwise ``touched`` would veto every repair."""
|
|
already_broken = _scoring_graph(operator="==")
|
|
|
|
vetted = vet_intents(already_broken, [_set_operator("≥")], _ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
assert len(vetted.applicable) == 1
|
|
|
|
|
|
def test_a_created_node_the_engine_would_refuse_is_named_in_the_rejection() -> None:
|
|
"""The node the batch ADDS has no "before" to compare against, so anything
|
|
the engine refuses about it is new by definition -- and the rejection names
|
|
the id the LLM chose, which is what makes it actionable in the re-prompt."""
|
|
intent = MutationIntent(op="create_node", args={"node_type": "if-else", "node_id": "n9", "config": {"cases": 7}})
|
|
|
|
vetted = vet_intents(_scoring_graph(), [intent], _ALLOWED)
|
|
|
|
assert len(vetted.rejections) == 1
|
|
assert "node 'n9' (if-else)" in vetted.rejections[0]
|
|
|
|
|
|
def test_a_structural_rejection_line_withholds_the_nodes_credentials() -> None:
|
|
"""A rejection line inlines the whole ``intent.args`` -- including a
|
|
``create_node``'s entire ``config`` -- into a corrective re-prompt, which is
|
|
the same channel ``_withheld_message`` already guards for a node's stored
|
|
config. These args are model-authored today, so nothing stored leaks; the
|
|
redaction is here so a later path that round-trips a real node's data
|
|
through an intent cannot silently re-open the hole."""
|
|
intent = MutationIntent(
|
|
op="create_node",
|
|
args={
|
|
"node_type": "http-request", # not in _ALLOWED -- a structural refusal
|
|
"node_id": "h",
|
|
"config": {"title": "t", "method": "get", "url": "u", "authorization": _AUTHORIZATION, "headers": _HEADERS},
|
|
},
|
|
)
|
|
|
|
vetted = vet_intents(_scoring_graph(), [intent], _ALLOWED)
|
|
|
|
assert len(vetted.rejections) == 1
|
|
assert _SECRET_KEY not in vetted.rejections[0]
|
|
assert _SECRET_HEADER not in vetted.rejections[0]
|
|
assert "__DIFY_BUILDER_REDACTED__" in vetted.rejections[0] # the KEYS still reach the model
|
|
assert "node_type not allowed" in vetted.rejections[0] # ...and so does the engine's reason
|
|
|
|
|
|
def test_two_crashes_with_different_messages_are_different_problems() -> None:
|
|
"""A node whose validation raises something other than a ``ValidationError``
|
|
has no field path, so its exception stands in for one. Keyed on the class
|
|
ALONE, every ``KeyError`` on a node would be the same problem and a change
|
|
that swapped one crashing shape for another would pass unnoticed, so the
|
|
(truncated) message is part of the key.
|
|
|
|
Reaches for the private key builder deliberately: graphon has exactly one
|
|
live bare-crash site today (``HttpRequestNodeAuthorization.check_config``,
|
|
always ``KeyError: 'type'``), so there is no pair of real nodes that differs
|
|
only in the crash message -- this pins the rule before there is."""
|
|
from services.dify_builder.preflight import _CRASH_LOCATION, _error_locations
|
|
|
|
first = _error_locations(KeyError("type"))
|
|
second = _error_locations(KeyError("method"))
|
|
|
|
assert first != second
|
|
assert first == frozenset({(_CRASH_LOCATION, "KeyError", "'type'")})
|
|
assert _error_locations(KeyError("type")) == first # and the SAME crash is still the same problem
|
|
|
|
|
|
def test_a_crash_message_never_reaches_the_text_the_model_is_shown() -> None:
|
|
"""The crash message is the one part of a location not derived from a field
|
|
path, so it is the one part that could echo a value out of the node. It is
|
|
keyed on, never rendered."""
|
|
from services.dify_builder.preflight import _CRASH_LOCATION, _location_label
|
|
|
|
assert _location_label((_CRASH_LOCATION, "KeyError", "'sk-live-SECRET'")) == "<crash>.KeyError"
|
|
assert _location_label(("cases", "0", "comparison_operator")) == "cases.0.comparison_operator"
|
|
|
|
|
|
# ---- a new branch must feed the variable-aggregator it rejoins ---------------
|
|
|
|
_REJOIN_ALLOWED = {"start", "if-else", "template-transform", "variable-aggregator", "end"}
|
|
|
|
_EXISTING_SELECTORS = [["node3", "output"], ["node4", "output"]]
|
|
|
|
|
|
def _rejoin_graph(aggregator_data: Graph | None = None) -> Graph:
|
|
"""The live S6 draft's shape: start -> if-else -> two template-transforms ->
|
|
variable-aggregator -> end, every node as the engine accepts it."""
|
|
return {
|
|
"nodes": [
|
|
{
|
|
"id": "node1",
|
|
"type": "custom",
|
|
"data": {
|
|
"type": "start",
|
|
"title": "Start",
|
|
"variables": [
|
|
{"variable": "score", "type": "number", "label": "Score", "required": True, "options": []}
|
|
],
|
|
},
|
|
},
|
|
{
|
|
"id": "node2",
|
|
"type": "custom",
|
|
"data": {
|
|
"type": "if-else",
|
|
"title": "Check Threshold",
|
|
"cases": [
|
|
{
|
|
"case_id": "true",
|
|
"logical_operator": "and",
|
|
"conditions": [
|
|
{
|
|
"id": "c1",
|
|
"varType": "number",
|
|
"variable_selector": ["node1", "score"],
|
|
"comparison_operator": "=",
|
|
"value": "60",
|
|
}
|
|
],
|
|
}
|
|
],
|
|
},
|
|
},
|
|
{
|
|
"id": "node3",
|
|
"type": "custom",
|
|
"data": {"type": "template-transform", "title": "Pass", "template": "pass", "variables": []},
|
|
},
|
|
{
|
|
"id": "node4",
|
|
"type": "custom",
|
|
"data": {"type": "template-transform", "title": "Fail", "template": "fail", "variables": []},
|
|
},
|
|
{
|
|
"id": "node5",
|
|
"type": "custom",
|
|
"data": aggregator_data
|
|
or {
|
|
"type": "variable-aggregator",
|
|
"title": "Merge Result",
|
|
"output_type": "string",
|
|
"variables": copy.deepcopy(_EXISTING_SELECTORS),
|
|
},
|
|
},
|
|
{
|
|
"id": "node6",
|
|
"type": "custom",
|
|
"data": {"type": "end", "title": "End", "outputs": [{"variable": "result", "value_selector": []}]},
|
|
},
|
|
],
|
|
"edges": [
|
|
{"id": "e1", "source": "node1", "target": "node2", "sourceHandle": "source", "targetHandle": "target"},
|
|
{"id": "e2", "source": "node2", "target": "node3", "sourceHandle": "true", "targetHandle": "target"},
|
|
{"id": "e3", "source": "node2", "target": "node4", "sourceHandle": "false", "targetHandle": "target"},
|
|
{"id": "e4", "source": "node3", "target": "node5", "sourceHandle": "source", "targetHandle": "target"},
|
|
{"id": "e5", "source": "node4", "target": "node5", "sourceHandle": "source", "targetHandle": "target"},
|
|
{"id": "e6", "source": "node5", "target": "node6", "sourceHandle": "source", "targetHandle": "target"},
|
|
],
|
|
}
|
|
|
|
|
|
def _new_branch_intents(target: str = "node5") -> list[MutationIntent]:
|
|
"""The structural half of the live S6 batch: a new arm off the if-else that
|
|
rejoins at ``target``."""
|
|
return [
|
|
MutationIntent(
|
|
op="create_node",
|
|
args={
|
|
"node_type": "template-transform",
|
|
"node_id": "node7",
|
|
"config": {"title": "Excellent Message", "template": "excellent", "variables": []},
|
|
},
|
|
),
|
|
MutationIntent(op="connect", args={"from_node": "node2", "to_node": "node7", "source_handle": "true"}),
|
|
MutationIntent(op="connect", args={"from_node": "node7", "to_node": target}),
|
|
]
|
|
|
|
|
|
def test_a_new_branch_into_an_aggregator_that_does_not_list_it_is_refused() -> None:
|
|
"""The one shape that still produced "All checks passed" on empty output:
|
|
the aggregator stays engine-VALID (its two existing selectors resolve), so
|
|
the preflight has nothing to say, and graphon's ``VariableAggregatorNode``
|
|
skips the arm it was never told about in silence."""
|
|
vetted = vet_intents(_rejoin_graph(), _new_branch_intents(), _REJOIN_ALLOWED)
|
|
|
|
assert len(vetted.rejections) == 1
|
|
reason = vetted.rejections[0]
|
|
assert "node5" in reason
|
|
assert "node7" in reason
|
|
assert '["node7", "output"]' in reason
|
|
|
|
|
|
def test_the_same_branch_is_accepted_once_the_aggregator_lists_it() -> None:
|
|
"""The other half: the batch that does the whole job goes through
|
|
untouched, so the guard cannot be satisfied by refusing everything."""
|
|
intents = [
|
|
*_new_branch_intents(),
|
|
MutationIntent(
|
|
op="set_node_config",
|
|
args={
|
|
"node_id": "node5",
|
|
"path": "variables",
|
|
"value": [*copy.deepcopy(_EXISTING_SELECTORS), ["node7", "output"]],
|
|
},
|
|
),
|
|
]
|
|
|
|
vetted = vet_intents(_rejoin_graph(), intents, _REJOIN_ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
assert len(vetted.applicable) == 4
|
|
|
|
|
|
def test_a_new_branch_that_reaches_no_aggregator_is_accepted() -> None:
|
|
"""The guard fires on an edge whose TARGET is an aggregator and nothing
|
|
else. A new arm that routes to the End node is an ordinary edit."""
|
|
vetted = vet_intents(_rejoin_graph(), _new_branch_intents(target="node6"), _REJOIN_ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
assert len(vetted.applicable) == 3
|
|
|
|
|
|
def test_an_edge_into_an_aggregator_that_already_existed_is_not_re_checked() -> None:
|
|
"""A draft the user already wired badly must not veto an unrelated edit --
|
|
the same rule ``new_preflight_problems`` keeps for node data. ``node4``
|
|
already feeds ``node5`` and is already missing from its ``variables``."""
|
|
graph = _rejoin_graph(
|
|
aggregator_data={
|
|
"type": "variable-aggregator",
|
|
"title": "Merge Result",
|
|
"output_type": "string",
|
|
"variables": [["node3", "output"]],
|
|
}
|
|
)
|
|
rename = MutationIntent(op="set_node_config", args={"node_id": "node3", "path": "title", "value": "Passed"})
|
|
|
|
vetted = vet_intents(graph, [rename], _REJOIN_ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
assert vetted.applicable == [rename]
|
|
|
|
|
|
def test_a_two_node_branch_is_accepted_when_the_aggregator_lists_its_head() -> None:
|
|
"""A branch of more than one node is an ordinary shape. The batch adds
|
|
``node7 -> node8`` and ``node8 -> node5``; both run whenever the new arm
|
|
runs, so an aggregator that lists EITHER of them has been told about it.
|
|
Keyed on the edge's immediate source alone, this was refused."""
|
|
graph = _rejoin_graph(
|
|
aggregator_data={
|
|
"type": "variable-aggregator",
|
|
"title": "Merge Result",
|
|
"output_type": "string",
|
|
"variables": [*copy.deepcopy(_EXISTING_SELECTORS), ["node7", "output"]],
|
|
}
|
|
)
|
|
intents = [
|
|
MutationIntent(
|
|
op="create_node",
|
|
args={
|
|
"node_type": "template-transform",
|
|
"node_id": "node7",
|
|
"config": {"title": "Excellent", "template": "excellent", "variables": []},
|
|
},
|
|
),
|
|
MutationIntent(
|
|
op="create_node",
|
|
args={
|
|
"node_type": "template-transform",
|
|
"node_id": "node8",
|
|
"config": {"title": "Decorate", "template": "{{ x }}", "variables": []},
|
|
},
|
|
),
|
|
MutationIntent(op="connect", args={"from_node": "node2", "to_node": "node7", "source_handle": "true"}),
|
|
MutationIntent(op="connect", args={"from_node": "node7", "to_node": "node8"}),
|
|
MutationIntent(op="connect", args={"from_node": "node8", "to_node": "node5"}),
|
|
]
|
|
|
|
vetted = vet_intents(graph, intents, _REJOIN_ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
|
|
|
|
def test_the_ancestor_walk_does_not_cross_into_the_graphs_other_arms() -> None:
|
|
"""The soundness limit of that walk, and the reason it follows only the
|
|
batch's OWN new edges. ``node5`` lists ``node3``/``node4``, which sit on the
|
|
if-else's OTHER arms -- they never run when the new one does. A walk through
|
|
pre-existing edges would find ``node2`` above them all and accept exactly
|
|
the batch that produces nothing."""
|
|
vetted = vet_intents(_rejoin_graph(), _new_branch_intents(), _REJOIN_ALLOWED)
|
|
|
|
assert len(vetted.rejections) == 1
|
|
|
|
|
|
def test_a_selector_naming_a_variable_the_node_does_not_publish_is_refused() -> None:
|
|
"""Rooted-at is not enough. A template-transform publishes ``output``; a
|
|
selector naming ``text`` on it is exactly as unresolvable as no selector at
|
|
all and the run skips it in the same silence, so accepting any name would
|
|
leave the failure intact behind a selector that looks right."""
|
|
graph = _rejoin_graph(
|
|
aggregator_data={
|
|
"type": "variable-aggregator",
|
|
"title": "Merge Result",
|
|
"output_type": "string",
|
|
"variables": [*copy.deepcopy(_EXISTING_SELECTORS), ["node7", "text"]],
|
|
}
|
|
)
|
|
|
|
vetted = vet_intents(graph, _new_branch_intents(), _REJOIN_ALLOWED)
|
|
|
|
assert len(vetted.rejections) == 1
|
|
assert '["node7", "output"]' in vetted.rejections[0] # ...and it says which name to use
|
|
|
|
|
|
def test_a_variable_name_this_module_cannot_know_is_accepted() -> None:
|
|
"""The other side of the same rule: a tool's outputs are whatever the tool
|
|
decides at run time, so no closed set exists and the guard must not invent
|
|
one. A name it cannot confirm is accepted; only a name it KNOWS is wrong is
|
|
refused."""
|
|
graph = _rejoin_graph(
|
|
aggregator_data={
|
|
"type": "variable-aggregator",
|
|
"title": "Merge Result",
|
|
"output_type": "string",
|
|
"variables": [*copy.deepcopy(_EXISTING_SELECTORS), ["node7", "whatever_the_tool_returns"]],
|
|
}
|
|
)
|
|
intents = [
|
|
MutationIntent(
|
|
op="create_node",
|
|
args={
|
|
"node_type": "tool",
|
|
"node_id": "node7",
|
|
"config": {
|
|
"title": "Call",
|
|
"provider_id": "p",
|
|
"provider_type": "builtin",
|
|
"provider_name": "p",
|
|
"tool_name": "t",
|
|
"tool_label": "T",
|
|
"tool_parameters": {},
|
|
"tool_configurations": {},
|
|
},
|
|
},
|
|
),
|
|
MutationIntent(op="connect", args={"from_node": "node2", "to_node": "node7", "source_handle": "true"}),
|
|
MutationIntent(op="connect", args={"from_node": "node7", "to_node": "node5"}),
|
|
]
|
|
|
|
vetted = vet_intents(graph, intents, {*_REJOIN_ALLOWED, "tool"})
|
|
|
|
assert [r for r in vetted.rejections if "variable-aggregator" in r] == []
|
|
|
|
|
|
def test_a_code_nodes_published_names_are_read_off_the_node_itself() -> None:
|
|
"""A code node declares its own outputs, so that is where the answer is --
|
|
``result`` is published and accepted, and nothing else would be."""
|
|
graph = _rejoin_graph(
|
|
aggregator_data={
|
|
"type": "variable-aggregator",
|
|
"title": "Merge Result",
|
|
"output_type": "string",
|
|
"variables": [*copy.deepcopy(_EXISTING_SELECTORS), ["node7", "result"]],
|
|
}
|
|
)
|
|
intents = [
|
|
MutationIntent(
|
|
op="create_node",
|
|
args={
|
|
"node_type": "code",
|
|
"node_id": "node7",
|
|
"config": {
|
|
"title": "Grade",
|
|
"code": "def main():\n return {'result': 'excellent'}",
|
|
"code_language": "python3",
|
|
"variables": [],
|
|
"outputs": {"result": {"type": "string"}},
|
|
},
|
|
},
|
|
),
|
|
MutationIntent(op="connect", args={"from_node": "node2", "to_node": "node7", "source_handle": "true"}),
|
|
MutationIntent(op="connect", args={"from_node": "node7", "to_node": "node5"}),
|
|
]
|
|
|
|
vetted = vet_intents(graph, intents, {*_REJOIN_ALLOWED, "code"})
|
|
|
|
assert [r for r in vetted.rejections if "variable-aggregator" in r] == []
|
|
|
|
|
|
def test_a_grouped_aggregator_that_lists_the_new_branch_in_a_group_is_accepted() -> None:
|
|
"""graphon reads the selectors out of ``advanced_settings.groups`` when
|
|
``group_enabled`` is set, so a guard that only looked at ``variables``
|
|
would refuse a branch that is already wired correctly."""
|
|
graph = _rejoin_graph(
|
|
aggregator_data={
|
|
"type": "variable-aggregator",
|
|
"title": "Merge Result",
|
|
"output_type": "string",
|
|
"variables": copy.deepcopy(_EXISTING_SELECTORS),
|
|
"advanced_settings": {
|
|
"group_enabled": True,
|
|
"groups": [
|
|
{
|
|
"group_name": "result",
|
|
"output_type": "string",
|
|
"variables": [*copy.deepcopy(_EXISTING_SELECTORS), ["node7", "output"]],
|
|
}
|
|
],
|
|
},
|
|
}
|
|
)
|
|
|
|
vetted = vet_intents(graph, _new_branch_intents(), _REJOIN_ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
|
|
|
|
# ---- a whole-array write must not drop an element that had an identity ------
|
|
|
|
|
|
_S6_CASES_REWRITE = [
|
|
{
|
|
"case_id": "excellent",
|
|
"logical_operator": "and",
|
|
"conditions": [{"variable_selector": ["node1", "score"], "comparison_operator": ">=", "value": 90}],
|
|
},
|
|
{
|
|
"case_id": "true",
|
|
"logical_operator": "and",
|
|
"conditions": [{"variable_selector": ["node1", "score"], "comparison_operator": ">=", "value": 60}],
|
|
},
|
|
]
|
|
|
|
|
|
def test_a_whole_array_rewrite_that_drops_an_elements_identity_is_refused() -> None:
|
|
"""The live S6 clobber. The rewrite keeps both ``case_id``s, so it looks
|
|
like an append -- but it regenerated the surviving case's condition from
|
|
scratch, losing its ``id`` and turning ``= 60`` into ``≥ 60``. graphon's
|
|
``Condition`` has no ``id`` field at all, so nothing downstream ever
|
|
complains."""
|
|
intent = MutationIntent(
|
|
op="set_node_config",
|
|
args={"node_id": "node2", "path": "cases", "value": copy.deepcopy(_S6_CASES_REWRITE)},
|
|
)
|
|
|
|
vetted = vet_intents(_rejoin_graph(), [intent], _REJOIN_ALLOWED)
|
|
|
|
assert len(vetted.rejections) == 1
|
|
reason = vetted.rejections[0]
|
|
assert "node2" in reason
|
|
assert "cases" in reason
|
|
assert "c1" in reason
|
|
|
|
|
|
def test_an_append_that_repeats_every_existing_element_is_accepted() -> None:
|
|
"""What the op schema tells the model to do, byte for byte: the existing
|
|
case comes back unchanged and the new one is appended."""
|
|
graph = _rejoin_graph()
|
|
existing = copy.deepcopy(next(n for n in graph["nodes"] if n["id"] == "node2")["data"]["cases"])
|
|
appended = [
|
|
*existing,
|
|
{
|
|
"case_id": "excellent",
|
|
"logical_operator": "and",
|
|
"conditions": [
|
|
{
|
|
"id": "c2",
|
|
"varType": "number",
|
|
"variable_selector": ["node1", "score"],
|
|
"comparison_operator": "≥",
|
|
"value": "90",
|
|
}
|
|
],
|
|
},
|
|
]
|
|
intent = MutationIntent(op="set_node_config", args={"node_id": "node2", "path": "cases", "value": appended})
|
|
|
|
vetted = vet_intents(graph, [intent], _REJOIN_ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
|
|
|
|
def test_changing_one_element_by_index_is_accepted() -> None:
|
|
"""The indexed path writes a scalar, not an array, so there is no
|
|
whole-array replacement to compare identities across."""
|
|
intent = MutationIntent(
|
|
op="set_node_config",
|
|
args={"node_id": "node2", "path": "cases.0.conditions.0.comparison_operator", "value": "≥"},
|
|
)
|
|
|
|
vetted = vet_intents(_rejoin_graph(), [intent], _REJOIN_ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
|
|
|
|
def test_rewriting_an_llm_prompt_is_an_ordinary_edit() -> None:
|
|
"""The false rejection this scoping exists to remove, and the commonest
|
|
thing anyone asks the Builder to do. A ``prompt_template`` message carries
|
|
an ``id`` the canvas mints and REGENERATES when it is absent, and graphon's
|
|
``ChatModelMessage`` has no ``id`` field at all -- so re-sending the array
|
|
without those uuids loses nothing. Judged by identity alone it looked like
|
|
the S6 clobber and the user's change was parked."""
|
|
graph = _rejoin_graph()
|
|
graph["nodes"].append(
|
|
{
|
|
"id": "llm1",
|
|
"type": "custom",
|
|
"data": {
|
|
"type": "llm",
|
|
"title": "Grade",
|
|
"model": {"provider": "openai", "name": "gpt-4o", "mode": "chat", "completion_params": {}},
|
|
"prompt_template": [
|
|
{"id": "m1", "role": "system", "text": "You grade scores."},
|
|
{"id": "m2", "role": "user", "text": "{{#node1.score#}}"},
|
|
],
|
|
"context": {"enabled": False, "variable_selector": []},
|
|
"vision": {"enabled": False},
|
|
},
|
|
}
|
|
)
|
|
intent = MutationIntent(
|
|
op="set_node_config",
|
|
args={
|
|
"node_id": "llm1",
|
|
"path": "prompt_template",
|
|
"value": [
|
|
{"role": "system", "text": "You grade scores strictly."},
|
|
{"role": "user", "text": "{{#node1.score#}}"},
|
|
],
|
|
},
|
|
)
|
|
|
|
vetted = vet_intents(graph, [intent], {*_REJOIN_ALLOWED, "llm"})
|
|
|
|
assert vetted.rejections == []
|
|
assert vetted.applicable == [intent]
|
|
|
|
|
|
def test_a_question_classifiers_classes_keep_their_branch_ids() -> None:
|
|
"""The other scoped array that is a branch handle. ``ClassConfig.id`` is
|
|
required by graphon and is what an edge routes on, so a rewrite that drops
|
|
one orphans that arm exactly the way a lost ``case_id`` would.
|
|
|
|
The replacement class carries an id of its own, so the engine is perfectly
|
|
happy with the result -- which is the point: only this guard notices that
|
|
``c-low``'s arm now hangs off nothing."""
|
|
graph = _rejoin_graph()
|
|
graph["nodes"].append(
|
|
{
|
|
"id": "qc1",
|
|
"type": "custom",
|
|
"data": {
|
|
"type": "question-classifier",
|
|
"title": "Sort",
|
|
"query_variable_selector": ["node1", "score"],
|
|
"model": {"provider": "openai", "name": "gpt-4o", "mode": "chat", "completion_params": {}},
|
|
"classes": [{"id": "c-high", "name": "high"}, {"id": "c-low", "name": "low"}],
|
|
},
|
|
}
|
|
)
|
|
intent = MutationIntent(
|
|
op="set_node_config",
|
|
args={
|
|
"node_id": "qc1",
|
|
"path": "classes",
|
|
"value": [{"id": "c-high", "name": "high"}, {"id": "c-new", "name": "middling"}],
|
|
},
|
|
)
|
|
|
|
vetted = vet_intents(graph, [intent], {*_REJOIN_ALLOWED, "question-classifier"})
|
|
|
|
assert len(vetted.rejections) == 1
|
|
assert "c-low" in vetted.rejections[0]
|
|
|
|
|
|
def test_an_array_whose_elements_never_had_identities_is_untouched() -> None:
|
|
"""An aggregator's ``variables`` is a list of bare selector arrays with no
|
|
ids in it, and re-sending it whole is the ONLY way to extend it -- the
|
|
guard must not stand in the way of the fix the other guard demands."""
|
|
intent = MutationIntent(
|
|
op="set_node_config",
|
|
args={"node_id": "node5", "path": "variables", "value": [["node3", "output"]]},
|
|
)
|
|
|
|
vetted = vet_intents(_rejoin_graph(), [intent], _REJOIN_ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
|
|
|
|
def _two_case_graph(operator: str = "≥", value: object = "60") -> Graph:
|
|
"""The rejoin draft with a SECOND case on ``node2``, so there is something
|
|
to delete."""
|
|
graph = _rejoin_graph()
|
|
node2 = next(n for n in graph["nodes"] if n["id"] == "node2")
|
|
node2["data"]["cases"] = [
|
|
{
|
|
"case_id": "true",
|
|
"logical_operator": "and",
|
|
"conditions": [
|
|
{
|
|
"id": "c1",
|
|
"varType": "number",
|
|
"variable_selector": ["node1", "score"],
|
|
"comparison_operator": operator,
|
|
"value": value,
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"case_id": "excellent",
|
|
"logical_operator": "and",
|
|
"conditions": [
|
|
{
|
|
"id": "c2",
|
|
"varType": "number",
|
|
"variable_selector": ["node1", "score"],
|
|
"comparison_operator": "≥",
|
|
"value": "90",
|
|
}
|
|
],
|
|
},
|
|
]
|
|
return graph
|
|
|
|
|
|
def _cases_of(graph: Graph) -> list[Graph]:
|
|
return next(n for n in graph["nodes"] if n["id"] == "node2")["data"]["cases"]
|
|
|
|
|
|
def test_deleting_a_case_outright_is_accepted() -> None:
|
|
"""Removing an if-else case is a legitimate edit, and a whole-array write
|
|
is the only way to express one. A guard that refused it would make the
|
|
feature unusable -- loudly, but unusably."""
|
|
graph = _two_case_graph()
|
|
kept = [copy.deepcopy(_cases_of(graph)[0])] # the survivor, byte-identical
|
|
intent = MutationIntent(op="set_node_config", args={"node_id": "node2", "path": "cases", "value": kept})
|
|
|
|
vetted = vet_intents(graph, [intent], _REJOIN_ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
assert vetted.applicable == [intent]
|
|
|
|
|
|
def test_deleting_one_case_while_modifying_another_is_refused() -> None:
|
|
"""The mixed write, and the reason a deletion is recognised by what it
|
|
KEEPS rather than by what is missing: one element is gone and the survivor
|
|
was rewritten in the same breath. That is the S6 clobber wearing a
|
|
deletion's clothes."""
|
|
graph = _two_case_graph()
|
|
modified = copy.deepcopy(_cases_of(graph)[0])
|
|
modified["conditions"][0]["comparison_operator"] = "≤"
|
|
intent = MutationIntent(op="set_node_config", args={"node_id": "node2", "path": "cases", "value": [modified]})
|
|
|
|
vetted = vet_intents(graph, [intent], _REJOIN_ALLOWED)
|
|
|
|
assert len(vetted.rejections) == 1
|
|
assert "c2" in vetted.rejections[0] # the identity that went missing
|
|
|
|
|
|
def test_a_deletion_is_not_a_licence_to_repeat_one_element_in_place_of_two() -> None:
|
|
"""Each original may be claimed once. Re-sending the surviving case twice
|
|
keeps the array's length honest while still losing the other case's
|
|
identity, and would pass a naive subset test."""
|
|
graph = _two_case_graph()
|
|
survivor = copy.deepcopy(_cases_of(graph)[0])
|
|
intent = MutationIntent(
|
|
op="set_node_config",
|
|
args={"node_id": "node2", "path": "cases", "value": [survivor, copy.deepcopy(survivor)]},
|
|
)
|
|
|
|
vetted = vet_intents(graph, [intent], _REJOIN_ALLOWED)
|
|
|
|
assert len(vetted.rejections) == 1
|
|
assert "c2" in vetted.rejections[0]
|
|
|
|
|
|
def test_a_deletion_survives_a_survivor_the_heal_set_will_normalize() -> None:
|
|
"""``after`` is the dry run's already-healed graph, so the comparison is
|
|
made against a healed copy of ``before`` too. A draft holding ``">="`` and
|
|
a JSON ``60`` -- the ESQ1-303 shape this whole plan grew out of -- would
|
|
otherwise make a survivor the model re-sent verbatim look modified, and a
|
|
genuine deletion would be refused for a difference the heal introduced."""
|
|
graph = _two_case_graph(operator=">=", value=60)
|
|
kept = [copy.deepcopy(_cases_of(graph)[0])]
|
|
assert kept[0]["conditions"][0]["comparison_operator"] == ">=" # the draft really is unhealed
|
|
intent = MutationIntent(op="set_node_config", args={"node_id": "node2", "path": "cases", "value": kept})
|
|
|
|
vetted = vet_intents(graph, [intent], _REJOIN_ALLOWED)
|
|
|
|
assert vetted.rejections == []
|
|
|
|
|
|
def test_adding_a_case_while_dropping_another_is_still_refused() -> None:
|
|
"""A write that both loses an identity and ADDS an element is not a
|
|
deletion by any reading -- this is the S6 shape at its plainest."""
|
|
graph = _two_case_graph()
|
|
intent = MutationIntent(
|
|
op="set_node_config",
|
|
args={
|
|
"node_id": "node2",
|
|
"path": "cases",
|
|
"value": [
|
|
copy.deepcopy(_cases_of(graph)[0]),
|
|
{
|
|
"case_id": "outstanding",
|
|
"logical_operator": "and",
|
|
"conditions": [
|
|
{"variable_selector": ["node1", "score"], "comparison_operator": "≥", "value": "95"}
|
|
],
|
|
},
|
|
],
|
|
},
|
|
)
|
|
|
|
vetted = vet_intents(graph, [intent], _REJOIN_ALLOWED)
|
|
|
|
assert len(vetted.rejections) == 1
|
|
assert "c2" in vetted.rejections[0]
|
|
|
|
|
|
def test_a_rejection_line_withholds_a_credential_the_intents_own_path_names() -> None:
|
|
"""``_withheld_args`` redacts a ``value`` that is shaped like a whole node
|
|
``data``. A ``set_node_config`` does not send one: it sends the value at a
|
|
PATH, so ``path="headers"`` with a header line, or ``path="authorization"``
|
|
with the auth block itself, reached the retry prompt unredacted.
|
|
|
|
Unreachable with a stored secret today -- these args are model-authored and
|
|
the model is never shown one -- so this closes the shape, not a live leak.
|
|
"""
|
|
from services.dify_builder.preflight import _withheld_args
|
|
|
|
header_line = _withheld_args(
|
|
MutationIntent(op="set_node_config", args={"node_id": "h", "path": "headers", "value": _HEADERS})
|
|
)
|
|
whole_block = _withheld_args(
|
|
MutationIntent(op="set_node_config", args={"node_id": "h", "path": "authorization", "value": _AUTHORIZATION})
|
|
)
|
|
nested_config = _withheld_args(
|
|
MutationIntent(
|
|
op="set_node_config",
|
|
args={"node_id": "h", "path": "authorization.config", "value": _AUTHORIZATION["config"]},
|
|
)
|
|
)
|
|
|
|
assert _SECRET_HEADER not in header_line["value"]
|
|
assert "Authorization" in header_line["value"] # the header NAME still reaches the model
|
|
assert _SECRET_KEY not in json.dumps(whole_block)
|
|
assert whole_block["value"]["config"]["header"] == "X-Auth" # ...and so does the structure
|
|
assert _SECRET_KEY not in json.dumps(nested_config)
|
|
assert nested_config["value"]["type"] == "bearer" # the auth MODE is structure, not a secret
|
|
|
|
|
|
def test_withholding_by_path_leaves_an_ordinary_value_exactly_as_it_was() -> None:
|
|
"""The redaction is keyed on the path a credential actually lives at. A
|
|
``cases`` rewrite, an indexed condition value, a title -- none of them is a
|
|
credential field, and a guard that mangled them would corrupt the one
|
|
corrective re-prompt the batch gets."""
|
|
from services.dify_builder.preflight import _withheld_args
|
|
|
|
cases = [{"case_id": "true", "conditions": [{"id": "c1", "value": "60"}]}]
|
|
intent = MutationIntent(op="set_node_config", args={"node_id": "branch", "path": "cases", "value": cases})
|
|
|
|
assert _withheld_args(intent)["value"] == cases
|
|
|
|
|
|
# These nodes pass native node-data validation: Builder's first-phase boundary
|
|
# must refuse their proposed addition independently of the native whitelist.
|
|
_FIRST_PHASE_CONFIGS = [
|
|
("knowledge-retrieval", {"dataset_ids": ["kb-1"]}),
|
|
("human-input", _human_input_graph([])["nodes"][1]["data"]),
|
|
("agent", {"agent_strategy_name": "legacy"}),
|
|
("agent", {"version": "2", "agent_node_kind": "dify_agent", "agent_task": "fabricated"}),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(("node_type", "config"), _FIRST_PHASE_CONFIGS)
|
|
@pytest.mark.parametrize("op", ["create_node", "insert_between"])
|
|
def test_candidate_refuses_first_phase_additions_without_losing_valid_batch_operations(
|
|
node_type: str, config: dict[str, object], op: str
|
|
) -> None:
|
|
from graphon.enums import BUILT_IN_NODE_TYPES
|
|
|
|
graph: Graph = {
|
|
"nodes": [
|
|
{"id": "s", "type": "custom", "data": {"type": "start", "title": "Start", "variables": []}},
|
|
{"id": "e", "type": "custom", "data": {"type": "end", "title": "End", "outputs": []}},
|
|
],
|
|
"edges": [{"id": "se", "source": "s", "target": "e"}],
|
|
}
|
|
args = {"node_type": node_type, "node_id": "new", "config": config}
|
|
if op == "insert_between":
|
|
args["edge"] = {"source": "s", "target": "e"}
|
|
good = MutationIntent(op="set_node_config", args={"node_id": "s", "path": "title", "value": "Begin"})
|
|
forbidden = MutationIntent(op=op, args=args)
|
|
|
|
vetted = vet_intents(graph, [good, forbidden], set(BUILT_IN_NODE_TYPES))
|
|
|
|
assert vetted.would_run_wrong, "native-valid unsupported additions must condemn the whole candidate batch"
|
|
assert good in vetted.applicable
|
|
assert forbidden in vetted.applicable
|
|
assert any(node_type in reason for reason in vetted.rejections)
|
|
|
|
|
|
@pytest.mark.parametrize(("node_type", "config"), _FIRST_PHASE_CONFIGS)
|
|
def test_candidate_refuses_type_conversion_into_first_phase_nodes(node_type: str, config: dict[str, object]) -> None:
|
|
graph: Graph = {
|
|
"nodes": [
|
|
{
|
|
"id": "n",
|
|
"type": "custom",
|
|
"data": {
|
|
"title": "Convert",
|
|
"code_language": "python3",
|
|
"code": "",
|
|
"variables": [],
|
|
"outputs": {},
|
|
"retrieval_mode": "multiple",
|
|
**config,
|
|
"type": "code",
|
|
},
|
|
}
|
|
],
|
|
"edges": [],
|
|
}
|
|
intent = MutationIntent(op="set_node_config", args={"node_id": "n", "path": "type", "value": node_type})
|
|
|
|
vetted = vet_intents(graph, [intent])
|
|
|
|
assert vetted.would_run_wrong
|
|
assert any(node_type in reason for reason in vetted.rejections)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("path", "value"), [("dataset_ids", ["kb-2"]), ("retrieval_mode", "single"), ("title", "New title")]
|
|
)
|
|
def test_candidate_refuses_retrieval_configuration_changes(path: str, value: object) -> None:
|
|
graph: Graph = {
|
|
"nodes": [
|
|
{
|
|
"id": "r",
|
|
"type": "custom",
|
|
"data": {
|
|
"type": "knowledge-retrieval",
|
|
"title": "Existing",
|
|
"dataset_ids": ["kb-1"],
|
|
"retrieval_mode": "multiple",
|
|
},
|
|
}
|
|
],
|
|
"edges": [],
|
|
}
|
|
intent = MutationIntent(op="set_node_config", args={"node_id": "r", "path": path, "value": value})
|
|
|
|
assert vet_intents(graph, [intent]).would_run_wrong
|
|
|
|
|
|
def test_candidate_refuses_legacy_agent_conversion_to_native_new_agent() -> None:
|
|
graph: Graph = {"nodes": [{"id": "a", "type": "custom", "data": {"type": "agent", "title": "Legacy"}}], "edges": []}
|
|
intents = [
|
|
MutationIntent(op="set_node_config", args={"node_id": "a", "path": "version", "value": "2"}),
|
|
MutationIntent(op="set_node_config", args={"node_id": "a", "path": "agent_node_kind", "value": "dify_agent"}),
|
|
]
|
|
|
|
assert vet_intents(graph, intents).would_run_wrong
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("node_type", "config"),
|
|
[
|
|
("start", {"variables": []}),
|
|
("end", {"outputs": []}),
|
|
("code", {}),
|
|
("llm", {}),
|
|
(
|
|
"list-operator",
|
|
{
|
|
"variable": ["s", "items"],
|
|
"filter_by": {"enabled": False},
|
|
"extract_by": {"enabled": False},
|
|
"order_by": {"enabled": False},
|
|
"limit": {"enabled": False},
|
|
},
|
|
),
|
|
("if-else", {}),
|
|
("iteration-start", {}),
|
|
("loop-start", {}),
|
|
("loop-end", {}),
|
|
],
|
|
)
|
|
def test_first_phase_keeps_supported_native_node_candidates(node_type: str, config: dict[str, object]) -> None:
|
|
intent = MutationIntent(op="create_node", args={"node_type": node_type, "node_id": "new", "config": config})
|
|
|
|
vetted = vet_intents({"nodes": [], "edges": []}, [intent])
|
|
|
|
assert vetted.applicable == [intent]
|
|
assert vetted.rejections == []
|
|
assert vetted.would_run_wrong == []
|
|
|
|
|
|
def test_edge_only_wiring_of_historical_retrieval_does_not_change_native_binding_config() -> None:
|
|
graph: Graph = {
|
|
"nodes": [
|
|
{"id": "s", "type": "custom", "data": {"type": "start", "title": "Start", "variables": []}},
|
|
{
|
|
"id": "r",
|
|
"type": "custom",
|
|
"data": {
|
|
"type": "knowledge-retrieval",
|
|
"title": "Knowledge",
|
|
"dataset_ids": ["kb-1"],
|
|
"query_variable_selector": ["s", "query"],
|
|
"retrieval_mode": "multiple",
|
|
},
|
|
},
|
|
],
|
|
"edges": [],
|
|
}
|
|
before = copy.deepcopy(graph)
|
|
intent = MutationIntent(op="connect", args={"from_node": "s", "to_node": "r"})
|
|
|
|
vetted = vet_intents(graph, [intent])
|
|
|
|
assert vetted.applicable == [intent]
|
|
assert vetted.rejections == []
|
|
assert graph == before
|