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
10 changes: 8 additions & 2 deletions apps/sim/app/api/workflows/[id]/state/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/custom-tools-persi
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers'
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'

const logger = createLogger('WorkflowStateAPI')

Expand Down Expand Up @@ -175,11 +177,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
{} as typeof state.blocks
)

const typedBlocks = filteredBlocks as Record<string, BlockState>
const canonicalLoops = generateLoopBlocks(typedBlocks)
const canonicalParallels = generateParallelBlocks(typedBlocks)

const workflowState = {
blocks: filteredBlocks,
edges: state.edges,
loops: state.loops || {},
parallels: state.parallels || {},
loops: canonicalLoops,
parallels: canonicalParallels,
lastSaved: state.lastSaved || Date.now(),
isDeployed: state.isDeployed || false,
deployedAt: state.deployedAt,
Expand Down
174 changes: 160 additions & 14 deletions apps/sim/lib/workflows/db-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,58 @@ const mockBlocksFromDb = [
parentId: 'loop-1',
extent: 'parent',
},
{
id: 'loop-1',
workflowId: mockWorkflowId,
type: 'loop',
name: 'Loop Container',
positionX: 50,
positionY: 50,
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode: false,
height: 250,
subBlocks: {},
outputs: {},
data: { width: 500, height: 300, loopType: 'for', count: 5 },
parentId: null,
extent: null,
},
{
id: 'parallel-1',
workflowId: mockWorkflowId,
type: 'parallel',
name: 'Parallel Container',
positionX: 600,
positionY: 50,
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode: false,
height: 250,
subBlocks: {},
outputs: {},
data: { width: 500, height: 300, parallelType: 'count', count: 3 },
parentId: null,
extent: null,
},
{
id: 'block-3',
workflowId: mockWorkflowId,
type: 'api',
name: 'Parallel Child',
positionX: 650,
positionY: 150,
enabled: true,
horizontalHandles: true,
height: 200,
subBlocks: {},
outputs: {},
data: { parentId: 'parallel-1', extent: 'parent' },
parentId: 'parallel-1',
extent: 'parent',
},
]

const mockEdgesFromDb = [
Expand Down Expand Up @@ -187,6 +239,42 @@ const mockWorkflowState: WorkflowState = {
height: 200,
data: { parentId: 'loop-1', extent: 'parent' },
},
'loop-1': {
id: 'loop-1',
type: 'loop',
name: 'Loop Container',
position: { x: 200, y: 50 },
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
height: 250,
data: { width: 500, height: 300, count: 5, loopType: 'for' },
},
'parallel-1': {
id: 'parallel-1',
type: 'parallel',
name: 'Parallel Container',
position: { x: 600, y: 50 },
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
height: 250,
data: { width: 500, height: 300, parallelType: 'count', count: 3 },
},
'block-3': {
id: 'block-3',
type: 'api',
name: 'Parallel Child',
position: { x: 650, y: 150 },
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
height: 180,
data: { parentId: 'parallel-1', extent: 'parent' },
},
},
edges: [
{
Expand Down Expand Up @@ -567,20 +655,36 @@ describe('Database Helpers', () => {

await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, mockWorkflowState)

expect(capturedBlockInserts).toHaveLength(2)
expect(capturedBlockInserts[0]).toMatchObject({
id: 'block-1',
workflowId: mockWorkflowId,
type: 'starter',
name: 'Start Block',
positionX: '100',
positionY: '100',
enabled: true,
horizontalHandles: true,
height: '150',
parentId: null,
extent: null,
})
expect(capturedBlockInserts).toHaveLength(5)
expect(capturedBlockInserts).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'block-1',
workflowId: mockWorkflowId,
type: 'starter',
name: 'Start Block',
positionX: '100',
positionY: '100',
enabled: true,
horizontalHandles: true,
height: '150',
parentId: null,
extent: null,
}),
expect.objectContaining({
id: 'loop-1',
workflowId: mockWorkflowId,
type: 'loop',
parentId: null,
}),
expect.objectContaining({
id: 'parallel-1',
workflowId: mockWorkflowId,
type: 'parallel',
parentId: null,
}),
])
)

expect(capturedEdgeInserts).toHaveLength(1)
expect(capturedEdgeInserts[0]).toMatchObject({
Expand All @@ -599,6 +703,48 @@ describe('Database Helpers', () => {
type: 'loop',
})
})

