mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
waleedlatif
parent
4964495abb
commit
727e5e8763
+27
-5
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user