fix: workflow saving

This commit is contained in:
Vukasin 2025-07-31 15:37:24 +02:00
parent 762f18f96f
commit 1900bf9b60
5 changed files with 243 additions and 61 deletions

View File

@ -90,12 +90,14 @@ class UpcomingRunsResponse(BaseModel):
# Workflow models # Workflow models
class WorkflowStepRequest(BaseModel): class WorkflowStepRequest(BaseModel):
id: Optional[str] = None # CRITICAL: Accept ID from frontend
name: str name: str
description: Optional[str] = None description: Optional[str] = None
type: Optional[str] = "instruction" type: Optional[str] = "instruction"
config: Dict[str, Any] = {} config: Dict[str, Any] = {}
conditions: Optional[Dict[str, Any]] = None conditions: Optional[Dict[str, Any]] = None
order: int order: int
parentConditionalId: Optional[str] = None # CRITICAL: Accept parentConditionalId
children: Optional[List['WorkflowStepRequest']] = None children: Optional[List['WorkflowStepRequest']] = None
@ -563,6 +565,13 @@ def convert_steps_to_json(steps: List[WorkflowStepRequest]) -> List[Dict[str, An
'conditions': step.conditions, 'conditions': step.conditions,
'order': step.order 'order': step.order
} }
# CRITICAL: Preserve ID and parentConditionalId if they exist
if hasattr(step, 'id') and step.id:
step_dict['id'] = step.id
if hasattr(step, 'parentConditionalId') and step.parentConditionalId:
step_dict['parentConditionalId'] = step.parentConditionalId
if step.children: if step.children:
step_dict['children'] = convert_steps_to_json(step.children) step_dict['children'] = convert_steps_to_json(step.children)
result.append(step_dict) result.append(step_dict)

View File

