feat(debug): added debug mode & tests (#178)

This commit is contained in:
Waleed Latif
2025-03-24 18:24:00 -07:00
committed by GitHub
parent 16458c7e5d
commit b33759fca1
11 changed files with 799 additions and 88 deletions

View File

@@ -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'}
</span>
<span className="text-xs text-muted-foreground">{timeAgo}</span>
</div>

View File

@@ -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() {
</Tooltip>
)
/**
* 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 (
<div className="flex items-center gap-2 ml-2 bg-muted rounded-md px-2 py-1">
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Debug Mode</span>
<span className="text-xs font-medium">
{pendingCount} block{pendingCount !== 1 ? 's' : ''} pending
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={handleStepDebug}
className="h-8 w-8 bg-background"
disabled={pendingCount === 0}
>
<StepForward className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Step Forward</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={handleResumeDebug}
className="h-8 w-8 bg-background"
disabled={pendingCount === 0}
>
<SkipForward className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Resume Until End</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={handleCancelDebug}
className="h-8 w-8 bg-background"
>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Cancel Debugging</TooltipContent>
</Tooltip>
</div>
)
}
/**
* 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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleToggleDebugMode}
disabled={isExecuting || isMultiRunning}
className={cn(isDebugModeEnabled && 'text-amber-500')}
>
<Bug className="h-5 w-5" />
<span className="sr-only">Toggle Debug Mode</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{isDebugModeEnabled ? 'Disable Debug Mode' : 'Enable Debug Mode'}
</TooltipContent>
</Tooltip>
)
}
/**
* Render run workflow button with multi-run dropdown
*/
const renderRunButton = () => (
<div className="flex items-center">
{showRunProgress && (
<div className="mr-3 w-28">
<Progress value={(completedRuns / runCount) * 100} className="h-2 bg-muted" />
<p className="text-xs text-muted-foreground mt-1 text-center">
{completedRuns}/{runCount} runs
</p>
</div>
)}
{/* Show how many blocks have been executed in debug mode if debugging */}
{isDebugging && (
<div className="mr-3 min-w-28 px-1 py-0.5 bg-muted rounded">
<div className="text-xs text-muted-foreground text-center">
<span className="font-medium">Debugging Mode</span>
</div>
</div>
)}
{renderDebugControls()}
<div className="flex ml-1">
{/* Main Run Button */}
<Button
@@ -588,52 +733,64 @@ export function ControlBar() {
(isExecuting || isMultiRunning) &&
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20',
'disabled:opacity-50 disabled:hover:bg-[#7F2FFF] disabled:hover:shadow-none',
'rounded-r-none border-r border-r-[#6420cc] py-1.5 px-3 h-10 text-sm rounded-l-sm'
isDebugModeEnabled
? 'rounded py-2 px-4 h-10'
: 'rounded-r-none border-r border-r-[#6420cc] py-2 px-4 h-10'
)}
onClick={handleMultipleRuns}
onClick={isDebugModeEnabled ? handleRunWorkflow : handleMultipleRuns}
disabled={isExecuting || isMultiRunning}
>
<Play className={cn('!h-3 !w-3', 'fill-current stroke-current')} />
{isDebugModeEnabled ? (
<Bug className={cn('h-3.5 w-3.5 mr-1.5', 'fill-current stroke-current')} />
) : (
<Play className={cn('h-3.5 w-3.5', 'fill-current stroke-current')} />
)}
{isMultiRunning
? `Running ${completedRuns}/${runCount}`
: isExecuting
? 'Running'
: runCount === 1
? 'Run'
: `Run (${runCount})`}
? isDebugging
? 'Debugging'
: 'Running'
: isDebugModeEnabled
? 'Debug'
: runCount === 1
? 'Run'
: `Run (${runCount})`}
</Button>
{/* Dropdown Trigger */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className={cn(
'px-1.5 font-medium',
'bg-[#7F2FFF] hover:bg-[#7028E6]',
'shadow-[0_0_0_0_#7F2FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
(isExecuting || isMultiRunning) &&
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20',
'disabled:opacity-50 disabled:hover:bg-[#7F2FFF] disabled:hover:shadow-none',
'rounded-l-none h-10 rounded-r-sm'
)}
disabled={isExecuting || isMultiRunning}
>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-20">
{RUN_COUNT_OPTIONS.map((count) => (
<DropdownMenuItem
key={count}
onClick={() => setRunCount(count)}
className={cn('justify-center cursor-pointer', runCount === count && 'bg-muted')}
{/* Dropdown Trigger - Only show when not in debug mode */}
{!isDebugModeEnabled && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className={cn(
'px-2 font-medium',
'bg-[#7F2FFF] hover:bg-[#7028E6]',
'shadow-[0_0_0_0_#7F2FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
(isExecuting || isMultiRunning) &&
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20',
'disabled:opacity-50 disabled:hover:bg-[#7F2FFF] disabled:hover:shadow-none',
'rounded-l-none h-10'
)}
disabled={isExecuting || isMultiRunning}
>
{count}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-20">
{RUN_COUNT_OPTIONS.map((count) => (
<DropdownMenuItem
key={count}
onClick={() => setRunCount(count)}
className={cn('justify-center', runCount === count && 'bg-muted')}
>
{count}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
)
@@ -651,6 +808,7 @@ export function ControlBar() {
{renderDeleteButton()}
{renderHistoryDropdown()}
{renderNotificationsDropdown()}
{renderDebugModeToggle()}
{renderPublishButton()}
{renderDeployButton()}
{renderRunButton()}

View File

@@ -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',
})}
/>
</div>
@@ -381,7 +384,13 @@ function NotificationAlert({ notification, isFading, onHide }: NotificationAlert
<div className="flex-1 space-y-2 mr-4">
<AlertTitle className="flex items-center justify-between -mt-0.5">
<span>
{type === 'error' ? 'Error' : type === 'marketplace' ? 'Marketplace' : 'Console'}
{type === 'error'
? 'Error'
: type === 'marketplace'
? 'Marketplace'
: type === 'info'
? 'Info'
: 'Console'}
</span>
{/* Close button for persistent notifications */}

View File

@@ -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<WorkflowBlockProps>) {
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<WorkflowBlockProps>) {
// 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<WorkflowBlockProps>) {
'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 && (
<div className="absolute -top-6 left-1/2 transform -translate-x-1/2 bg-amber-500 text-white text-xs px-2 py-0.5 rounded-t-md z-10">
Next Step
</div>
)}
<ActionBar blockId={id} blockType={type} />
<ConnectionBlocks blockId={id} setIsConnecting={setIsConnecting} />

View File

@@ -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<ExecutionResult | null>(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<string, string>
)
// 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,
}
}

View File

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

View File

@@ -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
*/

View File

@@ -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<ExecutionResult> {
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<ExecutionResult> {
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,
}
}
}

View File

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

View File

@@ -1,19 +1,33 @@
import { create } from 'zustand'
import { Executor } from '@/executor'
import { ExecutionContext } from '@/executor/types'
interface ExecutionState {
activeBlockIds: Set<string>
isExecuting: boolean
isDebugging: boolean
pendingBlocks: string[]
executor: Executor | null
debugContext: ExecutionContext | null
}
interface ExecutionActions {
setActiveBlocks: (blockIds: Set<string>) => 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<ExecutionState & ExecutionActions>()((set) => ({
@@ -21,5 +35,9 @@ export const useExecutionStore = create<ExecutionState & ExecutionActions>()((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),
}))

View File

@@ -1,4 +1,4 @@
export type NotificationType = 'error' | 'console' | 'api' | 'marketplace'
export type NotificationType = 'error' | 'console' | 'api' | 'marketplace' | 'info'
export interface Notification {
id: string