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
|
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.
|
// expandLoopIteration expands a single iteration of a loop.
|
||||||
@@ -155,7 +156,8 @@ func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
|
|||||||
WaitsFor: bodyStep.WaitsFor,
|
WaitsFor: bodyStep.WaitsFor,
|
||||||
Expand: bodyStep.Expand,
|
Expand: bodyStep.Expand,
|
||||||
Gate: bodyStep.Gate,
|
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
|
// Clone ExpandVars if present
|
||||||
@@ -408,3 +410,22 @@ func cloneStepDeep(s *Step) *Step {
|
|||||||
|
|
||||||
return clone
|
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))
|
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