@ -20,11 +20,9 @@ class ProviderError(TriggerError):
class WorkflowParser: class WorkflowParser:
def __init__(self): def __init__(self):
self.step_counter = 0 self.step_counter = 0
self.parsed_steps = []
def parse_workflow_steps(self, steps: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def parse_workflow_steps(self, steps: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
self.step_counter = 0 self.step_counter = 0
self.parsed_steps = []
start_node = next( start_node = next(
(step for step in steps if step.get('name') == 'Start' and step.get('description') == 'Click to add steps or use the Add Node button'), (step for step in steps if step.get('name') == 'Start' and step.get('description') == 'Click to add steps or use the Add Node button'),
@ -41,23 +39,98 @@ class WorkflowParser:
def _parse_steps_recursive(self, steps: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def _parse_steps_recursive(self, steps: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
result = [] result = []
processed_ids = set()
for step in steps: for step in steps:
parsed_step = self._parse_single_step(step) step_id = step.get('id')
if parsed_step:
result.append(parsed_step) # Skip if already processed as part of a conditional group
if step_id in processed_ids:
continue
step_type = step.get('type')
parent_conditional_id = step.get('parentConditionalId')
if step_type == 'condition' and not parent_conditional_id:
# This is a root conditional step - find all its siblings
conditional_group = [step]
# Find all siblings (steps with parentConditionalId pointing to this step)
for other_step in steps:
if other_step.get('parentConditionalId') == step_id:
conditional_group.append(other_step)
# Sort by condition type (if, elseif, else)
conditional_group.sort(key=lambda s: self._get_condition_order(s))
# Parse the conditional group as an instruction step with conditions
parsed_group = self._parse_conditional_group(conditional_group)
if parsed_group:
result.append(parsed_group)
# Mark all steps in group as processed
for group_step in conditional_group:
processed_ids.add(group_step.get('id'))
elif step_type == 'condition' and parent_conditional_id:
# This step belongs to a parent conditional - skip it
# It will be processed when we encounter the parent
continue
else:
# Regular instruction step
parsed_step = self._parse_single_step(step)
if parsed_step:
result.append(parsed_step)
processed_ids.add(step_id)
return result return result
def _get_condition_order(self, step: Dict[str, Any]) -> int:
"""Sort order for conditions: if=0, elseif=1, else=2"""
condition_type = step.get('conditions', {}).get('type', 'if')
return {'if': 0, 'elseif': 1, 'else': 2}.get(condition_type, 0)
def _parse_conditional_group(self, conditional_group: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Parse a group of related conditional steps (if/elseif/else) as one instruction step"""
if not conditional_group:
return None
self.step_counter += 1
# Use first step's name or generate one
first_step = conditional_group[0]
step_name = first_step.get('name', f'Conditional Step {self.step_counter}')
parsed_step = {
"step": step_name,
"step_number": self.step_counter
}
# Add description if meaningful
description = first_step.get('description', '').strip()
if description and description not in ['Add conditional logic', 'If/Then']:
parsed_step["description"] = description
# Parse all conditions in the group
conditions = []
for condition_step in conditional_group:
parsed_condition = self._parse_condition_step(condition_step)
if parsed_condition:
conditions.append(parsed_condition)
if conditions:
parsed_step["conditions"] = conditions
return parsed_step
def _parse_single_step(self, step: Dict[str, Any]) -> Optional[Dict[str, Any]]: def _parse_single_step(self, step: Dict[str, Any]) -> Optional[Dict[str, Any]]:
step_type = step.get('type') step_type = step.get('type')
step_name = step.get('name', '')
if step_type == 'instruction': if step_type == 'instruction':
return self._parse_instruction_step(step) return self._parse_instruction_step(step)
elif step_type == 'condition':
return self._parse_condition_step(step)
else: else:
# For non-instruction steps, treat as instruction by default
return self._parse_instruction_step(step) return self._parse_instruction_step(step)
def _parse_instruction_step(self, step: Dict[str, Any]) -> Dict[str, Any]: def _parse_instruction_step(self, step: Dict[str, Any]) -> Dict[str, Any]:
@ -69,7 +142,7 @@ class WorkflowParser:
} }
description = step.get('description', '').strip() description = step.get('description', '').strip()
if description: if description and description not in ['Click to add steps or use the Add Node button', 'Add conditional logic', 'Add a custom instruction step']:
parsed_step["description"] = description parsed_step["description"] = description
tool_name = step.get('config', {}).get('tool_name') tool_name = step.get('config', {}).get('tool_name')
@ -82,14 +155,23 @@ class WorkflowParser:
children = step.get('children', []) children = step.get('children', [])
if children: if children:
condition_children = [child for child in children if child.get('type') == 'condition'] parsed_children = self._parse_steps_recursive(children)
instruction_children = [child for child in children if child.get('type') == 'instruction']
# Group children by type - conditions vs regular steps
condition_children = []
instruction_children = []
for child in parsed_children:
if child.get('condition'): # This is a condition
condition_children.append(child)
else: # This is a regular step
instruction_children.append(child)
if condition_children: if condition_children:
parsed_step["conditions"] = self._parse_condition_branches(condition_children) parsed_step["conditions"] = condition_children
if instruction_children: if instruction_children:
parsed_step["then"] = self._parse_steps_recursive(instruction_children) parsed_step["then"] = instruction_children
return parsed_step return parsed_step
@ -113,16 +195,6 @@ class WorkflowParser:
return parsed_condition return parsed_condition
def _parse_condition_branches(self, condition_steps: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
branches = []
for condition_step in condition_steps:
branch = self._parse_condition_step(condition_step)
if branch:
branches.append(branch)
return branches
def get_workflow_summary(self, steps: List[Dict[str, Any]]) -> Dict[str, Any]: def get_workflow_summary(self, steps: List[Dict[str, Any]]) -> Dict[str, Any]:
def count_steps_recursive(steps_list): def count_steps_recursive(steps_list):
count = 0 count = 0
@ -152,6 +224,9 @@ class WorkflowParser:
total_steps, total_conditions, max_nesting_depth = count_steps_recursive(steps) total_steps, total_conditions, max_nesting_depth = count_steps_recursive(steps)
# Parse the workflow to see the actual output
parsed_steps = self.parse_workflow_steps(steps)
# Large debug print to show inputs and parsed output # Large debug print to show inputs and parsed output
print("=" * 80) print("=" * 80)
print("WORKFLOW PARSER DEBUG - get_workflow_summary") print("WORKFLOW PARSER DEBUG - get_workflow_summary")
@ -160,8 +235,11 @@ class WorkflowParser:
print(f"Type: {type(steps)}") print(f"Type: {type(steps)}")
print(f"Length: {len(steps) if isinstance(steps, list) else 'N/A'}") print(f"Length: {len(steps) if isinstance(steps, list) else 'N/A'}")
print(f"Raw steps: {steps}") print(f"Raw steps: {steps}")
print("-" * 80) print("-" * 40)
print(f"PARSED OUTPUT:") print(f"PARSED STEPS:")
print(f"Parsed: {parsed_steps}")
print("-" * 40)
print(f"SUMMARY:")
print(f"Total steps: {total_steps}") print(f"Total steps: {total_steps}")
print(f"Total conditions: {total_conditions}") print(f"Total conditions: {total_conditions}")
print(f"Max nesting depth: {max_nesting_depth}") print(f"Max nesting depth: {max_nesting_depth}")

View File

@ -15,21 +15,32 @@ import { ConditionalStep } from '@/components/agents/workflows/conditional-workf
import { WorkflowBuilder } from '@/components/workflows/workflow-builder'; import { WorkflowBuilder } from '@/components/workflows/workflow-builder';
const convertToNestedJSON = (steps: ConditionalStep[]): any[] => { const convertToNestedJSON = (steps: ConditionalStep[]): any[] => {
// Since we're using the old structure directly, just pass it through // Clean, simple conversion - preserve the exact structure with order field for validation
let globalOrder = 1; let globalOrder = 1;
const convertStepsWithNesting = (stepList: ConditionalStep[]): any[] => { const convertStepsWithNesting = (stepList: ConditionalStep[]): any[] => {
return stepList.map((step) => { return stepList.map((step) => {
// Build clean step object with required fields for backend validation
const jsonStep: any = { const jsonStep: any = {
id: step.id, id: step.id, // CRITICAL: Always include ID
name: step.name, name: step.name,
description: step.description, description: step.description,
type: step.type, type: step.type,
config: step.config, config: step.config || {},
order: globalOrder++ order: globalOrder++ // Required by backend validation
}; };
// Add conditional metadata if present
if (step.type === 'condition' && step.conditions) { if (step.type === 'condition' && step.conditions) {
jsonStep.conditions = step.conditions; jsonStep.conditions = step.conditions;
} }
// Add parent relationship if present
if (step.parentConditionalId) {
jsonStep.parentConditionalId = step.parentConditionalId;
}
// Add children if present
if (step.children && step.children.length > 0) { if (step.children && step.children.length > 0) {
jsonStep.children = convertStepsWithNesting(step.children); jsonStep.children = convertStepsWithNesting(step.children);
} }
@ -37,6 +48,7 @@ const convertToNestedJSON = (steps: ConditionalStep[]): any[] => {
return jsonStep; return jsonStep;
}); });
}; };
return convertStepsWithNesting(steps); return convertStepsWithNesting(steps);
}; };
@ -57,12 +69,12 @@ const reconstructFromNestedJSON = (nestedSteps: any[]): ConditionalStep[] => {
const convertStepsFromNested = (stepList: any[]): ConditionalStep[] => { const convertStepsFromNested = (stepList: any[]): ConditionalStep[] => {
return stepList.map((step) => { return stepList.map((step) => {
const conditionalStep: ConditionalStep = { const conditionalStep: ConditionalStep = {
id: step.id || Math.random().toString(36).substr(2, 9), id: step.id, // Always use the existing ID - never regenerate
name: step.name, name: step.name,
description: step.description || '', description: step.description || '',
type: step.type || 'instruction', type: step.type || 'instruction',
config: step.config || {}, config: step.config || {},
order: step.order || step.step_order || 0, order: step.order || 0, // Preserve order from backend
enabled: step.enabled !== false, enabled: step.enabled !== false,
hasIssues: step.hasIssues || false, hasIssues: step.hasIssues || false,
children: [] // Initialize children array children: [] // Initialize children array
@ -73,6 +85,11 @@ const reconstructFromNestedJSON = (nestedSteps: any[]): ConditionalStep[] => {
conditionalStep.conditions = step.conditions; conditionalStep.conditions = step.conditions;
} }
// Handle parentConditionalId for conditional grouping
if (step.parentConditionalId) {
conditionalStep.parentConditionalId = step.parentConditionalId;
}
// Handle children - this is crucial for nested conditions // Handle children - this is crucial for nested conditions
if (step.children && Array.isArray(step.children) && step.children.length > 0) { if (step.children && Array.isArray(step.children) && step.children.length > 0) {
conditionalStep.children = convertStepsFromNested(step.children); conditionalStep.children = convertStepsFromNested(step.children);
@ -110,7 +127,7 @@ const reconstructFromFlatJSON = (flatSteps: any[]): ConditionalStep[] => {
for (const flatStep of flatSteps) { for (const flatStep of flatSteps) {
if (flatStep.type === 'condition') { if (flatStep.type === 'condition') {
const conditionStep: ConditionalStep = { const conditionStep: ConditionalStep = {
id: flatStep.id || Math.random().toString(36).substr(2, 9), id: flatStep.id, // Always preserve existing ID
name: flatStep.name, name: flatStep.name,
description: flatStep.description || '', description: flatStep.description || '',
type: 'condition', type: 'condition',
@ -127,7 +144,7 @@ const reconstructFromFlatJSON = (flatSteps: any[]): ConditionalStep[] => {
for (const [conditionId, conditionStep] of conditionSteps) { for (const [conditionId, conditionStep] of conditionSteps) {
if (JSON.stringify(conditionStep.conditions) === JSON.stringify(flatStep.conditions)) { if (JSON.stringify(conditionStep.conditions) === JSON.stringify(flatStep.conditions)) {
const childStep: ConditionalStep = { const childStep: ConditionalStep = {
id: flatStep.id || Math.random().toString(36).substr(2, 9), id: flatStep.id, // Always preserve existing ID
name: flatStep.name, name: flatStep.name,
description: flatStep.description || '', description: flatStep.description || '',
type: flatStep.type || 'instruction', type: flatStep.type || 'instruction',
@ -165,7 +182,7 @@ const reconstructFromFlatJSON = (flatSteps: any[]): ConditionalStep[] => {
result.push(...conditionGroup); result.push(...conditionGroup);
} else if (!flatStep.conditions) { } else if (!flatStep.conditions) {
const step: ConditionalStep = { const step: ConditionalStep = {
id: flatStep.id || Math.random().toString(36).substr(2, 9), id: flatStep.id, // Always preserve existing ID
name: flatStep.name, name: flatStep.name,
description: flatStep.description || '', description: flatStep.description || '',
type: flatStep.type || 'instruction', type: flatStep.type || 'instruction',

View File

@ -97,33 +97,46 @@ export function ConditionalWorkflowBuilder({
order: 0, order: 0,
enabled: true, enabled: true,
}; };
const updateSteps = (items: ConditionalStep[]): ConditionalStep[] => {
const updateSteps = (items: ConditionalStep[]): { updatedItems: ConditionalStep[], found: boolean } => {
if (!parentId) { if (!parentId) {
if (afterStepId) { if (afterStepId) {
const index = items.findIndex(s => s.id === afterStepId); const index = items.findIndex(s => s.id === afterStepId);
return [...items.slice(0, index + 1), newStep, ...items.slice(index + 1)]; return {
updatedItems: [...items.slice(0, index + 1), newStep, ...items.slice(index + 1)],
found: true
};
} }
return [...items, newStep]; return { updatedItems: [...items, newStep], found: true };
} }
return items.map(step => { let found = false;
const updatedItems = items.map(step => {
if (step.id === parentId) { if (step.id === parentId) {
found = true;
return { return {
...step, ...step,
children: [...(step.children || []), newStep] children: [...(step.children || []), newStep]
}; };
} }
if (step.children) { if (step.children && !found) {
return { const result = updateSteps(step.children);
...step, if (result.found) {
children: updateSteps(step.children) found = true;
}; return {
...step,
children: result.updatedItems
};
}
} }
return step; return step;
}); });
return { updatedItems, found };
}; };
onStepsChange(updateSteps(steps)); const result = updateSteps(steps);
onStepsChange(result.updatedItems);
}, [steps, onStepsChange]); }, [steps, onStepsChange]);
const addCondition = useCallback((afterStepId: string) => { const addCondition = useCallback((afterStepId: string) => {

View File

@ -1,10 +1,18 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Plus, AlertTriangle, GripVertical, Settings } from 'lucide-react'; import { Plus, AlertTriangle, GripVertical, Settings, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ConditionalStep } from '@/components/agents/workflows/conditional-workflow-builder'; import { ConditionalStep } from '@/components/agents/workflows/conditional-workflow-builder';
import { StepCard } from './step-card'; import { StepCard } from './step-card';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -63,6 +71,7 @@ export function ConditionalGroup({
const [activeConditionTab, setActiveConditionTab] = useState<string>(conditionSteps[0]?.id || ''); const [activeConditionTab, setActiveConditionTab] = useState<string>(conditionSteps[0]?.id || '');
const [activeDragId, setActiveDragId] = useState<string | null>(null); const [activeDragId, setActiveDragId] = useState<string | null>(null);
const [pendingElseStepAdd, setPendingElseStepAdd] = useState<boolean>(false); const [pendingElseStepAdd, setPendingElseStepAdd] = useState<boolean>(false);
const [deleteConfirmStep, setDeleteConfirmStep] = useState<ConditionalStep | null>(null);
// Set up sensors for drag and drop within this conditional group // Set up sensors for drag and drop within this conditional group
const sensors = useSensors( const sensors = useSensors(
@ -197,6 +206,25 @@ export function ConditionalGroup({
} }
}, [activeStep, onAddElse, conditionSteps, onAddStep]); }, [activeStep, onAddElse, conditionSteps, onAddStep]);
// Handle delete confirmation
const handleDeleteConfirm = React.useCallback(() => {
if (deleteConfirmStep) {
// Switch to another tab before deleting if we're on the tab being deleted
if (activeConditionTab === deleteConfirmStep.id) {
const remainingSteps = conditionSteps.filter(s => s.id !== deleteConfirmStep.id);
if (remainingSteps.length > 0) {
setActiveConditionTab(remainingSteps[0].id);
}
}
onRemove(deleteConfirmStep.id);
setDeleteConfirmStep(null);
}
}, [deleteConfirmStep, activeConditionTab, conditionSteps, onRemove]);
const handleDeleteCancel = React.useCallback(() => {
setDeleteConfirmStep(null);
}, []);
return ( return (
<div ref={setNodeRef} style={style} className={cn(isDragging && "opacity-50")}> <div ref={setNodeRef} style={style} className={cn(isDragging && "opacity-50")}>
<div className="space-y-4 bg-zinc-50 dark:bg-zinc-900/50 rounded-2xl p-4 border border-zinc-200 dark:border-zinc-800 relative group"> <div className="space-y-4 bg-zinc-50 dark:bg-zinc-900/50 rounded-2xl p-4 border border-zinc-200 dark:border-zinc-800 relative group">
@ -232,24 +260,39 @@ export function ConditionalGroup({
step.conditions?.type === 'else' ? 'Else' : 'If'; step.conditions?.type === 'else' ? 'Else' : 'If';
return ( return (
<Button <div key={step.id} className="relative group/tab">
key={step.id} <Button
onClick={() => setActiveConditionTab(step.id)} onClick={() => setActiveConditionTab(step.id)}
className={cn( className={cn(
"h-9 px-3 border border-dashed text-xs", "h-9 px-3 border border-dashed text-xs",
isActive isActive
? "bg-blue-500 hover:bg-blue-600 " ? "bg-blue-500 hover:bg-blue-600 "
: "bg-white dark:bg-zinc-800 border-dashed border-zinc-300 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700" : "bg-white dark:bg-zinc-800 border-dashed border-zinc-300 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700"
)} )}
> >
<span className="font-mono text-xs font-bold">{letter}</span>
<span></span>
<span>{conditionType}</span>
{step.hasIssues && (
<AlertTriangle className="h-3 w-3 text-red-500" />
)}
</Button>
<span className="font-mono text-xs font-bold">{letter}</span> {/* Delete button - only for else-if tabs */}
<span></span> {step.conditions?.type === 'elseif' && (
<span>{conditionType}</span> <Button
{step.hasIssues && ( variant="ghost"
<AlertTriangle className="h-3 w-3 text-red-500" /> size="sm"
onClick={(e) => {
e.stopPropagation();
setDeleteConfirmStep(step);
}}
className="absolute -top-1 -right-1 h-4.5 w-4.5 !p-0 bg-primary hover:bg-primary hover:text-white hover:dark:text-black text-white dark:text-black rounded-full opacity-0 group-hover/tab:opacity-100 transition-opacity z-10 cursor-pointer"
>
<X className="!h-3 !w-3" />
</Button>
)} )}
</Button> </div>
); );
})} })}
@ -380,6 +423,8 @@ export function ConditionalGroup({
}; };
const handleNestedAddStep = (index: number, parentStepId?: string) => { const handleNestedAddStep = (index: number, parentStepId?: string) => {
// Call the parent's onAddStep which will open the side panel
// Pass the parentStepId correctly for nested context
onAddStep(index, parentStepId); onAddStep(index, parentStepId);
}; };
@ -505,6 +550,26 @@ export function ConditionalGroup({
</div> </div>
)} )}
</div> </div>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteConfirmStep} onOpenChange={(open) => !open && handleDeleteCancel()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Condition</DialogTitle>
<DialogDescription>
Are you sure you want to delete this "Else If" condition? This action cannot be undone and will remove all steps within this condition.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleDeleteCancel}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }