Skip to content

Commit b3caef1

Browse files
fix(copilot-subflows): copilot-added subflows id mismatch (#1977)
1 parent 5457d4b commit b3caef1

File tree

5 files changed

+188
-23
lines changed

5 files changed

+188
-23
lines changed

apps/sim/app/api/workflows/[id]/state/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/custom-tools-persi
1010
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers'
1111
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
1212
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation'
13+
import type { BlockState } from '@/stores/workflows/workflow/types'
14+
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
1315

1416
const logger = createLogger('WorkflowStateAPI')
1517

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

180+
const typedBlocks = filteredBlocks as Record<string, BlockState>
181+
const canonicalLoops = generateLoopBlocks(typedBlocks)
182+
const canonicalParallels = generateParallelBlocks(typedBlocks)
183+
178184
const workflowState = {
179185
blocks: filteredBlocks,
180186
edges: state.edges,
181-
loops: state.loops || {},
182-
parallels: state.parallels || {},
187+
loops: canonicalLoops,
188+
parallels: canonicalParallels,
183189
lastSaved: state.lastSaved || Date.now(),
184190
isDeployed: state.isDeployed || false,
185191
deployedAt: state.deployedAt,

apps/sim/lib/workflows/db-helpers.test.ts

Lines changed: 160 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,58 @@ const mockBlocksFromDb = [
124124
parentId: 'loop-1',
125125
extent: 'parent',
126126
},
127+
{
128+
id: 'loop-1',
129+
workflowId: mockWorkflowId,
130+
type: 'loop',
131+
name: 'Loop Container',
132+
positionX: 50,
133+
positionY: 50,
134+
enabled: true,
135+
horizontalHandles: true,
136+
advancedMode: false,
137+
triggerMode: false,
138+
height: 250,
139+
subBlocks: {},
140+
outputs: {},
141+
data: { width: 500, height: 300, loopType: 'for', count: 5 },
142+
parentId: null,
143+
extent: null,
144+
},
145+
{
146+
id: 'parallel-1',
147+
workflowId: mockWorkflowId,
148+
type: 'parallel',
149+
name: 'Parallel Container',
150+
positionX: 600,
151+
positionY: 50,
152+
enabled: true,
153+
horizontalHandles: true,
154+
advancedMode: false,
155+
triggerMode: false,
156+
height: 250,
157+
subBlocks: {},
158+
outputs: {},
159+
data: { width: 500, height: 300, parallelType: 'count', count: 3 },
160+
parentId: null,
161+
extent: null,
162+
},
163+
{
164+
id: 'block-3',
165+
workflowId: mockWorkflowId,
166+
type: 'api',
167+
name: 'Parallel Child',
168+
positionX: 650,
169+
positionY: 150,
170+
enabled: true,
171+
horizontalHandles: true,
172+
height: 200,
173+
subBlocks: {},
174+
outputs: {},
175+
data: { parentId: 'parallel-1', extent: 'parent' },
176+
parentId: 'parallel-1',
177+
extent: 'parent',
178+
},
127179
]
128180

129181
const mockEdgesFromDb = [
@@ -187,6 +239,42 @@ const mockWorkflowState: WorkflowState = {
187239
height: 200,
188240
data: { parentId: 'loop-1', extent: 'parent' },
189241
},
242+
'loop-1': {
243+
id: 'loop-1',
244+
type: 'loop',
245+
name: 'Loop Container',
246+
position: { x: 200, y: 50 },
247+
subBlocks: {},
248+
outputs: {},
249+
enabled: true,
250+
horizontalHandles: true,
251+
height: 250,
252+
data: { width: 500, height: 300, count: 5, loopType: 'for' },
253+
},
254+
'parallel-1': {
255+
id: 'parallel-1',
256+
type: 'parallel',
257+
name: 'Parallel Container',
258+
position: { x: 600, y: 50 },
259+
subBlocks: {},
260+
outputs: {},
261+
enabled: true,
262+
horizontalHandles: true,
263+
height: 250,
264+
data: { width: 500, height: 300, parallelType: 'count', count: 3 },
265+
},
266+
'block-3': {
267+
id: 'block-3',
268+
type: 'api',
269+
name: 'Parallel Child',
270+
position: { x: 650, y: 150 },
271+
subBlocks: {},
272+
outputs: {},
273+
enabled: true,
274+
horizontalHandles: true,
275+
height: 180,
276+
data: { parentId: 'parallel-1', extent: 'parent' },
277+
},
190278
},
191279
edges: [
192280
{
@@ -567,20 +655,36 @@ describe('Database Helpers', () => {
567655

568656
await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, mockWorkflowState)
569657

570-
expect(capturedBlockInserts).toHaveLength(2)
571-
expect(capturedBlockInserts[0]).toMatchObject({
572-
id: 'block-1',
573-
workflowId: mockWorkflowId,
574-
type: 'starter',
575-
name: 'Start Block',
576-
positionX: '100',
577-
positionY: '100',
578-
enabled: true,
579-
horizontalHandles: true,
580-
height: '150',
581-
parentId: null,
582-
extent: null,
583-
})
658+
expect(capturedBlockInserts).toHaveLength(5)
659+
expect(capturedBlockInserts).toEqual(
660+
expect.arrayContaining([
661+
expect.objectContaining({
662+
id: 'block-1',
663+
workflowId: mockWorkflowId,
664+
type: 'starter',
665+
name: 'Start Block',
666+
positionX: '100',
667+
positionY: '100',
668+
enabled: true,
669+
horizontalHandles: true,
670+
height: '150',
671+
parentId: null,
672+
extent: null,
673+
}),
674+
expect.objectContaining({
675+
id: 'loop-1',
676+
workflowId: mockWorkflowId,
677+
type: 'loop',
678+
parentId: null,
679+
}),
680+
expect.objectContaining({
681+
id: 'parallel-1',
682+
workflowId: mockWorkflowId,
683+
type: 'parallel',
684+
parentId: null,
685+
}),
686+
])
687+
)
584688

585689
expect(capturedEdgeInserts).toHaveLength(1)
586690
expect(capturedEdgeInserts[0]).toMatchObject({
@@ -599,6 +703,48 @@ describe('Database Helpers', () => {
599703
type: 'loop',
600704
})
601705
})
706+
707+
it('should regenerate missing loop and parallel definitions from block data', async () => {
708+
let capturedSubflowInserts: any[] = []
709+
710+
const mockTransaction = vi.fn().mockImplementation(async (callback) => {
711+
const tx = {
712+
select: vi.fn().mockReturnValue({
713+
from: vi.fn().mockReturnValue({
714+
where: vi.fn().mockResolvedValue([]),
715+
}),
716+
}),
717+
delete: vi.fn().mockReturnValue({
718+
where: vi.fn().mockResolvedValue([]),
719+
}),
720+
insert: vi.fn().mockReturnValue({
721+
values: vi.fn().mockImplementation((data) => {
722+
if (data.length > 0 && (data[0].type === 'loop' || data[0].type === 'parallel')) {
723+
capturedSubflowInserts = data
724+
}
725+
return Promise.resolve([])
726+
}),
727+
}),
728+
}
729+
return await callback(tx)
730+
})
731+
732+
mockDb.transaction = mockTransaction
733+
734+
const staleWorkflowState = JSON.parse(JSON.stringify(mockWorkflowState)) as WorkflowState
735+
staleWorkflowState.loops = {}
736+
staleWorkflowState.parallels = {}
737+
738+
await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, staleWorkflowState)
739+
740+
expect(capturedSubflowInserts).toHaveLength(2)
741+
expect(capturedSubflowInserts).toEqual(
742+
expect.arrayContaining([
743+
expect.objectContaining({ id: 'loop-1', type: 'loop' }),
744+
expect.objectContaining({ id: 'parallel-1', type: 'parallel' }),
745+
])
746+
)
747+
})
602748
})
603749

604750
describe('workflowExistsInNormalizedTables', () => {

apps/sim/lib/workflows/db-helpers.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { createLogger } from '@/lib/logs/console/logger'
1616
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation'
1717
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
1818
import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types'
19+
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
1920

2021
const logger = createLogger('WorkflowDBHelpers')
2122

@@ -248,6 +249,10 @@ export async function saveWorkflowToNormalizedTables(
248249
state: WorkflowState
249250
): Promise<{ success: boolean; error?: string }> {
250251
try {
252+
const blockRecords = state.blocks as Record<string, BlockState>
253+
const canonicalLoops = generateLoopBlocks(blockRecords)
254+
const canonicalParallels = generateParallelBlocks(blockRecords)
255+
251256
// Start a transaction
252257
await db.transaction(async (tx) => {
253258
// Snapshot existing webhooks before deletion to preserve them through the cycle
@@ -310,7 +315,7 @@ export async function saveWorkflowToNormalizedTables(
310315
const subflowInserts: any[] = []
311316

312317
// Add loops
313-
Object.values(state.loops || {}).forEach((loop) => {
318+
Object.values(canonicalLoops).forEach((loop) => {
314319
subflowInserts.push({
315320
id: loop.id,
316321
workflowId: workflowId,
@@ -320,7 +325,7 @@ export async function saveWorkflowToNormalizedTables(
320325
})
321326

322327
// Add parallels
323-
Object.values(state.parallels || {}).forEach((parallel) => {
328+
Object.values(canonicalParallels).forEach((parallel) => {
324329
subflowInserts.push({
325330
id: parallel.id,
326331
workflowId: workflowId,

apps/sim/lib/workflows/json-sanitizer.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Edge } from 'reactflow'
22
import { sanitizeWorkflowForSharing } from '@/lib/workflows/credential-extractor'
33
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
4+
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
45
import { TRIGGER_PERSISTED_SUBBLOCK_IDS } from '@/triggers/consts'
56

67
/**
@@ -386,12 +387,15 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState {
386387
* Users need positions to restore the visual layout when importing
387388
*/
388389
export function sanitizeForExport(state: WorkflowState): ExportWorkflowState {
390+
const canonicalLoops = generateLoopBlocks(state.blocks || {})
391+
const canonicalParallels = generateParallelBlocks(state.blocks || {})
392+
389393
// Preserve edges, loops, parallels, metadata, and variables
390394
const fullState = {
391395
blocks: state.blocks,
392396
edges: state.edges,
393-
loops: state.loops || {},
394-
parallels: state.parallels || {},
397+
loops: canonicalLoops,
398+
parallels: canonicalParallels,
395399
metadata: state.metadata,
396400
variables: state.variables,
397401
}

apps/sim/serializer/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getBlock } from '@/blocks'
55
import type { SubBlockConfig } from '@/blocks/types'
66
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
77
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
8+
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
89
import { getTool } from '@/tools/utils'
910

1011
const logger = createLogger('Serializer')
@@ -41,12 +42,15 @@ export class Serializer {
4142
serializeWorkflow(
4243
blocks: Record<string, BlockState>,
4344
edges: Edge[],
44-
loops: Record<string, Loop>,
45+
loops?: Record<string, Loop>,
4546
parallels?: Record<string, Parallel>,
4647
validateRequired = false
4748
): SerializedWorkflow {
48-
const safeLoops = loops || {}
49-
const safeParallels = parallels || {}
49+
const canonicalLoops = generateLoopBlocks(blocks)
50+
const canonicalParallels = generateParallelBlocks(blocks)
51+
const safeLoops = Object.keys(canonicalLoops).length > 0 ? canonicalLoops : loops || {}
52+
const safeParallels =
53+
Object.keys(canonicalParallels).length > 0 ? canonicalParallels : parallels || {}
5054
const accessibleBlocksMap = this.computeAccessibleBlockIds(
5155
blocks,
5256
edges,

0 commit comments

Comments
 (0)