From 5b3ea3e01889fdc5f9c30bf5d9e10630249e5407 Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Tue, 2 Jun 2026 08:39:47 -0700 Subject: [PATCH 1/4] 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 --- .../workflow/text_chat_session_service.py | 2 +- api/tests/test_text_chat_session_service.py | 28 ++++++++++++++++++- .../workflow/[workflowId]/RenderWorkflow.tsx | 1 + .../[workflowId]/hooks/useWorkflowState.ts | 9 ++++++ ui/src/components/flow/AddNodePanel.tsx | 18 ++++++++++-- 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/api/services/workflow/text_chat_session_service.py b/api/services/workflow/text_chat_session_service.py index 53354d5f..7d72393c 100644 --- a/api/services/workflow/text_chat_session_service.py +++ b/api/services/workflow/text_chat_session_service.py @@ -207,7 +207,7 @@ async def execute_pending_text_chat_turn( error_message=str(e), ) raise TextChatSessionExecutionError( - "Failed to execute text chat assistant turn" + f"Failed to execute text chat assistant turn: {e}" ) from e completed_session_data = normalize_text_chat_session_data(text_session.session_data) diff --git a/api/tests/test_text_chat_session_service.py b/api/tests/test_text_chat_session_service.py index abbba0e6..2b51fe04 100644 --- a/api/tests/test_text_chat_session_service.py +++ b/api/tests/test_text_chat_session_service.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -9,6 +9,7 @@ from api.services.workflow.text_chat_session_service import ( TextChatTurnNotFoundError, _reload_text_chat_session, build_pending_text_chat_turn, + execute_pending_text_chat_turn, truncate_text_chat_future_turns, validate_text_chat_turn_cursor, ) @@ -77,6 +78,31 @@ async def test_reload_text_chat_session_uses_run_id_to_resolve_organization( get_text_session.assert_awaited_once_with(123, organization_id=77) +@pytest.mark.asyncio +async def test_execute_pending_turn_surfaces_original_exception_message(monkeypatch): + session = WorkflowRunTextSessionModel(workflow_run_id=42) + session.session_data = {"turns": [{"id": "turn-1", "status": "pending"}], "cursor_turn_id": "turn-1"} + session.checkpoint = None + + monkeypatch.setattr( + text_chat_session_service, + "execute_text_chat_pending_turn", + AsyncMock(side_effect=RuntimeError("Workflow has 2 start nodes")), + ) + monkeypatch.setattr( + text_chat_session_service, + "_mark_pending_turn_failed", + AsyncMock(), + ) + + with pytest.raises(TextChatSessionExecutionError, match="Workflow has 2 start nodes"): + await execute_pending_text_chat_turn( + workflow_id=1, + run_id=42, + text_session=session, + ) + + @pytest.mark.asyncio async def test_reload_text_chat_session_raises_when_run_organization_is_missing( monkeypatch, diff --git a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx index 59a0ed54..602f74f1 100644 --- a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx +++ b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx @@ -690,6 +690,7 @@ function RenderWorkflow({ isOpen={isAddNodePanelOpen} onNodeSelect={handleNodeSelect} onClose={() => setIsAddNodePanelOpen(false)} + nodes={nodes} /> bySpecName.get(n.type)?.category === 'trigger', + ).length; + if (triggerCount > 1) { + toast.error('A workflow can only have one trigger. Remove the extra trigger node before saving.'); + return; + } const viewport = rfInstance.current.getViewport(); const flow = { nodes: currentNodes, edges: currentEdges, viewport }; let result: { versionNumber?: number; versionStatus?: string } | undefined; @@ -372,6 +380,7 @@ export const useWorkflowState = ({ user, validateWorkflow, applyWorkflowErrors, + bySpecName, ]); // Set up keyboard shortcut for save (Cmd/Ctrl + S) diff --git a/ui/src/components/flow/AddNodePanel.tsx b/ui/src/components/flow/AddNodePanel.tsx index 02346f90..0371bb10 100644 --- a/ui/src/components/flow/AddNodePanel.tsx +++ b/ui/src/components/flow/AddNodePanel.tsx @@ -6,12 +6,13 @@ import type { NodeSpec } from '@/client/types.gen'; import { useNodeSpecs } from '@/components/flow/renderer'; import { Button } from '@/components/ui/button'; -import { NodeType } from './types'; +import { FlowNode, NodeType } from './types'; type AddNodePanelProps = { isOpen: boolean; onClose: () => void; onNodeSelect: (nodeType: NodeType) => void; + nodes: FlowNode[]; }; // Section ordering and labels. Drives both the category → section title @@ -32,10 +33,12 @@ function NodeSection({ title, specs, onNodeSelect, + disableTriggers, }: { title: string; specs: NodeSpec[]; onNodeSelect: (nodeType: NodeType) => void; + disableTriggers: boolean; }) { if (specs.length === 0) return null; return ( @@ -46,12 +49,15 @@ function NodeSection({
{specs.map((spec) => { const Icon = resolveIcon(spec.icon); + const disabled = disableTriggers && spec.category === 'trigger'; return (