Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ import {
type ConnectionBlockSelectorData,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector'
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import {
ErrorBoundary,
ErrorUI,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { FocusBlockDeepLink } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link'
import { WorkflowSearchReplace } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace'
import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls'
Expand Down Expand Up @@ -2668,6 +2671,10 @@ const WorkflowContent = React.memo(
const loadingWorkflowRef = useRef<string | null>(null)
const currentWorkflowExists =
!isWorkflowMapPlaceholderData && Boolean(workflows[workflowIdParam])
const workflowLoadError =
hydration.phase === 'error' && hydration.workflowId === workflowIdParam
? hydration.error
: null

useEffect(() => {
const currentId = workflowIdParam
Expand Down Expand Up @@ -5126,16 +5133,28 @@ const WorkflowContent = React.memo(
>
{!isWorkflowReady && (
<div className='absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)]'>
<div
className='size-[18px] animate-spin rounded-full'
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
{workflowLoadError ? (
<ErrorUI
title='Unable to load workflow'
message={workflowLoadError}
onReset={() => {
setActiveWorkflow(workflowIdParam).catch((error) => {
logger.error(`Failed to retry workflow ${workflowIdParam}:`, error)
})
}}
/>
) : (
<div
className='size-[18px] animate-spin rounded-full'
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
)}
</div>
)}

Expand Down
8 changes: 5 additions & 3 deletions apps/sim/executor/orchestrators/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ describe('LoopOrchestrator', () => {
expect(loopEnd.incomingEdges.has(parallelEndId)).toBe(true)
})

it('resolves forEach collections with the loop start sentinel scope', async () => {
it('resolves forEach collections with the loop start sentinel scope independently of the count', async () => {
const loopId = 'loop-1'
const dag: DAG = {
nodes: new Map(),
Expand All @@ -165,14 +165,15 @@ describe('LoopOrchestrator', () => {
id: loopId,
nodes: ['task-1'],
loopType: 'forEach',
iterations: 1,
forEachItems: '<Producer.items>',
},
],
]),
parallelConfigs: new Map(),
}
const resolver = {
resolveSingleReference: vi.fn().mockResolvedValue(['item-1']),
resolveSingleReference: vi.fn().mockResolvedValue(['item-1', 'item-2', 'item-3']),
}
const orchestrator = new LoopOrchestrator(dag, createState(), resolver as any, {}, {
clearDeactivatedEdgesForNodes: vi.fn(),
Expand All @@ -188,7 +189,8 @@ describe('LoopOrchestrator', () => {
undefined,
{ allowLargeValueRefs: true }
)
expect(scope.maxIterations).toBe(1)
expect(scope.maxIterations).toBe(3)
expect(scope.items).toEqual(['item-1', 'item-2', 'item-3'])
})

it('projects forEach resolution failures before logging or persisting them', async () => {
Expand Down
107 changes: 107 additions & 0 deletions apps/sim/lib/workflows/persistence/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ import {
schemaMock,
} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { workflowStateSchema } from '@/lib/api/contracts/workflows'
import type {
BlockState as AppBlockState,
WorkflowState as AppWorkflowState,
} from '@/stores/workflows/workflow/types'
import { generateLoopBlocks } from '@/stores/workflows/workflow/utils'

/**
* Type helper for converting test workflow state to app workflow state.
Expand Down Expand Up @@ -348,6 +350,110 @@ describe('Database Helpers', () => {
})

describe('loadWorkflowFromNormalizedTables', () => {
it.each(['for', 'forEach', 'while', 'doWhile'] as const)(
'preserves valid block counts and expressions for %s loops even when subflow counts differ',
async (loopType) => {
const data = {
count: 9,
loopType,
collection: '<source.items>',
whileCondition: '<source.hasMore>',
doWhileCondition: '<source.hasMore>',
width: 600,
parentId: 'outer-loop',
extent: 'parent' as const,
}
queueLoadFixtures({
blocks: [{ ...toDbBlock(createLoopBlock({ id: 'loop-1' }), mockWorkflowId), data }],
subflows: [
{
id: 'loop-1',
type: 'loop',
config: {
nodes: [],
loopType,
iterations: 3,
forEachItems: data.collection,
whileCondition: data.whileCondition,
doWhileCondition: data.doWhileCondition,
},
},
],
})

const loaded = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId)
const parsed = workflowStateSchema.parse(loaded)

expect(parsed.blocks['loop-1'].data).toEqual(data)
expect(parsed.loops?.['loop-1'].iterations).toBe(3)
expect(generateLoopBlocks(loaded!.blocks)['loop-1']).toMatchObject({
iterations: 9,
loopType,
forEachItems: data.collection,
whileCondition: data.whileCondition,
doWhileCondition: data.doWhileCondition,
})
expect(dbChainMockFns.update).not.toHaveBeenCalled()
}
)

it('keeps an absent block count absent so serialization retains its existing default', async () => {
queueLoadFixtures({
blocks: [
{
...toDbBlock(createLoopBlock({ id: 'loop-1' }), mockWorkflowId),
data: { loopType: 'for' },
},
],
subflows: [
{ id: 'loop-1', type: 'loop', config: { nodes: [], loopType: 'for', iterations: 3 } },
],
})

const loaded = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId)

expect(loaded?.blocks['loop-1'].data?.count).toBeUndefined()
expect(loaded?.loops['loop-1'].iterations).toBe(3)
expect(generateLoopBlocks(loaded!.blocks)['loop-1'].iterations).toBe(5)
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})

it('serves a legacy forEach loop with a string count through the workflow read contract', async () => {
const collection = '<source.items>'
const loopRow = toDbBlock(createLoopBlock({ id: 'loop-1' }), mockWorkflowId)
queueLoadFixtures({
blocks: [
{
...loopRow,
data: { ...loopRow.data, loopType: 'forEach', count: collection, collection },
},
],
subflows: [
{
id: 'loop-1',
type: 'loop',
config: {
nodes: [],
loopType: 'forEach',
iterations: collection,
forEachItems: collection,
},
},
],
})

const loaded = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId)
const parsed = workflowStateSchema.parse(loaded)

expect(parsed.blocks['loop-1'].data).toMatchObject({ count: 1, collection })
expect(parsed.loops?.['loop-1']).toMatchObject({
loopType: 'forEach',
iterations: 1,
forEachItems: collection,
})
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})

it('should successfully load workflow data from normalized tables', async () => {
queueLoadFixtures({
blocks: mockBlocksFromDb,
Expand Down Expand Up @@ -401,6 +507,7 @@ describe('Database Helpers', () => {
whileCondition: '',
enabled: true,
})
expect(result?.blocks['loop-1'].data?.count).toBe(3)

expect(result?.parallels['parallel-1']).toEqual({
id: 'parallel-1',
Expand Down
51 changes: 51 additions & 0 deletions apps/sim/stores/workflows/registry/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,35 @@ describe('registry store loadWorkflowState (collapsed cache)', () => {
expect(mockRequestJson).toHaveBeenCalledTimes(2)
})

it('exposes a failed load and recovers when the user retries the same workflow', async () => {
mockRequestJson.mockRejectedValueOnce(new Error('Unable to fetch workflow'))

await expect(useWorkflowRegistry.getState().setActiveWorkflow('wf-1')).rejects.toThrow(
'Unable to fetch workflow'
)
expect(useWorkflowRegistry.getState().hydration).toMatchObject({
phase: 'error',
workflowId: 'wf-1',
error: 'Unable to fetch workflow',
})
expect(replaceWorkflowState).not.toHaveBeenCalled()

mockRequestJson.mockResolvedValueOnce({ data: makeEnvelope() })
const retry = useWorkflowRegistry.getState().setActiveWorkflow('wf-1')
expect(useWorkflowRegistry.getState().hydration).toMatchObject({
phase: 'state-loading',
error: null,
})
await retry

expect(mockRequestJson).toHaveBeenCalledTimes(2)
expect(useWorkflowRegistry.getState().hydration).toMatchObject({
phase: 'ready',
workflowId: 'wf-1',
error: null,
})
})

it('discards a superseded response via the staleness guard', async () => {
// First load (wf-1) is in-flight; a second load (wf-2) supersedes the
// hydration workflowId, then wf-1 finally resolves. The guard compares the
Expand Down Expand Up @@ -256,4 +285,26 @@ describe('registry store loadWorkflowState (collapsed cache)', () => {
expect(replaceWorkflowState.mock.calls.length).toBe(projectionsAfterSecond)
expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2')
})

it('does not show a stale load error after switching to another workflow', async () => {
let rejectFirst: (reason: Error) => void = () => {}
const firstPending = new Promise<never>((_resolve, reject) => {
rejectFirst = reject
})
mockRequestJson
.mockImplementationOnce(() => firstPending)
.mockResolvedValueOnce({ data: makeEnvelope({ id: 'wf-2' }) })

const firstLoad = useWorkflowRegistry.getState().setActiveWorkflow('wf-1')
await useWorkflowRegistry.getState().setActiveWorkflow('wf-2')
rejectFirst(new Error('Previous workflow failed to load'))
await firstLoad

expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2')
expect(useWorkflowRegistry.getState().hydration).toMatchObject({
phase: 'ready',
workflowId: 'wf-2',
error: null,
})
})
})
5 changes: 5 additions & 0 deletions packages/workflow-persistence/src/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@ export async function loadWorkflowFromNormalizedTablesRaw(
...block,
data: {
...block.data,
/** Repair legacy values without changing valid counts used by serialization. */
count:
block.data?.count === undefined || typeof block.data.count === 'number'
? block.data?.count
: loop.iterations,
collection: loop.forEachItems ?? block.data?.collection ?? '',
whileCondition: loop.whileCondition ?? block.data?.whileCondition ?? '',
doWhileCondition: loop.doWhileCondition ?? block.data?.doWhileCondition ?? '',
Expand Down
Loading