feat(formula): Add formula.toml parser and validation

Implement internal/formula package for parsing and validating formula.toml files:
- types.go: Formula, Step, Leg, Aspect, Template, Input, Var structs
- parser.go: TOML parsing using BurntSushi/toml, validation, dependency resolution
- Supports convoy, workflow, expansion, and aspect formula types
- Infers type from content when not explicitly set
- Validates required fields, unique IDs, and dependency references
- Detects circular dependencies in workflow steps
- Provides TopologicalSort and ReadySteps for execution planning

(gt-5chbk)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rictus
2026-01-01 15:00:08 -08:00
committed by Steve Yegge
parent 8726a6d493
commit a395b4e19b
6 changed files with 1046 additions and 0 deletions

1
go.mod
View File

@@ -10,6 +10,7 @@ require (
)
require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect

2
go.sum
View File

@@ -1,3 +1,5 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=

View File

@@ -0,0 +1,97 @@
package formula
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestParseRealFormulas tests parsing actual formula files from the filesystem.
// This is an integration test that validates our parser against real-world files.
func TestParseRealFormulas(t *testing.T) {
// Find formula files - they're in various .beads/formulas directories
formulaDirs := []string{
"/Users/stevey/gt/gastown/polecats/slit/.beads/formulas",
"/Users/stevey/gt/gastown/mayor/rig/.beads/formulas",
}
var formulaFiles []string
for _, dir := range formulaDirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue // Skip if directory doesn't exist
}
for _, e := range entries {
if filepath.Ext(e.Name()) == ".toml" {
formulaFiles = append(formulaFiles, filepath.Join(dir, e.Name()))
}
}
}
if len(formulaFiles) == 0 {
t.Skip("No formula files found to test")
}
// Known files that use advanced features not yet supported:
// - Composition (extends, compose): shiny-enterprise, shiny-secure
// - Aspect-oriented (advice, pointcuts): security-audit
skipAdvanced := map[string]string{
"shiny-enterprise.formula.toml": "uses formula composition (extends)",
"shiny-secure.formula.toml": "uses formula composition (extends)",
"security-audit.formula.toml": "uses aspect-oriented features (advice/pointcuts)",
}
for _, path := range formulaFiles {
t.Run(filepath.Base(path), func(t *testing.T) {
baseName := filepath.Base(path)
if reason, ok := skipAdvanced[baseName]; ok {
t.Skipf("Skipping advanced formula: %s", reason)
return
}
f, err := ParseFile(path)
if err != nil {
// Check if this is a composition formula (has extends)
if strings.Contains(err.Error(), "requires at least one") {
t.Skipf("Skipping: likely a composition formula - %v", err)
return
}
t.Errorf("ParseFile failed: %v", err)
return
}
// Basic sanity checks
if f.Name == "" {
t.Error("Formula name is empty")
}
if !f.Type.IsValid() {
t.Errorf("Invalid formula type: %s", f.Type)
}
// Type-specific checks
switch f.Type {
case TypeConvoy:
if len(f.Legs) == 0 {
t.Error("Convoy formula has no legs")
}
t.Logf("Convoy formula with %d legs", len(f.Legs))
case TypeWorkflow:
if len(f.Steps) == 0 {
t.Error("Workflow formula has no steps")
}
// Test topological sort
order, err := f.TopologicalSort()
if err != nil {
t.Errorf("TopologicalSort failed: %v", err)
}
t.Logf("Workflow formula with %d steps, sorted order: %v", len(f.Steps), order)
case TypeExpansion:
if len(f.Template) == 0 {
t.Error("Expansion formula has no templates")
}
t.Logf("Expansion formula with %d templates", len(f.Template))
}
})
}
}

420
internal/formula/parser.go Normal file
View File

