Skip to content

Commit 727e5e8

Browse files
waleedlatif1waleedlatif
andauthored
feat(workflow): added cancellation after launching manual execution (#796)
* feat(worfklow): added cancellation after launching manual execution * fix build error * ack PR comments --------- Co-authored-by: waleedlatif <waleedlatif@waleedlatifs-MacBook-Pro.local>
1 parent 4964495 commit 727e5e8

File tree

4 files changed

+189
-6
lines changed

4 files changed

+189
-6
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
9191
setDeploymentStatus,
9292
isLoading: isRegistryLoading,
9393
} = useWorkflowRegistry()
94-
const { isExecuting, handleRunWorkflow } = useWorkflowExecution()
94+
const { isExecuting, handleRunWorkflow, handleCancelExecution } = useWorkflowExecution()
9595
const { setActiveTab, togglePanel, isOpen } = usePanelStore()
9696
const { getFolderTree, expandedFolders } = useFolderStore()
9797

@@ -785,12 +785,36 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
785785
}
786786

787787
/**
788-
* Render run workflow button
788+
* Render run workflow button or cancel button when executing
789789
*/
790790
const renderRunButton = () => {
791791
const canRun = userPermissions.canRead // Running only requires read permissions
792792
const isLoadingPermissions = userPermissions.isLoading
793-
const isButtonDisabled = isWorkflowBlocked || (!canRun && !isLoadingPermissions)
793+
const isButtonDisabled =
794+
!isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions))
795+
796+
// If currently executing, show cancel button
797+
if (isExecuting) {
798+
return (
799+
<Tooltip>
800+
<TooltipTrigger asChild>
801+
<Button
802+
className={cn(
803+
'gap-2 font-medium',
804+
'bg-red-500 hover:bg-red-600',
805+
'shadow-[0_0_0_0_#ef4444] hover:shadow-[0_0_0_4px_rgba(239,68,68,0.15)]',
806+
'text-white transition-all duration-200',
807+
'h-12 rounded-[11px] px-4 py-2'
808+
)}
809+
onClick={handleCancelExecution}
810+
>
811+
<X className={cn('h-3.5 w-3.5')} />
812+
</Button>
813+
</TooltipTrigger>
814+
<TooltipContent>Cancel execution</TooltipContent>
815+
</Tooltip>
816+
)
817+
}
794818

795819
const getTooltipContent = () => {
796820
if (hasValidationErrors) {
@@ -843,8 +867,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
843867
'bg-[#701FFC] hover:bg-[#6518E6]',
844868
'shadow-[0_0_0_0_#701FFC] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
845869
'text-white transition-all duration-200',
846-
isExecuting &&
847-
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20',
848870
'disabled:opacity-50 disabled:hover:bg-[#701FFC] disabled:hover:shadow-none',
849871
'h-12 rounded-[11px] px-4 py-2'
850872
)}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,21 @@ export function useWorkflowExecution() {
306306
try {
307307
const result = await executeWorkflow(workflowInput, onStream, executionId)
308308

309+
// Check if execution was cancelled
310+
if (
311+
result &&
312+
'success' in result &&
313+
!result.success &&
314+
result.error === 'Workflow execution was cancelled'
315+
) {
316+
controller.enqueue(
317+
encoder.encode(
318+
`data: ${JSON.stringify({ event: 'cancelled', data: result })}\n\n`
319+
)
320+
)
321+
return
322+
}
323+
309324
await Promise.all(streamReadingPromises)
310325

311326
if (result && 'success' in result) {
@@ -737,6 +752,28 @@ export function useWorkflowExecution() {
737752
resetDebugState()
738753
}, [resetDebugState])
739754

755+
/**
756+
* Handles cancelling the current workflow execution
757+
*/
758+
const handleCancelExecution = useCallback(() => {
759+
logger.info('Workflow execution cancellation requested')
760+
761+
// Cancel the executor if it exists
762+
if (executor) {
763+
executor.cancel()
764+
}
765+
766+
// Reset execution state
767+
setIsExecuting(false)
768+
setIsDebugging(false)
769+
setActiveBlocks(new Set())
770+
771+
// If in debug mode, also reset debug state
772+
if (isDebugging) {
773+
resetDebugState()
774+
}
775+
}, [executor, isDebugging, resetDebugState, setIsExecuting, setIsDebugging, setActiveBlocks])
776+
740777
return {
741778
isExecuting,
742779
isDebugging,
@@ -746,5 +783,6 @@ export function useWorkflowExecution() {
746783
handleStepDebug,
747784
handleResumeDebug,
748785
handleCancelDebug,
786+
handleCancelExecution,
749787
}
750788
}

apps/sim/executor/index.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,4 +882,89 @@ describe('Executor', () => {
882882
expect(result).toBe(true)
883883
})
884884
})
885+
886+
/**
887+
* Cancellation tests
888+
*/
889+
describe('workflow cancellation', () => {
890+
test('should set cancellation flag when cancel() is called', () => {
891+
const workflow = createMinimalWorkflow()
892+
const executor = new Executor(workflow)
893+
894+
// Initially not cancelled
895+
expect((executor as any).isCancelled).toBe(false)
896+
897+
// Cancel and check flag
898+
executor.cancel()
899+
expect((executor as any).isCancelled).toBe(true)
900+
})
901+
902+
test('should handle cancellation in debug mode continueExecution', async () => {
903+
const workflow = createMinimalWorkflow()
904+
const executor = new Executor(workflow)
905+
906+
// Create mock context
907+
const mockContext = createMockContext()
908+
mockContext.blockStates.set('starter', {
909+
output: { input: {} },
910+
executed: true,
911+
executionTime: 0,
912+
})
913+
914+
// Cancel before continue execution
915+
executor.cancel()
916+
917+
const result = await executor.continueExecution(['block1'], mockContext)
918+
919+
expect(result.success).toBe(false)
920+
expect(result.error).toBe('Workflow execution was cancelled')
921+
})
922+
923+
test('should handle multiple cancel() calls gracefully', () => {
924+
const workflow = createMinimalWorkflow()
925+
const executor = new Executor(workflow)
926+
927+
// Multiple cancellations should not cause issues
928+
executor.cancel()
929+
executor.cancel()
930+
executor.cancel()
931+
932+
expect((executor as any).isCancelled).toBe(true)
933+
})
934+
935+
test('should prevent new execution on cancelled executor', async () => {
936+
const workflow = createMinimalWorkflow()
937+
const executor = new Executor(workflow)
938+
939+
// Cancel first
940+
executor.cancel()
941+
942+
// Try to execute
943+
const result = await executor.execute('test-workflow-id')
944+
945+
// Should immediately return cancelled result
946+
if ('success' in result) {
947+
expect(result.success).toBe(false)
948+
expect(result.error).toBe('Workflow execution was cancelled')
949+
}
950+
})
951+
952+
test('should return cancelled result when cancellation flag is checked', async () => {
953+
const workflow = createMinimalWorkflow()
954+
const executor = new Executor(workflow)
955+
956+
// Test cancellation during the execution loop check
957+
// Mock the while loop condition by setting cancelled before execution
958+
959+
;(executor as any).isCancelled = true
960+
961+
const result = await executor.execute('test-workflow-id')
962+
963+
// Should return cancelled result
964+
if ('success' in result) {
965+
expect(result.success).toBe(false)
966+
expect(result.error).toBe('Workflow execution was cancelled')
967+
}
968+
})
969+
})
885970
})

apps/sim/executor/index.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export class Executor {
7070
private isDebugging = false
7171
private contextExtensions: any = {}
7272
private actualWorkflow: SerializedWorkflow
73+
private isCancelled = false
7374

7475
constructor(
7576
private workflowParam:
@@ -163,6 +164,15 @@ export class Executor {
163164
this.isDebugging = useGeneralStore.getState().isDebugModeEnabled
164165
}
165166

167+
/**
168+
* Cancels the current workflow execution.
169+
* Sets the cancellation flag to stop further execution.
170+
*/
171+
public cancel(): void {
172+
logger.info('Workflow execution cancelled')
173+
this.isCancelled = true
174+
}
175+
166176
/**
167177
* Executes the workflow and returns the result.
168178
*
@@ -201,7 +211,7 @@ export class Executor {
201211
let iteration = 0
202212
const maxIterations = 100 // Safety limit for infinite loops
203213

204-
while (hasMoreLayers && iteration < maxIterations) {
214+
while (hasMoreLayers && iteration < maxIterations && !this.isCancelled) {
205215
const nextLayer = this.getNextExecutionLayer(context)
206216

207217
if (this.isDebugging) {
@@ -414,6 +424,24 @@ export class Executor {
414424
iteration++
415425
}
416426

427+
// Handle cancellation
428+
if (this.isCancelled) {
429+
trackWorkflowTelemetry('workflow_execution_cancelled', {
430+
workflowId,
431+
duration: Date.now() - startTime.getTime(),
432+
blockCount: this.actualWorkflow.blocks.length,
433+
executedBlockCount: context.executedBlocks.size,
434+
startTime: startTime.toISOString(),
435+
})
436+
437+
return {
438+
success: false,
439+
output: finalOutput,
440+
error: 'Workflow execution was cancelled',
441+
logs: context.blockLogs,
442+
}
443+
}
444+
417445
const endTime = new Date()
418446
context.metadata.endTime = endTime.toISOString()
419447
const duration = endTime.getTime() - startTime.getTime()
@@ -478,6 +506,16 @@ export class Executor {
478506
const { setPendingBlocks } = useExecutionStore.getState()
479507
let finalOutput: NormalizedBlockOutput = {}
480508

509+
// Check for cancellation
510+
if (this.isCancelled) {
511+
return {
512+
success: false,
513+
output: finalOutput,
514+
error: 'Workflow execution was cancelled',
515+
logs: context.blockLogs,
516+
}
517+
}
518+
481519
try {
482520
// Execute the current layer - using the original context, not a clone
483521
const outputs = await this.executeLayer(blockIds, context)

0 commit comments

Comments
 (0)