diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts index 2a9248ed6b..8c184e0cf5 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts @@ -134,6 +134,66 @@ function makeLoopWorkflow() { } } +function makeNestedLoopWorkflow() { + return { + blocks: { + 'outer-loop': { + id: 'outer-loop', + type: 'loop', + name: 'Outer Loop', + position: { x: 0, y: 0 }, + enabled: true, + subBlocks: {}, + outputs: {}, + data: { loopType: 'for', count: 2 }, + }, + 'inner-loop': { + id: 'inner-loop', + type: 'loop', + name: 'Inner Loop', + position: { x: 120, y: 80 }, + enabled: true, + subBlocks: {}, + outputs: {}, + data: { parentId: 'outer-loop', extent: 'parent', loopType: 'for', count: 3 }, + }, + 'inner-agent': { + id: 'inner-agent', + type: 'agent', + name: 'Inner Agent', + position: { x: 240, y: 120 }, + enabled: true, + subBlocks: { + systemPrompt: { id: 'systemPrompt', type: 'long-input', value: 'Original prompt' }, + model: { id: 'model', type: 'combobox', value: 'gpt-4o' }, + }, + outputs: {}, + data: { parentId: 'inner-loop', extent: 'parent' }, + }, + }, + edges: [ + { + id: 'edge-outer-inner', + source: 'outer-loop', + sourceHandle: 'loop-start-source', + target: 'inner-loop', + targetHandle: 'target', + type: 'default', + }, + { + id: 'edge-inner-agent', + source: 'inner-loop', + sourceHandle: 'loop-start-source', + target: 'inner-agent', + targetHandle: 'target', + type: 'default', + }, + ], + loops: {}, + parallels: {}, + } +} + describe('handleEditOperation nestedNodes merge', () => { it('preserves existing child block IDs when editing a loop with nestedNodes', () => { const workflow = makeLoopWorkflow() @@ -261,4 +321,129 @@ describe('handleEditOperation nestedNodes merge', () => { expect(agent).toBeDefined() expect(agent.subBlocks.systemPrompt.value).toBe('New prompt') }) + + it('recursively updates an existing nested loop and preserves grandchild IDs', () => { + const workflow = makeNestedLoopWorkflow() + + const { state } = applyOperationsToWorkflowState(workflow, [ + { + operation_type: 'edit', + block_id: 'outer-loop', + params: { + nestedNodes: { + 'new-inner-loop': { + type: 'loop', + name: 'Inner Loop', + inputs: { + loopType: 'forEach', + collection: '', + }, + nestedNodes: { + 'new-inner-agent': { + type: 'agent', + name: 'Inner Agent', + inputs: { systemPrompt: 'Updated prompt' }, + }, + 'new-helper': { + type: 'function', + name: 'Helper', + inputs: { code: 'return 1' }, + }, + }, + }, + }, + }, + }, + ]) + + expect(state.blocks['inner-loop']).toBeDefined() + expect(state.blocks['new-inner-loop']).toBeUndefined() + expect(state.blocks['inner-loop'].data.loopType).toBe('forEach') + expect(state.blocks['inner-loop'].data.collection).toBe('') + + expect(state.blocks['inner-agent']).toBeDefined() + expect(state.blocks['new-inner-agent']).toBeUndefined() + expect(state.blocks['inner-agent'].subBlocks.systemPrompt.value).toBe('Updated prompt') + + const helperBlock = Object.values(state.blocks).find((block: any) => block.name === 'Helper') as + | any + | undefined + expect(helperBlock).toBeDefined() + expect(helperBlock?.data?.parentId).toBe('inner-loop') + }) + + it('removes grandchildren omitted from an existing nested loop update', () => { + const workflow = makeNestedLoopWorkflow() + + const { state } = applyOperationsToWorkflowState(workflow, [ + { + operation_type: 'edit', + block_id: 'outer-loop', + params: { + nestedNodes: { + 'new-inner-loop': { + type: 'loop', + name: 'Inner Loop', + nestedNodes: { + 'new-helper': { + type: 'function', + name: 'Helper', + inputs: { code: 'return 1' }, + }, + }, + }, + }, + }, + }, + ]) + + expect(state.blocks['inner-loop']).toBeDefined() + expect(state.blocks['inner-agent']).toBeUndefined() + expect( + state.edges.some( + (edge: any) => edge.source === 'inner-agent' || edge.target === 'inner-agent' + ) + ).toBe(false) + + const helperBlock = Object.values(state.blocks).find((block: any) => block.name === 'Helper') + expect(helperBlock).toBeDefined() + }) + + it('removes an unmatched nested container with all descendants and edges', () => { + const workflow = makeNestedLoopWorkflow() + + const { state } = applyOperationsToWorkflowState(workflow, [ + { + operation_type: 'edit', + block_id: 'outer-loop', + params: { + nestedNodes: { + replacement: { + type: 'function', + name: 'Replacement', + inputs: { code: 'return 2' }, + }, + }, + }, + }, + ]) + + expect(state.blocks['inner-loop']).toBeUndefined() + expect(state.blocks['inner-agent']).toBeUndefined() + expect( + state.edges.some( + (edge: any) => + edge.source === 'inner-loop' || + edge.target === 'inner-loop' || + edge.source === 'inner-agent' || + edge.target === 'inner-agent' + ) + ).toBe(false) + + const replacementBlock = Object.values(state.blocks).find( + (block: any) => block.name === 'Replacement' + ) as any + expect(replacementBlock).toBeDefined() + expect(replacementBlock.data?.parentId).toBe('outer-loop') + }) }) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts index e0f45efd99..aaabf57cb6 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts @@ -26,6 +26,308 @@ import { const logger = createLogger('EditWorkflowServerTool') +/** + * Applies loop/parallel container config from `inputs` onto a block state (data.loopType, etc.). + */ +function applyLoopOrParallelContainerData(block: any, params: Record): void { + if (params.type === 'loop') { + const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] + const loopType = + params.inputs?.loopType && validLoopTypes.includes(params.inputs.loopType) + ? params.inputs.loopType + : 'for' + block.data = { + ...block.data, + loopType, + ...(loopType === 'forEach' && + params.inputs?.collection && { collection: params.inputs.collection }), + ...(loopType === 'for' && params.inputs?.iterations && { count: params.inputs.iterations }), + ...(loopType === 'while' && + params.inputs?.condition && { whileCondition: params.inputs.condition }), + ...(loopType === 'doWhile' && + params.inputs?.condition && { doWhileCondition: params.inputs.condition }), + } + } else if (params.type === 'parallel') { + const validParallelTypes = ['count', 'collection'] + const parallelType = + params.inputs?.parallelType && validParallelTypes.includes(params.inputs.parallelType) + ? params.inputs.parallelType + : 'count' + block.data = { + ...block.data, + parallelType, + ...(parallelType === 'collection' && + params.inputs?.collection && { collection: params.inputs.collection }), + ...(parallelType === 'count' && params.inputs?.count && { count: params.inputs.count }), + } + } +} + +/** + * Adds child blocks under a loop/parallel container, including nested loop/parallel subflows. + */ +function processNestedNodesForParent( + parentBlockId: string, + nestedNodes: Record, + ctx: OperationContext +): void { + const { modifiedState, skippedItems, validationErrors, permissionConfig, deferredConnections } = + ctx + + const parentBlock = modifiedState.blocks[parentBlockId] + if (parentBlock?.locked) { + logSkippedItem(skippedItems, { + type: 'block_locked', + operationType: 'add_nested_nodes', + blockId: parentBlockId, + reason: `Container "${parentBlockId}" is locked - cannot add nested nodes`, + }) + return + } + + Object.entries(nestedNodes).forEach(([childId, childBlock]: [string, any]) => { + if (!isValidKey(childId)) { + logSkippedItem(skippedItems, { + type: 'missing_required_params', + operationType: 'add_nested_node', + blockId: String(childId || 'invalid'), + reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`, + }) + logger.error('Invalid childId detected in nestedNodes', { + parentBlockId, + childId, + childId_type: typeof childId, + }) + return + } + + const childBlockState = createBlockFromParams( + childId, + childBlock, + parentBlockId, + validationErrors, + permissionConfig, + skippedItems + ) + if (childBlock.type === 'loop' || childBlock.type === 'parallel') { + applyLoopOrParallelContainerData(childBlockState, childBlock) + } + modifiedState.blocks[childId] = childBlockState + + if (childBlock.connections) { + deferredConnections.push({ + blockId: childId, + connections: childBlock.connections, + }) + } + + if (childBlock.nestedNodes && (childBlock.type === 'loop' || childBlock.type === 'parallel')) { + processNestedNodesForParent(childId, childBlock.nestedNodes, ctx) + } + }) +} + +function updateLoopOrParallelContainerData(block: any, params: Record): void { + if (block.type === 'loop') { + block.data = block.data || {} + if (params.inputs?.loopType) { + const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] + if (validLoopTypes.includes(params.inputs.loopType)) { + block.data.loopType = params.inputs.loopType + } + } + const effectiveLoopType = params.inputs?.loopType ?? block.data.loopType ?? 'for' + if (params.inputs?.iterations && effectiveLoopType === 'for') { + block.data.count = params.inputs.iterations + } + if (params.inputs?.collection && effectiveLoopType === 'forEach') { + block.data.collection = params.inputs.collection + } + if ( + params.inputs?.condition && + (effectiveLoopType === 'while' || effectiveLoopType === 'doWhile') + ) { + if (effectiveLoopType === 'doWhile') { + block.data.doWhileCondition = params.inputs.condition + } else { + block.data.whileCondition = params.inputs.condition + } + } + } else if (block.type === 'parallel') { + block.data = block.data || {} + if (params.inputs?.parallelType) { + const validParallelTypes = ['count', 'collection'] + if (validParallelTypes.includes(params.inputs.parallelType)) { + block.data.parallelType = params.inputs.parallelType + } + } + const effectiveParallelType = params.inputs?.parallelType ?? block.data.parallelType ?? 'count' + if (params.inputs?.count && effectiveParallelType === 'count') { + block.data.count = params.inputs.count + } + if (params.inputs?.collection && effectiveParallelType === 'collection') { + block.data.collection = params.inputs.collection + } + } +} + +function mergeNestedNodesForParent( + parentBlockId: string, + nestedNodes: Record, + ctx: OperationContext +): void { + const { modifiedState, skippedItems, validationErrors, permissionConfig, deferredConnections } = + ctx + + const existingChildren: Array<[string, any]> = Object.entries(modifiedState.blocks).filter( + ([, block]: [string, any]) => block.data?.parentId === parentBlockId + ) + + const existingByName = new Map() + for (const [id, child] of existingChildren) { + existingByName.set(normalizeName(child.name), [id, child]) + } + + const matchedExistingIds = new Set() + + Object.entries(nestedNodes).forEach(([childId, childBlock]: [string, any]) => { + const incomingName = normalizeName(childBlock.name || '') + const existingMatch = incomingName ? existingByName.get(incomingName) : undefined + + if (existingMatch) { + const [existingId, existingBlock] = existingMatch + matchedExistingIds.add(existingId) + + if (childBlock.inputs) { + if (!existingBlock.subBlocks) existingBlock.subBlocks = {} + const childValidation = validateInputsForBlock( + existingBlock.type, + childBlock.inputs, + existingId + ) + validationErrors.push(...childValidation.errors) + + Object.entries(childValidation.validInputs).forEach(([key, value]) => { + if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) return + let sanitizedValue = value + if (shouldNormalizeArrayIds(key)) { + sanitizedValue = normalizeArrayWithIds(value) + } + sanitizedValue = normalizeConditionRouterIds(existingId, key, sanitizedValue) + if (key === 'tools' && Array.isArray(value)) { + sanitizedValue = filterDisallowedTools( + normalizeTools(value), + permissionConfig, + existingId, + skippedItems + ) + } + if (key === 'responseFormat' && value) { + sanitizedValue = normalizeResponseFormat(value) + } + + const subBlockDef = getBlock(existingBlock.type)?.subBlocks.find( + (sb: any) => sb.id === key + ) + if (!existingBlock.subBlocks[key]) { + existingBlock.subBlocks[key] = { + id: key, + type: subBlockDef?.type || 'short-input', + value: sanitizedValue, + } + } else { + existingBlock.subBlocks[key].value = sanitizedValue + } + }) + } + + if (existingBlock.type === 'loop' || existingBlock.type === 'parallel') { + updateLoopOrParallelContainerData(existingBlock, childBlock) + } + + if (childBlock.connections) { + modifiedState.edges = modifiedState.edges.filter((edge: any) => edge.source !== existingId) + deferredConnections.push({ + blockId: existingId, + connections: childBlock.connections, + }) + } + + if ( + childBlock.nestedNodes && + (existingBlock.type === 'loop' || existingBlock.type === 'parallel') + ) { + mergeNestedNodesForParent(existingId, childBlock.nestedNodes, ctx) + } + return + } + + if (!isValidKey(childId)) { + logSkippedItem(skippedItems, { + type: 'missing_required_params', + operationType: 'add_nested_node', + blockId: String(childId || 'invalid'), + reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`, + }) + return + } + + const childBlockState = createBlockFromParams( + childId, + childBlock, + parentBlockId, + validationErrors, + permissionConfig, + skippedItems + ) + if (childBlock.type === 'loop' || childBlock.type === 'parallel') { + applyLoopOrParallelContainerData(childBlockState, childBlock) + } + modifiedState.blocks[childId] = childBlockState + + if (childBlock.connections) { + deferredConnections.push({ + blockId: childId, + connections: childBlock.connections, + }) + } + + if (childBlock.nestedNodes && (childBlock.type === 'loop' || childBlock.type === 'parallel')) { + processNestedNodesForParent(childId, childBlock.nestedNodes, ctx) + } + }) + + const collectBlockAndDescendants = ( + rootId: string, + collected = new Set() + ): Set => { + collected.add(rootId) + Object.entries(modifiedState.blocks).forEach(([childId, block]: [string, any]) => { + if (block.data?.parentId === rootId && !collected.has(childId)) { + collectBlockAndDescendants(childId, collected) + } + }) + return collected + } + + const removedIds = new Set() + for (const [existingId] of existingChildren) { + if (!matchedExistingIds.has(existingId)) { + const subtreeIds = collectBlockAndDescendants(existingId) + subtreeIds.forEach((id) => { + delete modifiedState.blocks[id] + removedIds.add(id) + }) + } + } + + if (removedIds.size > 0) { + modifiedState.edges = modifiedState.edges.filter( + (edge: any) => !removedIds.has(edge.source) && !removedIds.has(edge.target) + ) + } +} + export function handleDeleteOperation(op: EditWorkflowOperation, ctx: OperationContext): void { const { modifiedState, skippedItems } = ctx const { block_id } = op @@ -347,181 +649,10 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon // (preserving their block ID). New children are created. Children not present // in the incoming set are removed. if (params?.nestedNodes) { - const existingChildren: Array<[string, any]> = Object.entries(modifiedState.blocks).filter( - ([, b]: [string, any]) => b.data?.parentId === block_id - ) - - const existingByName = new Map() - for (const [id, child] of existingChildren) { - existingByName.set(normalizeName(child.name), [id, child]) - } - - const matchedExistingIds = new Set() - - Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => { - if (childBlock.type === 'loop' || childBlock.type === 'parallel') { - logSkippedItem(skippedItems, { - type: 'nested_subflow_not_allowed', - operationType: 'edit_nested_node', - blockId: childId, - reason: `Cannot nest ${childBlock.type} inside ${block.type} - nested subflows are not supported`, - details: { parentType: block.type, childType: childBlock.type }, - }) - return - } - - const incomingName = normalizeName(childBlock.name || '') - const existingMatch = incomingName ? existingByName.get(incomingName) : undefined - - if (existingMatch) { - const [existingId, existingBlock] = existingMatch - matchedExistingIds.add(existingId) - - if (childBlock.inputs) { - if (!existingBlock.subBlocks) existingBlock.subBlocks = {} - const childValidation = validateInputsForBlock( - existingBlock.type, - childBlock.inputs, - existingId - ) - validationErrors.push(...childValidation.errors) - - Object.entries(childValidation.validInputs).forEach(([key, value]) => { - if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) return - let sanitizedValue = value - if (shouldNormalizeArrayIds(key)) { - sanitizedValue = normalizeArrayWithIds(value) - } - sanitizedValue = normalizeConditionRouterIds(existingId, key, sanitizedValue) - if (key === 'tools' && Array.isArray(value)) { - sanitizedValue = filterDisallowedTools( - normalizeTools(value), - permissionConfig, - existingId, - skippedItems - ) - } - if (key === 'responseFormat' && value) { - sanitizedValue = normalizeResponseFormat(value) - } - - const subBlockDef = getBlock(existingBlock.type)?.subBlocks.find( - (sb: any) => sb.id === key - ) - if (!existingBlock.subBlocks[key]) { - existingBlock.subBlocks[key] = { - id: key, - type: subBlockDef?.type || 'short-input', - value: sanitizedValue, - } - } else { - existingBlock.subBlocks[key].value = sanitizedValue - } - }) - } - - if (childBlock.connections) { - modifiedState.edges = modifiedState.edges.filter( - (edge: any) => edge.source !== existingId - ) - deferredConnections.push({ - blockId: existingId, - connections: childBlock.connections, - }) - } - } else { - if (!isValidKey(childId)) { - logSkippedItem(skippedItems, { - type: 'missing_required_params', - operationType: 'add_nested_node', - blockId: String(childId || 'invalid'), - reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`, - }) - return - } - - const childBlockState = createBlockFromParams( - childId, - childBlock, - block_id, - validationErrors, - permissionConfig, - skippedItems - ) - modifiedState.blocks[childId] = childBlockState - - if (childBlock.connections) { - deferredConnections.push({ - blockId: childId, - connections: childBlock.connections, - }) - } - } - }) - - const removedIds = new Set() - for (const [existingId] of existingChildren) { - if (!matchedExistingIds.has(existingId)) { - delete modifiedState.blocks[existingId] - removedIds.add(existingId) - } - } - if (removedIds.size > 0) { - modifiedState.edges = modifiedState.edges.filter( - (edge: any) => !removedIds.has(edge.source) && !removedIds.has(edge.target) - ) - } + mergeNestedNodesForParent(block_id, params.nestedNodes, ctx) // Update loop/parallel configuration based on type (strict validation) - if (block.type === 'loop') { - block.data = block.data || {} - // loopType is always valid - if (params.inputs?.loopType) { - const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] - if (validLoopTypes.includes(params.inputs.loopType)) { - block.data.loopType = params.inputs.loopType - } - } - const effectiveLoopType = params.inputs?.loopType ?? block.data.loopType ?? 'for' - // iterations only valid for 'for' loopType - if (params.inputs?.iterations && effectiveLoopType === 'for') { - block.data.count = params.inputs.iterations - } - // collection only valid for 'forEach' loopType - if (params.inputs?.collection && effectiveLoopType === 'forEach') { - block.data.collection = params.inputs.collection - } - // condition only valid for 'while' or 'doWhile' loopType - if ( - params.inputs?.condition && - (effectiveLoopType === 'while' || effectiveLoopType === 'doWhile') - ) { - if (effectiveLoopType === 'doWhile') { - block.data.doWhileCondition = params.inputs.condition - } else { - block.data.whileCondition = params.inputs.condition - } - } - } else if (block.type === 'parallel') { - block.data = block.data || {} - // parallelType is always valid - if (params.inputs?.parallelType) { - const validParallelTypes = ['count', 'collection'] - if (validParallelTypes.includes(params.inputs.parallelType)) { - block.data.parallelType = params.inputs.parallelType - } - } - const effectiveParallelType = - params.inputs?.parallelType ?? block.data.parallelType ?? 'count' - // count only valid for 'count' parallelType - if (params.inputs?.count && effectiveParallelType === 'count') { - block.data.count = params.inputs.count - } - // collection only valid for 'collection' parallelType - if (params.inputs?.collection && effectiveParallelType === 'collection') { - block.data.collection = params.inputs.collection - } - } + updateLoopOrParallelContainerData(block, params) } // Defer connections to pass 2 so all blocks exist before edges are created @@ -664,41 +795,8 @@ export function handleAddOperation(op: EditWorkflowOperation, ctx: OperationCont skippedItems ) - // Set loop/parallel data on parent block BEFORE adding to blocks (strict validation) - if (params.nestedNodes) { - if (params.type === 'loop') { - const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] - const loopType = - params.inputs?.loopType && validLoopTypes.includes(params.inputs.loopType) - ? params.inputs.loopType - : 'for' - newBlock.data = { - ...newBlock.data, - loopType, - // Only include type-appropriate fields - ...(loopType === 'forEach' && - params.inputs?.collection && { collection: params.inputs.collection }), - ...(loopType === 'for' && params.inputs?.iterations && { count: params.inputs.iterations }), - ...(loopType === 'while' && - params.inputs?.condition && { whileCondition: params.inputs.condition }), - ...(loopType === 'doWhile' && - params.inputs?.condition && { doWhileCondition: params.inputs.condition }), - } - } else if (params.type === 'parallel') { - const validParallelTypes = ['count', 'collection'] - const parallelType = - params.inputs?.parallelType && validParallelTypes.includes(params.inputs.parallelType) - ? params.inputs.parallelType - : 'count' - newBlock.data = { - ...newBlock.data, - parallelType, - // Only include type-appropriate fields - ...(parallelType === 'collection' && - params.inputs?.collection && { collection: params.inputs.collection }), - ...(parallelType === 'count' && params.inputs?.count && { count: params.inputs.count }), - } - } + if (params.type === 'loop' || params.type === 'parallel') { + applyLoopOrParallelContainerData(newBlock, params) } // Add parent block FIRST before adding children @@ -707,65 +805,7 @@ export function handleAddOperation(op: EditWorkflowOperation, ctx: OperationCont // Handle nested nodes (for loops/parallels created from scratch) if (params.nestedNodes) { - // Defensive check: verify parent is not locked before adding children - // (Parent was just created with locked: false, but check for consistency) - const parentBlock = modifiedState.blocks[block_id] - if (parentBlock?.locked) { - logSkippedItem(skippedItems, { - type: 'block_locked', - operationType: 'add_nested_nodes', - blockId: block_id, - reason: `Container "${block_id}" is locked - cannot add nested nodes`, - }) - return - } - - Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => { - // Validate childId is a valid string - if (!isValidKey(childId)) { - logSkippedItem(skippedItems, { - type: 'missing_required_params', - operationType: 'add_nested_node', - blockId: String(childId || 'invalid'), - reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`, - }) - logger.error('Invalid childId detected in nestedNodes', { - parentBlockId: block_id, - childId, - childId_type: typeof childId, - }) - return - } - - if (childBlock.type === 'loop' || childBlock.type === 'parallel') { - logSkippedItem(skippedItems, { - type: 'nested_subflow_not_allowed', - operationType: 'add_nested_node', - blockId: childId, - reason: `Cannot nest ${childBlock.type} inside ${params.type} - nested subflows are not supported`, - details: { parentType: params.type, childType: childBlock.type }, - }) - return - } - - const childBlockState = createBlockFromParams( - childId, - childBlock, - block_id, - validationErrors, - permissionConfig, - skippedItems - ) - modifiedState.blocks[childId] = childBlockState - - // Defer connection processing to ensure all blocks exist first - if (childBlock.connections) { - deferredConnections.push({ - blockId: childId, - connections: childBlock.connections, - }) - } - }) + processNestedNodesForParent(block_id, params.nestedNodes, ctx) } // Defer connection processing to ensure all blocks exist first (pass 2) @@ -834,32 +874,10 @@ export function handleInsertIntoSubflowOperation( return } - if (params.type === 'loop' || params.type === 'parallel') { - logSkippedItem(skippedItems, { - type: 'nested_subflow_not_allowed', - operationType: 'insert_into_subflow', - blockId: block_id, - reason: `Cannot nest ${params.type} inside ${subflowBlock.type} - nested subflows are not supported`, - details: { parentType: subflowBlock.type, childType: params.type }, - }) - return - } - // Check if block already exists (moving into subflow) or is new const existingBlock = modifiedState.blocks[block_id] if (existingBlock) { - if (existingBlock.type === 'loop' || existingBlock.type === 'parallel') { - logSkippedItem(skippedItems, { - type: 'nested_subflow_not_allowed', - operationType: 'insert_into_subflow', - blockId: block_id, - reason: `Cannot move ${existingBlock.type} into ${subflowBlock.type} - nested subflows are not supported`, - details: { parentType: subflowBlock.type, childType: existingBlock.type }, - }) - return - } - // Check if existing block is locked if (existingBlock.locked) { logSkippedItem(skippedItems, { @@ -981,6 +999,12 @@ export function handleInsertIntoSubflowOperation( skippedItems ) modifiedState.blocks[block_id] = newBlock + if (params.type === 'loop' || params.type === 'parallel') { + applyLoopOrParallelContainerData(newBlock, params) + } + if (params.nestedNodes && (params.type === 'loop' || params.type === 'parallel')) { + processNestedNodesForParent(block_id, params.nestedNodes, ctx) + } } // Defer connection processing to ensure all blocks exist first