From 2827172a4396910c4d8839909e55b9dd64ffefc9 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 13 Nov 2025 17:48:39 -0800 Subject: [PATCH] fix(copilot-subflows): copilot-added subflows id mismatch --- .../sim/app/api/workflows/[id]/state/route.ts | 10 +- apps/sim/lib/workflows/db-helpers.test.ts | 174 ++++++++++++++++-- apps/sim/lib/workflows/db-helpers.ts | 9 +- apps/sim/lib/workflows/json-sanitizer.ts | 8 +- apps/sim/serializer/index.ts | 10 +- 5 files changed, 188 insertions(+), 23 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 1ca39d2154..a84afc4953 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -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') @@ -175,11 +177,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ {} as typeof state.blocks ) + const typedBlocks = filteredBlocks as Record + 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, diff --git a/apps/sim/lib/workflows/db-helpers.test.ts b/apps/sim/lib/workflows/db-helpers.test.ts index 27afcea80f..505609ccc9 100644 --- a/apps/sim/lib/workflows/db-helpers.test.ts +++ b/apps/sim/lib/workflows/db-helpers.test.ts @@ -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 = [ @@ -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: [ { @@ -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({ @@ -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', () => { diff --git a/apps/sim/lib/workflows/db-helpers.ts b/apps/sim/lib/workflows/db-helpers.ts index ea7675e946..b8b77afe6e 100644 --- a/apps/sim/lib/workflows/db-helpers.ts +++ b/apps/sim/lib/workflows/db-helpers.ts @@ -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') @@ -248,6 +249,10 @@ export async function saveWorkflowToNormalizedTables( state: WorkflowState ): Promise<{ success: boolean; error?: string }> { try { + const blockRecords = state.blocks as Record + 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 @@ -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, @@ -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, diff --git a/apps/sim/lib/workflows/json-sanitizer.ts b/apps/sim/lib/workflows/json-sanitizer.ts index 666eac258f..9f62c843b5 100644 --- a/apps/sim/lib/workflows/json-sanitizer.ts +++ b/apps/sim/lib/workflows/json-sanitizer.ts @@ -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' /** @@ -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, } diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index 1bae265c22..6232ff276c 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -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') @@ -41,12 +42,15 @@ export class Serializer { serializeWorkflow( blocks: Record, edges: Edge[], - loops: Record, + loops?: Record, parallels?: Record, 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,