From b33759fca10e8b6b5bcbc7ca52a7beb5deccf191 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 24 Mar 2025 18:24:00 -0700 Subject: [PATCH] feat(debug): added debug mode & tests (#178) --- .../notification-dropdown-item.tsx | 13 +- .../components/control-bar/control-bar.tsx | 234 ++++++++-- .../notifications/notifications.tsx | 13 +- .../workflow-block/workflow-block.tsx | 15 +- .../w/[id]/hooks/use-workflow-execution.ts | 413 +++++++++++++++++- sim/app/w/[id]/workflow.tsx | 12 +- sim/executor/index.test.ts | 32 +- sim/executor/index.ts | 130 +++++- sim/executor/types.ts | 5 +- sim/stores/execution/store.ts | 18 + sim/stores/notifications/types.ts | 2 +- 11 files changed, 799 insertions(+), 88 deletions(-) diff --git a/sim/app/w/[id]/components/control-bar/components/notification-dropdown-item/notification-dropdown-item.tsx b/sim/app/w/[id]/components/control-bar/components/notification-dropdown-item/notification-dropdown-item.tsx index 3f9a41a93..7fe56c91c 100644 --- a/sim/app/w/[id]/components/control-bar/components/notification-dropdown-item/notification-dropdown-item.tsx +++ b/sim/app/w/[id]/components/control-bar/components/notification-dropdown-item/notification-dropdown-item.tsx @@ -2,15 +2,10 @@ import { useEffect, useState } from 'react' import { formatDistanceToNow } from 'date-fns' import { AlertCircle, Copy, Rocket, Store, Terminal, X } from 'lucide-react' import { ErrorIcon } from '@/components/icons' -import { Button } from '@/components/ui/button' import { DropdownMenuItem } from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' import { useNotificationStore } from '@/stores/notifications/store' -import { - NotificationOptions, - NotificationStore, - NotificationType, -} from '@/stores/notifications/types' +import { NotificationOptions, NotificationType } from '@/stores/notifications/types' interface NotificationDropdownItemProps { id: string @@ -25,6 +20,7 @@ const NotificationIcon = { console: Terminal, api: Rocket, marketplace: Store, + info: AlertCircle, } const NotificationColors = { @@ -32,6 +28,7 @@ const NotificationColors = { console: 'text-foreground', api: 'text-[#7F2FFF]', marketplace: 'text-foreground', + info: 'text-blue-500', } export function NotificationDropdownItem({ @@ -70,7 +67,9 @@ export function NotificationDropdownItem({ ? 'API' : type === 'marketplace' ? 'Marketplace' - : 'Console'} + : type === 'info' + ? 'Info' + : 'Console'} {timeAgo} diff --git a/sim/app/w/[id]/components/control-bar/control-bar.tsx b/sim/app/w/[id]/components/control-bar/control-bar.tsx index a1555c240..46b35b368 100644 --- a/sim/app/w/[id]/components/control-bar/control-bar.tsx +++ b/sim/app/w/[id]/components/control-bar/control-bar.tsx @@ -3,7 +3,20 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { formatDistanceToNow } from 'date-fns' -import { Bell, ChevronDown, History, Loader2, Play, Rocket, Store, Trash2 } from 'lucide-react' +import { + Bell, + Bug, + ChevronDown, + History, + Loader2, + Play, + Rocket, + SkipForward, + StepForward, + Store, + Trash2, + X, +} from 'lucide-react' import { AlertDialog, AlertDialogAction, @@ -26,7 +39,9 @@ import { Progress } from '@/components/ui/progress' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' +import { useExecutionStore } from '@/stores/execution/store' import { useNotificationStore } from '@/stores/notifications/store' +import { useGeneralStore } from '@/stores/settings/general/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowExecution } from '../../hooks/use-workflow-execution' @@ -61,6 +76,11 @@ export function ControlBar() { const { workflows, updateWorkflow, activeWorkflowId, removeWorkflow } = useWorkflowRegistry() const { isExecuting, handleRunWorkflow } = useWorkflowExecution() + // Debug mode state + const { isDebugModeEnabled, toggleDebugMode } = useGeneralStore() + const { isDebugging, pendingBlocks, handleStepDebug, handleCancelDebug, handleResumeDebug } = + useWorkflowExecution() + // Local state const [mounted, setMounted] = useState(false) const [, forceUpdate] = useState({}) @@ -84,6 +104,7 @@ export function ControlBar() { const [runCount, setRunCount] = useState(1) const [completedRuns, setCompletedRuns] = useState(0) const [isMultiRunning, setIsMultiRunning] = useState(false) + const [showRunProgress, setShowRunProgress] = useState(false) // Get notifications for current workflow const workflowNotifications = activeWorkflowId @@ -304,6 +325,7 @@ export function ControlBar() { // Reset state for a new batch of runs setCompletedRuns(0) setIsMultiRunning(true) + setShowRunProgress(runCount > 1) try { // Run the workflow multiple times sequentially @@ -329,6 +351,10 @@ export function ControlBar() { addNotification('error', 'Failed to complete all workflow runs', activeWorkflowId) } finally { setIsMultiRunning(false) + // Keep progress visible for a moment after completion + if (runCount > 1) { + setTimeout(() => setShowRunProgress(false), 2000) + } } } @@ -572,11 +598,130 @@ export function ControlBar() { ) + /** + * Render debug mode controls + */ + const renderDebugControls = () => { + // Display debug controls only when in debug mode and actively debugging + if (!isDebugModeEnabled || !isDebugging) return null + + const pendingCount = pendingBlocks.length + + return ( +
+
+ Debug Mode + + {pendingCount} block{pendingCount !== 1 ? 's' : ''} pending + +
+ + + + + Step Forward + + + + + + Resume Until End + + + + + + Cancel Debugging + +
+ ) + } + + /** + * Render debug mode toggle button + */ + const renderDebugModeToggle = () => { + const handleToggleDebugMode = () => { + // If turning off debug mode, make sure to clean up any debug state + if (isDebugModeEnabled) { + // Only clean up if we're not actively executing + if (!isExecuting) { + useExecutionStore.getState().setIsDebugging(false) + useExecutionStore.getState().setPendingBlocks([]) + } + } + toggleDebugMode() + } + + return ( + + + + + + {isDebugModeEnabled ? 'Disable Debug Mode' : 'Enable Debug Mode'} + + + ) + } + /** * Render run workflow button with multi-run dropdown */ const renderRunButton = () => (
+ {showRunProgress && ( +
+ +

+ {completedRuns}/{runCount} runs +

+
+ )} + + {/* Show how many blocks have been executed in debug mode if debugging */} + {isDebugging && ( +
+
+ Debugging Mode +
+
+ )} + + {renderDebugControls()} +
{/* Main Run Button */} - {/* Dropdown Trigger */} - - - - - - {RUN_COUNT_OPTIONS.map((count) => ( - setRunCount(count)} - className={cn('justify-center cursor-pointer', runCount === count && 'bg-muted')} + {/* Dropdown Trigger - Only show when not in debug mode */} + {!isDebugModeEnabled && ( + + + + + + {RUN_COUNT_OPTIONS.map((count) => ( + setRunCount(count)} + className={cn('justify-center', runCount === count && 'bg-muted')} + > + {count} + + ))} + + + )}
) @@ -651,6 +808,7 @@ export function ControlBar() { {renderDeleteButton()} {renderHistoryDropdown()} {renderNotificationsDropdown()} + {renderDebugModeToggle()} {renderPublishButton()} {renderDeployButton()} {renderRunButton()} diff --git a/sim/app/w/[id]/components/notifications/notifications.tsx b/sim/app/w/[id]/components/notifications/notifications.tsx index ea4c961c5..ab6f5b2e7 100644 --- a/sim/app/w/[id]/components/notifications/notifications.tsx +++ b/sim/app/w/[id]/components/notifications/notifications.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Rocket, Store, Terminal, X } from 'lucide-react' +import { Info, Rocket, Store, Terminal, X } from 'lucide-react' import { ErrorIcon } from '@/components/icons' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { @@ -68,6 +68,7 @@ const NotificationIcon = { console: Terminal, api: Rocket, marketplace: Store, + info: Info, } // Color schemes for different notification types @@ -79,6 +80,7 @@ const NotificationColors = { api: 'border-border bg-background text-foreground dark:border-border dark:text-foreground dark:bg-background', marketplace: 'border-border bg-background text-foreground dark:border-border dark:text-foreground dark:bg-background', + info: 'border-blue-500 bg-blue-50 text-blue-800 dark:border-border dark:bg-blue-900/20 dark:text-blue-400', } // API deployment status styling @@ -373,6 +375,7 @@ function NotificationAlert({ notification, isFading, onHide }: NotificationAlert '!text-red-500 mt-[-3px]': type === 'error', 'text-foreground mt-[-3px]': type === 'console', 'mt-[-4.5px] text-foreground ': type === 'marketplace', + '!text-blue-500 mt-[-3px]': type === 'info', })} /> @@ -381,7 +384,13 @@ function NotificationAlert({ notification, isFading, onHide }: NotificationAlert
- {type === 'error' ? 'Error' : type === 'marketplace' ? 'Marketplace' : 'Console'} + {type === 'error' + ? 'Error' + : type === 'marketplace' + ? 'Marketplace' + : type === 'info' + ? 'Info' + : 'Console'} {/* Close button for persistent notifications */} diff --git a/sim/app/w/[id]/components/workflow-block/workflow-block.tsx b/sim/app/w/[id]/components/workflow-block/workflow-block.tsx index e6359eabd..884913d0e 100644 --- a/sim/app/w/[id]/components/workflow-block/workflow-block.tsx +++ b/sim/app/w/[id]/components/workflow-block/workflow-block.tsx @@ -20,11 +20,13 @@ interface WorkflowBlockProps { type: string config: BlockConfig name: string + isActive?: boolean + isPending?: boolean } // Combine both interfaces into a single component export function WorkflowBlock({ id, data }: NodeProps) { - const { type, config, name } = data + const { type, config, name, isActive: dataIsActive, isPending } = data // State management const [isConnecting, setIsConnecting] = useState(false) @@ -52,6 +54,7 @@ export function WorkflowBlock({ id, data }: NodeProps) { // Execution store const isActiveBlock = useExecutionStore((state) => state.activeBlockIds.has(id)) + const isActive = dataIsActive || isActiveBlock // Update node internals when handles change useEffect(() => { @@ -200,9 +203,17 @@ export function WorkflowBlock({ id, data }: NodeProps) { 'transition-ring transition-block-bg', isWide ? 'w-[480px]' : 'w-[320px]', !isEnabled && 'shadow-sm', - isActiveBlock && 'ring-2 animate-pulse-ring' + isActive && 'ring-2 animate-pulse-ring ring-blue-500', + isPending && 'ring-2 ring-amber-500' )} > + {/* Show debug indicator for pending blocks */} + {isPending && ( +
+ Next Step +
+ )} + diff --git a/sim/app/w/[id]/hooks/use-workflow-execution.ts b/sim/app/w/[id]/hooks/use-workflow-execution.ts index 74eb53ec9..e9be3aaed 100644 --- a/sim/app/w/[id]/hooks/use-workflow-execution.ts +++ b/sim/app/w/[id]/hooks/use-workflow-execution.ts @@ -6,6 +6,7 @@ import { useConsoleStore } from '@/stores/console/store' import { useExecutionStore } from '@/stores/execution/store' import { useNotificationStore } from '@/stores/notifications/store' import { useEnvironmentStore } from '@/stores/settings/environment/store' +import { useGeneralStore } from '@/stores/settings/general/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -21,7 +22,19 @@ export function useWorkflowExecution() { const { addNotification } = useNotificationStore() const { toggleConsole } = useConsoleStore() const { getAllVariables } = useEnvironmentStore() - const { isExecuting, setIsExecuting } = useExecutionStore() + const { isDebugModeEnabled } = useGeneralStore() + const { + isExecuting, + isDebugging, + pendingBlocks, + executor, + debugContext, + setIsExecuting, + setIsDebugging, + setPendingBlocks, + setExecutor, + setDebugContext, + } = useExecutionStore() const [executionResult, setExecutionResult] = useState(null) const persistLogs = async (executionId: string, result: ExecutionResult) => { @@ -59,9 +72,14 @@ export function useWorkflowExecution() { if (!activeWorkflowId) return setIsExecuting(true) + // Set debug mode if it's enabled in settings + if (isDebugModeEnabled) { + setIsDebugging(true) + } + // Get the current console state directly from the store const currentIsOpen = useConsoleStore.getState().isOpen - + // Open console if it's not already open if (!currentIsOpen) { toggleConsole() @@ -70,6 +88,9 @@ export function useWorkflowExecution() { const executionId = uuidv4() try { + // Clear any existing state + setDebugContext(null) + // Use the mergeSubblockState utility to get all block states const mergedStates = mergeSubblockState(blocks) const currentBlockStates = Object.entries(mergedStates).reduce( @@ -96,23 +117,42 @@ export function useWorkflowExecution() { {} as Record ) - // Execute workflow + // Create serialized workflow const workflow = new Serializer().serializeWorkflow(mergedStates, edges, loops) - const executor = new Executor(workflow, currentBlockStates, envVarValues) - const result = await executor.execute(activeWorkflowId) - // Set result and show notification immediately - setExecutionResult(result) - addNotification( - result.success ? 'console' : 'error', - result.success - ? 'Workflow completed successfully' - : `Workflow execution failed: ${result.error}`, - activeWorkflowId - ) + // Create executor and store in global state + const newExecutor = new Executor(workflow, currentBlockStates, envVarValues) + setExecutor(newExecutor) - // Send the entire execution result to our API to be processed server-side - await persistLogs(executionId, result) + // Execute workflow + const result = await newExecutor.execute(activeWorkflowId) + + // If we're in debug mode, store the execution context for later steps + if (result.metadata?.isDebugSession && result.metadata.context) { + setDebugContext(result.metadata.context) + + // Make sure to update pending blocks + if (result.metadata.pendingBlocks) { + setPendingBlocks(result.metadata.pendingBlocks) + } + } else { + // Normal execution completed + setExecutionResult(result) + + // Show notification + addNotification( + result.success ? 'console' : 'error', + result.success + ? 'Workflow completed successfully' + : `Workflow execution failed: ${result.error}`, + activeWorkflowId + ) + + // In non-debug mode, persist logs + await persistLogs(executionId, result) + setIsExecuting(false) + setIsDebugging(false) + } } catch (error: any) { logger.error('Workflow Execution Error:', error) @@ -184,12 +224,19 @@ export function useWorkflowExecution() { notificationMessage += `: ${errorMessage}` } - addNotification('error', notificationMessage, activeWorkflowId) + // Safely show error notification + try { + addNotification('error', notificationMessage, activeWorkflowId) + } catch (notificationError) { + logger.error('Error showing error notification:', notificationError) + // Fallback console error + console.error('Workflow execution failed:', errorMessage) + } // Also send the error result to the API await persistLogs(executionId, errorResult) - } finally { setIsExecuting(false) + setIsDebugging(false) } }, [ activeWorkflowId, @@ -200,7 +247,335 @@ export function useWorkflowExecution() { toggleConsole, getAllVariables, setIsExecuting, + setIsDebugging, + isDebugModeEnabled, ]) - return { isExecuting, executionResult, handleRunWorkflow } + /** + * Handles stepping through workflow execution in debug mode + */ + const handleStepDebug = useCallback(async () => { + // Log debug information + logger.info('Step Debug requested', { + hasExecutor: !!executor, + hasContext: !!debugContext, + pendingBlockCount: pendingBlocks.length, + }) + + if (!executor || !debugContext || pendingBlocks.length === 0) { + logger.error('Cannot step debug - missing required state', { + executor: !!executor, + debugContext: !!debugContext, + pendingBlocks: pendingBlocks.length, + }) + + // Show error notification + addNotification( + 'error', + 'Cannot step through debugging - missing execution state. Try restarting debug mode.', + activeWorkflowId || '' + ) + + // Reset debug state + setIsDebugging(false) + setIsExecuting(false) + return + } + + try { + console.log('Executing debug step with blocks:', pendingBlocks) + + // Execute the next step with the pending blocks + const result = await executor.continueExecution(pendingBlocks, debugContext) + + console.log('Debug step execution result:', result) + + // Save the new context in the store + if (result.metadata?.context) { + setDebugContext(result.metadata.context) + } + + // Check if the debug session is complete + if ( + !result.metadata?.isDebugSession || + !result.metadata.pendingBlocks || + result.metadata.pendingBlocks.length === 0 + ) { + logger.info('Debug session complete') + // Debug session complete + setExecutionResult(result) + + // Show completion notification + addNotification( + result.success ? 'console' : 'error', + result.success + ? 'Workflow completed successfully' + : `Workflow execution failed: ${result.error}`, + activeWorkflowId || '' + ) + + // Persist logs + await persistLogs(uuidv4(), result) + + // Reset debug state + setIsExecuting(false) + setIsDebugging(false) + setDebugContext(null) + setExecutor(null) + setPendingBlocks([]) + } else { + // Debug session continues - update UI with new pending blocks + logger.info('Debug step completed, next blocks pending', { + nextPendingBlocks: result.metadata.pendingBlocks.length, + }) + + // This is critical - ensure we update the pendingBlocks in the store + setPendingBlocks(result.metadata.pendingBlocks) + } + } catch (error: any) { + logger.error('Debug Step Error:', error) + + const errorMessage = error instanceof Error ? error.message : String(error) + + // Create error result + const errorResult = { + success: false, + output: { response: {} }, + error: errorMessage, + logs: debugContext.blockLogs, + } + + setExecutionResult(errorResult) + + // Safely show error notification + try { + addNotification('error', `Debug step failed: ${errorMessage}`, activeWorkflowId || '') + } catch (notificationError) { + logger.error('Error showing step error notification:', notificationError) + console.error('Debug step failed:', errorMessage) + } + + // Persist logs + await persistLogs(uuidv4(), errorResult) + + // Reset debug state + setIsExecuting(false) + setIsDebugging(false) + setDebugContext(null) + setExecutor(null) + setPendingBlocks([]) + } + }, [ + executor, + debugContext, + pendingBlocks, + activeWorkflowId, + addNotification, + setIsExecuting, + setIsDebugging, + setPendingBlocks, + setDebugContext, + setExecutor, + ]) + + /** + * Handles resuming execution in debug mode until completion + */ + const handleResumeDebug = useCallback(async () => { + // Log debug information + logger.info('Resume Debug requested', { + hasExecutor: !!executor, + hasContext: !!debugContext, + pendingBlockCount: pendingBlocks.length, + }) + + if (!executor || !debugContext || pendingBlocks.length === 0) { + logger.error('Cannot resume debug - missing required state', { + executor: !!executor, + debugContext: !!debugContext, + pendingBlocks: pendingBlocks.length, + }) + + // Show error notification + addNotification( + 'error', + 'Cannot resume debugging - missing execution state. Try restarting debug mode.', + activeWorkflowId || '' + ) + + // Reset debug state + setIsDebugging(false) + setIsExecuting(false) + return + } + + try { + // Show a notification that we're resuming execution + try { + addNotification( + 'info', + 'Resuming workflow execution until completion', + activeWorkflowId || '' + ) + } catch (notificationError) { + logger.error('Error showing resume notification:', notificationError) + console.info('Resuming workflow execution until completion') + } + + let currentResult: ExecutionResult = { + success: true, + output: { response: {} }, + logs: debugContext.blockLogs, + } + + // Create copies to avoid mutation issues + let currentContext = { ...debugContext } + let currentPendingBlocks = [...pendingBlocks] + + console.log('Starting resume execution with blocks:', currentPendingBlocks) + + // Continue execution until there are no more pending blocks + let iterationCount = 0 + const maxIterations = 100 // Safety to prevent infinite loops + + while (currentPendingBlocks.length > 0 && iterationCount < maxIterations) { + logger.info( + `Resume iteration ${iterationCount + 1}, executing ${currentPendingBlocks.length} blocks` + ) + + currentResult = await executor.continueExecution(currentPendingBlocks, currentContext) + + logger.info(`Resume iteration result:`, { + success: currentResult.success, + hasPendingBlocks: !!currentResult.metadata?.pendingBlocks, + pendingBlockCount: currentResult.metadata?.pendingBlocks?.length || 0, + }) + + // Update context for next iteration + if (currentResult.metadata?.context) { + currentContext = currentResult.metadata.context + } else { + logger.info('No context in result, ending resume') + break // No context means we're done + } + + // Update pending blocks for next iteration + if (currentResult.metadata?.pendingBlocks) { + currentPendingBlocks = currentResult.metadata.pendingBlocks + } else { + logger.info('No pending blocks in result, ending resume') + break // No pending blocks means we're done + } + + // If we don't have a debug session anymore, we're done + if (!currentResult.metadata?.isDebugSession) { + logger.info('Debug session ended, ending resume') + break + } + + iterationCount++ + } + + if (iterationCount >= maxIterations) { + logger.warn('Resume execution reached maximum iteration limit') + } + + logger.info('Resume execution complete', { + iterationCount, + success: currentResult.success, + }) + + // Final result is the last step's result + setExecutionResult(currentResult) + + // Show completion notification + try { + addNotification( + currentResult.success ? 'console' : 'error', + currentResult.success + ? 'Workflow completed successfully' + : `Workflow execution failed: ${currentResult.error}`, + activeWorkflowId || '' + ) + } catch (notificationError) { + logger.error('Error showing completion notification:', notificationError) + console.info('Workflow execution completed') + } + + // Persist logs + await persistLogs(uuidv4(), currentResult) + + // Reset debug state + setIsExecuting(false) + setIsDebugging(false) + setDebugContext(null) + setExecutor(null) + setPendingBlocks([]) + } catch (error: any) { + logger.error('Debug Resume Error:', error) + + const errorMessage = error instanceof Error ? error.message : String(error) + + // Create error result + const errorResult = { + success: false, + output: { response: {} }, + error: errorMessage, + logs: debugContext.blockLogs, + } + + setExecutionResult(errorResult) + + // Safely show error notification + try { + addNotification('error', `Resume execution failed: ${errorMessage}`, activeWorkflowId || '') + } catch (notificationError) { + logger.error('Error showing resume error notification:', notificationError) + console.error('Resume execution failed:', errorMessage) + } + + // Persist logs + await persistLogs(uuidv4(), errorResult) + + // Reset debug state + setIsExecuting(false) + setIsDebugging(false) + setDebugContext(null) + setExecutor(null) + setPendingBlocks([]) + } + }, [ + executor, + debugContext, + pendingBlocks, + activeWorkflowId, + addNotification, + setIsExecuting, + setIsDebugging, + setPendingBlocks, + setDebugContext, + setExecutor, + ]) + + /** + * Handles cancelling the current debugging session + */ + const handleCancelDebug = useCallback(() => { + setIsExecuting(false) + setIsDebugging(false) + setDebugContext(null) + setExecutor(null) + setPendingBlocks([]) + }, [setIsExecuting, setIsDebugging, setDebugContext, setExecutor, setPendingBlocks]) + + return { + isExecuting, + isDebugging, + pendingBlocks, + executionResult, + handleRunWorkflow, + handleStepDebug, + handleResumeDebug, + handleCancelDebug, + } } diff --git a/sim/app/w/[id]/workflow.tsx b/sim/app/w/[id]/workflow.tsx index 64dc04f2d..89d54a482 100644 --- a/sim/app/w/[id]/workflow.tsx +++ b/sim/app/w/[id]/workflow.tsx @@ -12,6 +12,7 @@ import ReactFlow, { } from 'reactflow' import 'reactflow/dist/style.css' import { createLogger } from '@/lib/logs/console-logger' +import { useExecutionStore } from '@/stores/execution/store' import { useNotificationStore } from '@/stores/notifications/store' import { useGeneralStore } from '@/stores/settings/general/store' import { initializeSyncManagers, isSyncInitialized } from '@/stores/sync-registry' @@ -54,6 +55,10 @@ function WorkflowContent() { const { setValue: setSubBlockValue } = useSubBlockStore() const { markAllAsRead } = useNotificationStore() + // Execution and debug mode state + const { activeBlockIds, pendingBlocks } = useExecutionStore() + const { isDebugModeEnabled } = useGeneralStore() + // Initialize workflow useEffect(() => { if (typeof window !== 'undefined') { @@ -219,6 +224,9 @@ function WorkflowContent() { } } + const isActive = activeBlockIds.has(block.id) + const isPending = isDebugModeEnabled && pendingBlocks.includes(block.id) + nodeArray.push({ id: block.id, type: 'workflowBlock', @@ -229,12 +237,14 @@ function WorkflowContent() { type: block.type, config: blockConfig, name: block.name, + isActive, + isPending, }, }) }) return nodeArray - }, [blocks, loops]) + }, [blocks, loops, activeBlockIds, pendingBlocks, isDebugModeEnabled]) // Update nodes const onNodesChange = useCallback( diff --git a/sim/executor/index.test.ts b/sim/executor/index.test.ts index 24753ade1..16c9c97b1 100644 --- a/sim/executor/index.test.ts +++ b/sim/executor/index.test.ts @@ -10,9 +10,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { SerializedWorkflow } from '../serializer/types' import { Executor } from './index' -import { NormalizedBlockOutput } from './types' -// Mock all dependencies vi.mock('@/lib/logs/console-logger', () => ({ createLogger: () => ({ error: vi.fn(), @@ -36,13 +34,23 @@ vi.mock('@/stores/execution/store', () => ({ setIsExecuting: vi.fn(), reset: vi.fn(), setActiveBlocks: vi.fn(), + setPendingBlocks: vi.fn(), + setIsDebugging: vi.fn(), }), }, })) -// Mock all handler classes with a proper mock implementation +vi.mock('@/stores/settings/general/store', () => ({ + useGeneralStore: { + getState: () => ({ + isDebugModeEnabled: true, + }), + }, +})) + +// Mock all handler classes vi.mock('./handlers', () => { - // Create a factory function that returns a handler implementation + // Factory function for handler mocks const createHandler = (handlerName: string) => { return vi.fn().mockImplementation(() => ({ canHandle: (block: any) => block.metadata?.id === handlerName || handlerName === 'generic', @@ -86,7 +94,7 @@ vi.mock('./loops', () => ({ * Test Fixtures */ -// Create a minimal workflow with just a starter and one block +// Create a minimal workflow const createMinimalWorkflow = (): SerializedWorkflow => ({ version: '1.0', blocks: [ @@ -415,6 +423,20 @@ describe('Executor', () => { }) }) + /** + * Debug mode tests + */ + describe('debug mode', () => { + // Test that the executor can be put into debug mode + test('should detect debug mode from settings', () => { + const workflow = createMinimalWorkflow() + const executor = new Executor(workflow) + const isDebugging = (executor as any).isDebugging + + expect(isDebugging).toBe(true) + }) + }) + /** * Additional tests to improve coverage */ diff --git a/sim/executor/index.ts b/sim/executor/index.ts index 1b9afbcba..0c6e2a562 100644 --- a/sim/executor/index.ts +++ b/sim/executor/index.ts @@ -1,6 +1,7 @@ import { createLogger } from '@/lib/logs/console-logger' import { useConsoleStore } from '@/stores/console/store' import { useExecutionStore } from '@/stores/execution/store' +import { useGeneralStore } from '@/stores/settings/general/store' import { BlockOutput } from '@/blocks/types' import { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import { @@ -32,6 +33,7 @@ export class Executor { private pathTracker: PathTracker private blockHandlers: BlockHandler[] private workflowInput: any + private isDebugging: boolean = false constructor( private workflow: SerializedWorkflow, @@ -55,6 +57,8 @@ export class Executor { new ApiBlockHandler(), new GenericBlockHandler(), ] + + this.isDebugging = useGeneralStore.getState().isDebugModeEnabled } /** @@ -64,7 +68,7 @@ export class Executor { * @returns Execution result containing output, logs, and metadata */ async execute(workflowId: string): Promise { - const { setIsExecuting, reset } = useExecutionStore.getState() + const { setIsExecuting, setIsDebugging, setPendingBlocks, reset } = useExecutionStore.getState() const startTime = new Date() let finalOutput: NormalizedBlockOutput = { response: {} } @@ -75,6 +79,10 @@ export class Executor { try { setIsExecuting(true) + if (this.isDebugging) { + setIsDebugging(true) + } + let hasMoreLayers = true let iteration = 0 const maxIterations = 100 // Safety limit for infinite loops @@ -82,18 +90,45 @@ export class Executor { while (hasMoreLayers && iteration < maxIterations) { const nextLayer = this.getNextExecutionLayer(context) - if (nextLayer.length === 0) { - hasMoreLayers = false - } else { - const outputs = await this.executeLayer(nextLayer, context) + if (this.isDebugging) { + // In debug mode, update the pending blocks and wait for user interaction + setPendingBlocks(nextLayer) - if (outputs.length > 0) { - finalOutput = outputs[outputs.length - 1] - } - - const hasLoopReachedMaxIterations = await this.loopManager.processLoopIterations(context) - if (hasLoopReachedMaxIterations) { + // If there are no more blocks, we're done + if (nextLayer.length === 0) { hasMoreLayers = false + } else { + // Return early to wait for manual stepping + // The caller (useWorkflowExecution) will handle resumption + return { + success: true, + output: finalOutput, + metadata: { + duration: Date.now() - startTime.getTime(), + startTime: context.metadata.startTime!, + pendingBlocks: nextLayer, + isDebugSession: true, + context: context, // Include context for resumption + }, + logs: context.blockLogs, + } + } + } else { + // Normal execution without debug mode + if (nextLayer.length === 0) { + hasMoreLayers = false + } else { + const outputs = await this.executeLayer(nextLayer, context) + + if (outputs.length > 0) { + finalOutput = outputs[outputs.length - 1] + } + + const hasLoopReachedMaxIterations = + await this.loopManager.processLoopIterations(context) + if (hasLoopReachedMaxIterations) { + hasMoreLayers = false + } } } @@ -123,7 +158,78 @@ export class Executor { logs: context.blockLogs, } } finally { - reset() + if (!this.isDebugging) { + reset() + } + } + } + + /** + * Continues execution in debug mode from the current state. + * + * @param blockIds - Block IDs to execute in this step + * @param context - The current execution context + * @returns Updated execution result + */ + async continueExecution(blockIds: string[], context: ExecutionContext): Promise { + const { setPendingBlocks } = useExecutionStore.getState() + let finalOutput: NormalizedBlockOutput = { response: {} } + + try { + // Execute the current layer - using the original context, not a clone + const outputs = await this.executeLayer(blockIds, context) + + if (outputs.length > 0) { + finalOutput = outputs[outputs.length - 1] + } + + await this.loopManager.processLoopIterations(context) + const nextLayer = this.getNextExecutionLayer(context) + setPendingBlocks(nextLayer) + + // Check if we've completed execution + const isComplete = nextLayer.length === 0 + + if (isComplete) { + const endTime = new Date() + context.metadata.endTime = endTime.toISOString() + + return { + success: true, + output: finalOutput, + metadata: { + duration: endTime.getTime() - new Date(context.metadata.startTime!).getTime(), + startTime: context.metadata.startTime!, + endTime: context.metadata.endTime!, + pendingBlocks: [], + isDebugSession: false, + }, + logs: context.blockLogs, + } + } + + // Return the updated state for the next step + return { + success: true, + output: finalOutput, + metadata: { + duration: Date.now() - new Date(context.metadata.startTime!).getTime(), + startTime: context.metadata.startTime!, + pendingBlocks: nextLayer, + isDebugSession: true, + context: context, // Return the same context object for continuity + }, + logs: context.blockLogs, + } + } catch (error: any) { + console.error('Debug step execution failed:', this.sanitizeError(error)) + + return { + success: false, + output: finalOutput, + error: this.extractErrorMessage(error), + logs: context.blockLogs, + } } } diff --git a/sim/executor/types.ts b/sim/executor/types.ts index 1e40f1970..12d95219f 100644 --- a/sim/executor/types.ts +++ b/sim/executor/types.ts @@ -104,7 +104,10 @@ export interface ExecutionResult { metadata?: { duration: number // Total execution time in milliseconds startTime: string // ISO timestamp when execution started - endTime: string // ISO timestamp when execution completed + endTime?: string // ISO timestamp when execution completed + pendingBlocks?: string[] // Blocks pending execution in debug mode + isDebugSession?: boolean // Whether this is a debug session + context?: ExecutionContext // Execution context for resuming in debug mode } } diff --git a/sim/stores/execution/store.ts b/sim/stores/execution/store.ts index 04b6f5b93..1c16e7745 100644 --- a/sim/stores/execution/store.ts +++ b/sim/stores/execution/store.ts @@ -1,19 +1,33 @@ import { create } from 'zustand' +import { Executor } from '@/executor' +import { ExecutionContext } from '@/executor/types' interface ExecutionState { activeBlockIds: Set isExecuting: boolean + isDebugging: boolean + pendingBlocks: string[] + executor: Executor | null + debugContext: ExecutionContext | null } interface ExecutionActions { setActiveBlocks: (blockIds: Set) => void setIsExecuting: (isExecuting: boolean) => void + setIsDebugging: (isDebugging: boolean) => void + setPendingBlocks: (blockIds: string[]) => void + setExecutor: (executor: Executor | null) => void + setDebugContext: (context: ExecutionContext | null) => void reset: () => void } const initialState: ExecutionState = { activeBlockIds: new Set(), isExecuting: false, + isDebugging: false, + pendingBlocks: [], + executor: null, + debugContext: null, } export const useExecutionStore = create()((set) => ({ @@ -21,5 +35,9 @@ export const useExecutionStore = create()((se setActiveBlocks: (blockIds) => set({ activeBlockIds: new Set(blockIds) }), setIsExecuting: (isExecuting) => set({ isExecuting }), + setIsDebugging: (isDebugging) => set({ isDebugging }), + setPendingBlocks: (pendingBlocks) => set({ pendingBlocks }), + setExecutor: (executor) => set({ executor }), + setDebugContext: (debugContext) => set({ debugContext }), reset: () => set(initialState), })) diff --git a/sim/stores/notifications/types.ts b/sim/stores/notifications/types.ts index d94a17fd8..05f10dbac 100644 --- a/sim/stores/notifications/types.ts +++ b/sim/stores/notifications/types.ts @@ -1,4 +1,4 @@ -export type NotificationType = 'error' | 'console' | 'api' | 'marketplace' +export type NotificationType = 'error' | 'console' | 'api' | 'marketplace' | 'info' export interface Notification { id: string