Skip to content

Commit 8558c19

Browse files
committed
run from block ui disabling
1 parent d417123 commit 8558c19

10 files changed

Lines changed: 202 additions & 52 deletions

File tree

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

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import { useShallow } from 'zustand/react/shallow'
55
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
66
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
77
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
8-
import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
8+
import {
9+
getRunFromBlockDependencyState,
10+
validateTriggerPaste,
11+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
912
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
10-
import { useExecutionStore, useIsCurrentWorkflowExecuting } from '@/stores/execution'
13+
import { useIsCurrentWorkflowExecuting, useLastExecutionSnapshot } from '@/stores/execution'
1114
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
1215
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
1316

@@ -105,7 +108,7 @@ export const ActionBar = memo(
105108

106109
const { activeWorkflowId } = useWorkflowRegistry()
107110
const isExecuting = useIsCurrentWorkflowExecuting()
108-
const getLastExecutionSnapshot = useExecutionStore((s) => s.getLastExecutionSnapshot)
111+
const snapshot = useLastExecutionSnapshot(activeWorkflowId)
109112
const userPermissions = useUserPermissionsContext()
110113
const edges = useWorkflowStore((state) => state.edges)
111114

@@ -115,21 +118,7 @@ export const ActionBar = memo(
115118
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
116119
const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel')
117120

118-
const snapshot = activeWorkflowId ? getLastExecutionSnapshot(activeWorkflowId) : null
119-
const incomingEdges = edges.filter((edge) => edge.target === blockId)
120-
const isTriggerBlock = incomingEdges.length === 0
121-
122-
// Check if each source block is either executed OR is a trigger block (triggers don't need prior execution)
123-
const isSourceSatisfied = (sourceId: string) => {
124-
if (snapshot?.executedBlocks.includes(sourceId)) return true
125-
// Check if source is a trigger (has no incoming edges itself)
126-
const sourceIncomingEdges = edges.filter((edge) => edge.target === sourceId)
127-
return sourceIncomingEdges.length === 0
128-
}
129-
130-
// Non-trigger blocks need a snapshot to exist (so upstream outputs are available)
131-
const dependenciesSatisfied =
132-
isTriggerBlock || (snapshot && incomingEdges.every((edge) => isSourceSatisfied(edge.source)))
121+
const { dependenciesSatisfied } = getRunFromBlockDependencyState(blockId, edges, snapshot)
133122
const canRunFromBlock =
134123
dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting
135124

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -680,7 +680,7 @@ describe('useWorkflowExecution attachment uploads', () => {
680680
unmount()
681681
})
682682

683-
it('uses fresh execution for trigger block runs instead of restoring an empty snapshot', async () => {
683+
it('uses fresh execution for trigger block runs and stores their snapshot', async () => {
684684
const currentBlocks = {
685685
...workflowBlocks,
686686
start: {
@@ -694,6 +694,18 @@ describe('useWorkflowExecution attachment uploads', () => {
694694
loops: {},
695695
parallels: {},
696696
})
697+
mockExecute.mockImplementationOnce(async (options) => {
698+
executionStoreState.getCurrentExecutionId.mockReturnValue('execution-1')
699+
options.onExecutionId?.('execution-1')
700+
await options.callbacks?.onExecutionCompleted?.({
701+
success: true,
702+
output: {},
703+
duration: 10,
704+
startTime: '2026-08-04T00:00:00.000Z',
705+
endTime: '2026-08-04T00:00:00.010Z',
706+
finalBlockLogs: [],
707+
})
708+
})
697709
const { result, unmount } = renderWorkflowExecutionHook()
698710

699711
await act(async () => {
@@ -717,6 +729,13 @@ describe('useWorkflowExecution attachment uploads', () => {
717729
)
718730
expect(mockExecute.mock.calls[0]?.[0]).not.toHaveProperty('sourceSnapshot')
719731
expect(mockExecuteFromBlock).not.toHaveBeenCalled()
732+
expect(executionStoreState.setLastExecutionSnapshot).toHaveBeenCalledWith(
733+
'workflow-1',
734+
expect.objectContaining({
735+
sourceExecutionId: 'execution-1',
736+
executedBlocks: ['start'],
737+
})
738+
)
720739

721740
unmount()
722741
})

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

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
TriggerUtils,
4242
} from '@/lib/workflows/triggers/triggers'
4343
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
44+
import { getRunFromBlockDependencyState } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/run-from-block'
4445
import {
4546
type UploadedWorkflowAttachment,
4647
uploadWorkflowAttachments,
@@ -1921,26 +1922,14 @@ export function useWorkflowExecution() {
19211922
const snapshot = getLastExecutionSnapshot(workflowId)
19221923
const latestWorkflowState = useWorkflowStore.getState().getWorkflowState()
19231924
const workflowEdges = latestWorkflowState.edges
1924-
const incomingEdges = workflowEdges.filter((edge) => edge.target === blockId)
1925-
const isTriggerBlock = incomingEdges.length === 0
1926-
1927-
// Check if each source block is either executed OR is a trigger block (triggers don't need prior execution)
1928-
const isSourceSatisfied = (sourceId: string) => {
1929-
if (snapshot?.executedBlocks.includes(sourceId)) return true
1930-
// Check if source is a trigger (has no incoming edges itself)
1931-
const sourceIncomingEdges = workflowEdges.filter((edge) => edge.target === sourceId)
1932-
return sourceIncomingEdges.length === 0
1933-
}
1925+
const { isEntryBlock: isTriggerBlock, dependenciesSatisfied } =
1926+
getRunFromBlockDependencyState(blockId, workflowEdges, snapshot ?? undefined)
19341927

1935-
// Non-trigger blocks need a snapshot to exist (so upstream outputs are available)
19361928
if (!snapshot && !isTriggerBlock) {
19371929
logger.error('No execution snapshot available for run-from-block', { workflowId, blockId })
19381930
return
19391931
}
19401932

1941-
const dependenciesSatisfied =
1942-
isTriggerBlock || incomingEdges.every((edge) => isSourceSatisfied(edge.source))
1943-
19441933
if (!dependenciesSatisfied) {
19451934
logger.error('Upstream dependencies not satisfied for run-from-block', {
19461935
workflowId,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ export * from './block-protection-utils'
33
export * from './block-ring-utils'
44
export * from './node-derivation'
55
export * from './node-position-utils'
6+
export * from './run-from-block'
67
export * from './workflow-canvas-helpers'
78
export * from './workflow-execution-utils'
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { getRunFromBlockDependencyState } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/run-from-block'
6+
7+
describe('getRunFromBlockDependencyState', () => {
8+
it('allows an entry block without a snapshot', () => {
9+
expect(getRunFromBlockDependencyState('trigger', [], undefined)).toEqual({
10+
isEntryBlock: true,
11+
dependenciesSatisfied: true,
12+
})
13+
})
14+
15+
it('requires a snapshot before running a downstream block', () => {
16+
const edges = [{ source: 'trigger', target: 'function' }]
17+
18+
expect(getRunFromBlockDependencyState('function', edges, undefined)).toEqual({
19+
isEntryBlock: false,
20+
dependenciesSatisfied: false,
21+
})
22+
})
23+
24+
it('allows a downstream block after its entry predecessor has run', () => {
25+
const edges = [{ source: 'trigger', target: 'function' }]
26+
27+
expect(
28+
getRunFromBlockDependencyState('function', edges, { executedBlocks: ['trigger'] })
29+
).toEqual({
30+
isEntryBlock: false,
31+
dependenciesSatisfied: true,
32+
})
33+
})
34+
35+
it('requires every non-entry predecessor to have cached output', () => {
36+
const edges = [
37+
{ source: 'trigger', target: 'producer' },
38+
{ source: 'producer', target: 'consumer' },
39+
{ source: 'trigger', target: 'consumer' },
40+
]
41+
42+
expect(
43+
getRunFromBlockDependencyState('consumer', edges, { executedBlocks: ['trigger'] })
44+
).toEqual({
45+
isEntryBlock: false,
46+
dependenciesSatisfied: false,
47+
})
48+
expect(
49+
getRunFromBlockDependencyState('consumer', edges, {
50+
executedBlocks: ['trigger', 'producer'],
51+
})
52+
).toEqual({
53+
isEntryBlock: false,
54+
dependenciesSatisfied: true,
55+
})
56+
})
57+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { WorkflowEdgeEndpoints } from '@sim/workflow-types/workflow'
2+
import type { SerializableExecutionState } from '@/executor/execution/types'
3+
4+
export interface RunFromBlockDependencyState {
5+
isEntryBlock: boolean
6+
dependenciesSatisfied: boolean
7+
}
8+
9+
/**
10+
* Evaluates whether a block has the cached upstream state required for run-from-block execution.
11+
*/
12+
export function getRunFromBlockDependencyState(
13+
blockId: string,
14+
edges: WorkflowEdgeEndpoints[],
15+
snapshot?: Pick<SerializableExecutionState, 'executedBlocks'>
16+
): RunFromBlockDependencyState {
17+
const incomingEdges = edges.filter((edge) => edge.target === blockId)
18+
const isEntryBlock = incomingEdges.length === 0
19+
20+
if (isEntryBlock) {
21+
return { isEntryBlock, dependenciesSatisfied: true }
22+
}
23+
24+
if (!snapshot) {
25+
return { isEntryBlock, dependenciesSatisfied: false }
26+
}
27+
28+
const executedBlocks = new Set(snapshot.executedBlocks)
29+
const blocksWithIncomingEdges = new Set(edges.map((edge) => edge.target))
30+
const dependenciesSatisfied = incomingEdges.every(
31+
(edge) => executedBlocks.has(edge.source) || !blocksWithIncomingEdges.has(edge.source)
32+
)
33+
34+
return { isEntryBlock, dependenciesSatisfied }
35+
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
getDescendantBlockIds,
5959
getEdgeSelectionContextId,
6060
getNodeSelectionContextId,
61+
getRunFromBlockDependencyState,
6162
getWorkflowLockToggleIds,
6263
isBlockProtected,
6364
isEdgeProtected,
@@ -93,7 +94,11 @@ import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
9394
import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return'
9495
import { useCanvasModeStore } from '@/stores/canvas-mode'
9596
import { useChatStore } from '@/stores/chat/store'
96-
import { defaultWorkflowExecutionState, useExecutionStore } from '@/stores/execution'
97+
import {
98+
defaultWorkflowExecutionState,
99+
useExecutionStore,
100+
useLastExecutionSnapshot,
101+
} from '@/stores/execution'
97102
import { useSearchModalStore } from '@/stores/modals/search/store'
98103
import type { PendingConnect } from '@/stores/modals/search/types'
99104
import { usePanelEditorStore } from '@/stores/panel'
@@ -839,7 +844,7 @@ const WorkflowContent = React.memo(
839844
}
840845
})
841846
)
842-
const getLastExecutionSnapshot = useExecutionStore((s) => s.getLastExecutionSnapshot)
847+
const lastExecutionSnapshot = useLastExecutionSnapshot(workflowIdParam)
843848

844849
const [dragStartParentId, setDragStartParentId] = useState<string | null>(null)
845850

@@ -1386,22 +1391,11 @@ const WorkflowContent = React.memo(
13861391
return { canRun: false, reason: undefined }
13871392
}
13881393
const block = contextMenuBlocks[0]
1389-
const snapshot = getLastExecutionSnapshot(workflowIdParam)
1390-
const incomingEdges = edges.filter((edge) => edge.target === block.id)
1391-
const isTriggerBlock = incomingEdges.length === 0
1392-
1393-
// Check if each source block is either executed OR is a trigger block (triggers don't need prior execution)
1394-
const isSourceSatisfied = (sourceId: string) => {
1395-
if (snapshot?.executedBlocks.includes(sourceId)) return true
1396-
// Check if source is a trigger (has no incoming edges itself)
1397-
const sourceIncomingEdges = edges.filter((edge) => edge.target === sourceId)
1398-
return sourceIncomingEdges.length === 0
1399-
}
1400-
1401-
// Non-trigger blocks need a snapshot to exist (so upstream outputs are available)
1402-
const dependenciesSatisfied =
1403-
isTriggerBlock ||
1404-
(snapshot && incomingEdges.every((edge) => isSourceSatisfied(edge.source)))
1394+
const { dependenciesSatisfied } = getRunFromBlockDependencyState(
1395+
block.id,
1396+
edges,
1397+
lastExecutionSnapshot
1398+
)
14051399
const isNoteBlock = block.type === 'note'
14061400
const isInsideSubflow =
14071401
block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -1412,7 +1406,7 @@ const WorkflowContent = React.memo(
14121406
if (isExecuting) return { canRun: false, reason: undefined }
14131407

14141408
return { canRun: true, reason: undefined }
1415-
}, [contextMenuBlocks, edges, workflowIdParam, getLastExecutionSnapshot, isExecuting])
1409+
}, [contextMenuBlocks, edges, lastExecutionSnapshot, isExecuting])
14161410

14171411
const handleContextAddBlock = useCallback(() => {
14181412
useSearchModalStore.getState().open()

apps/sim/stores/execution/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export {
22
useExecutionStore,
33
useIsBlockActive,
44
useIsCurrentWorkflowExecuting,
5+
useLastExecutionSnapshot,
56
useLastRunEdges,
67
useLastRunPath,
78
} from './store'
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import type { SerializableExecutionState } from '@/executor/execution/types'
8+
9+
vi.mock('@/stores/workflows/registry/store', () => ({
10+
useWorkflowRegistry: Object.assign(
11+
vi.fn(() => null),
12+
{
13+
getState: vi.fn(() => ({ activeWorkflowId: null })),
14+
}
15+
),
16+
}))
17+
18+
vi.unmock('@/stores/execution/store')
19+
vi.unmock('@/stores/execution/types')
20+
21+
import { useExecutionStore, useLastExecutionSnapshot } from '@/stores/execution/store'
22+
23+
describe('useLastExecutionSnapshot', () => {
24+
beforeEach(() => {
25+
useExecutionStore.getState().reset()
26+
})
27+
28+
it('updates consumers when the workflow snapshot changes', () => {
29+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
30+
const container = document.createElement('div')
31+
const root: Root = createRoot(container)
32+
let latest: SerializableExecutionState | undefined
33+
34+
function Probe() {
35+
latest = useLastExecutionSnapshot('workflow-1')
36+
return null
37+
}
38+
39+
act(() => root.render(<Probe />))
40+
expect(latest).toBeUndefined()
41+
42+
const snapshot: SerializableExecutionState = {
43+
blockStates: {},
44+
executedBlocks: ['trigger'],
45+
blockLogs: [],
46+
decisions: { router: {}, condition: {} },
47+
completedLoops: [],
48+
activeExecutionPath: ['trigger'],
49+
}
50+
51+
act(() => useExecutionStore.getState().setLastExecutionSnapshot('workflow-1', snapshot))
52+
expect(latest).toBe(snapshot)
53+
54+
act(() => root.unmount())
55+
})
56+
})

apps/sim/stores/execution/store.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,15 @@ export function useIsCurrentWorkflowExecuting(): boolean {
213213
})
214214
}
215215

216+
/**
217+
* Returns the latest execution snapshot for a workflow and updates when that snapshot changes.
218+
*/
219+
export function useLastExecutionSnapshot(workflowId?: string | null) {
220+
return useExecutionStore((state) =>
221+
workflowId ? state.lastExecutionSnapshots.get(workflowId) : undefined
222+
)
223+
}
224+
216225
/**
217226
* Returns whether a specific block is currently active (executing) in the current workflow.
218227
* More granular than useCurrentWorkflowExecution — only re-renders when

0 commit comments

Comments
 (0)