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>
This commit is contained in:
Waleed Latif
2025-07-26 12:54:08 -07:00
committed by GitHub
co-authored by waleedlatif
parent 4964495abb
commit 727e5e8763
4 changed files with 189 additions and 6 deletions
@@ -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 (
<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) {
@@ -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'
)}
@@ -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,
}
}
+85
View File
@@ -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')
}
})
})
})
+39 -1
View File
@@ -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)