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