feat(debug): create debugger (#1174)

* Updates

* Updates

* Updates

* Checkpoint

* Checkpoint

* Checkpoitn

* Var improvements

* Fixes

* Execution status

* UI improvements

* Ui updates

* Fix

* Fix scoping

* Fix workflow vars

* Fix env vars

* Remove number styling

* Variable highlighting

* Updates

* Update

* Fix resume

* Stuff

* Breakpoint ui

* Ui

* Ui updates

* Loops and parallels

* HIde env vars

* Checkpoint

* Stuff

* Panel toggle

* Lint
This commit is contained in:
Siddharth Ganesan
2025-08-28 18:19:20 -07:00
committed by GitHub
parent bb5f40a027
commit 7c73f5ffe0
9 changed files with 2322 additions and 71 deletions

View File

@@ -8,8 +8,6 @@ import {
Layers,
Play,
RefreshCw,
SkipForward,
StepForward,
Store,
Trash2,
WifiOff,
@@ -44,6 +42,7 @@ import {
getKeyboardShortcutText,
useKeyboardShortcuts,
} from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
import { useExecutionStore } from '@/stores/execution/store'
import { useFolderStore } from '@/stores/folders/store'
import { usePanelStore } from '@/stores/panel/store'
import { useGeneralStore } from '@/stores/settings/general/store'
@@ -111,6 +110,9 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false)
const [isAutoLayouting, setIsAutoLayouting] = useState(false)
// Remove chat modal state
// const [isChatPromptOpen, setIsChatPromptOpen] = useState(false)
// const [chatPrompt, setChatPrompt] = useState('')
// Delete workflow state - grouped for better organization
const [deleteState, setDeleteState] = useState({
@@ -146,6 +148,13 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
}
}, [setActiveTab, isOpen, togglePanel])
const openDebugPanel = useCallback(() => {
setActiveTab('debug')
if (!isOpen) {
togglePanel()
}
}, [setActiveTab, isOpen, togglePanel])
// Shared condition for keyboard shortcut and button disabled state
const isWorkflowBlocked = isExecuting || hasValidationErrors
@@ -819,15 +828,29 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
return // Do nothing if no executable blocks
}
// Start debugging
// Determine starter id for focus
const starter = Object.values(blocks).find((b) => b.type === 'starter') as any
const starterId = starter?.id as string | undefined
// Enable debug UI but do NOT start execution
if (!isDebugModeEnabled) {
toggleDebugMode()
}
if (usageExceeded) {
openSubscriptionSettings()
} else {
openConsolePanel()
handleRunWorkflow(undefined, true) // Start in debug mode
// Activate debug session state so the panel is active
const execStore = useExecutionStore.getState()
execStore.setIsExecuting(false)
execStore.setIsDebugging(true)
// Set the Start block as pending - it will execute on first Step
execStore.setPendingBlocks(starterId ? [starterId] : [])
// Show Debug tab and mark starter as the current block to execute
openDebugPanel()
if (starterId) {
execStore.setActiveBlocks(new Set([starterId]))
}
}
}
}, [
@@ -838,8 +861,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
blocks,
handleCancelDebug,
toggleDebugMode,
handleRunWorkflow,
openConsolePanel,
openDebugPanel,
])
/**
@@ -859,40 +881,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
return (
<div className='flex items-center gap-1'>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => {
openConsolePanel()
handleStepDebug()
}}
className={debugButtonClass}
disabled={isControlDisabled}
>
<StepForward className='h-5 w-5' />
<span className='sr-only'>Step Forward</span>
</Button>
</TooltipTrigger>
<TooltipContent>Step Forward</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => {
openConsolePanel()
handleResumeDebug()
}}
className={debugButtonClass}
disabled={isControlDisabled}
>
<SkipForward className='h-5 w-5' />
<span className='sr-only'>Resume Until End</span>
</Button>
</TooltipTrigger>
<TooltipContent>Resume Until End</TooltipContent>
</Tooltip>
{/* Keep only cancel (X) here; step/resume moved to panel */}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1214,7 +1203,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
{isExpanded && renderPublishButton()}
{renderDeleteButton()}
{renderDuplicateButton()}
{!isDebugging && renderDebugModeToggle()}
{renderDebugModeToggle()}
{renderDeployButton()}
{isDebugging ? renderDebugControlsBar() : renderRunButton()}
@@ -1226,6 +1215,8 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
workflowId={activeWorkflowId}
/>
)}
{/* Removed chat prompt dialog; chat input now lives in DebugPanel */}
</div>
)
}