@@ -0,0 +1,420 @@
package formula
import (
"fmt"
"os"
"github.com/BurntSushi/toml"
)
// ParseFile reads and parses a formula.toml file.
func ParseFile(path string) (*Formula, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading formula file: %w", err)
}
return Parse(data)
}
// Parse parses formula.toml content from bytes.
func Parse(data []byte) (*Formula, error) {
var f Formula
if _, err := toml.Decode(string(data), &f); err != nil {
return nil, fmt.Errorf("parsing TOML: %w", err)
}
// Infer type from content if not explicitly set
f.inferType()
if err := f.Validate(); err != nil {
return nil, err
}
return &f, nil
}
// inferType sets the formula type based on content when not explicitly set.
func (f *Formula) inferType() {
if f.Type != "" {
return // Type already set
}
// Infer from content
if len(f.Steps) > 0 {
f.Type = TypeWorkflow
} else if len(f.Legs) > 0 {
f.Type = TypeConvoy
} else if len(f.Template) > 0 {
f.Type = TypeExpansion
} else if len(f.Aspects) > 0 {
f.Type = TypeAspect
}
}
// Validate checks that the formula has all required fields and valid structure.
func (f *Formula) Validate() error {
// Check required common fields
if f.Name == "" {
return fmt.Errorf("formula field is required")
}
if !f.Type.IsValid() {
return fmt.Errorf("invalid formula type %q (must be convoy, workflow, expansion, or aspect)", f.Type)
}
// Type-specific validation
switch f.Type {
case TypeConvoy:
return f.validateConvoy()
case TypeWorkflow:
return f.validateWorkflow()
case TypeExpansion:
return f.validateExpansion()
case TypeAspect:
return f.validateAspect()
}
return nil
}
func (f *Formula) validateConvoy() error {
if len(f.Legs) == 0 {
return fmt.Errorf("convoy formula requires at least one leg")
}
// Check leg IDs are unique
seen := make(map[string]bool)
for _, leg := range f.Legs {
if leg.ID == "" {
return fmt.Errorf("leg missing required id field")
}
if seen[leg.ID] {
return fmt.Errorf("duplicate leg id: %s", leg.ID)
}
seen[leg.ID] = true
}
// Validate synthesis depends_on references valid legs
if f.Synthesis != nil {
for _, dep := range f.Synthesis.DependsOn {
if !seen[dep] {
return fmt.Errorf("synthesis depends_on references unknown leg: %s", dep)
}
}
}
return nil
}
func (f *Formula) validateWorkflow() error {
if len(f.Steps) == 0 {
return fmt.Errorf("workflow formula requires at least one step")
}
// Check step IDs are unique
seen := make(map[string]bool)
for _, step := range f.Steps {
if step.ID == "" {
return fmt.Errorf("step missing required id field")
}
if seen[step.ID] {
return fmt.Errorf("duplicate step id: %s", step.ID)
}
seen[step.ID] = true
}
// Validate step needs references
for _, step := range f.Steps {
for _, need := range step.Needs {
if !seen[need] {
return fmt.Errorf("step %q needs unknown step: %s", step.ID, need)
}
}
}
// Check for cycles
if err := f.checkCycles(); err != nil {
return err
}
return nil
}
func (f *Formula) validateExpansion() error {
if len(f.Template) == 0 {
return fmt.Errorf("expansion formula requires at least one template")
}
// Check template IDs are unique
seen := make(map[string]bool)
for _, tmpl := range f.Template {
if tmpl.ID == "" {
return fmt.Errorf("template missing required id field")
}
if seen[tmpl.ID] {
return fmt.Errorf("duplicate template id: %s", tmpl.ID)
}
seen[tmpl.ID] = true
}
// Validate template needs references
for _, tmpl := range f.Template {
for _, need := range tmpl.Needs {
if !seen[need] {
return fmt.Errorf("template %q needs unknown template: %s", tmpl.ID, need)
}
}
}
return nil
}
func (f *Formula) validateAspect() error {
if len(f.Aspects) == 0 {
return fmt.Errorf("aspect formula requires at least one aspect")
}
// Check aspect IDs are unique
seen := make(map[string]bool)
for _, aspect := range f.Aspects {
if aspect.ID == "" {
return fmt.Errorf("aspect missing required id field")
}
if seen[aspect.ID] {
return fmt.Errorf("duplicate aspect id: %s", aspect.ID)
}
seen[aspect.ID] = true
}
return nil
}
// checkCycles detects circular dependencies in steps.
func (f *Formula) checkCycles() error {
// Build adjacency list
deps := make(map[string][]string)
for _, step := range f.Steps {
deps[step.ID] = step.Needs
}
// DFS for cycle detection
visited := make(map[string]bool)
inStack := make(map[string]bool)
var visit func(id string) error
visit = func(id string) error {
if inStack[id] {
return fmt.Errorf("cycle detected involving step: %s", id)
}
if visited[id] {
return nil
}
visited[id] = true
inStack[id] = true
for _, dep := range deps[id] {
if err := visit(dep); err != nil {
return err
}
}
inStack[id] = false
return nil
}
for _, step := range f.Steps {
if err := visit(step.ID); err != nil {
return err
}
}
return nil
}
// TopologicalSort returns steps in dependency order (dependencies before dependents).
// Only applicable to workflow and expansion formulas.
// Returns an error if there are cycles.
func (f *Formula) TopologicalSort() ([]string, error) {
var items []string
var deps map[string][]string
switch f.Type {
case TypeWorkflow:
for _, step := range f.Steps {
items = append(items, step.ID)
}
deps = make(map[string][]string)
for _, step := range f.Steps {
deps[step.ID] = step.Needs
}
case TypeExpansion:
for _, tmpl := range f.Template {
items = append(items, tmpl.ID)
}
deps = make(map[string][]string)
for _, tmpl := range f.Template {
deps[tmpl.ID] = tmpl.Needs
}
case TypeConvoy:
// Convoy legs are parallel; return all leg IDs
for _, leg := range f.Legs {
items = append(items, leg.ID)
}
return items, nil
case TypeAspect:
// Aspect aspects are parallel; return all aspect IDs
for _, aspect := range f.Aspects {
items = append(items, aspect.ID)
}
return items, nil
default:
return nil, fmt.Errorf("unsupported formula type for topological sort")
}
// Kahn's algorithm
inDegree := make(map[string]int)
for _, id := range items {
inDegree[id] = 0
}
for _, id := range items {
for _, dep := range deps[id] {
inDegree[id]++
_ = dep // dep already exists (validated)
}
}
// Find all nodes with no dependencies
var queue []string
for _, id := range items {
if inDegree[id] == 0 {
queue = append(queue, id)
}
}
// Build reverse adjacency (who depends on me)
dependents := make(map[string][]string)
for _, id := range items {
for _, dep := range deps[id] {
dependents[dep] = append(dependents[dep], id)
}
}
var result []string
for len(queue) > 0 {
// Pop from queue
id := queue[0]
queue = queue[1:]
result = append(result, id)
// Reduce in-degree of dependents
for _, dependent := range dependents[id] {
inDegree[dependent]--
if inDegree[dependent] == 0 {
queue = append(queue, dependent)
}
}
}
if len(result) != len(items) {
return nil, fmt.Errorf("cycle detected in dependencies")
}
return result, nil
}
// ReadySteps returns steps that have no unmet dependencies.
// completed is a set of step IDs that have been completed.
func (f *Formula) ReadySteps(completed map[string]bool) []string {
var ready []string
switch f.Type {
case TypeWorkflow:
for _, step := range f.Steps {
if completed[step.ID] {
continue
}
allMet := true
for _, need := range step.Needs {
if !completed[need] {
allMet = false
break
}
}
if allMet {
ready = append(ready, step.ID)
}
}
case TypeExpansion:
for _, tmpl := range f.Template {
if completed[tmpl.ID] {
continue
}
allMet := true
for _, need := range tmpl.Needs {
if !completed[need] {
allMet = false
break
}
}
if allMet {
ready = append(ready, tmpl.ID)
}
}
case TypeConvoy:
// All legs are ready unless already completed
for _, leg := range f.Legs {
if !completed[leg.ID] {
ready = append(ready, leg.ID)
}
}
case TypeAspect:
// All aspects are ready unless already completed
for _, aspect := range f.Aspects {
if !completed[aspect.ID] {
ready = append(ready, aspect.ID)
}
}
}
return ready
}
// GetStep returns a step by ID, or nil if not found.
func (f *Formula) GetStep(id string) *Step {
for i := range f.Steps {
if f.Steps[i].ID == id {
return &f.Steps[i]
}
}
return nil
}
// GetLeg returns a leg by ID, or nil if not found.
func (f *Formula) GetLeg(id string) *Leg {
for i := range f.Legs {
if f.Legs[i].ID == id {
return &f.Legs[i]
}
}
return nil
}
// GetTemplate returns a template by ID, or nil if not found.
func (f *Formula) GetTemplate(id string) *Template {
for i := range f.Template {
if f.Template[i].ID == id {
return &f.Template[i]
}
}
return nil
}
// GetAspect returns an aspect by ID, or nil if not found.
func (f *Formula) GetAspect(id string) *Aspect {
for i := range f.Aspects {
if f.Aspects[i].ID == id {
return &f.Aspects[i]
}
}
return nil
}