it('should regenerate missing loop and parallel definitions from block data', async () => {
let capturedSubflowInserts: any[] = []

const mockTransaction = vi.fn().mockImplementation(async (callback) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockImplementation((data) => {
if (data.length > 0 && (data[0].type === 'loop' || data[0].type === 'parallel')) {
capturedSubflowInserts = data
}
return Promise.resolve([])
}),
}),
}
return await callback(tx)
})

mockDb.transaction = mockTransaction

const staleWorkflowState = JSON.parse(JSON.stringify(mockWorkflowState)) as WorkflowState
staleWorkflowState.loops = {}
staleWorkflowState.parallels = {}

await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, staleWorkflowState)

expect(capturedSubflowInserts).toHaveLength(2)
expect(capturedSubflowInserts).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'loop-1', type: 'loop' }),
expect.objectContaining({ id: 'parallel-1', type: 'parallel' }),
])
)
})
})

describe('workflowExistsInNormalizedTables', () => {
Expand Down
9 changes: 7 additions & 2 deletions apps/sim/lib/workflows/db-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'

const logger = createLogger('WorkflowDBHelpers')

Expand Down Expand Up @@ -248,6 +249,10 @@ export async function saveWorkflowToNormalizedTables(
state: WorkflowState
): Promise<{ success: boolean; error?: string }> {
try {
const blockRecords = state.blocks as Record<string, BlockState>
const canonicalLoops = generateLoopBlocks(blockRecords)
const canonicalParallels = generateParallelBlocks(blockRecords)

// Start a transaction
await db.transaction(async (tx) => {
// Snapshot existing webhooks before deletion to preserve them through the cycle
Expand Down Expand Up @@ -310,7 +315,7 @@ export async function saveWorkflowToNormalizedTables(
const subflowInserts: any[] = []

// Add loops
Object.values(state.loops || {}).forEach((loop) => {
Object.values(canonicalLoops).forEach((loop) => {
subflowInserts.push({
id: loop.id,
workflowId: workflowId,
Expand All @@ -320,7 +325,7 @@ export async function saveWorkflowToNormalizedTables(
})

// Add parallels
Object.values(state.parallels || {}).forEach((parallel) => {
Object.values(canonicalParallels).forEach((parallel) => {
subflowInserts.push({
id: parallel.id,
workflowId: workflowId,
Expand Down
8 changes: 6 additions & 2 deletions apps/sim/lib/workflows/json-sanitizer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Edge } from 'reactflow'
import { sanitizeWorkflowForSharing } from '@/lib/workflows/credential-extractor'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { TRIGGER_PERSISTED_SUBBLOCK_IDS } from '@/triggers/consts'

/**
Expand Down Expand Up @@ -386,12 +387,15 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState {
* Users need positions to restore the visual layout when importing
*/
export function sanitizeForExport(state: WorkflowState): ExportWorkflowState {
const canonicalLoops = generateLoopBlocks(state.blocks || {})
const canonicalParallels = generateParallelBlocks(state.blocks || {})

// Preserve edges, loops, parallels, metadata, and variables
const fullState = {
blocks: state.blocks,
edges: state.edges,
loops: state.loops || {},
parallels: state.parallels || {},
loops: canonicalLoops,
parallels: canonicalParallels,
metadata: state.metadata,
variables: state.variables,
}
Expand Down
10 changes: 7 additions & 3 deletions apps/sim/serializer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { getTool } from '@/tools/utils'

const logger = createLogger('Serializer')
Expand Down Expand Up @@ -41,12 +42,15 @@ export class Serializer {
serializeWorkflow(
blocks: Record<string, BlockState>,
edges: Edge[],
loops: Record<string, Loop>,
loops?: Record<string, Loop>,
parallels?: Record<string, Parallel>,
validateRequired = false
): SerializedWorkflow {
const safeLoops = loops || {}
const safeParallels = parallels || {}
const canonicalLoops = generateLoopBlocks(blocks)
const canonicalParallels = generateParallelBlocks(blocks)
const safeLoops = Object.keys(canonicalLoops).length > 0 ? canonicalLoops : loops || {}
const safeParallels =
Object.keys(canonicalParallels).length > 0 ? canonicalParallels : parallels || {}
const accessibleBlocksMap = this.computeAccessibleBlockIds(
blocks,
edges,
Expand Down