fix(copilot) Allow loop-in-loop workflow edits (#3723)

* Allow loop-in-loop workflow edits

* Fix lint

* Fix orphaned loop-in-loop if parent id not found

---------

Co-authored-by: Theodore Li <theo@sim.ai>
This commit is contained in:
Theodore Li
2026-03-23 19:16:27 -04:00
committed by GitHub
co-authored by Theodore Li
parent 4c8395928a
commit 288aa0851b
2 changed files with 498 additions and 289 deletions
@@ -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: '<start.input.items>',
},
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('<start.input.items>')
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')
})
})
@@ -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<string, any>): 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<string, any>,
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<string, any>): 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<string, any>,
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<string, [string, any]>()
for (const [id, child] of existingChildren) {
existingByName.set(normalizeName(child.name), [id, child])
}
const matchedExistingIds = new Set<string>()
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<string>()
): Set<string> => {
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<string>()
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<string, [string, any]>()
for (const [id, child] of existingChildren) {
existingByName.set(normalizeName(child.name), [id, child])
}
const matchedExistingIds = new Set<string>()
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<string>()
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