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
This commit is contained in:
Varun Nuthalapati 2026-06-02 08:39:47 -07:00
parent acc2ef9e96
commit 5b3ea3e018
5 changed files with 53 additions and 5 deletions

View file

@ -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)

View file

@ -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,

View file

@ -690,6 +690,7 @@ function RenderWorkflow({
isOpen={isAddNodePanelOpen}
onNodeSelect={handleNodeSelect}
onClose={() => setIsAddNodePanelOpen(false)}
nodes={nodes}
/>
<VersionHistoryPanel

View file

@ -10,6 +10,7 @@ import { EdgeChange, NodeChange } from "@xyflow/system";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useCallback, useEffect, useRef } from "react";
import { toast } from "sonner";
import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore";
import {
@ -306,6 +307,13 @@ export const useWorkflowState = ({
// This avoids a race condition where rfInstance.toObject() may return
// stale node data if React hasn't re-rendered yet after a store update.
const { nodes: currentNodes, edges: currentEdges } = useWorkflowStore.getState();
const triggerCount = currentNodes.filter(
(n) => 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)

View file

@ -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({
<div className="space-y-2">
{specs.map((spec) => {
const Icon = resolveIcon(spec.icon);
const disabled = disableTriggers && spec.category === 'trigger';
return (
<Button
key={spec.name}
variant="outline"
className="w-full justify-start p-4 h-auto hover:bg-accent/50 transition-colors"
onClick={() => onNodeSelect(spec.name as NodeType)}
disabled={disabled}
title={disabled ? 'A trigger already exists in this workflow' : undefined}
>
<div className="flex items-center">
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
@ -74,8 +80,8 @@ function NodeSection({
);
}
export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodePanelProps) {
const { specs } = useNodeSpecs();
export default function AddNodePanel({ isOpen, onNodeSelect, onClose, nodes }: AddNodePanelProps) {
const { specs, bySpecName } = useNodeSpecs();
// Group registered specs by category, preserving the SECTION_ORDER.
// Adding a new node type with a new spec.category just shows up here.
@ -86,6 +92,11 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
}));
}, [specs]);
const hasTrigger = useMemo(
() => nodes.some((n) => bySpecName.get(n.type)?.category === 'trigger'),
[nodes, bySpecName],
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
@ -128,6 +139,7 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
title={title}
specs={specs}
onNodeSelect={onNodeSelect}
disableTriggers={hasTrigger}
/>
))}
</div>