Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
className={cn(
'gap-2 font-medium',
'bg-red-500 hover:bg-red-600',
'shadow-[0_0_0_0_#ef4444] hover:shadow-[0_0_0_4px_rgba(239,68,68,0.15)]',
'text-white transition-all duration-200',
'h-12 rounded-[11px] px-4 py-2'
)}
onClick={handleCancelExecution}
>
<X className={cn('h-3.5 w-3.5')} />
</Button>
</TooltipTrigger>
<TooltipContent>Cancel execution</TooltipContent>
</Tooltip>
)
}

const getTooltipContent = () => {
if (hasValidationErrors) {
Expand Down Expand Up @@ -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'
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -746,5 +783,6 @@ export function useWorkflowExecution() {
handleStepDebug,
handleResumeDebug,
handleCancelDebug,
handleCancelExecution,
}
}
85 changes: 85 additions & 0 deletions apps/sim/executor/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
})
})
})
40 changes: 39 additions & 1 deletion apps/sim/executor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class Executor {
private isDebugging = false
private contextExtensions: any = {}
private actualWorkflow: SerializedWorkflow
private isCancelled = false

constructor(
private workflowParam:
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down