feat: Add nested loop support in control flow (gt-zn35j)
Enables loops within loop bodies to be properly expanded: - Copy Loop field in expandLoopIteration (was intentionally omitted) - Add cloneLoopSpec for deep copying of LoopSpec with body steps - Recursively call ApplyLoops at end of expandLoop to expand nested loops - Also copy OnComplete field for consistency Nested IDs follow natural chaining pattern: outer.iter1.inner.iter2.step Tested with 2-level and 3-level nesting scenarios. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -129,7 +129,8 @@ func expandLoop(step *Step) ([]*Step, error) {
|
||||
result = iterSteps
|
||||
}
|
||||
|
||||
return result, nil
|
||||
// Recursively expand any nested loops in the result (gt-zn35j)
|
||||
return ApplyLoops(result)
|
||||
}
|
||||
|
||||
// expandLoopIteration expands a single iteration of a loop.
|
||||
@@ -155,7 +156,8 @@ func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
|
||||
WaitsFor: bodyStep.WaitsFor,
|
||||
Expand: bodyStep.Expand,
|
||||
Gate: bodyStep.Gate,
|
||||
// Note: Loop field intentionally not copied - nested loops need explicit support
|
||||
Loop: cloneLoopSpec(bodyStep.Loop), // Support nested loops (gt-zn35j)
|
||||
OnComplete: cloneOnComplete(bodyStep.OnComplete),
|
||||
}
|
||||
|
||||
// Clone ExpandVars if present
|
||||
@@ -408,3 +410,22 @@ func cloneStepDeep(s *Step) *Step {
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// cloneLoopSpec creates a deep copy of a LoopSpec (gt-zn35j).
|
||||
func cloneLoopSpec(loop *LoopSpec) *LoopSpec {
|
||||
if loop == nil {
|
||||
return nil
|
||||
}
|
||||
clone := &LoopSpec{
|
||||
Count: loop.Count,
|
||||
Until: loop.Until,
|
||||
Max: loop.Max,
|
||||
}
|
||||
if len(loop.Body) > 0 {
|
||||
clone.Body = make([]*Step, len(loop.Body))
|
||||
for i, step := range loop.Body {
|
||||
clone.Body[i] = cloneStepDeep(step)
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
@@ -507,3 +507,164 @@ func TestApplyLoops_NestedChildren(t *testing.T) {
|
||||
t.Errorf("Expected 1 child, got %d", len(result[0].Children))
|
||||
}
|
||||
}
|
||||
|
||||
// gt-zn35j: Tests for nested loop support
|
||||
|
||||
func TestApplyLoops_NestedLoops(t *testing.T) {
|
||||
// Create a loop containing another loop
|
||||
steps := []*Step{
|
||||
{
|
||||
ID: "outer",
|
||||
Title: "Outer loop",
|
||||
Loop: &LoopSpec{
|
||||
Count: 2,
|
||||
Body: []*Step{
|
||||
{
|
||||
ID: "inner",
|
||||
Title: "Inner loop",
|
||||
Loop: &LoopSpec{
|
||||
Count: 2,
|
||||
Body: []*Step{
|
||||
{ID: "work", Title: "Do work"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyLoops(steps)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLoops failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have 4 steps total (2 outer * 2 inner)
|
||||
if len(result) != 4 {
|
||||
t.Errorf("Expected 4 steps, got %d", len(result))
|
||||
for i, s := range result {
|
||||
t.Logf(" Step %d: %s", i, s.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Check step IDs follow nested pattern
|
||||
expectedIDs := []string{
|
||||
"outer.iter1.inner.iter1.work",
|
||||
"outer.iter1.inner.iter2.work",
|
||||
"outer.iter2.inner.iter1.work",
|
||||
"outer.iter2.inner.iter2.work",
|
||||
}
|
||||
|
||||
for i, expected := range expectedIDs {
|
||||
if i >= len(result) {
|
||||
t.Errorf("Missing step %d: %s", i, expected)
|
||||
continue
|
||||
}
|
||||
if result[i].ID != expected {
|
||||
t.Errorf("Step %d: expected ID %s, got %s", i, expected, result[i].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLoops_NestedLoopsWithDependencies(t *testing.T) {
|
||||
// Nested loops with dependencies between inner steps
|
||||
steps := []*Step{
|
||||
{
|
||||
ID: "outer",
|
||||
Title: "Outer loop",
|
||||
Loop: &LoopSpec{
|
||||
Count: 2,
|
||||
Body: []*Step{
|
||||
{
|
||||
ID: "inner",
|
||||
Title: "Inner loop",
|
||||
Loop: &LoopSpec{
|
||||
Count: 2,
|
||||
Body: []*Step{
|
||||
{ID: "fetch", Title: "Fetch data"},
|
||||
{ID: "process", Title: "Process data", Needs: []string{"fetch"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyLoops(steps)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLoops failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have 8 steps (2 outer * 2 inner * 2 body steps)
|
||||
if len(result) != 8 {
|
||||
t.Errorf("Expected 8 steps, got %d", len(result))
|
||||
}
|
||||
|
||||
// Check that inner dependencies are correctly prefixed
|
||||
// Find outer.iter1.inner.iter1.process - should depend on outer.iter1.inner.iter1.fetch
|
||||
var process1 *Step
|
||||
for _, s := range result {
|
||||
if s.ID == "outer.iter1.inner.iter1.process" {
|
||||
process1 = s
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if process1 == nil {
|
||||
t.Fatal("outer.iter1.inner.iter1.process not found")
|
||||
}
|
||||
|
||||
if len(process1.Needs) != 1 || process1.Needs[0] != "outer.iter1.inner.iter1.fetch" {
|
||||
t.Errorf("process should need outer.iter1.inner.iter1.fetch, got %v", process1.Needs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLoops_ThreeLevelNesting(t *testing.T) {
|
||||
// Three levels of nesting
|
||||
steps := []*Step{
|
||||
{
|
||||
ID: "l1",
|
||||
Loop: &LoopSpec{
|
||||
Count: 2,
|
||||
Body: []*Step{
|
||||
{
|
||||
ID: "l2",
|
||||
Loop: &LoopSpec{
|
||||
Count: 2,
|
||||
Body: []*Step{
|
||||
{
|
||||
ID: "l3",
|
||||
Loop: &LoopSpec{
|
||||
Count: 2,
|
||||
Body: []*Step{
|
||||
{ID: "leaf", Title: "Leaf step"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyLoops(steps)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLoops failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have 8 steps (2 * 2 * 2)
|
||||
if len(result) != 8 {
|
||||
t.Errorf("Expected 8 steps, got %d", len(result))
|
||||
}
|
||||
|
||||
// Check first and last step IDs
|
||||
if result[0].ID != "l1.iter1.l2.iter1.l3.iter1.leaf" {
|
||||
t.Errorf("First step ID wrong: %s", result[0].ID)
|
||||
}
|
||||
if result[7].ID != "l1.iter2.l2.iter2.l3.iter2.leaf" {
|
||||
t.Errorf("Last step ID wrong: %s", result[7].ID)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user