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:
nuthalapativarun 2026-06-19 03:29:30 -07:00 committed by GitHub
parent 7c31dd3eec
commit 7d053320df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 591 additions and 91 deletions

View file

@ -58,7 +58,10 @@ from api.services.workflow.trigger_paths import (
trigger_path_to_node_id,
validate_trigger_paths,
)
from api.services.workflow.workflow_graph import WorkflowGraph
from api.services.workflow.workflow_graph import (
WorkflowGraph,
validate_node_instance_constraints,
)
from api.utils.artifacts import artifact_url
from api.utils.recording_artifacts import (
get_recording_storage_key,
@ -192,6 +195,27 @@ def _validation_errors_http_exception(
)
def _node_instance_validation_errors(
workflow_definition: Optional[dict],
) -> list[WorkflowError]:
"""Validate spec-driven max_instances without requiring a complete draft."""
if not workflow_definition:
return []
nodes = workflow_definition.get("nodes")
if not isinstance(nodes, list):
return []
node_types = [
node.get("type")
for node in nodes
if isinstance(node, dict) and isinstance(node.get("type"), str)
]
return validate_node_instance_constraints(
node_types,
enforce_min_instances=False,
)
class CallDispositionCodes(BaseModel):
disposition_codes: list[str] = []
@ -384,6 +408,9 @@ async def create_workflow(
trigger_path_issues = validate_trigger_paths(workflow_definition)
if trigger_path_issues:
raise _trigger_path_validation_http_exception(trigger_path_issues)
instance_errors = _node_instance_validation_errors(workflow_definition)
if instance_errors:
raise _validation_errors_http_exception(instance_errors)
# Validate trigger path uniqueness BEFORE creating the workflow so we
# don't leave an orphaned workflow record when the trigger conflicts.
@ -990,6 +1017,9 @@ async def update_workflow(
trigger_path_issues = validate_trigger_paths(workflow_definition)
if trigger_path_issues:
raise _trigger_path_validation_http_exception(trigger_path_issues)
instance_errors = _node_instance_validation_errors(workflow_definition)
if instance_errors:
raise _validation_errors_http_exception(instance_errors, status_code=409)
if workflow_definition:
existing_workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id