mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
fix: disable duplicate trigger nodes in workflow builder (#402)
* fix: disable duplicate trigger nodes in workflow builder AddNodePanel: disable trigger buttons and show tooltip when a trigger already exists on the canvas, using bySpecName to identify trigger- category specs from the live node list. useWorkflowState: preflight in saveWorkflow rejects saves with multiple trigger nodes via a sonner toast before the network request is made. text_chat_session_service: include the original exception message in TextChatSessionExecutionError so the HTTP 500 detail surfaces the root cause without DB inspection. Closes #378 * style: format test_text_chat_session_service.py with ruff * chore: retrigger CI checks * fix(workflow): enforce node instance constraints --------- Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
parent
7c31dd3eec
commit
7d053320df
27 changed files with 591 additions and 91 deletions
|
|
@ -5,7 +5,7 @@ from typing import Dict, List, Set
|
|||
from api.services.workflow.dto import EdgeDataDTO, NodeType, ReactFlowDTO
|
||||
from api.services.workflow.errors import ItemKind, WorkflowError
|
||||
from api.services.workflow.node_data import BaseNodeData
|
||||
from api.services.workflow.node_specs import get_spec
|
||||
from api.services.workflow.node_specs import all_specs, get_spec
|
||||
|
||||
# Regex for matching {{ variable }} template placeholders.
|
||||
# Captures: group(1) = variable path, group(2) = filter name, group(3) = filter value.
|
||||
|
|
@ -68,10 +68,11 @@ class Node:
|
|||
self.out: Dict[str, "Node"] = {} # forward nodes
|
||||
self.out_edges: List[Edge] = [] # forward edges with properties
|
||||
|
||||
# name/is_start/is_end live on every per-type data class (base).
|
||||
# Start/end semantics are defined by node type. The persisted
|
||||
# data flags are legacy UI/runtime state and may be stale.
|
||||
self.name = data.name
|
||||
self.is_start = data.is_start
|
||||
self.is_end = data.is_end
|
||||
self.is_start = node_type == NodeType.startNode.value
|
||||
self.is_end = node_type == NodeType.endNode.value
|
||||
|
||||
# Type-specific fields — read with getattr so this works for every
|
||||
# node variant in the discriminated union.
|
||||
|
|
@ -98,13 +99,89 @@ class Node:
|
|||
self.data = data
|
||||
|
||||
|
||||
def _instance_constraint_message(
|
||||
label: str,
|
||||
count: int,
|
||||
*,
|
||||
min_count: int | None = None,
|
||||
max_count: int | None = None,
|
||||
) -> str:
|
||||
if max_count is not None and count > max_count:
|
||||
if max_count == 1:
|
||||
return f"Workflow can have at most one {label}"
|
||||
return f"Workflow can have at most {max_count} {label} nodes"
|
||||
if min_count is not None and count < min_count:
|
||||
if min_count == 1:
|
||||
return f"Workflow must have at least one {label}"
|
||||
return f"Workflow must have at least {min_count} {label} nodes"
|
||||
return ""
|
||||
|
||||
|
||||
def validate_node_instance_constraints(
|
||||
node_types: list[str],
|
||||
*,
|
||||
enforce_min_instances: bool = True,
|
||||
skip_types: Set[str] | None = None,
|
||||
) -> list[WorkflowError]:
|
||||
"""Validate workflow-level node type counts from NodeSpec.graph_constraints."""
|
||||
errors: list[WorkflowError] = []
|
||||
skip_types = skip_types or set()
|
||||
counts = Counter(node_types)
|
||||
|
||||
for spec in all_specs():
|
||||
if spec.name in skip_types:
|
||||
continue
|
||||
gc = spec.graph_constraints
|
||||
if gc is None:
|
||||
continue
|
||||
|
||||
count = counts.get(spec.name, 0)
|
||||
if gc.max_instances is not None and count > gc.max_instances:
|
||||
errors.append(
|
||||
WorkflowError(
|
||||
kind=ItemKind.workflow,
|
||||
id=None,
|
||||
field=None,
|
||||
message=_instance_constraint_message(
|
||||
spec.display_name,
|
||||
count,
|
||||
max_count=gc.max_instances,
|
||||
),
|
||||
)
|
||||
)
|
||||
if (
|
||||
enforce_min_instances
|
||||
and gc.min_instances is not None
|
||||
and count < gc.min_instances
|
||||
):
|
||||
errors.append(
|
||||
WorkflowError(
|
||||
kind=ItemKind.workflow,
|
||||
id=None,
|
||||
field=None,
|
||||
message=_instance_constraint_message(
|
||||
spec.display_name,
|
||||
count,
|
||||
min_count=gc.min_instances,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
class WorkflowGraph:
|
||||
"""
|
||||
*All* business invariants (acyclic, cardinality, etc.) are verified here.
|
||||
The constructor accepts a validated ReactFlowDTO.
|
||||
"""
|
||||
|
||||
def __init__(self, dto: ReactFlowDTO):
|
||||
def __init__(
|
||||
self,
|
||||
dto: ReactFlowDTO,
|
||||
*,
|
||||
skip_instance_constraints_for: Set[str] | None = None,
|
||||
):
|
||||
# Build adjacency list from validated DTO nodes. Core node comparisons
|
||||
# still use NodeType string enums; integration nodes remain plain
|
||||
# strings and resolve constraints through node specs.
|
||||
|
|
@ -131,10 +208,12 @@ class WorkflowGraph:
|
|||
# Set up the node references for backward compatibility
|
||||
source_node.out[target_node.id] = target_node
|
||||
|
||||
self._validate_graph()
|
||||
self._validate_graph(skip_instance_constraints_for or set())
|
||||
|
||||
# Get a reference to the start node
|
||||
self.start_node_id = [n.id for n in dto.nodes if n.data.is_start][0]
|
||||
self.start_node_id = [
|
||||
n.id for n in dto.nodes if n.type == NodeType.startNode.value
|
||||
][0]
|
||||
|
||||
# Get a reference to the global node
|
||||
try:
|
||||
|
|
@ -185,7 +264,7 @@ class WorkflowGraph:
|
|||
# -----------------------------------------------------------
|
||||
# validators
|
||||
# -----------------------------------------------------------
|
||||
def _validate_graph(self) -> None:
|
||||
def _validate_graph(self, skip_instance_constraints_for: Set[str]) -> None:
|
||||
errors: list[WorkflowError] = []
|
||||
|
||||
# TODO: Figure out what kind of cyclic contraints can be applied, since there can be a cycle in the graph
|
||||
|
|
@ -198,9 +277,13 @@ class WorkflowGraph:
|
|||
# )
|
||||
# )
|
||||
|
||||
errors.extend(self._assert_start_node())
|
||||
errors.extend(
|
||||
validate_node_instance_constraints(
|
||||
[n.node_type for n in self.nodes.values()],
|
||||
skip_types=skip_instance_constraints_for,
|
||||
)
|
||||
)
|
||||
errors.extend(self._assert_connection_counts())
|
||||
errors.extend(self._assert_global_node())
|
||||
errors.extend(self._assert_node_configs())
|
||||
if errors:
|
||||
raise ValueError(errors)
|
||||
|
|
@ -220,48 +303,6 @@ class WorkflowGraph:
|
|||
for n in self.nodes.values():
|
||||
dfs(n)
|
||||
|
||||
def _assert_start_node(self):
|
||||
errors: list[WorkflowError] = []
|
||||
start_nodes = [n for n in self.nodes.values() if n.data.is_start]
|
||||
if not start_nodes:
|
||||
errors.append(
|
||||
WorkflowError(
|
||||
kind=ItemKind.workflow,
|
||||
id=None,
|
||||
field=None,
|
||||
message="Workflow has no start node — exactly one is required",
|
||||
)
|
||||
)
|
||||
elif len(start_nodes) > 1:
|
||||
errors.append(
|
||||
WorkflowError(
|
||||
kind=ItemKind.workflow,
|
||||
id=None,
|
||||
field=None,
|
||||
message=(
|
||||
f"Workflow has {len(start_nodes)} start nodes — "
|
||||
f"exactly one is required"
|
||||
),
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
def _assert_global_node(self):
|
||||
errors: list[WorkflowError] = []
|
||||
global_node = [
|
||||
n for n in self.nodes.values() if n.node_type == NodeType.globalNode.value
|
||||
]
|
||||
if not len(global_node) <= 1:
|
||||
errors.append(
|
||||
WorkflowError(
|
||||
kind=ItemKind.workflow,
|
||||
id=None,
|
||||
field=None,
|
||||
message="Workflow must have at most one global node",
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
def _assert_connection_counts(self):
|
||||
"""Enforce per-type incoming/outgoing edge constraints.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue