chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
useExecutionStore,
|
||||
useIsBlockActive,
|
||||
useIsCurrentWorkflowExecuting,
|
||||
useLastRunEdges,
|
||||
useLastRunPath,
|
||||
} from './store'
|
||||
export { defaultWorkflowExecutionState } from './types'
|
||||
@@ -0,0 +1,620 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Tests for the per-workflow execution store.
|
||||
*
|
||||
* These tests cover:
|
||||
* - Default state for unknown workflows
|
||||
* - Per-workflow state isolation
|
||||
* - Execution lifecycle (start/stop clears run path)
|
||||
* - Block and edge run status tracking
|
||||
* - Active block management
|
||||
* - The {@link ExecutionStatus} enum and its derived `isExecuting` /
|
||||
* `isDebugging` booleans (exhaustive status → flag mapping + transitions)
|
||||
* - Execution snapshot management
|
||||
* - Store reset
|
||||
* - Immutability guarantees
|
||||
*
|
||||
* @remarks
|
||||
* The store under test transitively imports the workflow registry store,
|
||||
* which drags in the block registry and emcn icon CSS. To keep this a true
|
||||
* unit test that loads under the node environment, the registry store is
|
||||
* mocked to a minimal stub (the store actions never touch it — only the
|
||||
* convenience hooks do, which are not exercised here).
|
||||
*
|
||||
* Most tests use `it.concurrent` with unique workflow IDs per test.
|
||||
* Because the store isolates state by workflow ID, concurrent tests
|
||||
* do not interfere with each other. The `reset` and `immutability`
|
||||
* groups run sequentially since they affect or read global store state.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/stores/workflows/registry/store', () => ({
|
||||
useWorkflowRegistry: Object.assign(
|
||||
vi.fn(() => null),
|
||||
{ getState: vi.fn(() => ({ activeWorkflowId: null })) }
|
||||
),
|
||||
}))
|
||||
|
||||
vi.unmock('@/stores/execution/store')
|
||||
vi.unmock('@/stores/execution/types')
|
||||
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import {
|
||||
defaultWorkflowExecutionState,
|
||||
deriveExecutionFlags,
|
||||
type ExecutionStatus,
|
||||
initialState,
|
||||
} from '@/stores/execution/types'
|
||||
|
||||
describe('useExecutionStore', () => {
|
||||
describe('getWorkflowExecution', () => {
|
||||
it.concurrent('should return default state for an unknown workflow', () => {
|
||||
const state = useExecutionStore.getState().getWorkflowExecution('wf-get-default')
|
||||
|
||||
expect(state.status).toBe('idle')
|
||||
expect(state.isExecuting).toBe(false)
|
||||
expect(state.isDebugging).toBe(false)
|
||||
expect(state.activeBlockIds.size).toBe(0)
|
||||
expect(state.pendingBlocks).toEqual([])
|
||||
expect(state.executor).toBeNull()
|
||||
expect(state.debugContext).toBeNull()
|
||||
expect(state.lastRunPath.size).toBe(0)
|
||||
expect(state.lastRunEdges.size).toBe(0)
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should return fresh collections for unknown workflows, not shared references',
|
||||
() => {
|
||||
const stateA = useExecutionStore.getState().getWorkflowExecution('wf-fresh-a')
|
||||
const stateB = useExecutionStore.getState().getWorkflowExecution('wf-fresh-b')
|
||||
|
||||
expect(stateA.activeBlockIds).not.toBe(stateB.activeBlockIds)
|
||||
expect(stateA.lastRunPath).not.toBe(stateB.lastRunPath)
|
||||
expect(stateA.lastRunEdges).not.toBe(stateB.lastRunEdges)
|
||||
expect(stateA.activeBlockIds).not.toBe(defaultWorkflowExecutionState.activeBlockIds)
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('should return the stored state after a mutation', () => {
|
||||
useExecutionStore.getState().setIsExecuting('wf-get-stored', true)
|
||||
|
||||
const state = useExecutionStore.getState().getWorkflowExecution('wf-get-stored')
|
||||
expect(state.isExecuting).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveExecutionFlags', () => {
|
||||
it.concurrent('maps every status to the documented legacy booleans', () => {
|
||||
const cases: Array<[ExecutionStatus, boolean, boolean]> = [
|
||||
['idle', false, false],
|
||||
['running', true, false],
|
||||
['debugging', true, true],
|
||||
]
|
||||
for (const [status, isExecuting, isDebugging] of cases) {
|
||||
expect(deriveExecutionFlags(status)).toEqual({ isExecuting, isDebugging })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('setIsExecuting', () => {
|
||||
it.concurrent('should set isExecuting to true (status running)', () => {
|
||||
useExecutionStore.getState().setIsExecuting('wf-exec-true', true)
|
||||
|
||||
const state = useExecutionStore.getState().getWorkflowExecution('wf-exec-true')
|
||||
expect(state.isExecuting).toBe(true)
|
||||
expect(state.status).toBe('running')
|
||||
})
|
||||
|
||||
it.concurrent('should set isExecuting to false (status idle)', () => {
|
||||
useExecutionStore.getState().setIsExecuting('wf-exec-false', true)
|
||||
useExecutionStore.getState().setIsExecuting('wf-exec-false', false)
|
||||
|
||||
const state = useExecutionStore.getState().getWorkflowExecution('wf-exec-false')
|
||||
expect(state.isExecuting).toBe(false)
|
||||
expect(state.status).toBe('idle')
|
||||
})
|
||||
|
||||
it.concurrent('should clear lastRunPath and lastRunEdges when starting execution', () => {
|
||||
const wf = 'wf-exec-clears-run'
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
|
||||
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-1', 'success')
|
||||
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.size).toBe(1)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunEdges.size).toBe(1)
|
||||
|
||||
useExecutionStore.getState().setIsExecuting(wf, true)
|
||||
|
||||
const state = useExecutionStore.getState().getWorkflowExecution(wf)
|
||||
expect(state.lastRunPath.size).toBe(0)
|
||||
expect(state.lastRunEdges.size).toBe(0)
|
||||
expect(state.isExecuting).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should NOT clear lastRunPath when stopping execution', () => {
|
||||
const wf = 'wf-exec-stop-keeps-path'
|
||||
useExecutionStore.getState().setIsExecuting(wf, true)
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
|
||||
useExecutionStore.getState().setIsExecuting(wf, false)
|
||||
|
||||
const state = useExecutionStore.getState().getWorkflowExecution(wf)
|
||||
expect(state.isExecuting).toBe(false)
|
||||
expect(state.lastRunPath.get('block-1')).toBe('success')
|
||||
})
|
||||
|
||||
it.concurrent('starting a debug run then setIsExecuting(true) clears the run path', () => {
|
||||
const wf = 'wf-exec-debug-start-clears'
|
||||
useExecutionStore.getState().setIsExecuting(wf, true)
|
||||
useExecutionStore.getState().setIsDebugging(wf, true)
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
|
||||
|
||||
useExecutionStore.getState().setIsExecuting(wf, true)
|
||||
|
||||
const state = useExecutionStore.getState().getWorkflowExecution(wf)
|
||||
expect(state.status).toBe('debugging')
|
||||
expect(state.isExecuting).toBe(true)
|
||||
expect(state.isDebugging).toBe(true)
|
||||
expect(state.lastRunPath.size).toBe(0)
|
||||
expect(state.lastRunEdges.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setIsDebugging', () => {
|
||||
it.concurrent('should toggle debug mode', () => {
|
||||
const wf = 'wf-debug-toggle'
|
||||
useExecutionStore.getState().setIsDebugging(wf, true)
|
||||
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).isDebugging).toBe(true)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).isExecuting).toBe(true)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('debugging')
|
||||
|
||||
useExecutionStore.getState().setIsDebugging(wf, false)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).isDebugging).toBe(false)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).isExecuting).toBe(true)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('running')
|
||||
})
|
||||
|
||||
it.concurrent('setIsDebugging(false) while idle is a no-op (stays idle)', () => {
|
||||
const wf = 'wf-debug-false-idle'
|
||||
useExecutionStore.getState().setIsDebugging(wf, false)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('idle')
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).isExecuting).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('setIsDebugging(false) while running keeps running', () => {
|
||||
const wf = 'wf-debug-false-running'
|
||||
useExecutionStore.getState().setIsExecuting(wf, true)
|
||||
useExecutionStore.getState().setIsDebugging(wf, false)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('running')
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).isExecuting).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('does not clear the run path when entering debug mode', () => {
|
||||
const wf = 'wf-debug-keeps-path'
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
|
||||
useExecutionStore.getState().setIsDebugging(wf, true)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.get('block-1')).toBe(
|
||||
'success'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('status enum', () => {
|
||||
it.concurrent('idle derives both flags false', () => {
|
||||
const wf = 'wf-status-idle'
|
||||
const state = useExecutionStore.getState().getWorkflowExecution(wf)
|
||||
expect(state.status).toBe('idle')
|
||||
expect(state.isExecuting).toBe(false)
|
||||
expect(state.isDebugging).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('running derives isExecuting only', () => {
|
||||
const wf = 'wf-status-running'
|
||||
useExecutionStore.getState().setStatus(wf, 'running')
|
||||
const state = useExecutionStore.getState().getWorkflowExecution(wf)
|
||||
expect(state.status).toBe('running')
|
||||
expect(state.isExecuting).toBe(true)
|
||||
expect(state.isDebugging).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('debugging derives both flags true', () => {
|
||||
const wf = 'wf-status-debugging'
|
||||
useExecutionStore.getState().setStatus(wf, 'debugging')
|
||||
const state = useExecutionStore.getState().getWorkflowExecution(wf)
|
||||
expect(state.status).toBe('debugging')
|
||||
expect(state.isExecuting).toBe(true)
|
||||
expect(state.isDebugging).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('setStatus preserves the run path unless clearRunPath is passed', () => {
|
||||
const wf = 'wf-status-path-rules'
|
||||
useExecutionStore.getState().setStatus(wf, 'debugging')
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.size).toBe(1)
|
||||
|
||||
useExecutionStore.getState().setStatus(wf, 'running')
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.size).toBe(1)
|
||||
|
||||
useExecutionStore.getState().setStatus(wf, 'running', { clearRunPath: true })
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.size).toBe(0)
|
||||
})
|
||||
|
||||
it.concurrent('the derived booleans always agree with the stored status', () => {
|
||||
const wf = 'wf-status-no-drift'
|
||||
for (const status of ['idle', 'running', 'debugging', 'idle'] as const) {
|
||||
useExecutionStore.getState().setStatus(wf, status)
|
||||
const state = useExecutionStore.getState().getWorkflowExecution(wf)
|
||||
expect({ isExecuting: state.isExecuting, isDebugging: state.isDebugging }).toEqual(
|
||||
deriveExecutionFlags(status)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('setIsExecuting(true) preserves an active debug session', () => {
|
||||
const wf = 'wf-status-debug-preserve'
|
||||
useExecutionStore.getState().setStatus(wf, 'debugging')
|
||||
useExecutionStore.getState().setIsExecuting(wf, true)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('debugging')
|
||||
})
|
||||
|
||||
it.concurrent('setIsExecuting(false) returns to idle from any mode', () => {
|
||||
const wf = 'wf-status-stop'
|
||||
useExecutionStore.getState().setStatus(wf, 'debugging')
|
||||
useExecutionStore.getState().setIsExecuting(wf, false)
|
||||
const state = useExecutionStore.getState().getWorkflowExecution(wf)
|
||||
expect(state.status).toBe('idle')
|
||||
expect(state.isExecuting).toBe(false)
|
||||
expect(state.isDebugging).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setActiveBlocks', () => {
|
||||
it.concurrent('should set the active block IDs', () => {
|
||||
const wf = 'wf-active-set'
|
||||
useExecutionStore.getState().setActiveBlocks(wf, new Set(['block-1', 'block-2']))
|
||||
|
||||
const state = useExecutionStore.getState().getWorkflowExecution(wf)
|
||||
expect(state.activeBlockIds.has('block-1')).toBe(true)
|
||||
expect(state.activeBlockIds.has('block-2')).toBe(true)
|
||||
expect(state.activeBlockIds.size).toBe(2)
|
||||
})
|
||||
|
||||
it.concurrent('should replace the previous set', () => {
|
||||
const wf = 'wf-active-replace'
|
||||
useExecutionStore.getState().setActiveBlocks(wf, new Set(['block-1']))
|
||||
useExecutionStore.getState().setActiveBlocks(wf, new Set(['block-2']))
|
||||
|
||||
const state = useExecutionStore.getState().getWorkflowExecution(wf)
|
||||
expect(state.activeBlockIds.has('block-1')).toBe(false)
|
||||
expect(state.activeBlockIds.has('block-2')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should clear active blocks with an empty set', () => {
|
||||
const wf = 'wf-active-clear'
|
||||
useExecutionStore.getState().setActiveBlocks(wf, new Set(['block-1']))
|
||||
useExecutionStore.getState().setActiveBlocks(wf, new Set())
|
||||
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).activeBlockIds.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setPendingBlocks', () => {
|
||||
it.concurrent('should set pending block IDs', () => {
|
||||
const wf = 'wf-pending'
|
||||
useExecutionStore.getState().setPendingBlocks(wf, ['block-1', 'block-2'])
|
||||
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).pendingBlocks).toEqual([
|
||||
'block-1',
|
||||
'block-2',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('setExecutor', () => {
|
||||
it.concurrent('should store and clear executor', () => {
|
||||
const wf = 'wf-executor'
|
||||
const mockExecutor = { run: () => {} } as any
|
||||
|
||||
useExecutionStore.getState().setExecutor(wf, mockExecutor)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).executor).toBe(mockExecutor)
|
||||
|
||||
useExecutionStore.getState().setExecutor(wf, null)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).executor).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setDebugContext', () => {
|
||||
it.concurrent('should store and clear debug context', () => {
|
||||
const wf = 'wf-debug-ctx'
|
||||
const mockContext = { blockId: 'block-1' } as any
|
||||
|
||||
useExecutionStore.getState().setDebugContext(wf, mockContext)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).debugContext).toBe(mockContext)
|
||||
|
||||
useExecutionStore.getState().setDebugContext(wf, null)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).debugContext).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setBlockRunStatus', () => {
|
||||
it.concurrent('should record a success status for a block', () => {
|
||||
const wf = 'wf-block-success'
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
|
||||
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.get('block-1')).toBe(
|
||||
'success'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should record an error status for a block', () => {
|
||||
const wf = 'wf-block-error'
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'error')
|
||||
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.get('block-1')).toBe(
|
||||
'error'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should accumulate statuses for multiple blocks', () => {
|
||||
const wf = 'wf-block-accum'
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-2', 'error')
|
||||
|
||||
const runPath = useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath
|
||||
expect(runPath.get('block-1')).toBe('success')
|
||||
expect(runPath.get('block-2')).toBe('error')
|
||||
expect(runPath.size).toBe(2)
|
||||
})
|
||||
|
||||
it.concurrent('should overwrite a previous status for the same block', () => {
|
||||
const wf = 'wf-block-overwrite'
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'error')
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
|
||||
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.get('block-1')).toBe(
|
||||
'success'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setEdgeRunStatus', () => {
|
||||
it.concurrent('should record a success status for an edge', () => {
|
||||
const wf = 'wf-edge-success'
|
||||
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-1', 'success')
|
||||
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunEdges.get('edge-1')).toBe(
|
||||
'success'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should accumulate statuses for multiple edges', () => {
|
||||
const wf = 'wf-edge-accum'
|
||||
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-1', 'success')
|
||||
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-2', 'error')
|
||||
|
||||
const runEdges = useExecutionStore.getState().getWorkflowExecution(wf).lastRunEdges
|
||||
expect(runEdges.get('edge-1')).toBe('success')
|
||||
expect(runEdges.get('edge-2')).toBe('error')
|
||||
expect(runEdges.size).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearRunPath', () => {
|
||||
it.concurrent('should clear both lastRunPath and lastRunEdges', () => {
|
||||
const wf = 'wf-clear-both'
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
|
||||
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-1', 'success')
|
||||
useExecutionStore.getState().clearRunPath(wf)
|
||||
|
||||
const state = useExecutionStore.getState().getWorkflowExecution(wf)
|
||||
expect(state.lastRunPath.size).toBe(0)
|
||||
expect(state.lastRunEdges.size).toBe(0)
|
||||
})
|
||||
|
||||
it.concurrent('should not affect other workflow state', () => {
|
||||
const wf = 'wf-clear-other'
|
||||
useExecutionStore.getState().setIsExecuting(wf, true)
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
|
||||
useExecutionStore.getState().clearRunPath(wf)
|
||||
|
||||
const state = useExecutionStore.getState().getWorkflowExecution(wf)
|
||||
expect(state.isExecuting).toBe(true)
|
||||
expect(state.lastRunPath.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-workflow isolation', () => {
|
||||
it.concurrent('should keep execution state independent between workflows', () => {
|
||||
const wfA = 'wf-iso-exec-a'
|
||||
const wfB = 'wf-iso-exec-b'
|
||||
|
||||
useExecutionStore.getState().setIsExecuting(wfA, true)
|
||||
useExecutionStore.getState().setActiveBlocks(wfA, new Set(['block-a1']))
|
||||
|
||||
useExecutionStore.getState().setIsExecuting(wfB, false)
|
||||
useExecutionStore.getState().setActiveBlocks(wfB, new Set(['block-b1', 'block-b2']))
|
||||
|
||||
const stateA = useExecutionStore.getState().getWorkflowExecution(wfA)
|
||||
const stateB = useExecutionStore.getState().getWorkflowExecution(wfB)
|
||||
|
||||
expect(stateA.isExecuting).toBe(true)
|
||||
expect(stateA.activeBlockIds.size).toBe(1)
|
||||
expect(stateA.activeBlockIds.has('block-a1')).toBe(true)
|
||||
|
||||
expect(stateB.isExecuting).toBe(false)
|
||||
expect(stateB.activeBlockIds.size).toBe(2)
|
||||
expect(stateB.activeBlockIds.has('block-b1')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should keep run path independent between workflows', () => {
|
||||
const wfA = 'wf-iso-path-a'
|
||||
const wfB = 'wf-iso-path-b'
|
||||
|
||||
useExecutionStore.getState().setBlockRunStatus(wfA, 'block-1', 'success')
|
||||
useExecutionStore.getState().setEdgeRunStatus(wfA, 'edge-1', 'success')
|
||||
|
||||
useExecutionStore.getState().setBlockRunStatus(wfB, 'block-1', 'error')
|
||||
useExecutionStore.getState().setEdgeRunStatus(wfB, 'edge-1', 'error')
|
||||
|
||||
const stateA = useExecutionStore.getState().getWorkflowExecution(wfA)
|
||||
const stateB = useExecutionStore.getState().getWorkflowExecution(wfB)
|
||||
|
||||
expect(stateA.lastRunPath.get('block-1')).toBe('success')
|
||||
expect(stateA.lastRunEdges.get('edge-1')).toBe('success')
|
||||
|
||||
expect(stateB.lastRunPath.get('block-1')).toBe('error')
|
||||
expect(stateB.lastRunEdges.get('edge-1')).toBe('error')
|
||||
})
|
||||
|
||||
it.concurrent('should not affect workflow B when starting execution on workflow A', () => {
|
||||
const wfA = 'wf-iso-start-a'
|
||||
const wfB = 'wf-iso-start-b'
|
||||
|
||||
useExecutionStore.getState().setBlockRunStatus(wfA, 'block-1', 'success')
|
||||
useExecutionStore.getState().setBlockRunStatus(wfB, 'block-1', 'success')
|
||||
|
||||
useExecutionStore.getState().setIsExecuting(wfA, true)
|
||||
|
||||
const stateA = useExecutionStore.getState().getWorkflowExecution(wfA)
|
||||
const stateB = useExecutionStore.getState().getWorkflowExecution(wfB)
|
||||
|
||||
expect(stateA.lastRunPath.size).toBe(0)
|
||||
expect(stateB.lastRunPath.get('block-1')).toBe('success')
|
||||
})
|
||||
|
||||
it.concurrent('should not affect workflow B when clearing run path on workflow A', () => {
|
||||
const wfA = 'wf-iso-clear-a'
|
||||
const wfB = 'wf-iso-clear-b'
|
||||
|
||||
useExecutionStore.getState().setBlockRunStatus(wfA, 'block-1', 'success')
|
||||
useExecutionStore.getState().setBlockRunStatus(wfB, 'block-2', 'error')
|
||||
useExecutionStore.getState().clearRunPath(wfA)
|
||||
|
||||
expect(useExecutionStore.getState().getWorkflowExecution(wfA).lastRunPath.size).toBe(0)
|
||||
expect(
|
||||
useExecutionStore.getState().getWorkflowExecution(wfB).lastRunPath.get('block-2')
|
||||
).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution snapshots', () => {
|
||||
const mockSnapshot = {
|
||||
blockStates: {},
|
||||
blockLogs: [],
|
||||
executionOrder: [],
|
||||
} as any
|
||||
|
||||
it.concurrent('should store a snapshot', () => {
|
||||
const wf = 'wf-snap-store'
|
||||
useExecutionStore.getState().setLastExecutionSnapshot(wf, mockSnapshot)
|
||||
expect(useExecutionStore.getState().getLastExecutionSnapshot(wf)).toBe(mockSnapshot)
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for unknown workflows', () => {
|
||||
expect(
|
||||
useExecutionStore.getState().getLastExecutionSnapshot('wf-snap-unknown')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should clear a snapshot', () => {
|
||||
const wf = 'wf-snap-clear'
|
||||
useExecutionStore.getState().setLastExecutionSnapshot(wf, mockSnapshot)
|
||||
useExecutionStore.getState().clearLastExecutionSnapshot(wf)
|
||||
|
||||
expect(useExecutionStore.getState().getLastExecutionSnapshot(wf)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should keep snapshots independent between workflows', () => {
|
||||
const wfA = 'wf-snap-iso-a'
|
||||
const wfB = 'wf-snap-iso-b'
|
||||
const snapshotB = { blockStates: { x: 1 } } as any
|
||||
|
||||
useExecutionStore.getState().setLastExecutionSnapshot(wfA, mockSnapshot)
|
||||
useExecutionStore.getState().setLastExecutionSnapshot(wfB, snapshotB)
|
||||
|
||||
expect(useExecutionStore.getState().getLastExecutionSnapshot(wfA)).toBe(mockSnapshot)
|
||||
expect(useExecutionStore.getState().getLastExecutionSnapshot(wfB)).toBe(snapshotB)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
beforeEach(() => {
|
||||
useExecutionStore.setState(initialState)
|
||||
})
|
||||
|
||||
it('should clear all workflow execution state', () => {
|
||||
useExecutionStore.getState().setIsExecuting('wf-reset-a', true)
|
||||
useExecutionStore.getState().setBlockRunStatus('wf-reset-a', 'block-1', 'success')
|
||||
useExecutionStore.getState().setLastExecutionSnapshot('wf-reset-a', {} as any)
|
||||
|
||||
useExecutionStore.getState().reset()
|
||||
|
||||
const state = useExecutionStore.getState()
|
||||
expect(state.workflowExecutions.size).toBe(0)
|
||||
expect(state.lastExecutionSnapshots.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should return defaults for all workflows after reset', () => {
|
||||
useExecutionStore.getState().setIsExecuting('wf-reset-b', true)
|
||||
useExecutionStore.getState().setIsExecuting('wf-reset-c', true)
|
||||
useExecutionStore.getState().reset()
|
||||
|
||||
expect(useExecutionStore.getState().getWorkflowExecution('wf-reset-b').isExecuting).toBe(
|
||||
false
|
||||
)
|
||||
expect(useExecutionStore.getState().getWorkflowExecution('wf-reset-c').isExecuting).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('immutability', () => {
|
||||
beforeEach(() => {
|
||||
useExecutionStore.setState(initialState)
|
||||
})
|
||||
|
||||
it('should create a new workflowExecutions map on each mutation', () => {
|
||||
const mapBefore = useExecutionStore.getState().workflowExecutions
|
||||
|
||||
useExecutionStore.getState().setIsExecuting('wf-immut-map', true)
|
||||
const mapAfter = useExecutionStore.getState().workflowExecutions
|
||||
|
||||
expect(mapBefore).not.toBe(mapAfter)
|
||||
})
|
||||
|
||||
it('should create a new lastRunPath map when adding block status', () => {
|
||||
const wf = 'wf-immut-path'
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
|
||||
const pathBefore = useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath
|
||||
|
||||
useExecutionStore.getState().setBlockRunStatus(wf, 'block-2', 'error')
|
||||
const pathAfter = useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath
|
||||
|
||||
expect(pathBefore).not.toBe(pathAfter)
|
||||
expect(pathBefore.size).toBe(1)
|
||||
expect(pathAfter.size).toBe(2)
|
||||
})
|
||||
|
||||
it('should create a new lastRunEdges map when adding edge status', () => {
|
||||
const wf = 'wf-immut-edges'
|
||||
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-1', 'success')
|
||||
const edgesBefore = useExecutionStore.getState().getWorkflowExecution(wf).lastRunEdges
|
||||
|
||||
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-2', 'error')
|
||||
const edgesAfter = useExecutionStore.getState().getWorkflowExecution(wf).lastRunEdges
|
||||
|
||||
expect(edgesBefore).not.toBe(edgesAfter)
|
||||
expect(edgesBefore.size).toBe(1)
|
||||
expect(edgesAfter.size).toBe(2)
|
||||
})
|
||||
|
||||
it.concurrent('should not mutate the default state constant', () => {
|
||||
useExecutionStore.getState().setBlockRunStatus('wf-immut-const', 'block-1', 'success')
|
||||
|
||||
expect(defaultWorkflowExecutionState.lastRunPath.size).toBe(0)
|
||||
expect(defaultWorkflowExecutionState.lastRunEdges.size).toBe(0)
|
||||
expect(defaultWorkflowExecutionState.activeBlockIds.size).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import { create } from 'zustand'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import {
|
||||
type BlockRunStatus,
|
||||
defaultWorkflowExecutionState,
|
||||
deriveExecutionFlags,
|
||||
type EdgeRunStatus,
|
||||
type ExecutionActions,
|
||||
type ExecutionState,
|
||||
type ExecutionStatus,
|
||||
initialState,
|
||||
type WorkflowExecutionState,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Returns the execution state for a workflow, creating a fresh default if absent.
|
||||
*
|
||||
* @remarks
|
||||
* When the workflow has no entry in the map, fresh `Set` and `Map` instances
|
||||
* are created so that callers never share mutable collections with
|
||||
* {@link defaultWorkflowExecutionState}.
|
||||
*/
|
||||
function getOrCreate(
|
||||
map: Map<string, WorkflowExecutionState>,
|
||||
workflowId: string
|
||||
): WorkflowExecutionState {
|
||||
return (
|
||||
map.get(workflowId) ?? {
|
||||
...defaultWorkflowExecutionState,
|
||||
activeBlockIds: new Set<string>(),
|
||||
lastRunPath: new Map<string, BlockRunStatus>(),
|
||||
lastRunEdges: new Map<string, EdgeRunStatus>(),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably updates a single workflow's execution state within the map.
|
||||
*
|
||||
* Creates a shallow copy of the outer map, merges the patch into the
|
||||
* target workflow's entry, and returns the new map. This ensures Zustand
|
||||
* detects the top-level reference change and notifies subscribers.
|
||||
*/
|
||||
function updatedMap(
|
||||
map: Map<string, WorkflowExecutionState>,
|
||||
workflowId: string,
|
||||
patch: Partial<WorkflowExecutionState>
|
||||
): Map<string, WorkflowExecutionState> {
|
||||
const next = new Map(map)
|
||||
const current = getOrCreate(map, workflowId)
|
||||
next.set(workflowId, { ...current, ...patch })
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Global Zustand store for per-workflow execution state.
|
||||
*
|
||||
* All execution state (running, debugging, block/edge highlights) is keyed
|
||||
* by workflow ID so users can run multiple workflows concurrently, each
|
||||
* with independent visual feedback.
|
||||
*/
|
||||
export const useExecutionStore = create<ExecutionState & ExecutionActions>()((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
getWorkflowExecution: (workflowId) => {
|
||||
return getOrCreate(get().workflowExecutions, workflowId)
|
||||
},
|
||||
|
||||
setActiveBlocks: (workflowId, blockIds) => {
|
||||
set({
|
||||
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, {
|
||||
activeBlockIds: new Set(blockIds),
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
setPendingBlocks: (workflowId, pendingBlocks) => {
|
||||
set({
|
||||
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, { pendingBlocks }),
|
||||
})
|
||||
},
|
||||
|
||||
setStatus: (workflowId, status, options) => {
|
||||
const patch: Partial<WorkflowExecutionState> = {
|
||||
status,
|
||||
...deriveExecutionFlags(status),
|
||||
}
|
||||
if (options?.clearRunPath) {
|
||||
patch.lastRunPath = new Map()
|
||||
patch.lastRunEdges = new Map()
|
||||
}
|
||||
set({
|
||||
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, patch),
|
||||
})
|
||||
},
|
||||
|
||||
setIsExecuting: (workflowId, isExecuting) => {
|
||||
const current = getOrCreate(get().workflowExecutions, workflowId)
|
||||
const nextStatus: ExecutionStatus = isExecuting
|
||||
? current.status === 'debugging'
|
||||
? 'debugging'
|
||||
: 'running'
|
||||
: 'idle'
|
||||
get().setStatus(workflowId, nextStatus, { clearRunPath: isExecuting })
|
||||
},
|
||||
|
||||
setIsDebugging: (workflowId, isDebugging) => {
|
||||
const current = getOrCreate(get().workflowExecutions, workflowId)
|
||||
const nextStatus: ExecutionStatus = isDebugging
|
||||
? 'debugging'
|
||||
: current.status === 'debugging'
|
||||
? 'running'
|
||||
: current.status
|
||||
get().setStatus(workflowId, nextStatus)
|
||||
},
|
||||
|
||||
setExecutor: (workflowId, executor) => {
|
||||
set({
|
||||
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, { executor }),
|
||||
})
|
||||
},
|
||||
|
||||
setDebugContext: (workflowId, debugContext) => {
|
||||
set({
|
||||
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, { debugContext }),
|
||||
})
|
||||
},
|
||||
|
||||
setBlockRunStatus: (workflowId, blockId, status) => {
|
||||
const current = getOrCreate(get().workflowExecutions, workflowId)
|
||||
const newRunPath = new Map(current.lastRunPath)
|
||||
newRunPath.set(blockId, status)
|
||||
set({
|
||||
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, {
|
||||
lastRunPath: newRunPath,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
setEdgeRunStatus: (workflowId, edgeId, status) => {
|
||||
const current = getOrCreate(get().workflowExecutions, workflowId)
|
||||
const newRunEdges = new Map(current.lastRunEdges)
|
||||
newRunEdges.set(edgeId, status)
|
||||
set({
|
||||
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, {
|
||||
lastRunEdges: newRunEdges,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
setCurrentExecutionId: (workflowId, executionId) => {
|
||||
set({
|
||||
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, {
|
||||
currentExecutionId: executionId,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
getCurrentExecutionId: (workflowId) => {
|
||||
return getOrCreate(get().workflowExecutions, workflowId).currentExecutionId
|
||||
},
|
||||
|
||||
clearRunPath: (workflowId) => {
|
||||
set({
|
||||
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, {
|
||||
lastRunPath: new Map(),
|
||||
lastRunEdges: new Map(),
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
reset: () => set(initialState),
|
||||
|
||||
setLastExecutionSnapshot: (workflowId, snapshot) => {
|
||||
const newSnapshots = new Map(get().lastExecutionSnapshots)
|
||||
newSnapshots.set(workflowId, snapshot)
|
||||
set({ lastExecutionSnapshots: newSnapshots })
|
||||
},
|
||||
|
||||
getLastExecutionSnapshot: (workflowId) => {
|
||||
return get().lastExecutionSnapshots.get(workflowId)
|
||||
},
|
||||
|
||||
clearLastExecutionSnapshot: (workflowId) => {
|
||||
const newSnapshots = new Map(get().lastExecutionSnapshots)
|
||||
newSnapshots.delete(workflowId)
|
||||
set({ lastExecutionSnapshots: newSnapshots })
|
||||
},
|
||||
}))
|
||||
|
||||
/**
|
||||
* Convenience hook that returns the execution state for the currently active workflow.
|
||||
*/
|
||||
export function useCurrentWorkflowExecution(): WorkflowExecutionState {
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
return useExecutionStore((state) => {
|
||||
if (!activeWorkflowId) return defaultWorkflowExecutionState
|
||||
return state.workflowExecutions.get(activeWorkflowId) ?? defaultWorkflowExecutionState
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the active workflow is currently executing.
|
||||
* More granular than useCurrentWorkflowExecution — only re-renders when
|
||||
* the isExecuting boolean changes, not on every iteration update during
|
||||
* parallel-loop runs.
|
||||
*/
|
||||
export function useIsCurrentWorkflowExecuting(): boolean {
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
return useExecutionStore((state) => {
|
||||
if (!activeWorkflowId) return false
|
||||
return state.workflowExecutions.get(activeWorkflowId)?.isExecuting ?? false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a specific block is currently active (executing) in the current workflow.
|
||||
* More granular than useCurrentWorkflowExecution — only re-renders when
|
||||
* the boolean result changes for this specific block.
|
||||
*/
|
||||
export function useIsBlockActive(blockId: string): boolean {
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
return useExecutionStore((state) => {
|
||||
if (!activeWorkflowId) return false
|
||||
return state.workflowExecutions.get(activeWorkflowId)?.activeBlockIds.has(blockId) ?? false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last run path (block statuses) for the current workflow.
|
||||
* More granular than useCurrentWorkflowExecution — only re-renders when
|
||||
* the lastRunPath map reference changes.
|
||||
*/
|
||||
export function useLastRunPath(): Map<string, BlockRunStatus> {
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
return useExecutionStore((state) => {
|
||||
if (!activeWorkflowId) return defaultWorkflowExecutionState.lastRunPath
|
||||
return (
|
||||
state.workflowExecutions.get(activeWorkflowId)?.lastRunPath ??
|
||||
defaultWorkflowExecutionState.lastRunPath
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last run edges (edge statuses) for the current workflow.
|
||||
* More granular than useCurrentWorkflowExecution — only re-renders when
|
||||
* the lastRunEdges map reference changes.
|
||||
*/
|
||||
export function useLastRunEdges(): Map<string, EdgeRunStatus> {
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
return useExecutionStore((state) => {
|
||||
if (!activeWorkflowId) return defaultWorkflowExecutionState.lastRunEdges
|
||||
return (
|
||||
state.workflowExecutions.get(activeWorkflowId)?.lastRunEdges ??
|
||||
defaultWorkflowExecutionState.lastRunEdges
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { Executor } from '@/executor'
|
||||
import type { SerializableExecutionState } from '@/executor/execution/types'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
|
||||
/**
|
||||
* Represents the execution result of a block in the last run
|
||||
*/
|
||||
export type BlockRunStatus = 'success' | 'error'
|
||||
|
||||
/**
|
||||
* Represents the execution result of an edge in the last run
|
||||
*/
|
||||
export type EdgeRunStatus = 'success' | 'error'
|
||||
|
||||
/**
|
||||
* The mutually-exclusive execution mode of a single workflow.
|
||||
*
|
||||
* @remarks
|
||||
* This is the single source of truth for whether a workflow is running.
|
||||
* The legacy `isExecuting` / `isDebugging` booleans are derived from it
|
||||
* via {@link deriveExecutionFlags} so illegal combinations — such as
|
||||
* "debugging while not executing" — are unrepresentable.
|
||||
*
|
||||
* - `idle` — not running.
|
||||
* - `running` — executing normally (derives `isExecuting`).
|
||||
* - `debugging` — executing in step-by-step debug mode (derives both
|
||||
* `isExecuting` and `isDebugging`).
|
||||
*/
|
||||
export type ExecutionStatus = 'idle' | 'running' | 'debugging'
|
||||
|
||||
/**
|
||||
* Execution state scoped to a single workflow.
|
||||
*
|
||||
* Each workflow has its own independent instance so concurrent executions
|
||||
* do not interfere with one another.
|
||||
*/
|
||||
export interface WorkflowExecutionState {
|
||||
/** Mutually-exclusive execution mode; the source of truth for run state */
|
||||
status: ExecutionStatus
|
||||
/** Derived from {@link status}: whether this workflow is currently executing */
|
||||
isExecuting: boolean
|
||||
/** Derived from {@link status}: whether this workflow is in step-by-step debug mode */
|
||||
isDebugging: boolean
|
||||
/** Block IDs that are currently running (pulsing in the UI) */
|
||||
activeBlockIds: Set<string>
|
||||
/** Block IDs queued to execute next (used during debug stepping) */
|
||||
pendingBlocks: string[]
|
||||
/** The executor instance when running client-side */
|
||||
executor: Executor | null
|
||||
/** Debug execution context preserved across steps */
|
||||
debugContext: ExecutionContext | null
|
||||
/** Maps block IDs to their run result from the last execution */
|
||||
lastRunPath: Map<string, BlockRunStatus>
|
||||
/** Maps edge IDs to their run result from the last execution */
|
||||
lastRunEdges: Map<string, EdgeRunStatus>
|
||||
/** The execution ID of the currently running execution */
|
||||
currentExecutionId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the legacy `isExecuting` / `isDebugging` booleans from a status.
|
||||
*
|
||||
* @remarks
|
||||
* Keeping the derived booleans on the stored state object lets existing
|
||||
* consumers keep reading `state.isExecuting` / `state.isDebugging`
|
||||
* unchanged while {@link ExecutionStatus} remains the single source of truth.
|
||||
*/
|
||||
export function deriveExecutionFlags(status: ExecutionStatus): {
|
||||
isExecuting: boolean
|
||||
isDebugging: boolean
|
||||
} {
|
||||
return {
|
||||
isExecuting: status !== 'idle',
|
||||
isDebugging: status === 'debugging',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default values for a workflow that has never been executed.
|
||||
*
|
||||
* @remarks
|
||||
* This constant is used as the fallback in selectors when no per-workflow
|
||||
* entry exists. Its reference identity is stable, which prevents unnecessary
|
||||
* re-renders in Zustand selectors that use `Object.is` equality.
|
||||
*/
|
||||
export const defaultWorkflowExecutionState: WorkflowExecutionState = {
|
||||
status: 'idle',
|
||||
...deriveExecutionFlags('idle'),
|
||||
activeBlockIds: new Set(),
|
||||
pendingBlocks: [],
|
||||
executor: null,
|
||||
debugContext: null,
|
||||
lastRunPath: new Map(),
|
||||
lastRunEdges: new Map(),
|
||||
currentExecutionId: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Root state shape for the execution store.
|
||||
*
|
||||
* All execution state is keyed by workflow ID so multiple workflows
|
||||
* can be executed concurrently with independent visual feedback.
|
||||
*/
|
||||
export interface ExecutionState {
|
||||
/** Per-workflow execution state keyed by workflow ID */
|
||||
workflowExecutions: Map<string, WorkflowExecutionState>
|
||||
/** Serializable snapshots of the last successful execution per workflow */
|
||||
lastExecutionSnapshots: Map<string, SerializableExecutionState>
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions available on the execution store.
|
||||
*
|
||||
* Every setter takes a `workflowId` as its first argument so mutations
|
||||
* are scoped to a single workflow.
|
||||
*/
|
||||
export interface ExecutionActions {
|
||||
/** Returns the execution state for a workflow, falling back to defaults */
|
||||
getWorkflowExecution: (workflowId: string) => WorkflowExecutionState
|
||||
/** Replaces the set of currently-executing block IDs for a workflow */
|
||||
setActiveBlocks: (workflowId: string, blockIds: Set<string>) => void
|
||||
/**
|
||||
* Sets the {@link ExecutionStatus} for a workflow.
|
||||
*
|
||||
* @remarks
|
||||
* Pass `{ clearRunPath: true }` to also reset `lastRunPath` / `lastRunEdges`.
|
||||
* Run-path clearing is opt-in: it is owned by
|
||||
* {@link ExecutionActions.setIsExecuting} (which clears on start), matching
|
||||
* the legacy behavior where only starting execution wiped the run history.
|
||||
*/
|
||||
setStatus: (
|
||||
workflowId: string,
|
||||
status: ExecutionStatus,
|
||||
options?: { clearRunPath?: boolean }
|
||||
) => void
|
||||
/**
|
||||
* Marks a workflow as executing or idle. Starting (`true`) clears the run path.
|
||||
*
|
||||
* @remarks
|
||||
* Translates to {@link ExecutionActions.setStatus}: `true` preserves an
|
||||
* active debug session (`debugging`) and otherwise enters `running`, and
|
||||
* always clears the run path; `false` returns to `idle` and preserves it.
|
||||
*/
|
||||
setIsExecuting: (workflowId: string, isExecuting: boolean) => void
|
||||
/**
|
||||
* Toggles step-by-step debug mode for a workflow.
|
||||
*
|
||||
* @remarks
|
||||
* Translates to {@link ExecutionActions.setStatus}: `true` enters
|
||||
* `debugging` (which implies executing); `false` returns to `running` only
|
||||
* when currently `debugging`, otherwise the status is preserved (e.g. calling
|
||||
* it while `idle` is a no-op).
|
||||
*/
|
||||
setIsDebugging: (workflowId: string, isDebugging: boolean) => void
|
||||
/** Sets the list of blocks pending execution during debug stepping */
|
||||
setPendingBlocks: (workflowId: string, blockIds: string[]) => void
|
||||
/** Stores the executor instance for a workflow */
|
||||
setExecutor: (workflowId: string, executor: Executor | null) => void
|
||||
/** Stores the debug execution context for a workflow */
|
||||
setDebugContext: (workflowId: string, context: ExecutionContext | null) => void
|
||||
/** Records a block's run result (success/error) in the run path */
|
||||
setBlockRunStatus: (workflowId: string, blockId: string, status: BlockRunStatus) => void
|
||||
/** Records an edge's run result (success/error) in the run edges */
|
||||
setEdgeRunStatus: (workflowId: string, edgeId: string, status: EdgeRunStatus) => void
|
||||
/** Clears the run path and run edges for a workflow */
|
||||
clearRunPath: (workflowId: string) => void
|
||||
/** Stores the current execution ID for a workflow */
|
||||
setCurrentExecutionId: (workflowId: string, executionId: string | null) => void
|
||||
/** Returns the current execution ID for a workflow */
|
||||
getCurrentExecutionId: (workflowId: string) => string | null
|
||||
/** Resets the entire store to its initial empty state */
|
||||
reset: () => void
|
||||
/** Stores a serializable execution snapshot for a workflow */
|
||||
setLastExecutionSnapshot: (workflowId: string, snapshot: SerializableExecutionState) => void
|
||||
/** Returns the stored execution snapshot for a workflow, if any */
|
||||
getLastExecutionSnapshot: (workflowId: string) => SerializableExecutionState | undefined
|
||||
/** Removes the stored execution snapshot for a workflow */
|
||||
clearLastExecutionSnapshot: (workflowId: string) => void
|
||||
}
|
||||
|
||||
/** Empty initial state used by the store and by {@link ExecutionActions.reset} */
|
||||
export const initialState: ExecutionState = {
|
||||
workflowExecutions: new Map(),
|
||||
lastExecutionSnapshots: new Map(),
|
||||
}
|
||||
Reference in New Issue
Block a user