View File

@@ -0,0 +1,355 @@
package formula
import (
"testing"
)
func TestParse_Workflow(t *testing.T) {
data := []byte(`
description = "Test workflow"
formula = "test-workflow"
type = "workflow"
version = 1
[[steps]]
id = "step1"
title = "First Step"
description = "Do the first thing"
[[steps]]
id = "step2"
title = "Second Step"
description = "Do the second thing"
needs = ["step1"]
[[steps]]
id = "step3"
title = "Third Step"
description = "Do the third thing"
needs = ["step2"]
[vars]
[vars.feature]
description = "The feature to implement"
required = true
`)
f, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if f.Name != "test-workflow" {
t.Errorf("Name = %q, want %q", f.Name, "test-workflow")
}
if f.Type != TypeWorkflow {
t.Errorf("Type = %q, want %q", f.Type, TypeWorkflow)
}
if len(f.Steps) != 3 {
t.Errorf("len(Steps) = %d, want 3", len(f.Steps))
}
if f.Steps[1].Needs[0] != "step1" {
t.Errorf("step2.Needs[0] = %q, want %q", f.Steps[1].Needs[0], "step1")
}
}
func TestParse_Convoy(t *testing.T) {
data := []byte(`
description = "Test convoy"
formula = "test-convoy"
type = "convoy"
version = 1
[[legs]]
id = "leg1"
title = "Leg One"
focus = "Focus area 1"
description = "First leg"
[[legs]]
id = "leg2"
title = "Leg Two"
focus = "Focus area 2"
description = "Second leg"
[synthesis]
title = "Synthesis"
description = "Combine results"
depends_on = ["leg1", "leg2"]
`)
f, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if f.Name != "test-convoy" {
t.Errorf("Name = %q, want %q", f.Name, "test-convoy")
}
if f.Type != TypeConvoy {
t.Errorf("Type = %q, want %q", f.Type, TypeConvoy)
}
if len(f.Legs) != 2 {
t.Errorf("len(Legs) = %d, want 2", len(f.Legs))
}
if f.Synthesis == nil {
t.Fatal("Synthesis is nil")
}
if len(f.Synthesis.DependsOn) != 2 {
t.Errorf("len(Synthesis.DependsOn) = %d, want 2", len(f.Synthesis.DependsOn))
}
}
func TestParse_Expansion(t *testing.T) {
data := []byte(`
description = "Test expansion"
formula = "test-expansion"
type = "expansion"
version = 1
[[template]]
id = "{target}.draft"
title = "Draft: {target.title}"
description = "Initial draft"
[[template]]
id = "{target}.refine"
title = "Refine"
description = "Refine the draft"
needs = ["{target}.draft"]
`)
f, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if f.Name != "test-expansion" {
t.Errorf("Name = %q, want %q", f.Name, "test-expansion")
}
if f.Type != TypeExpansion {
t.Errorf("Type = %q, want %q", f.Type, TypeExpansion)
}
if len(f.Template) != 2 {
t.Errorf("len(Template) = %d, want 2", len(f.Template))
}
}
func TestValidate_MissingName(t *testing.T) {
data := []byte(`
type = "workflow"
version = 1
[[steps]]
id = "step1"
title = "Step"
`)
_, err := Parse(data)
if err == nil {
t.Error("expected error for missing formula name")
}
}
func TestValidate_InvalidType(t *testing.T) {
data := []byte(`
formula = "test"
type = "invalid"
version = 1
[[steps]]
id = "step1"
`)
_, err := Parse(data)
if err == nil {
t.Error("expected error for invalid type")
}
}
func TestValidate_DuplicateStepID(t *testing.T) {
data := []byte(`
formula = "test"
type = "workflow"
version = 1
[[steps]]
id = "step1"
title = "Step 1"
[[steps]]
id = "step1"
title = "Step 1 duplicate"
`)
_, err := Parse(data)
if err == nil {
t.Error("expected error for duplicate step id")
}
}
func TestValidate_UnknownDependency(t *testing.T) {
data := []byte(`
formula = "test"
type = "workflow"
version = 1
[[steps]]
id = "step1"
title = "Step 1"
needs = ["nonexistent"]
`)
_, err := Parse(data)
if err == nil {
t.Error("expected error for unknown dependency")
}
}
func TestValidate_Cycle(t *testing.T) {
data := []byte(`
formula = "test"
type = "workflow"
version = 1
[[steps]]
id = "step1"
title = "Step 1"
needs = ["step2"]
[[steps]]
id = "step2"
title = "Step 2"
needs = ["step1"]
`)
_, err := Parse(data)
if err == nil {
t.Error("expected error for cycle")
}
}
func TestTopologicalSort(t *testing.T) {
data := []byte(`
formula = "test"
type = "workflow"
version = 1
[[steps]]
id = "step3"
title = "Step 3"
needs = ["step2"]
[[steps]]
id = "step1"
title = "Step 1"
[[steps]]
id = "step2"
title = "Step 2"
needs = ["step1"]
`)
f, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
order, err := f.TopologicalSort()
if err != nil {
t.Fatalf("TopologicalSort failed: %v", err)
}
// step1 must come before step2, step2 must come before step3
indexOf := func(id string) int {
for i, x := range order {
if x == id {
return i
}
}
return -1
}
if indexOf("step1") > indexOf("step2") {
t.Error("step1 should come before step2")
}
if indexOf("step2") > indexOf("step3") {
t.Error("step2 should come before step3")
}
}
func TestReadySteps(t *testing.T) {
data := []byte(`
formula = "test"
type = "workflow"
version = 1
[[steps]]
id = "step1"
title = "Step 1"
[[steps]]
id = "step2"
title = "Step 2"
needs = ["step1"]
[[steps]]
id = "step3"
title = "Step 3"
needs = ["step1"]
[[steps]]
id = "step4"
title = "Step 4"
needs = ["step2", "step3"]
`)
f, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Initially only step1 is ready
ready := f.ReadySteps(map[string]bool{})
if len(ready) != 1 || ready[0] != "step1" {
t.Errorf("ReadySteps({}) = %v, want [step1]", ready)
}
// After completing step1, step2 and step3 are ready
ready = f.ReadySteps(map[string]bool{"step1": true})
if len(ready) != 2 {
t.Errorf("ReadySteps({step1}) = %v, want [step2, step3]", ready)
}
// After completing step1, step2, step3 is still ready
ready = f.ReadySteps(map[string]bool{"step1": true, "step2": true})
if len(ready) != 1 || ready[0] != "step3" {
t.Errorf("ReadySteps({step1, step2}) = %v, want [step3]", ready)
}
// After completing step1, step2, step3, only step4 is ready
ready = f.ReadySteps(map[string]bool{"step1": true, "step2": true, "step3": true})
if len(ready) != 1 || ready[0] != "step4" {
t.Errorf("ReadySteps({step1, step2, step3}) = %v, want [step4]", ready)
}
}
func TestConvoyReadySteps(t *testing.T) {
data := []byte(`
formula = "test"
type = "convoy"
version = 1
[[legs]]
id = "leg1"
title = "Leg 1"
[[legs]]
id = "leg2"
title = "Leg 2"
[[legs]]
id = "leg3"
title = "Leg 3"
`)
f, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// All legs are ready initially (parallel)
ready := f.ReadySteps(map[string]bool{})
if len(ready) != 3 {
t.Errorf("ReadySteps({}) = %v, want 3 legs", ready)
}
// After completing leg1, leg2 and leg3 still ready
ready = f.ReadySteps(map[string]bool{"leg1": true})
if len(ready) != 2 {
t.Errorf("ReadySteps({leg1}) = %v, want 2 legs", ready)
}
}

