Skip to content

fix(copilot): allow loop-in-loop workflow edits#3723

Merged
TheodoreSpeaks merged 3 commits intostagingfrom
fix/loop-in-loop
Mar 23, 2026
Merged

fix(copilot): allow loop-in-loop workflow edits#3723
TheodoreSpeaks merged 3 commits intostagingfrom
fix/loop-in-loop

Conversation

@TheodoreSpeaks
Copy link
Collaborator

Summary

We now support loop in loop. Adjusted edit-workflow to handle this.

  • Added helpers for processNestedNodeForParents, updateLoopOrParallelContainerData
  • Removed throwing on nested loops

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Other: ___________

Testing

Tested with loop in loop and loop in loop in loop

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Screenshots/Videos

@vercel
Copy link

vercel bot commented Mar 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Mar 23, 2026 10:34pm

Request Review

@TheodoreSpeaks TheodoreSpeaks marked this pull request as ready for review March 23, 2026 22:21
@cursor
Copy link

cursor bot commented Mar 23, 2026

PR Summary

Medium Risk
Changes core workflow-edit merge logic for nestedNodes, including recursive updates and subtree deletions, which could impact how existing blocks/edges are preserved or removed during edits.

Overview
Enables nested containers (loop/parallel inside loop/parallel) to be created and edited via nestedNodes, replacing the previous behavior that skipped/disallowed nested subflows.

Refactors container handling into shared helpers and makes nestedNodes merging recursive: matched children are updated in place (preserving IDs), new descendants are created, and omitted descendants are removed along with their connected edges.

Adds tests covering loop-in-loop editing, grandchild ID preservation, and descendant/edge cleanup when nested nodes are omitted or replaced.

Written by Cursor Bugbot for commit 0fd55db. This will update automatically on new commits. Configure here.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 23, 2026

Greptile Summary

This PR enables loop-in-loop (and deeper nesting) workflow editing by refactoring the handleEditOperation, handleAddOperation, and handleInsertIntoSubflowOperation paths in operations.ts. The previous code rejected any attempt to nest a loop/parallel inside another loop/parallel; now three helpers — processNestedNodesForParent, mergeNestedNodesForParent, and applyLoopOrParallelContainerData / updateLoopOrParallelContainerData — recursively traverse and apply incoming nested-node diffs.

Key changes:

  • Removed the nested_subflow_not_allowed guard in handleEditOperation, handleAddOperation, and handleInsertIntoSubflowOperation
  • Extracted the container-data update logic into applyLoopOrParallelContainerData (for new blocks) and updateLoopOrParallelContainerData (for existing blocks), eliminating ~80 lines of duplicated if/else
  • mergeNestedNodesForParent now recursively calls itself for matched nested containers, propagating the merge logic to any depth
  • One unaddressed bug: when a nested container is removed (not matched by name) in mergeNestedNodesForParent, its own children are not cleaned up, leaving orphaned blocks and edges in the workflow state. A recursive descent on removed containers is needed in the removedIds loop (lines 300–312 of operations.ts)
  • Test coverage confirms the happy path (update + grandchild removal within a matched inner loop), but the orphaned-grandchild scenario (inner loop removed by name-mismatch) is not yet covered

Confidence Score: 3/5

  • Safe to merge once the orphaned-grandchild cleanup bug is resolved; all other refactoring is clean and well-tested.
  • The refactoring is well-structured and the two new test cases cover the primary use cases. However, a concrete data-integrity bug exists: removing a nested container block does not recursively remove its children, leaving orphaned blocks and edges in the persisted workflow state. This can affect both the React Flow UI and the executor in production for any workflow where an inner loop is renamed or replaced during an edit.
  • apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts — specifically the removedIds cleanup block in mergeNestedNodesForParent (lines 300–312)

Important Files Changed

Filename Overview
apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts Adds mergeNestedNodesForParent, processNestedNodesForParent, and applyLoopOrParallelContainerData helpers to enable recursive loop-in-loop edits, and removes the previous hard block on nesting subflows. One bug: mergeNestedNodesForParent does not recursively delete grandchildren when a nested container block is itself removed (unmatched by name), leaving orphaned blocks and edges in state.
apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts Adds a makeNestedLoopWorkflow fixture and two new test cases: recursive update preserving grandchild IDs, and removal of grandchildren omitted from an inner loop edit. Both tests pass. The orphaned-grandchild scenario (inner loop removed by name-mismatch) is not exercised.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[handleEditOperation\nparams.nestedNodes?] --> B[mergeNestedNodesForParent\nparentBlockId, nestedNodes]
    B --> C{For each incoming\nnestedNode}
    C --> D{Match by name in\nexisting children?}
    D -- Yes --> E[Update existing block\nsubBlocks / inputs]
    E --> F{Existing block is\nloop or parallel?}
    F -- Yes --> G[updateLoopOrParallelContainerData]
    G --> H{childBlock has\nnestedNodes?}
    H -- Yes --> B
    H -- No --> C
    F -- No --> C
    D -- No --> I[createBlockFromParams\nnew child block]
    I --> J{New child is\nloop or parallel?}
    J -- Yes --> K[applyLoopOrParallelContainerData\nprocessNestedNodesForParent]
    J -- No --> C
    C -- Done --> L[Remove unmatched\nexisting children\nfrom blocks + edges]
    L --> M{⚠️ Removed child is\nloop or parallel?}
    M -- Yes --> N[BUG: grandchildren\nnot cleaned up]
    M -- No --> O[Done]
