mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
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:
parent
acc2ef9e96
commit
5b3ea3e018
5 changed files with 53 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -690,6 +690,7 @@ function RenderWorkflow({
|
|||
isOpen={isAddNodePanelOpen}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onClose={() => setIsAddNodePanelOpen(false)}
|
||||
nodes={nodes}
|
||||
/>
|
||||
|
||||
<VersionHistoryPanel
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue