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
class WorkflowStepRequest(BaseModel):
id: Optional[str] = None # CRITICAL: Accept ID from frontend
name: str
description: Optional[str] = None
type: Optional[str] = "instruction"
config: Dict[str, Any] = {}
conditions: Optional[Dict[str, Any]] = None
order: int
parentConditionalId: Optional[str] = None # CRITICAL: Accept parentConditionalId
children: Optional[List['WorkflowStepRequest']] = None
@ -563,6 +565,13 @@ def convert_steps_to_json(steps: List[WorkflowStepRequest]) -> List[Dict[str, An
'conditions': step.conditions,
'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:
step_dict['children'] = convert_steps_to_json(step.children)
result.append(step_dict)

View File

@ -20,11 +20,9 @@ class ProviderError(TriggerError):
class WorkflowParser:
def __init__(self):
self.step_counter = 0
self.parsed_steps = []
def parse_workflow_steps(self, steps: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
self.step_counter = 0
self.parsed_steps = []
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'),
@ -41,23 +39,98 @@ class WorkflowParser:
def _parse_steps_recursive(self, steps: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
result = []
processed_ids = set()
for step in steps:
parsed_step = self._parse_single_step(step)
if parsed_step:
result.append(parsed_step)
step_id = step.get('id')
# 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
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]]:
step_type = step.get('type')
step_name = step.get('name', '')
if step_type == 'instruction':
return self._parse_instruction_step(step)
elif step_type == 'condition':
return self._parse_condition_step(step)
else:
# For non-instruction steps, treat as instruction by default
return self._parse_instruction_step(step)
def _parse_instruction_step(self, step: Dict[str, Any]) -> Dict[str, Any]:
@ -69,7 +142,7 @@ class WorkflowParser:
}
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
tool_name = step.get('config', {}).get('tool_name')
@ -82,14 +155,23 @@ class WorkflowParser:
children = step.get('children', [])
if children:
condition_children = [child for child in children if child.get('type') == 'condition']
instruction_children = [child for child in children if child.get('type') == 'instruction']
parsed_children = self._parse_steps_recursive(children)
# 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:
parsed_step["conditions"] = self._parse_condition_branches(condition_children)
parsed_step["conditions"] = condition_children
if instruction_children:
parsed_step["then"] = self._parse_steps_recursive(instruction_children)
parsed_step["then"] = instruction_children
return parsed_step
@ -113,16 +195,6 @@ class WorkflowParser:
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 count_steps_recursive(steps_list):
count = 0
@ -152,6 +224,9 @@ class WorkflowParser:
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
print("=" * 80)
print("WORKFLOW PARSER DEBUG - get_workflow_summary")
@ -160,8 +235,11 @@ class WorkflowParser:
print(f"Type: {type(steps)}")
print(f"Length: {len(steps) if isinstance(steps, list) else 'N/A'}")
print(f"Raw steps: {steps}")
print("-" * 80)
print(f"PARSED OUTPUT:")
print("-" * 40)
print(f"PARSED STEPS:")
print(f"Parsed: {parsed_steps}")
print("-" * 40)
print(f"SUMMARY:")
print(f"Total steps: {total_steps}")
print(f"Total conditions: {total_conditions}")
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';
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;
const convertStepsWithNesting = (stepList: ConditionalStep[]): any[] => {
return stepList.map((step) => {
// Build clean step object with required fields for backend validation
const jsonStep: any = {
id: step.id,
id: step.id, // CRITICAL: Always include ID
name: step.name,
description: step.description,
type: step.type,
config: step.config,
order: globalOrder++
config: step.config || {},
order: globalOrder++ // Required by backend validation
};
// Add conditional metadata if present
if (step.type === 'condition' && 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) {
jsonStep.children = convertStepsWithNesting(step.children);
}
@ -37,6 +48,7 @@ const convertToNestedJSON = (steps: ConditionalStep[]): any[] => {
return jsonStep;
});
};
return convertStepsWithNesting(steps);
};
@ -57,12 +69,12 @@ const reconstructFromNestedJSON = (nestedSteps: any[]): ConditionalStep[] => {
const convertStepsFromNested = (stepList: any[]): ConditionalStep[] => {
return stepList.map((step) => {
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,
description: step.description || '',
type: step.type || 'instruction',
config: step.config || {},
order: step.order || step.step_order || 0,
order: step.order || 0, // Preserve order from backend
enabled: step.enabled !== false,
hasIssues: step.hasIssues || false,
children: [] // Initialize children array
@ -73,6 +85,11 @@ const reconstructFromNestedJSON = (nestedSteps: any[]): ConditionalStep[] => {
conditionalStep.conditions = step.conditions;
}
// Handle parentConditionalId for conditional grouping
if (step.parentConditionalId) {
conditionalStep.parentConditionalId = step.parentConditionalId;
}
// Handle children - this is crucial for nested conditions
if (step.children && Array.isArray(step.children) && step.children.length > 0) {
conditionalStep.children = convertStepsFromNested(step.children);
@ -110,7 +127,7 @@ const reconstructFromFlatJSON = (flatSteps: any[]): ConditionalStep[] => {
for (const flatStep of flatSteps) {
if (flatStep.type === 'condition') {
const conditionStep: ConditionalStep = {
id: flatStep.id || Math.random().toString(36).substr(2, 9),
id: flatStep.id, // Always preserve existing ID
name: flatStep.name,
description: flatStep.description || '',
type: 'condition',
@ -127,7 +144,7 @@ const reconstructFromFlatJSON = (flatSteps: any[]): ConditionalStep[] => {
for (const [conditionId, conditionStep] of conditionSteps) {
if (JSON.stringify(conditionStep.conditions) === JSON.stringify(flatStep.conditions)) {
const childStep: ConditionalStep = {
id: flatStep.id || Math.random().toString(36).substr(2, 9),
id: flatStep.id, // Always preserve existing ID
name: flatStep.name,
description: flatStep.description || '',
type: flatStep.type || 'instruction',
@ -165,7 +182,7 @@ const reconstructFromFlatJSON = (flatSteps: any[]): ConditionalStep[] => {
result.push(...conditionGroup);
} else if (!flatStep.conditions) {
const step: ConditionalStep = {
id: flatStep.id || Math.random().toString(36).substr(2, 9),
id: flatStep.id, // Always preserve existing ID
name: flatStep.name,
description: flatStep.description || '',
type: flatStep.type || 'instruction',

View File

@ -97,33 +97,46 @@ export function ConditionalWorkflowBuilder({
order: 0,
enabled: true,
};
const updateSteps = (items: ConditionalStep[]): ConditionalStep[] => {
const updateSteps = (items: ConditionalStep[]): { updatedItems: ConditionalStep[], found: boolean } => {
if (!parentId) {
if (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) {
found = true;
return {
...step,
children: [...(step.children || []), newStep]
};
}
if (step.children) {
return {
...step,
children: updateSteps(step.children)
};
if (step.children && !found) {
const result = updateSteps(step.children);
if (result.found) {
found = true;
return {
...step,
children: result.updatedItems
};
}
}
return step;
});
return { updatedItems, found };
};
onStepsChange(updateSteps(steps));
const result = updateSteps(steps);
onStepsChange(result.updatedItems);
}, [steps, onStepsChange]);
const addCondition = useCallback((afterStepId: string) => {

View File

@ -1,10 +1,18 @@
'use client';
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 { Label } from '@/components/ui/label';
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 { StepCard } from './step-card';
import { cn } from '@/lib/utils';
@ -63,6 +71,7 @@ export function ConditionalGroup({
const [activeConditionTab, setActiveConditionTab] = useState<string>(conditionSteps[0]?.id || '');
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const [pendingElseStepAdd, setPendingElseStepAdd] = useState<boolean>(false);
const [deleteConfirmStep, setDeleteConfirmStep] = useState<ConditionalStep | null>(null);
// Set up sensors for drag and drop within this conditional group
const sensors = useSensors(
@ -197,6 +206,25 @@ export function ConditionalGroup({
}
}, [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 (
<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">
@ -232,24 +260,39 @@ export function ConditionalGroup({
step.conditions?.type === 'else' ? 'Else' : 'If';
return (
<Button
key={step.id}
onClick={() => setActiveConditionTab(step.id)}
className={cn(
"h-9 px-3 border border-dashed text-xs",
isActive
? "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"
)}
>
<div key={step.id} className="relative group/tab">
<Button
onClick={() => setActiveConditionTab(step.id)}
className={cn(
"h-9 px-3 border border-dashed text-xs",
isActive
? "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"
)}
>
<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>
<span></span>
<span>{conditionType}</span>
{step.hasIssues && (
<AlertTriangle className="h-3 w-3 text-red-500" />
{/* Delete button - only for else-if tabs */}
{step.conditions?.type === 'elseif' && (
<Button
variant="ghost"
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) => {
// Call the parent's onAddStep which will open the side panel
// Pass the parentStepId correctly for nested context
onAddStep(index, parentStepId);
};
@ -505,6 +550,26 @@ export function ConditionalGroup({
</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>
);
}