feat: Implement control flow operators (gt-8tmz.4)
Add loop, branch, and gate operators for formula step transformation: - LoopSpec: Fixed-count loops expand body N times with chained iterations. Conditional loops expand once with runtime metadata labels. - BranchRule: Fork-join patterns wire dependencies for parallel paths. - GateRule: Adds condition labels for runtime evaluation before steps. Types added to types.go: - LoopSpec (count/until/max/body) - BranchRule (from/steps/join) - GateRule (before/condition) - Loop field on Step - Branch/Gate arrays on ComposeRules New controlflow.go with ApplyLoops, ApplyBranches, ApplyGates, and ApplyControlFlow convenience function. Wired into cook.go before advice and expansion operators. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,15 @@ func runCook(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Apply control flow operators (gt-8tmz.4) - loops, branches, gates
|
||||
// This must happen before advice and expansions so they can act on expanded loop steps
|
||||
controlFlowSteps, err := formula.ApplyControlFlow(resolved.Steps, resolved.Compose)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error applying control flow: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
resolved.Steps = controlFlowSteps
|
||||
|
||||
// Apply advice transformations (gt-8tmz.2)
|
||||
if len(resolved.Advice) > 0 {
|
||||
resolved.Steps = formula.ApplyAdvice(resolved.Steps, resolved.Advice)
|
||||
|
||||
342
internal/formula/controlflow.go
Normal file
342
internal/formula/controlflow.go
Normal file
@@ -0,0 +1,342 @@
|
||||
// Package formula provides control flow operators for step transformation.
|
||||
//
|
||||
// Control flow operators (gt-8tmz.4) enable:
|
||||
// - loop: Repeat a body of steps (fixed count or conditional)
|
||||
// - branch: Fork-join parallel execution patterns
|
||||
// - gate: Conditional waits before steps proceed
|
||||
//
|
||||
// These operators are applied during formula cooking to transform
|
||||
// the step graph before creating the proto bead.
|
||||
package formula
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ApplyLoops expands loop bodies in a formula's steps.
|
||||
// Fixed-count loops expand the body N times with indexed step IDs.
|
||||
// Conditional loops expand once and add a "loop:until" label for runtime evaluation.
|
||||
// Returns a new steps slice with loops expanded.
|
||||
func ApplyLoops(steps []*Step) ([]*Step, error) {
|
||||
result := make([]*Step, 0, len(steps))
|
||||
|
||||
for _, step := range steps {
|
||||
if step.Loop == nil {
|
||||
// No loop - recursively process children
|
||||
clone := cloneStep(step)
|
||||
if len(step.Children) > 0 {
|
||||
children, err := ApplyLoops(step.Children)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clone.Children = children
|
||||
}
|
||||
result = append(result, clone)
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate loop spec
|
||||
if err := validateLoopSpec(step.Loop, step.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Expand the loop
|
||||
expanded, err := expandLoop(step)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, expanded...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// validateLoopSpec checks that a loop spec is valid.
|
||||
func validateLoopSpec(loop *LoopSpec, stepID string) error {
|
||||
if len(loop.Body) == 0 {
|
||||
return fmt.Errorf("loop %q: body is required", stepID)
|
||||
}
|
||||
|
||||
if loop.Count > 0 && loop.Until != "" {
|
||||
return fmt.Errorf("loop %q: cannot have both count and until", stepID)
|
||||
}
|
||||
|
||||
if loop.Count == 0 && loop.Until == "" {
|
||||
return fmt.Errorf("loop %q: either count or until is required", stepID)
|
||||
}
|
||||
|
||||
if loop.Until != "" && loop.Max == 0 {
|
||||
return fmt.Errorf("loop %q: max is required when until is set", stepID)
|
||||
}
|
||||
|
||||
if loop.Count < 0 {
|
||||
return fmt.Errorf("loop %q: count must be positive", stepID)
|
||||
}
|
||||
|
||||
if loop.Max < 0 {
|
||||
return fmt.Errorf("loop %q: max must be positive", stepID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandLoop expands a loop step into its constituent steps.
|
||||
func expandLoop(step *Step) ([]*Step, error) {
|
||||
var result []*Step
|
||||
|
||||
if step.Loop.Count > 0 {
|
||||
// Fixed-count loop: expand body N times
|
||||
for i := 1; i <= step.Loop.Count; i++ {
|
||||
iterSteps, err := expandLoopIteration(step, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, iterSteps...)
|
||||
}
|
||||
|
||||
// Chain iterations: each iteration depends on previous
|
||||
if len(step.Loop.Body) > 0 && step.Loop.Count > 1 {
|
||||
result = chainLoopIterations(result, step.Loop.Body, step.Loop.Count)
|
||||
}
|
||||
} else {
|
||||
// Conditional loop: expand once with loop metadata
|
||||
// The runtime executor will re-run until condition is met or max reached
|
||||
iterSteps, err := expandLoopIteration(step, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add loop metadata to first step for runtime evaluation
|
||||
if len(iterSteps) > 0 {
|
||||
firstStep := iterSteps[0]
|
||||
// Add labels for runtime loop control
|
||||
firstStep.Labels = append(firstStep.Labels, fmt.Sprintf("loop:until:%s", step.Loop.Until))
|
||||
firstStep.Labels = append(firstStep.Labels, fmt.Sprintf("loop:max:%d", step.Loop.Max))
|
||||
}
|
||||
|
||||
result = iterSteps
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// expandLoopIteration expands a single iteration of a loop.
|
||||
// The iteration index is used to generate unique step IDs.
|
||||
func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
|
||||
result := make([]*Step, 0, len(step.Loop.Body))
|
||||
|
||||
for _, bodyStep := range step.Loop.Body {
|
||||
// Create unique ID for this iteration
|
||||
iterID := fmt.Sprintf("%s.iter%d.%s", step.ID, iteration, bodyStep.ID)
|
||||
|
||||
clone := &Step{
|
||||
ID: iterID,
|
||||
Title: bodyStep.Title,
|
||||
Description: bodyStep.Description,
|
||||
Type: bodyStep.Type,
|
||||
Priority: bodyStep.Priority,
|
||||
Assignee: bodyStep.Assignee,
|
||||
Condition: bodyStep.Condition,
|
||||
WaitsFor: bodyStep.WaitsFor,
|
||||
}
|
||||
|
||||
// Clone labels
|
||||
if len(bodyStep.Labels) > 0 {
|
||||
clone.Labels = make([]string, len(bodyStep.Labels))
|
||||
copy(clone.Labels, bodyStep.Labels)
|
||||
}
|
||||
|
||||
// Clone dependencies - prefix with iteration context
|
||||
if len(bodyStep.DependsOn) > 0 {
|
||||
clone.DependsOn = make([]string, len(bodyStep.DependsOn))
|
||||
for i, dep := range bodyStep.DependsOn {
|
||||
clone.DependsOn[i] = fmt.Sprintf("%s.iter%d.%s", step.ID, iteration, dep)
|
||||
}
|
||||
}
|
||||
|
||||
if len(bodyStep.Needs) > 0 {
|
||||
clone.Needs = make([]string, len(bodyStep.Needs))
|
||||
for i, need := range bodyStep.Needs {
|
||||
clone.Needs[i] = fmt.Sprintf("%s.iter%d.%s", step.ID, iteration, need)
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively handle children
|
||||
if len(bodyStep.Children) > 0 {
|
||||
children := make([]*Step, 0, len(bodyStep.Children))
|
||||
for _, child := range bodyStep.Children {
|
||||
childClone := cloneStepDeep(child)
|
||||
childClone.ID = fmt.Sprintf("%s.iter%d.%s", step.ID, iteration, child.ID)
|
||||
children = append(children, childClone)
|
||||
}
|
||||
clone.Children = children
|
||||
}
|
||||
|
||||
result = append(result, clone)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// chainLoopIterations adds dependencies between loop iterations.
|
||||
// Each iteration's first step depends on the previous iteration's last step.
|
||||
func chainLoopIterations(steps []*Step, body []*Step, count int) []*Step {
|
||||
if len(body) == 0 || count < 2 {
|
||||
return steps
|
||||
}
|
||||
|
||||
stepsPerIter := len(body)
|
||||
|
||||
for iter := 2; iter <= count; iter++ {
|
||||
// First step of this iteration
|
||||
firstIdx := (iter - 1) * stepsPerIter
|
||||
// Last step of previous iteration
|
||||
lastStep := steps[(iter-2)*stepsPerIter+stepsPerIter-1]
|
||||
|
||||
if firstIdx < len(steps) {
|
||||
steps[firstIdx].Needs = appendUnique(steps[firstIdx].Needs, lastStep.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return steps
|
||||
}
|
||||
|
||||
// ApplyBranches wires fork-join dependency patterns.
|
||||
// For each branch rule:
|
||||
// - All branch steps depend on the 'from' step
|
||||
// - The 'join' step depends on all branch steps
|
||||
//
|
||||
// Returns the modified steps slice (steps are modified in place for dependencies).
|
||||
func ApplyBranches(steps []*Step, compose *ComposeRules) ([]*Step, error) {
|
||||
if compose == nil || len(compose.Branch) == 0 {
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// Build step map for quick lookup
|
||||
stepMap := buildStepMap(steps)
|
||||
|
||||
for _, branch := range compose.Branch {
|
||||
// Validate the branch rule
|
||||
if branch.From == "" {
|
||||
return nil, fmt.Errorf("branch: from is required")
|
||||
}
|
||||
if len(branch.Steps) == 0 {
|
||||
return nil, fmt.Errorf("branch: steps is required")
|
||||
}
|
||||
if branch.Join == "" {
|
||||
return nil, fmt.Errorf("branch: join is required")
|
||||
}
|
||||
|
||||
// Verify all steps exist
|
||||
if _, ok := stepMap[branch.From]; !ok {
|
||||
return nil, fmt.Errorf("branch: from step %q not found", branch.From)
|
||||
}
|
||||
if _, ok := stepMap[branch.Join]; !ok {
|
||||
return nil, fmt.Errorf("branch: join step %q not found", branch.Join)
|
||||
}
|
||||
for _, stepID := range branch.Steps {
|
||||
if _, ok := stepMap[stepID]; !ok {
|
||||
return nil, fmt.Errorf("branch: parallel step %q not found", stepID)
|
||||
}
|
||||
}
|
||||
|
||||
// Add dependencies: branch steps depend on 'from'
|
||||
for _, stepID := range branch.Steps {
|
||||
step := stepMap[stepID]
|
||||
step.Needs = appendUnique(step.Needs, branch.From)
|
||||
}
|
||||
|
||||
// Add dependencies: 'join' depends on all branch steps
|
||||
joinStep := stepMap[branch.Join]
|
||||
for _, stepID := range branch.Steps {
|
||||
joinStep.Needs = appendUnique(joinStep.Needs, stepID)
|
||||
}
|
||||
}
|
||||
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// ApplyGates adds gate conditions to steps.
|
||||
// For each gate rule:
|
||||
// - The target step gets a "gate:condition" label
|
||||
// - At runtime, the patrol executor evaluates the condition
|
||||
//
|
||||
// Returns the modified steps slice.
|
||||
func ApplyGates(steps []*Step, compose *ComposeRules) ([]*Step, error) {
|
||||
if compose == nil || len(compose.Gate) == 0 {
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// Build step map for quick lookup
|
||||
stepMap := buildStepMap(steps)
|
||||
|
||||
for _, gate := range compose.Gate {
|
||||
// Validate the gate rule
|
||||
if gate.Before == "" {
|
||||
return nil, fmt.Errorf("gate: before is required")
|
||||
}
|
||||
if gate.Condition == "" {
|
||||
return nil, fmt.Errorf("gate: condition is required")
|
||||
}
|
||||
|
||||
// Validate the condition syntax
|
||||
_, err := ParseCondition(gate.Condition)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gate: invalid condition %q: %w", gate.Condition, err)
|
||||
}
|
||||
|
||||
// Find the target step
|
||||
step, ok := stepMap[gate.Before]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("gate: target step %q not found", gate.Before)
|
||||
}
|
||||
|
||||
// Add gate label for runtime evaluation
|
||||
gateLabel := fmt.Sprintf("gate:condition:%s", gate.Condition)
|
||||
step.Labels = appendUnique(step.Labels, gateLabel)
|
||||
}
|
||||
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// ApplyControlFlow applies all control flow operators in the correct order:
|
||||
// 1. Loops (expand iterations)
|
||||
// 2. Branches (wire fork-join dependencies)
|
||||
// 3. Gates (add condition labels)
|
||||
func ApplyControlFlow(steps []*Step, compose *ComposeRules) ([]*Step, error) {
|
||||
var err error
|
||||
|
||||
// Apply loops first (expands steps)
|
||||
steps, err = ApplyLoops(steps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("applying loops: %w", err)
|
||||
}
|
||||
|
||||
// Apply branches (wires dependencies)
|
||||
steps, err = ApplyBranches(steps, compose)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("applying branches: %w", err)
|
||||
}
|
||||
|
||||
// Apply gates (adds labels)
|
||||
steps, err = ApplyGates(steps, compose)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("applying gates: %w", err)
|
||||
}
|
||||
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// cloneStepDeep creates a deep copy of a step including children.
|
||||
func cloneStepDeep(s *Step) *Step {
|
||||
clone := cloneStep(s)
|
||||
|
||||
if len(s.Children) > 0 {
|
||||
clone.Children = make([]*Step, len(s.Children))
|
||||
for i, child := range s.Children {
|
||||
clone.Children[i] = cloneStepDeep(child)
|
||||
}
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
442
internal/formula/controlflow_test.go
Normal file
442
internal/formula/controlflow_test.go
Normal file
@@ -0,0 +1,442 @@
|
||||
package formula
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyLoops_FixedCount(t *testing.T) {
|
||||
// Create a step with a fixed-count loop
|
||||
steps := []*Step{
|
||||
{
|
||||
ID: "process",
|
||||
Title: "Process items",
|
||||
Loop: &LoopSpec{
|
||||
Count: 3,
|
||||
Body: []*Step{
|
||||
{ID: "fetch", Title: "Fetch item"},
|
||||
{ID: "transform", Title: "Transform item", Needs: []string{"fetch"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyLoops(steps)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLoops failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have 6 steps (3 iterations * 2 steps each)
|
||||
if len(result) != 6 {
|
||||
t.Errorf("Expected 6 steps, got %d", len(result))
|
||||
}
|
||||
|
||||
// Check step IDs
|
||||
expectedIDs := []string{
|
||||
"process.iter1.fetch",
|
||||
"process.iter1.transform",
|
||||
"process.iter2.fetch",
|
||||
"process.iter2.transform",
|
||||
"process.iter3.fetch",
|
||||
"process.iter3.transform",
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that inner dependencies are preserved (within same iteration)
|
||||
transform1 := result[1]
|
||||
if len(transform1.Needs) != 1 || transform1.Needs[0] != "process.iter1.fetch" {
|
||||
t.Errorf("transform1 should need process.iter1.fetch, got %v", transform1.Needs)
|
||||
}
|
||||
|
||||
// Check that iterations are chained (iter2 depends on iter1)
|
||||
fetch2 := result[2]
|
||||
if len(fetch2.Needs) != 1 || fetch2.Needs[0] != "process.iter1.transform" {
|
||||
t.Errorf("iter2.fetch should need iter1.transform, got %v", fetch2.Needs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLoops_Conditional(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{
|
||||
ID: "retry",
|
||||
Title: "Retry operation",
|
||||
Loop: &LoopSpec{
|
||||
Until: "step.status == 'complete'",
|
||||
Max: 5,
|
||||
Body: []*Step{
|
||||
{ID: "attempt", Title: "Attempt operation"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyLoops(steps)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLoops failed: %v", err)
|
||||
}
|
||||
|
||||
// Conditional loops expand once (runtime re-executes)
|
||||
if len(result) != 1 {
|
||||
t.Errorf("Expected 1 step for conditional loop, got %d", len(result))
|
||||
}
|
||||
|
||||
// Should have loop metadata labels
|
||||
step := result[0]
|
||||
hasUntil := false
|
||||
hasMax := false
|
||||
for _, label := range step.Labels {
|
||||
if label == "loop:until:step.status == 'complete'" {
|
||||
hasUntil = true
|
||||
}
|
||||
if label == "loop:max:5" {
|
||||
hasMax = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUntil {
|
||||
t.Error("Missing loop:until label")
|
||||
}
|
||||
if !hasMax {
|
||||
t.Error("Missing loop:max label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLoops_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loop *LoopSpec
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty body",
|
||||
loop: &LoopSpec{Count: 3, Body: nil},
|
||||
wantErr: "body is required",
|
||||
},
|
||||
{
|
||||
name: "both count and until",
|
||||
loop: &LoopSpec{Count: 3, Until: "cond", Max: 5, Body: []*Step{{ID: "a", Title: "A"}}},
|
||||
wantErr: "cannot have both count and until",
|
||||
},
|
||||
{
|
||||
name: "neither count nor until",
|
||||
loop: &LoopSpec{Body: []*Step{{ID: "a", Title: "A"}}},
|
||||
wantErr: "either count or until is required",
|
||||
},
|
||||
{
|
||||
name: "until without max",
|
||||
loop: &LoopSpec{Until: "cond", Body: []*Step{{ID: "a", Title: "A"}}},
|
||||
wantErr: "max is required when until is set",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
steps := []*Step{{ID: "test", Title: "Test", Loop: tt.loop}}
|
||||
_, err := ApplyLoops(steps)
|
||||
if err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
} else if tt.wantErr != "" && err.Error() != "" {
|
||||
// Just check that an error was returned
|
||||
// The exact message format may vary
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBranches(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{ID: "setup", Title: "Setup"},
|
||||
{ID: "test", Title: "Run tests"},
|
||||
{ID: "lint", Title: "Run linter"},
|
||||
{ID: "build", Title: "Build"},
|
||||
{ID: "deploy", Title: "Deploy"},
|
||||
}
|
||||
|
||||
compose := &ComposeRules{
|
||||
Branch: []*BranchRule{
|
||||
{
|
||||
From: "setup",
|
||||
Steps: []string{"test", "lint", "build"},
|
||||
Join: "deploy",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyBranches(steps, compose)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyBranches failed: %v", err)
|
||||
}
|
||||
|
||||
// Build step map for checking
|
||||
stepMap := make(map[string]*Step)
|
||||
for _, s := range result {
|
||||
stepMap[s.ID] = s
|
||||
}
|
||||
|
||||
// Verify branch steps depend on 'from'
|
||||
for _, branchStep := range []string{"test", "lint", "build"} {
|
||||
s := stepMap[branchStep]
|
||||
found := false
|
||||
for _, need := range s.Needs {
|
||||
if need == "setup" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Step %s should need 'setup', got %v", branchStep, s.Needs)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify 'join' depends on all branch steps
|
||||
deploy := stepMap["deploy"]
|
||||
for _, branchStep := range []string{"test", "lint", "build"} {
|
||||
found := false
|
||||
for _, need := range deploy.Needs {
|
||||
if need == branchStep {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("deploy should need %s, got %v", branchStep, deploy.Needs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBranches_Validation(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{ID: "a", Title: "A"},
|
||||
{ID: "b", Title: "B"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
branch *BranchRule
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing from",
|
||||
branch: &BranchRule{Steps: []string{"a"}, Join: "b"},
|
||||
wantErr: "from is required",
|
||||
},
|
||||
{
|
||||
name: "missing steps",
|
||||
branch: &BranchRule{From: "a", Join: "b"},
|
||||
wantErr: "steps is required",
|
||||
},
|
||||
{
|
||||
name: "missing join",
|
||||
branch: &BranchRule{From: "a", Steps: []string{"b"}},
|
||||
wantErr: "join is required",
|
||||
},
|
||||
{
|
||||
name: "from not found",
|
||||
branch: &BranchRule{From: "notfound", Steps: []string{"a"}, Join: "b"},
|
||||
wantErr: "from step \"notfound\" not found",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
compose := &ComposeRules{Branch: []*BranchRule{tt.branch}}
|
||||
_, err := ApplyBranches(steps, compose)
|
||||
if err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyGates(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{ID: "tests", Title: "Run tests"},
|
||||
{ID: "deploy", Title: "Deploy to production"},
|
||||
}
|
||||
|
||||
compose := &ComposeRules{
|
||||
Gate: []*GateRule{
|
||||
{
|
||||
Before: "deploy",
|
||||
Condition: "tests.status == 'complete'",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyGates(steps, compose)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyGates failed: %v", err)
|
||||
}
|
||||
|
||||
// Find deploy step
|
||||
var deploy *Step
|
||||
for _, s := range result {
|
||||
if s.ID == "deploy" {
|
||||
deploy = s
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if deploy == nil {
|
||||
t.Fatal("deploy step not found")
|
||||
}
|
||||
|
||||
// Check for gate label
|
||||
found := false
|
||||
expectedLabel := "gate:condition:tests.status == 'complete'"
|
||||
for _, label := range deploy.Labels {
|
||||
if label == expectedLabel {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("deploy should have gate label, got %v", deploy.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyGates_InvalidCondition(t *testing.T) {
|
||||
steps := []*Step{
|
||||
{ID: "deploy", Title: "Deploy"},
|
||||
}
|
||||
|
||||
compose := &ComposeRules{
|
||||
Gate: []*GateRule{
|
||||
{
|
||||
Before: "deploy",
|
||||
Condition: "invalid condition syntax ???",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ApplyGates(steps, compose)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid condition, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyControlFlow_Integration(t *testing.T) {
|
||||
// Test the combined ApplyControlFlow function
|
||||
steps := []*Step{
|
||||
{ID: "setup", Title: "Setup"},
|
||||
{
|
||||
ID: "process",
|
||||
Title: "Process items",
|
||||
Loop: &LoopSpec{
|
||||
Count: 2,
|
||||
Body: []*Step{
|
||||
{ID: "item", Title: "Process item"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ID: "cleanup", Title: "Cleanup"},
|
||||
}
|
||||
|
||||
compose := &ComposeRules{
|
||||
Branch: []*BranchRule{
|
||||
{
|
||||
From: "setup",
|
||||
Steps: []string{"process.iter1.item", "process.iter2.item"},
|
||||
Join: "cleanup",
|
||||
},
|
||||
},
|
||||
Gate: []*GateRule{
|
||||
{
|
||||
Before: "cleanup",
|
||||
Condition: "steps.complete >= 2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyControlFlow(steps, compose)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyControlFlow failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have: setup, process.iter1.item, process.iter2.item, cleanup
|
||||
if len(result) != 4 {
|
||||
t.Errorf("Expected 4 steps, got %d", len(result))
|
||||
}
|
||||
|
||||
// Verify cleanup has gate label
|
||||
var cleanup *Step
|
||||
for _, s := range result {
|
||||
if s.ID == "cleanup" {
|
||||
cleanup = s
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cleanup == nil {
|
||||
t.Fatal("cleanup step not found")
|
||||
}
|
||||
|
||||
hasGate := false
|
||||
for _, label := range cleanup.Labels {
|
||||
if label == "gate:condition:steps.complete >= 2" {
|
||||
hasGate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasGate {
|
||||
t.Errorf("cleanup should have gate label, got %v", cleanup.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLoops_NoLoops(t *testing.T) {
|
||||
// Test with steps that have no loops
|
||||
steps := []*Step{
|
||||
{ID: "a", Title: "A"},
|
||||
{ID: "b", Title: "B", Needs: []string{"a"}},
|
||||
}
|
||||
|
||||
result, err := ApplyLoops(steps)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLoops failed: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Errorf("Expected 2 steps, got %d", len(result))
|
||||
}
|
||||
|
||||
// Dependencies should be preserved
|
||||
if len(result[1].Needs) != 1 || result[1].Needs[0] != "a" {
|
||||
t.Errorf("Dependencies not preserved: %v", result[1].Needs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLoops_NestedChildren(t *testing.T) {
|
||||
// Test that children are preserved when recursing
|
||||
steps := []*Step{
|
||||
{
|
||||
ID: "parent",
|
||||
Title: "Parent",
|
||||
Children: []*Step{
|
||||
{ID: "child", Title: "Child"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyLoops(steps)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLoops failed: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("Expected 1 step, got %d", len(result))
|
||||
}
|
||||
|
||||
if len(result[0].Children) != 1 {
|
||||
t.Errorf("Expected 1 child, got %d", len(result[0].Children))
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,10 @@ type Step struct {
|
||||
// Gate defines an async wait condition for this step.
|
||||
// TODO(future): Not yet implemented in bd cook. Will integrate with bd-udsi gates.
|
||||
Gate *Gate `json:"gate,omitempty"`
|
||||
|
||||
// Loop defines iteration for this step (gt-8tmz.4).
|
||||
// When set, the step becomes a container that expands its body.
|
||||
Loop *LoopSpec `json:"loop,omitempty"`
|
||||
}
|
||||
|
||||
// Gate defines an async wait condition (integrates with bd-udsi).
|
||||
@@ -195,6 +199,53 @@ type Gate struct {
|
||||
Timeout string `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// LoopSpec defines iteration over a body of steps (gt-8tmz.4).
|
||||
// Either Count or Until must be specified (not both).
|
||||
type LoopSpec struct {
|
||||
// Count is the fixed number of iterations.
|
||||
// When set, the loop body is expanded Count times.
|
||||
Count int `json:"count,omitempty"`
|
||||
|
||||
// Until is a condition that ends the loop.
|
||||
// Format matches condition evaluator syntax (e.g., "step.status == 'complete'").
|
||||
Until string `json:"until,omitempty"`
|
||||
|
||||
// Max is the maximum iterations for conditional loops.
|
||||
// Required when Until is set, to prevent unbounded loops.
|
||||
Max int `json:"max,omitempty"`
|
||||
|
||||
// Body contains the steps to repeat.
|
||||
Body []*Step `json:"body"`
|
||||
}
|
||||
|
||||
// BranchRule defines parallel execution paths that rejoin (gt-8tmz.4).
|
||||
// Creates a fork-join pattern: from -> [parallel steps] -> join.
|
||||
type BranchRule struct {
|
||||
// From is the step ID that precedes the parallel paths.
|
||||
// All branch steps will depend on this step.
|
||||
From string `json:"from"`
|
||||
|
||||
// Steps are the step IDs that run in parallel.
|
||||
// These steps will all depend on From.
|
||||
Steps []string `json:"steps"`
|
||||
|
||||
// Join is the step ID that follows all parallel paths.
|
||||
// This step will depend on all Steps completing.
|
||||
Join string `json:"join"`
|
||||
}
|
||||
|
||||
// GateRule defines a condition that must be satisfied before a step proceeds (gt-8tmz.4).
|
||||
// Gates are evaluated at runtime by the patrol executor.
|
||||
type GateRule struct {
|
||||
// Before is the step ID that the gate applies to.
|
||||
// The condition must be satisfied before this step can start.
|
||||
Before string `json:"before"`
|
||||
|
||||
// Condition is the expression to evaluate.
|
||||
// Format matches condition evaluator syntax (e.g., "tests.status == 'complete'").
|
||||
Condition string `json:"condition"`
|
||||
}
|
||||
|
||||
// ComposeRules define how formulas can be bonded together.
|
||||
type ComposeRules struct {
|
||||
// BondPoints are named locations where other formulas can attach.
|
||||
@@ -211,6 +262,14 @@ type ComposeRules struct {
|
||||
// Each matching step is replaced by the expanded template steps.
|
||||
Map []*MapRule `json:"map,omitempty"`
|
||||
|
||||
// Branch defines fork-join parallel execution patterns (gt-8tmz.4).
|
||||
// Each rule creates dependencies for parallel paths that rejoin.
|
||||
Branch []*BranchRule `json:"branch,omitempty"`
|
||||
|
||||
// Gate defines conditional waits before steps (gt-8tmz.4).
|
||||
// Each rule adds a condition that must be satisfied at runtime.
|
||||
Gate []*GateRule `json:"gate,omitempty"`
|
||||
|
||||
// Aspects lists aspect formula names to apply to this formula.
|
||||
// Aspects are applied after expansions, adding before/after/around
|
||||
// steps to matching targets based on the aspect's advice rules.
|
||||
|
||||
Reference in New Issue
Block a user