View File

@@ -10,6 +10,7 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCopilotStore } from '@/stores/copilot/store'
import { useExecutionStore } from '@/stores/execution/store'
import { useChatStore } from '@/stores/panel/chat/store'
import { useConsoleStore } from '@/stores/panel/console/store'
import { usePanelStore } from '@/stores/panel/store'
@@ -17,6 +18,7 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { Chat } from './components/chat/chat'
import { Console } from './components/console/console'
import { Copilot } from './components/copilot/copilot'
import { DebugPanel } from './components/debug/debug'
import { Variables } from './components/variables/variables'
export function Panel() {
@@ -44,6 +46,9 @@ export function Panel() {
const exportChatCSV = useChatStore((state) => state.exportChatCSV)
const { activeWorkflowId } = useWorkflowRegistry()
// Get debug state
const isDebugging = useExecutionStore((state) => state.isDebugging)
// Copilot store for chat management
const {
chats,
@@ -216,7 +221,11 @@ export function Panel() {
)
// Handle tab clicks - no loading, just switch tabs
const handleTabClick = async (tab: 'chat' | 'console' | 'variables' | 'copilot') => {
const handleTabClick = async (tab: 'chat' | 'console' | 'variables' | 'copilot' | 'debug') => {
// Don't allow clicking debug tab if not debugging
if (tab === 'debug' && !isDebugging) {
return
}
setActiveTab(tab)
if (!isOpen) {
togglePanel()
@@ -284,10 +293,30 @@ export function Panel() {
}
}, [activeWorkflowId, copilotWorkflowId, ensureCopilotDataLoaded])
// When debug mode ends, switch to a different tab if debug was active
useEffect(() => {
if (!isDebugging && activeTab === 'debug') {
setActiveTab('console')
}
}, [isDebugging, activeTab, setActiveTab])
// When debug mode starts, automatically open the debug panel
useEffect(() => {
if (isDebugging) {
setActiveTab('debug')
if (!isOpen) {
togglePanel()
}
}
}, [isDebugging, setActiveTab, isOpen, togglePanel])
return (
<>
{/* Tab Selector - Always visible */}
<div className='fixed top-[76px] right-4 z-20 flex h-9 w-[308px] items-center gap-1 rounded-[14px] border bg-card px-[2.5px] py-1 shadow-xs'>
<div
className='fixed top-[76px] right-4 z-20 flex h-9 items-center gap-1 rounded-[14px] border bg-card px-[2.5px] py-1 shadow-xs'
style={{ width: isDebugging ? '380px' : '308px' }}
>
<button
onClick={() => handleTabClick('chat')}
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
@@ -320,6 +349,16 @@ export function Panel() {
>
Variables
</button>
{isDebugging && (
<button
onClick={() => handleTabClick('debug')}
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
isOpen && activeTab === 'debug' ? 'panel-tab-active' : 'panel-tab-inactive'
}`}
>
Debug
</button>
)}
</div>
{/* Panel Content - Only visible when isOpen is true */}
@@ -512,6 +551,9 @@ export function Panel() {
<div style={{ display: activeTab === 'variables' ? 'block' : 'none', height: '100%' }}>
<Variables />
</div>
<div style={{ display: activeTab === 'debug' ? 'block' : 'none', height: '100%' }}>
<DebugPanel />
</div>
</div>
</div>
)}

View File

@@ -13,6 +13,8 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useExecutionStore } from '@/stores/execution/store'
import { usePanelStore } from '@/stores/panel/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -580,6 +582,71 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null
const userPermissions = useUserPermissionsContext()
// Debug mode and active selection
const isDebugModeEnabled = useGeneralStore((s) => s.isDebugModeEnabled)
const activeBlockIds = useExecutionStore((s) => s.activeBlockIds)
const panelFocusedBlockId = useExecutionStore((s) => s.panelFocusedBlockId)
const setPanelFocusedBlockId = useExecutionStore((s) => s.setPanelFocusedBlockId)
const executingBlockIds = useExecutionStore((s) => s.executingBlockIds)
const setActiveBlocks = useExecutionStore((s) => s.setActiveBlocks)
const setActiveTab = usePanelStore((s) => s.setActiveTab)
const breakpointId = useExecutionStore((s) => s.breakpointId)
const debugContext = useExecutionStore((s) => s.debugContext)
const handleDebugOpen = (e: React.MouseEvent) => {
if (!isDebugModeEnabled) return
e.stopPropagation()
setActiveBlocks(new Set([id]))
setActiveTab('debug')
// Always select this block for the debug panel focus
setPanelFocusedBlockId(id)
}
// In debug mode, use executingBlockIds to detect actual executing blocks (not selection);
// outside debug, fall back to activeBlockIds driven by the executor
const isExecutingNow = isDebugModeEnabled ? executingBlockIds.has(id) : activeBlockIds.has(id)
const isCurrentBlock = isDebugModeEnabled && isPending
const isPanelFocused = isDebugModeEnabled && panelFocusedBlockId === id
// Check if block has errored during debug execution
const hasError =
isDebugModeEnabled && debugContext
? (() => {
// Check direct block state for error
const directState = debugContext.blockStates?.get(id)
if (
directState?.output &&
typeof directState.output === 'object' &&
'error' in directState.output
) {
return true
}
// Check virtual executions for errors (for blocks inside parallels)
for (const [key, state] of debugContext.blockStates?.entries() || []) {
// Check if this is a virtual ID for our block
if (typeof key === 'string' && key.startsWith(`${id}_parallel_`)) {
if (state?.output && typeof state.output === 'object' && 'error' in state.output) {
return true
}
}
}
// Also check block logs for this block
const hasErrorLog = debugContext.blockLogs?.some((log: any) => {
if (log.blockId === id && !log.success) return true
// Check if log is for a virtual version of this block
if (
typeof log.blockId === 'string' &&
log.blockId.startsWith(`${id}_parallel_`) &&
!log.success
) {
return true
}
return false
})
return hasErrorLog || false
})()
: false
return (
<div className='group relative'>
<Card
@@ -589,20 +656,45 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
'transition-block-bg transition-ring',
displayIsWide ? 'w-[480px]' : 'w-[320px]',
!isEnabled && 'shadow-sm',
isActive && 'animate-pulse-ring ring-2 ring-blue-500',
isPending && 'ring-2 ring-amber-500',
// Diff highlighting
diffStatus === 'new' && 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10',
diffStatus === 'edited' && 'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10',
// Error state - highest priority (only border, no background)
hasError && 'ring-2 ring-red-500',
// Panel-focused block highlight (unless errored)
!hasError && isPanelFocused && 'bg-blue-50/60 dark:bg-blue-900/5',
// Executing blocks match staging: pulsing blue ring
!hasError && isExecutingNow && 'animate-pulse-ring ring-2 ring-blue-500',
// Pending blocks show blue border when not executing
!hasError && !isExecutingNow && isCurrentBlock && 'ring-2 ring-blue-500',
// Diff highlighting (only if not in debug error state)
!hasError &&
diffStatus === 'new' &&
'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10',
!hasError &&
diffStatus === 'edited' &&
'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10',
// Deleted block highlighting (in original workflow)
isDeletedBlock && 'bg-red-50/50 ring-2 ring-red-500 dark:bg-red-900/10',
'z-[20]'
)}
onClick={handleDebugOpen}
>
{/* Show debug indicator for pending blocks */}
{isPending && (
<div className='-top-6 -translate-x-1/2 absolute left-1/2 z-10 transform rounded-t-md bg-amber-500 px-2 py-0.5 text-white text-xs'>
Next Step
{/* Show error indicator for errored blocks */}
{hasError && (
<div className='-top-6 -translate-x-1/2 absolute left-1/2 z-10 transform rounded-t-md bg-red-500 px-2 py-0.5 text-white text-xs'>
Error
</div>
)}
{/* Show debug indicator for current blocks in debug mode (pending or executing) - but not if errored */}
{!hasError && isDebugModeEnabled && (isPending || executingBlockIds.has(id)) && (
<div className='-top-6 -translate-x-1/2 absolute left-1/2 z-10 transform rounded-t-md bg-blue-500 px-2 py-0.5 text-white text-xs'>
Current
</div>
)}
{/* Show breakpoint indicator */}
{isDebugModeEnabled && breakpointId === id && (
<div className='-bottom-6 -translate-x-1/2 absolute left-1/2 z-10 transform rounded-b-md bg-red-500 px-2 py-0.5 text-white text-xs'>
Breakpoint
</div>
)}

View File

@@ -62,6 +62,7 @@ export function useWorkflowExecution() {
setExecutor,
setDebugContext,
setActiveBlocks,
setExecutingBlockIds,
} = useExecutionStore()
const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null)
@@ -93,6 +94,7 @@ export function useWorkflowExecution() {
setExecutor(null)
setPendingBlocks([])
setActiveBlocks(new Set())
setExecutingBlockIds(new Set())
// Reset debug mode setting if it was enabled
if (isDebugModeEnabled) {
@@ -105,6 +107,7 @@ export function useWorkflowExecution() {
setExecutor,
setPendingBlocks,
setActiveBlocks,
setExecutingBlockIds,
isDebugModeEnabled,
])
@@ -120,7 +123,7 @@ export function useWorkflowExecution() {
}, [])
/**
* Handles debug session completion
* Handles debug session completion - keep debug session open for inspection
*/
const handleDebugSessionComplete = useCallback(
async (result: ExecutionResult) => {
@@ -130,10 +133,14 @@ export function useWorkflowExecution() {
// Persist logs
await persistLogs(uuidv4(), result)
// Reset debug state
resetDebugState()
// Keep debug mode open for inspection: stop executing, clear pending
setIsExecuting(false)
setPendingBlocks([])
setExecutingBlockIds(new Set())
// Keep debugContext and executor so the panel can inspect state
// Do not reset isDebugging
},
[activeWorkflowId, resetDebugState]
[activeWorkflowId, setIsExecuting, setPendingBlocks, setExecutingBlockIds]
)
/**
@@ -157,7 +164,7 @@ export function useWorkflowExecution() {
)
/**
* Handles debug execution errors
* Handles debug execution errors - keep debug open for inspection
*/
const handleDebugExecutionError = useCallback(
async (error: any, operation: string) => {
@@ -176,10 +183,13 @@ export function useWorkflowExecution() {
// Persist logs
await persistLogs(uuidv4(), errorResult)
// Reset debug state
resetDebugState()
// Keep debug session open for inspection
setIsExecuting(false)
setPendingBlocks([])
setExecutingBlockIds(new Set())
// Keep isDebugging, debugContext, and executor intact
},
[debugContext, activeWorkflowId, resetDebugState]
[debugContext, activeWorkflowId, setIsExecuting, setPendingBlocks, setExecutingBlockIds]
)
const persistLogs = async (
@@ -268,8 +278,8 @@ export function useWorkflowExecution() {
const isChatExecution =
workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput
// For chat executions, we'll use a streaming approach
if (isChatExecution) {
// For chat executions, use streaming only when NOT debugging
if (isChatExecution && !enableDebug) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
@@ -448,7 +458,6 @@ export function useWorkflowExecution() {
} catch (error: any) {
controller.error(error)
} finally {
controller.close()
setIsExecuting(false)
setIsDebugging(false)
setActiveBlocks(new Set())
@@ -458,7 +467,7 @@ export function useWorkflowExecution() {
return { success: true, stream }
}
// For manual (non-chat) execution
// For manual (non-streaming) execution including debug and non-chat
const executionId = uuidv4()
try {
const result = await executeWorkflow(workflowInput, undefined, executionId)
@@ -748,14 +757,19 @@ export function useWorkflowExecution() {
// Validate debug state
const validation = validateDebugState()
if (!validation.isValid) {
resetDebugState()
// Keep session open for inspection; simply stop executing
setIsExecuting(false)
return
}
try {
logger.info('Executing debug step with blocks:', pendingBlocks)
// Mark current pending blocks as executing for UI pulse
setExecutingBlockIds(new Set(pendingBlocks))
const result = await executor!.continueExecution(pendingBlocks, debugContext!)
logger.info('Debug step execution result:', result)
// Clear executing state after step returns
setExecutingBlockIds(new Set())
if (isDebugSessionComplete(result)) {
await handleDebugSessionComplete(result)
@@ -763,6 +777,7 @@ export function useWorkflowExecution() {
handleDebugSessionContinuation(result)
}
} catch (error: any) {
setExecutingBlockIds(new Set())
await handleDebugExecutionError(error, 'step')
}
}, [
@@ -771,7 +786,8 @@ export function useWorkflowExecution() {
pendingBlocks,
activeWorkflowId,
validateDebugState,
resetDebugState,
setIsExecuting,
setExecutingBlockIds,
isDebugSessionComplete,
handleDebugSessionComplete,
handleDebugSessionContinuation,
@@ -791,7 +807,8 @@ export function useWorkflowExecution() {
// Validate debug state
const validation = validateDebugState()
if (!validation.isValid) {
resetDebugState()
// Keep session open for inspection; simply stop executing
setIsExecuting(false)
return
}
@@ -819,7 +836,9 @@ export function useWorkflowExecution() {
`Resume iteration ${iterationCount + 1}, executing ${currentPendingBlocks.length} blocks`
)
setExecutingBlockIds(new Set(currentPendingBlocks))
currentResult = await executor!.continueExecution(currentPendingBlocks, currentContext)
setExecutingBlockIds(new Set())
logger.info('Resume iteration result:', {
success: currentResult.success,
@@ -864,6 +883,7 @@ export function useWorkflowExecution() {
// Handle completion
await handleDebugSessionComplete(currentResult)
} catch (error: any) {
setExecutingBlockIds(new Set())
await handleDebugExecutionError(error, 'resume')
}
}, [
@@ -872,7 +892,8 @@ export function useWorkflowExecution() {
pendingBlocks,
activeWorkflowId,
validateDebugState,
resetDebugState,
setIsExecuting,
setExecutingBlockIds,
handleDebugSessionComplete,
handleDebugExecutionError,
])

View File

@@ -746,7 +746,7 @@ export class Executor {
Object.entries(this.initialBlockStates).forEach(([blockId, output]) => {
context.blockStates.set(blockId, {
output: output as NormalizedBlockOutput,
executed: true,
executed: false,
executionTime: 0,
})
})

View File

@@ -61,5 +61,8 @@ export const useExecutionStore = create<ExecutionState & ExecutionActions>()((se
setExecutor: (executor) => set({ executor }),
setDebugContext: (debugContext) => set({ debugContext }),
setAutoPanDisabled: (disabled) => set({ autoPanDisabled: disabled }),
setPanelFocusedBlockId: (id) => set({ panelFocusedBlockId: id }),
setExecutingBlockIds: (ids) => set({ executingBlockIds: new Set(ids) }),
setBreakpointId: (id) => set({ breakpointId: id }),
reset: () => set(initialState),
}))

View File

@@ -9,6 +9,9 @@ export interface ExecutionState {
executor: Executor | null
debugContext: ExecutionContext | null
autoPanDisabled: boolean
panelFocusedBlockId?: string | null
executingBlockIds: Set<string>
breakpointId: string | null
}
export interface ExecutionActions {
@@ -19,6 +22,9 @@ export interface ExecutionActions {
setExecutor: (executor: Executor | null) => void
setDebugContext: (context: ExecutionContext | null) => void
setAutoPanDisabled: (disabled: boolean) => void
setPanelFocusedBlockId: (id: string | null) => void
setExecutingBlockIds: (ids: Set<string>) => void
setBreakpointId: (id: string | null) => void
reset: () => void
}
@@ -30,6 +36,9 @@ export const initialState: ExecutionState = {
executor: null,
debugContext: null,
autoPanDisabled: false,
panelFocusedBlockId: null,
executingBlockIds: new Set(),
breakpointId: null,
}
// Types for panning functionality

View File

@@ -1,4 +1,4 @@
export type PanelTab = 'console' | 'variables' | 'chat' | 'copilot'
export type PanelTab = 'console' | 'variables' | 'chat' | 'copilot' | 'debug'
export interface PanelStore {
isOpen: boolean