Loading

Reviews (1): Last reviewed commit: "Fix lint" | Re-trigger Greptile

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Removing nested loop orphans its descendant blocks
    • Updated nested merge deletion to recursively collect and remove unmatched descendants (and their edges), and added a regression test covering nested container removal.

Create PR

Or push these changes by commenting:

@cursor push ed6ee626fd
Preview (ed6ee626fd)
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
--- 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
@@ -408,4 +408,39 @@
     const helperBlock = Object.values(state.blocks).find((block: any) => block.name === 'Helper')
     expect(helperBlock).toBeDefined()
   })
+
+  it('removes descendants when a nested container child is removed', () => {
+    const workflow = makeNestedLoopWorkflow()
+
+    const { state } = applyOperationsToWorkflowState(workflow, [
+      {
+        operation_type: 'edit',
+        block_id: 'outer-loop',
+        params: {
+          nestedNodes: {
+            replacement: {
+              type: 'function',
+              name: 'Replacement Helper',
+              inputs: { code: 'return 1' },
+            },
+          },
+        },
+      },
+    ])
+
+    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')
+    ).toBe(false)
+    expect(
+      state.edges.some((edge: any) => edge.source === 'inner-agent' || edge.target === 'inner-agent')
+    ).toBe(false)
+
+    const replacementBlock = Object.values(state.blocks).find(
+      (block: any) => block.name === 'Replacement Helper'
+    ) 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
--- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts
+++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts
@@ -298,14 +298,23 @@
   })
 
   const removedIds = new Set<string>()
+  const findChildrenToRemove = (parentId: string) => {
+    Object.entries(modifiedState.blocks).forEach(([childId, child]: [string, any]) => {
+      if (child.data?.parentId === parentId && !removedIds.has(childId)) {
+        removedIds.add(childId)
+        findChildrenToRemove(childId)
+      }
+    })
+  }
   for (const [existingId] of existingChildren) {
     if (!matchedExistingIds.has(existingId)) {
-      delete modifiedState.blocks[existingId]
       removedIds.add(existingId)
+      findChildrenToRemove(existingId)
     }
   }
 
   if (removedIds.size > 0) {
+    removedIds.forEach((id) => delete modifiedState.blocks[id])
     modifiedState.edges = modifiedState.edges.filter(
       (edge: any) => !removedIds.has(edge.source) && !removedIds.has(edge.target)
     )

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@TheodoreSpeaks TheodoreSpeaks merged commit 288aa08 into staging Mar 23, 2026
11 of 12 checks passed
@TheodoreSpeaks TheodoreSpeaks deleted the fix/loop-in-loop branch March 23, 2026 23:16
@icecrasher321 icecrasher321 changed the title Allow loop-in-loop workflow edits fix(copilot): allow loop-in-loop workflow edits Mar 24, 2026
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.


if (childBlock.nestedNodes && (childBlock.type === 'loop' || childBlock.type === 'parallel')) {
processNestedNodesForParent(childId, childBlock.nestedNodes, ctx)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated block creation logic across two new functions

Low Severity

The "create new child block" path inside mergeNestedNodesForParent (the else branch for unmatched nodes) is a near-exact copy of the body of processNestedNodesForParent: both call createBlockFromParams, conditionally call applyLoopOrParallelContainerData, add to modifiedState.blocks, push deferred connections, and recurse via processNestedNodesForParent. If a bug is fixed in one copy, the other could easily be missed.

Additional Locations (1)
Fix in Cursor Fix in Web

(existingBlock.type === 'loop' || existingBlock.type === 'parallel')
) {
mergeNestedNodesForParent(existingId, childBlock.nestedNodes, ctx)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Credential pre-validation skips deeply nested blocks

Medium Severity

The new recursive mergeNestedNodesForParent and processNestedNodesForParent functions correctly process arbitrarily nested nestedNodes, but preValidateCredentialInputs in validation.ts only iterates one level of nestedNodes. Credentials (e.g., oauth-input IDs) inside a deeply nested block—such as an agent inside an inner loop inside an outer loop—will bypass the ownership pre-validation check that guards against unauthorized credential references.

Additional Locations (1)
Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant