Skip to content

Commit 40ed2ec

Browse files
committed
refactor(stores): model execution and workflow-diff state as status enums
1 parent b212a5d commit 40ed2ec

8 files changed

Lines changed: 567 additions & 70 deletions

File tree

apps/sim/hooks/use-undo-redo.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
type UpdateParentOperation,
4242
useUndoRedoStore,
4343
} from '@/stores/undo-redo'
44+
import { deriveDiffFlags } from '@/stores/workflow-diff/types'
4445
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
4546
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
4647
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -1234,9 +1235,7 @@ export function useUndoRedo() {
12341235

12351236
// Restore diff state with baseline (local UI only)
12361237
diffStore._batchedStateUpdate({
1237-
hasActiveDiff: true,
1238-
isShowingDiff: true,
1239-
isDiffReady: true,
1238+
...deriveDiffFlags('showing'),
12401239
baselineWorkflow: originalBaseline || null,
12411240
baselineWorkflowId: activeWorkflowId,
12421241
diffAnalysis: diffAnalysis,
@@ -1285,9 +1284,7 @@ export function useUndoRedo() {
12851284
// Restore diff state with baseline (local UI only)
12861285
const diffStore = useWorkflowDiffStore.getState()
12871286
diffStore._batchedStateUpdate({
1288-
hasActiveDiff: true,
1289-
isShowingDiff: true,
1290-
isDiffReady: true,
1287+
...deriveDiffFlags('showing'),
12911288
baselineWorkflow: baselineSnapshot || null,
12921289
baselineWorkflowId: activeWorkflowId,
12931290
diffAnalysis: diffAnalysis,
@@ -1805,9 +1802,7 @@ export function useUndoRedo() {
18051802

18061803
// Restore diff state with original baseline (local UI only)
18071804
diffStore._batchedStateUpdate({
1808-
hasActiveDiff: true,
1809-
isShowingDiff: true,
1810-
isDiffReady: true,
1805+
...deriveDiffFlags('showing'),
18111806
baselineWorkflow: baselineSnapshot,
18121807
baselineWorkflowId: activeWorkflowId,
18131808
diffAnalysis: diffAnalysis,
@@ -1834,9 +1829,7 @@ export function useUndoRedo() {
18341829
// Clear diff state FIRST to prevent flash of colors (local UI only)
18351830
// Use setState directly to ensure synchronous clearing
18361831
useWorkflowDiffStore.setState({
1837-
hasActiveDiff: false,
1838-
isShowingDiff: false,
1839-
isDiffReady: false,
1832+
...deriveDiffFlags('none'),
18401833
baselineWorkflow: null,
18411834
baselineWorkflowId: null,
18421835
diffAnalysis: null,
@@ -1886,9 +1879,7 @@ export function useUndoRedo() {
18861879
// Clear diff state FIRST to prevent flash of colors (local UI only)
18871880
// Use setState directly to ensure synchronous clearing
18881881
useWorkflowDiffStore.setState({
1889-
hasActiveDiff: false,
1890-
isShowingDiff: false,
1891-
isDiffReady: false,
1882+
...deriveDiffFlags('none'),
18921883
baselineWorkflow: null,
18931884
baselineWorkflowId: null,
18941885
diffAnalysis: null,

apps/sim/stores/execution/store.test.ts

Lines changed: 170 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
/**
2+
* @vitest-environment node
3+
*
24
* Tests for the per-workflow execution store.
35
*
46
* These tests cover:
@@ -7,12 +9,19 @@
79
* - Execution lifecycle (start/stop clears run path)
810
* - Block and edge run status tracking
911
* - Active block management
10-
* - Debug state management
12+
* - The {@link ExecutionStatus} enum and its derived `isExecuting` /
13+
* `isDebugging` booleans (exhaustive status → flag mapping + transitions)
1114
* - Execution snapshot management
1215
* - Store reset
1316
* - Immutability guarantees
1417
*
1518
* @remarks
19+
* The store under test transitively imports the workflow registry store,
20+
* which drags in the block registry and emcn icon CSS. To keep this a true
21+
* unit test that loads under the node environment, the registry store is
22+
* mocked to a minimal stub (the store actions never touch it — only the
23+
* convenience hooks do, which are not exercised here).
24+
*
1625
* Most tests use `it.concurrent` with unique workflow IDs per test.
1726
* Because the store isolates state by workflow ID, concurrent tests
1827
* do not interfere with each other. The `reset` and `immutability`
@@ -21,17 +30,30 @@
2130

2231
import { beforeEach, describe, expect, it, vi } from 'vitest'
2332

33+
vi.mock('@/stores/workflows/registry/store', () => ({
34+
useWorkflowRegistry: Object.assign(
35+
vi.fn(() => null),
36+
{ getState: vi.fn(() => ({ activeWorkflowId: null })) }
37+
),
38+
}))
39+
2440
vi.unmock('@/stores/execution/store')
2541
vi.unmock('@/stores/execution/types')
2642

2743
import { useExecutionStore } from '@/stores/execution/store'
28-
import { defaultWorkflowExecutionState, initialState } from '@/stores/execution/types'
44+
import {
45+
defaultWorkflowExecutionState,
46+
deriveExecutionFlags,
47+
type ExecutionStatus,
48+
initialState,
49+
} from '@/stores/execution/types'
2950

3051
describe('useExecutionStore', () => {
3152
describe('getWorkflowExecution', () => {
3253
it.concurrent('should return default state for an unknown workflow', () => {
3354
const state = useExecutionStore.getState().getWorkflowExecution('wf-get-default')
3455

56+
expect(state.status).toBe('idle')
3557
expect(state.isExecuting).toBe(false)
3658
expect(state.isDebugging).toBe(false)
3759
expect(state.activeBlockIds.size).toBe(0)
@@ -63,22 +85,35 @@ describe('useExecutionStore', () => {
6385
})
6486
})
6587

88+
describe('deriveExecutionFlags', () => {
89+
it.concurrent('maps every status to the documented legacy booleans', () => {
90+
const cases: Array<[ExecutionStatus, boolean, boolean]> = [
91+
['idle', false, false],
92+
['running', true, false],
93+
['debugging', true, true],
94+
]
95+
for (const [status, isExecuting, isDebugging] of cases) {
96+
expect(deriveExecutionFlags(status)).toEqual({ isExecuting, isDebugging })
97+
}
98+
})
99+
})
100+
66101
describe('setIsExecuting', () => {
67-
it.concurrent('should set isExecuting to true', () => {
102+
it.concurrent('should set isExecuting to true (status running)', () => {
68103
useExecutionStore.getState().setIsExecuting('wf-exec-true', true)
69104

70-
expect(useExecutionStore.getState().getWorkflowExecution('wf-exec-true').isExecuting).toBe(
71-
true
72-
)
105+
const state = useExecutionStore.getState().getWorkflowExecution('wf-exec-true')
106+
expect(state.isExecuting).toBe(true)
107+
expect(state.status).toBe('running')
73108
})
74109

75-
it.concurrent('should set isExecuting to false', () => {
110+
it.concurrent('should set isExecuting to false (status idle)', () => {
76111
useExecutionStore.getState().setIsExecuting('wf-exec-false', true)
77112
useExecutionStore.getState().setIsExecuting('wf-exec-false', false)
78113

79-
expect(useExecutionStore.getState().getWorkflowExecution('wf-exec-false').isExecuting).toBe(
80-
false
81-
)
114+
const state = useExecutionStore.getState().getWorkflowExecution('wf-exec-false')
115+
expect(state.isExecuting).toBe(false)
116+
expect(state.status).toBe('idle')
82117
})
83118

84119
it.concurrent('should clear lastRunPath and lastRunEdges when starting execution', () => {
@@ -107,6 +142,131 @@ describe('useExecutionStore', () => {
107142
expect(state.isExecuting).toBe(false)
108143
expect(state.lastRunPath.get('block-1')).toBe('success')
109144
})
145+
146+
it.concurrent('starting a debug run then setIsExecuting(true) clears the run path', () => {
147+
const wf = 'wf-exec-debug-start-clears'
148+
useExecutionStore.getState().setIsExecuting(wf, true)
149+
useExecutionStore.getState().setIsDebugging(wf, true)
150+
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
151+
152+
useExecutionStore.getState().setIsExecuting(wf, true)
153+
154+
const state = useExecutionStore.getState().getWorkflowExecution(wf)
155+
expect(state.status).toBe('debugging')
156+
expect(state.isExecuting).toBe(true)
157+
expect(state.isDebugging).toBe(true)
158+
expect(state.lastRunPath.size).toBe(0)
159+
expect(state.lastRunEdges.size).toBe(0)
160+
})
161+
})
162+
163+
describe('setIsDebugging', () => {
164+
it.concurrent('should toggle debug mode', () => {
165+
const wf = 'wf-debug-toggle'
166+
useExecutionStore.getState().setIsDebugging(wf, true)
167+
168+
expect(useExecutionStore.getState().getWorkflowExecution(wf).isDebugging).toBe(true)
169+
expect(useExecutionStore.getState().getWorkflowExecution(wf).isExecuting).toBe(true)
170+
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('debugging')
171+
172+
useExecutionStore.getState().setIsDebugging(wf, false)
173+
expect(useExecutionStore.getState().getWorkflowExecution(wf).isDebugging).toBe(false)
174+
expect(useExecutionStore.getState().getWorkflowExecution(wf).isExecuting).toBe(true)
175+
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('running')
176+
})
177+
178+
it.concurrent('setIsDebugging(false) while idle is a no-op (stays idle)', () => {
179+
const wf = 'wf-debug-false-idle'
180+
useExecutionStore.getState().setIsDebugging(wf, false)
181+
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('idle')
182+
expect(useExecutionStore.getState().getWorkflowExecution(wf).isExecuting).toBe(false)
183+
})
184+
185+
it.concurrent('setIsDebugging(false) while running keeps running', () => {
186+
const wf = 'wf-debug-false-running'
187+
useExecutionStore.getState().setIsExecuting(wf, true)
188+
useExecutionStore.getState().setIsDebugging(wf, false)
189+
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('running')
190+
expect(useExecutionStore.getState().getWorkflowExecution(wf).isExecuting).toBe(true)
191+
})
192+
193+
it.concurrent('does not clear the run path when entering debug mode', () => {
194+
const wf = 'wf-debug-keeps-path'
195+
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
196+
useExecutionStore.getState().setIsDebugging(wf, true)
197+
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.get('block-1')).toBe(
198+
'success'
199+
)
200+
})
201+
})
202+
203+
describe('status enum', () => {
204+
it.concurrent('idle derives both flags false', () => {
205+
const wf = 'wf-status-idle'
206+
const state = useExecutionStore.getState().getWorkflowExecution(wf)
207+
expect(state.status).toBe('idle')
208+
expect(state.isExecuting).toBe(false)
209+
expect(state.isDebugging).toBe(false)
210+
})
211+
212+
it.concurrent('running derives isExecuting only', () => {
213+
const wf = 'wf-status-running'
214+
useExecutionStore.getState().setStatus(wf, 'running')
215+
const state = useExecutionStore.getState().getWorkflowExecution(wf)
216+
expect(state.status).toBe('running')
217+
expect(state.isExecuting).toBe(true)
218+
expect(state.isDebugging).toBe(false)
219+
})
220+
221+
it.concurrent('debugging derives both flags true', () => {
222+
const wf = 'wf-status-debugging'
223+
useExecutionStore.getState().setStatus(wf, 'debugging')
224+
const state = useExecutionStore.getState().getWorkflowExecution(wf)
225+
expect(state.status).toBe('debugging')
226+
expect(state.isExecuting).toBe(true)
227+
expect(state.isDebugging).toBe(true)
228+
})
229+
230+
it.concurrent('setStatus preserves the run path unless clearRunPath is passed', () => {
231+
const wf = 'wf-status-path-rules'
232+
useExecutionStore.getState().setStatus(wf, 'debugging')
233+
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
234+
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.size).toBe(1)
235+
236+
useExecutionStore.getState().setStatus(wf, 'running')
237+
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.size).toBe(1)
238+
239+
useExecutionStore.getState().setStatus(wf, 'running', { clearRunPath: true })
240+
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.size).toBe(0)
241+
})
242+
243+
it.concurrent('the derived booleans always agree with the stored status', () => {
244+
const wf = 'wf-status-no-drift'
245+
for (const status of ['idle', 'running', 'debugging', 'idle'] as const) {
246+
useExecutionStore.getState().setStatus(wf, status)
247+
const state = useExecutionStore.getState().getWorkflowExecution(wf)
248+
expect({ isExecuting: state.isExecuting, isDebugging: state.isDebugging }).toEqual(
249+
deriveExecutionFlags(status)
250+
)
251+
}
252+
})
253+
254+
it.concurrent('setIsExecuting(true) preserves an active debug session', () => {
255+
const wf = 'wf-status-debug-preserve'
256+
useExecutionStore.getState().setStatus(wf, 'debugging')
257+
useExecutionStore.getState().setIsExecuting(wf, true)
258+
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('debugging')
259+
})
260+
261+
it.concurrent('setIsExecuting(false) returns to idle from any mode', () => {
262+
const wf = 'wf-status-stop'
263+
useExecutionStore.getState().setStatus(wf, 'debugging')
264+
useExecutionStore.getState().setIsExecuting(wf, false)
265+
const state = useExecutionStore.getState().getWorkflowExecution(wf)
266+
expect(state.status).toBe('idle')
267+
expect(state.isExecuting).toBe(false)
268+
expect(state.isDebugging).toBe(false)
269+
})
110270
})
111271

112272
describe('setActiveBlocks', () => {
@@ -151,18 +311,6 @@ describe('useExecutionStore', () => {
151311
})
152312
})
153313

154-
describe('setIsDebugging', () => {
155-
it.concurrent('should toggle debug mode', () => {
156-
const wf = 'wf-debug-toggle'
157-
useExecutionStore.getState().setIsDebugging(wf, true)
158-
159-
expect(useExecutionStore.getState().getWorkflowExecution(wf).isDebugging).toBe(true)
160-
161-
useExecutionStore.getState().setIsDebugging(wf, false)
162-
expect(useExecutionStore.getState().getWorkflowExecution(wf).isDebugging).toBe(false)
163-
})
164-
})
165-
166314
describe('setExecutor', () => {
167315
it.concurrent('should store and clear executor', () => {
168316
const wf = 'wf-executor'

apps/sim/stores/execution/store.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
33
import {
44
type BlockRunStatus,
55
defaultWorkflowExecutionState,
6+
deriveExecutionFlags,
67
type EdgeRunStatus,
78
type ExecutionActions,
89
type ExecutionState,
10+
type ExecutionStatus,
911
initialState,
1012
type WorkflowExecutionState,
1113
} from './types'
@@ -78,9 +80,12 @@ export const useExecutionStore = create<ExecutionState & ExecutionActions>()((se
7880
})
7981
},
8082

81-
setIsExecuting: (workflowId, isExecuting) => {
82-
const patch: Partial<WorkflowExecutionState> = { isExecuting }
83-
if (isExecuting) {
83+
setStatus: (workflowId, status, options) => {
84+
const patch: Partial<WorkflowExecutionState> = {
85+
status,
86+
...deriveExecutionFlags(status),
87+
}
88+
if (options?.clearRunPath) {
8489
patch.lastRunPath = new Map()
8590
patch.lastRunEdges = new Map()
8691
}
@@ -89,10 +94,24 @@ export const useExecutionStore = create<ExecutionState & ExecutionActions>()((se
8994
})
9095
},
9196

97+
setIsExecuting: (workflowId, isExecuting) => {
98+
const current = getOrCreate(get().workflowExecutions, workflowId)
99+
const nextStatus: ExecutionStatus = isExecuting
100+
? current.status === 'debugging'
101+
? 'debugging'
102+
: 'running'
103+
: 'idle'
104+
get().setStatus(workflowId, nextStatus, { clearRunPath: isExecuting })
105+
},
106+
92107
setIsDebugging: (workflowId, isDebugging) => {
93-
set({
94-
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, { isDebugging }),
95-
})
108+
const current = getOrCreate(get().workflowExecutions, workflowId)
109+
const nextStatus: ExecutionStatus = isDebugging
110+
? 'debugging'
111+
: current.status === 'debugging'
112+
? 'running'
113+
: current.status
114+
get().setStatus(workflowId, nextStatus)
96115
},
97116

98117
setExecutor: (workflowId, executor) => {

0 commit comments

Comments
 (0)