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:
Steve Yegge
2025-12-25 14:51:37 -08:00
parent 2ff17686d0
commit 4f065024b5
4 changed files with 852 additions and 0 deletions

View File

@@ -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)

View 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
}

View 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))
}
}

View File

@@ -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.