171
internal/formula/types.go Normal file
View File

@@ -0,0 +1,171 @@
// Package formula provides parsing and validation for formula.toml files.
//
// Formulas define structured workflows that can be executed by agents.
// There are four types of formulas:
// - convoy: Parallel execution of legs with synthesis
// - workflow: Sequential steps with dependencies
// - expansion: Template-based step generation
// - aspect: Multi-aspect parallel analysis (like convoy but for analysis)
package formula
// FormulaType represents the type of formula.
type FormulaType string
const (
// TypeConvoy is a convoy formula with parallel legs and synthesis.
TypeConvoy FormulaType = "convoy"
// TypeWorkflow is a workflow formula with sequential steps.
TypeWorkflow FormulaType = "workflow"
// TypeExpansion is an expansion formula with template-based steps.
TypeExpansion FormulaType = "expansion"
// TypeAspect is an aspect-based formula for multi-aspect parallel analysis.
TypeAspect FormulaType = "aspect"
)
// Formula represents a parsed formula.toml file.
type Formula struct {
// Common fields
Name string `toml:"formula"`
Description string `toml:"description"`
Type FormulaType `toml:"type"`
Version int `toml:"version"`
// Convoy-specific
Inputs map[string]Input `toml:"inputs"`
Prompts map[string]string `toml:"prompts"`
Output *Output `toml:"output"`
Legs []Leg `toml:"legs"`
Synthesis *Synthesis `toml:"synthesis"`
// Workflow-specific
Steps []Step `toml:"steps"`
Vars map[string]Var `toml:"vars"`
// Expansion-specific
Template []Template `toml:"template"`
// Aspect-specific (similar to convoy but for analysis)
Aspects []Aspect `toml:"aspects"`
}
// Aspect represents a parallel analysis aspect in an aspect formula.
type Aspect struct {
ID string `toml:"id"`
Title string `toml:"title"`
Focus string `toml:"focus"`
Description string `toml:"description"`
}
// Input represents an input parameter for a formula.
type Input struct {
Description string `toml:"description"`
Type string `toml:"type"`
Required bool `toml:"required"`
RequiredUnless []string `toml:"required_unless"`
Default string `toml:"default"`
}
// Output configures where formula outputs are written.
type Output struct {
Directory string `toml:"directory"`
LegPattern string `toml:"leg_pattern"`
Synthesis string `toml:"synthesis"`
}
// Leg represents a parallel execution unit in a convoy formula.
type Leg struct {
ID string `toml:"id"`
Title string `toml:"title"`
Focus string `toml:"focus"`
Description string `toml:"description"`
}
// Synthesis represents the synthesis step that combines leg outputs.
type Synthesis struct {
Title string `toml:"title"`
Description string `toml:"description"`
DependsOn []string `toml:"depends_on"`
}
// Step represents a sequential step in a workflow formula.
type Step struct {
ID string `toml:"id"`
Title string `toml:"title"`
Description string `toml:"description"`
Needs []string `toml:"needs"`
}
// Template represents a template step in an expansion formula.
type Template struct {
ID string `toml:"id"`
Title string `toml:"title"`
Description string `toml:"description"`
Needs []string `toml:"needs"`
}
// Var represents a variable definition for formulas.
type Var struct {
Description string `toml:"description"`
Required bool `toml:"required"`
Default string `toml:"default"`
}
// IsValid returns true if the formula type is recognized.
func (t FormulaType) IsValid() bool {
switch t {
case TypeConvoy, TypeWorkflow, TypeExpansion, TypeAspect:
return true
default:
return false
}
}
// GetDependencies returns the ordered dependencies for a step/template.
// For convoy formulas, legs are parallel so this returns an empty slice.
// For workflow and expansion formulas, this returns the Needs field.
func (f *Formula) GetDependencies(id string) []string {
switch f.Type {
case TypeWorkflow:
for _, step := range f.Steps {
if step.ID == id {
return step.Needs
}
}
case TypeExpansion:
for _, tmpl := range f.Template {
if tmpl.ID == id {
return tmpl.Needs
}
}
case TypeConvoy:
// Legs are parallel; synthesis depends on all legs
if f.Synthesis != nil && id == "synthesis" {
return f.Synthesis.DependsOn
}
}
return nil
}
// GetAllIDs returns all step/leg/template/aspect IDs in the formula.
func (f *Formula) GetAllIDs() []string {
var ids []string
switch f.Type {
case TypeWorkflow:
for _, step := range f.Steps {
ids = append(ids, step.ID)
}
case TypeExpansion:
for _, tmpl := range f.Template {
ids = append(ids, tmpl.ID)
}
case TypeConvoy:
for _, leg := range f.Legs {
ids = append(ids, leg.ID)
}
case TypeAspect:
for _, aspect := range f.Aspects {
ids = append(ids, aspect.ID)
}
}
return ids
}