diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index a103082d54..edf05cc3cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -91,7 +91,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { setDeploymentStatus, isLoading: isRegistryLoading, } = useWorkflowRegistry() - const { isExecuting, handleRunWorkflow } = useWorkflowExecution() + const { isExecuting, handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() const { setActiveTab, togglePanel, isOpen } = usePanelStore() const { getFolderTree, expandedFolders } = useFolderStore() @@ -785,12 +785,36 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { } /** - * Render run workflow button + * Render run workflow button or cancel button when executing */ const renderRunButton = () => { const canRun = userPermissions.canRead // Running only requires read permissions const isLoadingPermissions = userPermissions.isLoading - const isButtonDisabled = isWorkflowBlocked || (!canRun && !isLoadingPermissions) + const isButtonDisabled = + !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions)) + + // If currently executing, show cancel button + if (isExecuting) { + return ( + + + + + Cancel execution + + ) + } const getTooltipContent = () => { if (hasValidationErrors) { @@ -843,8 +867,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { 'bg-[#701FFC] hover:bg-[#6518E6]', 'shadow-[0_0_0_0_#701FFC] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]', 'text-white transition-all duration-200', - isExecuting && - 'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20', 'disabled:opacity-50 disabled:hover:bg-[#701FFC] disabled:hover:shadow-none', 'h-12 rounded-[11px] px-4 py-2' )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 9d57249bbb..6abd90a512 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -306,6 +306,21 @@ export function useWorkflowExecution() { try { const result = await executeWorkflow(workflowInput, onStream, executionId) + // Check if execution was cancelled + if ( + result && + 'success' in result && + !result.success && + result.error === 'Workflow execution was cancelled' + ) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ event: 'cancelled', data: result })}\n\n` + ) + ) + return + } + await Promise.all(streamReadingPromises) if (result && 'success' in result) { @@ -737,6 +752,28 @@ export function useWorkflowExecution() { resetDebugState() }, [resetDebugState]) + /** + * Handles cancelling the current workflow execution + */ + const handleCancelExecution = useCallback(() => { + logger.info('Workflow execution cancellation requested') + + // Cancel the executor if it exists + if (executor) { + executor.cancel() + } + + // Reset execution state + setIsExecuting(false) + setIsDebugging(false) + setActiveBlocks(new Set()) + + // If in debug mode, also reset debug state + if (isDebugging) { + resetDebugState() + } + }, [executor, isDebugging, resetDebugState, setIsExecuting, setIsDebugging, setActiveBlocks]) + return { isExecuting, isDebugging, @@ -746,5 +783,6 @@ export function useWorkflowExecution() { handleStepDebug, handleResumeDebug, handleCancelDebug, + handleCancelExecution, } } diff --git a/apps/sim/executor/index.test.ts b/apps/sim/executor/index.test.ts index a6eafcf2ad..a85b06d27a 100644 --- a/apps/sim/executor/index.test.ts +++ b/apps/sim/executor/index.test.ts @@ -882,4 +882,89 @@ describe('Executor', () => { expect(result).toBe(true) }) }) + + /** + * Cancellation tests + */ + describe('workflow cancellation', () => { + test('should set cancellation flag when cancel() is called', () => { + const workflow = createMinimalWorkflow() + const executor = new Executor(workflow) + + // Initially not cancelled + expect((executor as any).isCancelled).toBe(false) + + // Cancel and check flag + executor.cancel() + expect((executor as any).isCancelled).toBe(true) + }) + + test('should handle cancellation in debug mode continueExecution', async () => { + const workflow = createMinimalWorkflow() + const executor = new Executor(workflow) + + // Create mock context + const mockContext = createMockContext() + mockContext.blockStates.set('starter', { + output: { input: {} }, + executed: true, + executionTime: 0, + }) + + // Cancel before continue execution + executor.cancel() + + const result = await executor.continueExecution(['block1'], mockContext) + + expect(result.success).toBe(false) + expect(result.error).toBe('Workflow execution was cancelled') + }) + + test('should handle multiple cancel() calls gracefully', () => { + const workflow = createMinimalWorkflow() + const executor = new Executor(workflow) + + // Multiple cancellations should not cause issues + executor.cancel() + executor.cancel() + executor.cancel() + + expect((executor as any).isCancelled).toBe(true) + }) + + test('should prevent new execution on cancelled executor', async () => { + const workflow = createMinimalWorkflow() + const executor = new Executor(workflow) + + // Cancel first + executor.cancel() + + // Try to execute + const result = await executor.execute('test-workflow-id') + + // Should immediately return cancelled result + if ('success' in result) { + expect(result.success).toBe(false) + expect(result.error).toBe('Workflow execution was cancelled') + } + }) + + test('should return cancelled result when cancellation flag is checked', async () => { + const workflow = createMinimalWorkflow() + const executor = new Executor(workflow) + + // Test cancellation during the execution loop check + // Mock the while loop condition by setting cancelled before execution + + ;(executor as any).isCancelled = true + + const result = await executor.execute('test-workflow-id') + + // Should return cancelled result + if ('success' in result) { + expect(result.success).toBe(false) + expect(result.error).toBe('Workflow execution was cancelled') + } + }) + }) }) diff --git a/apps/sim/executor/index.ts b/apps/sim/executor/index.ts index 68a90b2588..23cda89c2b 100644 --- a/apps/sim/executor/index.ts +++ b/apps/sim/executor/index.ts @@ -70,6 +70,7 @@ export class Executor { private isDebugging = false private contextExtensions: any = {} private actualWorkflow: SerializedWorkflow + private isCancelled = false constructor( private workflowParam: @@ -163,6 +164,15 @@ export class Executor { this.isDebugging = useGeneralStore.getState().isDebugModeEnabled } + /** + * Cancels the current workflow execution. + * Sets the cancellation flag to stop further execution. + */ + public cancel(): void { + logger.info('Workflow execution cancelled') + this.isCancelled = true + } + /** * Executes the workflow and returns the result. * @@ -201,7 +211,7 @@ export class Executor { let iteration = 0 const maxIterations = 100 // Safety limit for infinite loops - while (hasMoreLayers && iteration < maxIterations) { + while (hasMoreLayers && iteration < maxIterations && !this.isCancelled) { const nextLayer = this.getNextExecutionLayer(context) if (this.isDebugging) { @@ -414,6 +424,24 @@ export class Executor { iteration++ } + // Handle cancellation + if (this.isCancelled) { + trackWorkflowTelemetry('workflow_execution_cancelled', { + workflowId, + duration: Date.now() - startTime.getTime(), + blockCount: this.actualWorkflow.blocks.length, + executedBlockCount: context.executedBlocks.size, + startTime: startTime.toISOString(), + }) + + return { + success: false, + output: finalOutput, + error: 'Workflow execution was cancelled', + logs: context.blockLogs, + } + } + const endTime = new Date() context.metadata.endTime = endTime.toISOString() const duration = endTime.getTime() - startTime.getTime() @@ -478,6 +506,16 @@ export class Executor { const { setPendingBlocks } = useExecutionStore.getState() let finalOutput: NormalizedBlockOutput = {} + // Check for cancellation + if (this.isCancelled) { + return { + success: false, + output: finalOutput, + error: 'Workflow execution was cancelled', + logs: context.blockLogs, + } + } + try { // Execute the current layer - using the original context, not a clone const outputs = await this.executeLayer(blockIds, context)