feat(formula): support needs and waits_for fields, add --prefix flag
- bd-hr39: Add `needs` field to Step as alias for depends_on. Converts to blocking dependencies between sibling steps during cooking. - bd-j4cr: Add `waits_for` field to Step. Values: all-children or any-children. Preserved as gate:<value> label during cooking. - bd-47qx: Add --prefix flag to bd cook command to prepend a prefix to proto IDs, enabling use of project prefixes like gt-. Includes validation, dry-run output, and comprehensive tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,7 @@ func runCook(cmd *cobra.Command, args []string) {
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
searchPaths, _ := cmd.Flags().GetStringSlice("search-path")
|
||||
prefix, _ := cmd.Flags().GetString("prefix")
|
||||
|
||||
// Create parser with search paths
|
||||
parser := formula.NewParser(searchPaths...)
|
||||
@@ -92,16 +93,22 @@ func runCook(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Apply prefix to proto ID if specified (bd-47qx)
|
||||
protoID := resolved.Formula
|
||||
if prefix != "" {
|
||||
protoID = prefix + resolved.Formula
|
||||
}
|
||||
|
||||
// Check if proto already exists
|
||||
existingProto, err := store.GetIssue(ctx, resolved.Formula)
|
||||
existingProto, err := store.GetIssue(ctx, protoID)
|
||||
if err == nil && existingProto != nil {
|
||||
if !force {
|
||||
fmt.Fprintf(os.Stderr, "Error: proto %s already exists\n", resolved.Formula)
|
||||
fmt.Fprintf(os.Stderr, "Error: proto %s already exists\n", protoID)
|
||||
fmt.Fprintf(os.Stderr, "Hint: use --force to replace it\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
// Delete existing proto and its children
|
||||
if err := deleteProtoSubgraph(ctx, store, resolved.Formula); err != nil {
|
||||
if err := deleteProtoSubgraph(ctx, store, protoID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error deleting existing proto: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -119,7 +126,7 @@ func runCook(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("\nDry run: would cook formula %s\n\n", resolved.Formula)
|
||||
fmt.Printf("\nDry run: would cook formula %s as proto %s\n\n", resolved.Formula, protoID)
|
||||
fmt.Printf("Steps (%d):\n", len(resolved.Steps))
|
||||
printFormulaSteps(resolved.Steps, " ")
|
||||
|
||||
@@ -155,7 +162,7 @@ func runCook(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
// Create the proto bead from the formula
|
||||
result, err := cookFormula(ctx, store, resolved)
|
||||
result, err := cookFormula(ctx, store, resolved, protoID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error cooking formula: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -193,7 +200,8 @@ type cookFormulaResult struct {
|
||||
}
|
||||
|
||||
// cookFormula creates a proto bead from a resolved formula.
|
||||
func cookFormula(ctx context.Context, s storage.Storage, f *formula.Formula) (*cookFormulaResult, error) {
|
||||
// protoID is the final ID for the proto (may include a prefix).
|
||||
func cookFormula(ctx context.Context, s storage.Storage, f *formula.Formula, protoID string) (*cookFormulaResult, error) {
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("no database connection")
|
||||
}
|
||||
@@ -212,10 +220,10 @@ func cookFormula(ctx context.Context, s storage.Storage, f *formula.Formula) (*c
|
||||
var deps []*types.Dependency
|
||||
var labels []struct{ issueID, label string }
|
||||
|
||||
// Create root proto epic
|
||||
// Create root proto epic using provided protoID (may include prefix, bd-47qx)
|
||||
rootIssue := &types.Issue{
|
||||
ID: f.Formula,
|
||||
Title: f.Formula, // Title is the formula name
|
||||
ID: protoID,
|
||||
Title: f.Formula, // Title is the original formula name
|
||||
Description: f.Description,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
@@ -225,10 +233,10 @@ func cookFormula(ctx context.Context, s storage.Storage, f *formula.Formula) (*c
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
issues = append(issues, rootIssue)
|
||||
labels = append(labels, struct{ issueID, label string }{f.Formula, MoleculeLabel})
|
||||
labels = append(labels, struct{ issueID, label string }{protoID, MoleculeLabel})
|
||||
|
||||
// Collect issues for each step
|
||||
collectStepsRecursive(f.Steps, f.Formula, idMapping, &issues, &deps, &labels)
|
||||
// Collect issues for each step (use protoID as parent for step IDs)
|
||||
collectStepsRecursive(f.Steps, protoID, idMapping, &issues, &deps, &labels)
|
||||
|
||||
// Collect dependencies from depends_on
|
||||
for _, step := range f.Steps {
|
||||
@@ -282,7 +290,7 @@ func cookFormula(ctx context.Context, s storage.Storage, f *formula.Formula) (*c
|
||||
}
|
||||
|
||||
return &cookFormulaResult{
|
||||
ProtoID: f.Formula,
|
||||
ProtoID: protoID,
|
||||
Created: len(issues),
|
||||
}, nil
|
||||
}
|
||||
@@ -342,6 +350,12 @@ func collectStepsRecursive(steps []*formula.Step, parentID string, idMapping map
|
||||
*labels = append(*labels, struct{ issueID, label string }{issueID, label})
|
||||
}
|
||||
|
||||
// Add gate label for waits_for field (bd-j4cr)
|
||||
if step.WaitsFor != "" {
|
||||
gateLabel := fmt.Sprintf("gate:%s", step.WaitsFor)
|
||||
*labels = append(*labels, struct{ issueID, label string }{issueID, gateLabel})
|
||||
}
|
||||
|
||||
idMapping[step.ID] = issueID
|
||||
|
||||
// Add parent-child dependency
|
||||
@@ -358,10 +372,11 @@ func collectStepsRecursive(steps []*formula.Step, parentID string, idMapping map
|
||||
}
|
||||
}
|
||||
|
||||
// collectDependencies collects blocking dependencies from depends_on.
|
||||
// collectDependencies collects blocking dependencies from depends_on and needs fields.
|
||||
func collectDependencies(step *formula.Step, idMapping map[string]string, deps *[]*types.Dependency) {
|
||||
issueID := idMapping[step.ID]
|
||||
|
||||
// Process depends_on field
|
||||
for _, depID := range step.DependsOn {
|
||||
depIssueID, ok := idMapping[depID]
|
||||
if !ok {
|
||||
@@ -375,6 +390,20 @@ func collectDependencies(step *formula.Step, idMapping map[string]string, deps *
|
||||
})
|
||||
}
|
||||
|
||||
// Process needs field (bd-hr39) - simpler alias for sibling dependencies
|
||||
for _, needID := range step.Needs {
|
||||
needIssueID, ok := idMapping[needID]
|
||||
if !ok {
|
||||
continue // Will be caught during validation
|
||||
}
|
||||
|
||||
*deps = append(*deps, &types.Dependency{
|
||||
IssueID: issueID,
|
||||
DependsOnID: needIssueID,
|
||||
Type: types.DepBlocks,
|
||||
})
|
||||
}
|
||||
|
||||
// Recursively handle children
|
||||
for _, child := range step.Children {
|
||||
collectDependencies(child, idMapping, deps)
|
||||
@@ -409,9 +438,21 @@ func printFormulaSteps(steps []*formula.Step, indent string) {
|
||||
connector = "└──"
|
||||
}
|
||||
|
||||
depStr := ""
|
||||
// Collect dependency info
|
||||
var depParts []string
|
||||
if len(step.DependsOn) > 0 {
|
||||
depStr = fmt.Sprintf(" [depends: %s]", strings.Join(step.DependsOn, ", "))
|
||||
depParts = append(depParts, fmt.Sprintf("depends: %s", strings.Join(step.DependsOn, ", ")))
|
||||
}
|
||||
if len(step.Needs) > 0 {
|
||||
depParts = append(depParts, fmt.Sprintf("needs: %s", strings.Join(step.Needs, ", ")))
|
||||
}
|
||||
if step.WaitsFor != "" {
|
||||
depParts = append(depParts, fmt.Sprintf("waits_for: %s", step.WaitsFor))
|
||||
}
|
||||
|
||||
depStr := ""
|
||||
if len(depParts) > 0 {
|
||||
depStr = fmt.Sprintf(" [%s]", strings.Join(depParts, ", "))
|
||||
}
|
||||
|
||||
typeStr := ""
|
||||
@@ -437,6 +478,7 @@ func init() {
|
||||
cookCmd.Flags().Bool("dry-run", false, "Preview what would be created")
|
||||
cookCmd.Flags().Bool("force", false, "Replace existing proto if it exists")
|
||||
cookCmd.Flags().StringSlice("search-path", []string{}, "Additional paths to search for formula inheritance")
|
||||
cookCmd.Flags().String("prefix", "", "Prefix to prepend to proto ID (e.g., 'gt-' creates 'gt-mol-feature')")
|
||||
|
||||
rootCmd.AddCommand(cookCmd)
|
||||
}
|
||||
|
||||
@@ -603,3 +603,172 @@ func TestFormulaType_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_NeedsField tests validation of the needs field (bd-hr39)
|
||||
func TestValidate_NeedsField(t *testing.T) {
|
||||
// Valid needs reference
|
||||
formula := &Formula{
|
||||
Formula: "mol-needs",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{
|
||||
{ID: "step1", Title: "Step 1"},
|
||||
{ID: "step2", Title: "Step 2", Needs: []string{"step1"}},
|
||||
},
|
||||
}
|
||||
|
||||
if err := formula.Validate(); err != nil {
|
||||
t.Errorf("Validate failed for valid needs reference: %v", err)
|
||||
}
|
||||
|
||||
// Invalid needs reference
|
||||
formulaBad := &Formula{
|
||||
Formula: "mol-bad-needs",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{
|
||||
{ID: "step1", Title: "Step 1"},
|
||||
{ID: "step2", Title: "Step 2", Needs: []string{"nonexistent"}},
|
||||
},
|
||||
}
|
||||
|
||||
err := formulaBad.Validate()
|
||||
if err == nil {
|
||||
t.Error("Validate should fail for needs referencing unknown step")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_WaitsForField tests validation of the waits_for field (bd-j4cr)
|
||||
func TestValidate_WaitsForField(t *testing.T) {
|
||||
// Valid waits_for value
|
||||
formula := &Formula{
|
||||
Formula: "mol-waits-for",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{
|
||||
{ID: "fanout", Title: "Fanout"},
|
||||
{ID: "aggregate", Title: "Aggregate", Needs: []string{"fanout"}, WaitsFor: "all-children"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := formula.Validate(); err != nil {
|
||||
t.Errorf("Validate failed for valid waits_for: %v", err)
|
||||
}
|
||||
|
||||
// Invalid waits_for value
|
||||
formulaBad := &Formula{
|
||||
Formula: "mol-bad-waits-for",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{
|
||||
{ID: "step1", Title: "Step 1", WaitsFor: "invalid-gate"},
|
||||
},
|
||||
}
|
||||
|
||||
err := formulaBad.Validate()
|
||||
if err == nil {
|
||||
t.Error("Validate should fail for invalid waits_for value")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_ChildNeedsAndWaitsFor tests needs and waits_for in child steps
|
||||
func TestValidate_ChildNeedsAndWaitsFor(t *testing.T) {
|
||||
formula := &Formula{
|
||||
Formula: "mol-child-fields",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{
|
||||
{
|
||||
ID: "epic1",
|
||||
Title: "Epic 1",
|
||||
Children: []*Step{
|
||||
{ID: "child1", Title: "Child 1"},
|
||||
{ID: "child2", Title: "Child 2", Needs: []string{"child1"}, WaitsFor: "any-children"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := formula.Validate(); err != nil {
|
||||
t.Errorf("Validate failed for valid child needs/waits_for: %v", err)
|
||||
}
|
||||
|
||||
// Invalid child needs
|
||||
formulaBadNeeds := &Formula{
|
||||
Formula: "mol-bad-child-needs",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{
|
||||
{
|
||||
ID: "epic1",
|
||||
Title: "Epic 1",
|
||||
Children: []*Step{
|
||||
{ID: "child1", Title: "Child 1", Needs: []string{"nonexistent"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := formulaBadNeeds.Validate(); err == nil {
|
||||
t.Error("Validate should fail for child with invalid needs reference")
|
||||
}
|
||||
|
||||
// Invalid child waits_for
|
||||
formulaBadWaitsFor := &Formula{
|
||||
Formula: "mol-bad-child-waits-for",
|
||||
Version: 1,
|
||||
Type: TypeWorkflow,
|
||||
Steps: []*Step{
|
||||
{
|
||||
ID: "epic1",
|
||||
Title: "Epic 1",
|
||||
Children: []*Step{
|
||||
{ID: "child1", Title: "Child 1", WaitsFor: "bad-value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := formulaBadWaitsFor.Validate(); err == nil {
|
||||
t.Error("Validate should fail for child with invalid waits_for")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParse_NeedsAndWaitsFor tests YAML parsing of needs and waits_for fields
|
||||
func TestParse_NeedsAndWaitsFor(t *testing.T) {
|
||||
yaml := `
|
||||
formula: mol-deacon
|
||||
version: 1
|
||||
type: workflow
|
||||
steps:
|
||||
- id: inbox-check
|
||||
title: Check inbox
|
||||
- id: health-scan
|
||||
title: Check health
|
||||
needs: [inbox-check]
|
||||
- id: aggregate
|
||||
title: Aggregate results
|
||||
needs: [health-scan]
|
||||
waits_for: all-children
|
||||
`
|
||||
p := NewParser()
|
||||
formula, err := p.Parse([]byte(yaml))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
// Validate parsed formula
|
||||
if err := formula.Validate(); err != nil {
|
||||
t.Errorf("Validate failed: %v", err)
|
||||
}
|
||||
|
||||
// Check needs field
|
||||
if len(formula.Steps[1].Needs) != 1 || formula.Steps[1].Needs[0] != "inbox-check" {
|
||||
t.Errorf("Steps[1].Needs = %v, want [inbox-check]", formula.Steps[1].Needs)
|
||||
}
|
||||
|
||||
// Check waits_for field
|
||||
if formula.Steps[2].WaitsFor != "all-children" {
|
||||
t.Errorf("Steps[2].WaitsFor = %q, want 'all-children'", formula.Steps[2].WaitsFor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,15 @@ type Step struct {
|
||||
// DependsOn lists step IDs this step blocks on (within the formula).
|
||||
DependsOn []string `yaml:"depends_on,omitempty" json:"depends_on,omitempty"`
|
||||
|
||||
// Needs is a simpler alias for DependsOn - lists sibling step IDs that must complete first.
|
||||
// Either Needs or DependsOn can be used; they are merged during cooking.
|
||||
Needs []string `yaml:"needs,omitempty" json:"needs,omitempty"`
|
||||
|
||||
// WaitsFor specifies a fanout gate type for this step.
|
||||
// Values: "all-children" (wait for all dynamic children) or "any-children" (wait for first).
|
||||
// When set, the cooked issue gets a "gate:<value>" label.
|
||||
WaitsFor string `yaml:"waits_for,omitempty" json:"waits_for,omitempty"`
|
||||
|
||||
// Assignee is the default assignee (supports substitution).
|
||||
Assignee string `yaml:"assignee,omitempty" json:"assignee,omitempty"`
|
||||
|
||||
@@ -278,7 +287,20 @@ func (f *Formula) Validate() error {
|
||||
errs = append(errs, fmt.Sprintf("steps[%d] (%s): depends_on references unknown step %q", i, step.ID, dep))
|
||||
}
|
||||
}
|
||||
// Validate children's depends_on recursively
|
||||
// Validate needs field (bd-hr39) - same validation as depends_on
|
||||
for _, need := range step.Needs {
|
||||
if _, exists := stepIDLocations[need]; !exists {
|
||||
errs = append(errs, fmt.Sprintf("steps[%d] (%s): needs references unknown step %q", i, step.ID, need))
|
||||
}
|
||||
}
|
||||
// Validate waits_for field (bd-j4cr) - must be a known gate type
|
||||
if step.WaitsFor != "" {
|
||||
validGates := map[string]bool{"all-children": true, "any-children": true}
|
||||
if !validGates[step.WaitsFor] {
|
||||
errs = append(errs, fmt.Sprintf("steps[%d] (%s): waits_for has invalid value %q (must be all-children or any-children)", i, step.ID, step.WaitsFor))
|
||||
}
|
||||
}
|
||||
// Validate children's depends_on and needs recursively
|
||||
validateChildDependsOn(step.Children, stepIDLocations, &errs, fmt.Sprintf("steps[%d]", i))
|
||||
}
|
||||
|
||||
@@ -348,7 +370,7 @@ func collectChildIDs(children []*Step, idLocations map[string]string, errs *[]st
|
||||
}
|
||||
}
|
||||
|
||||
// validateChildDependsOn recursively validates depends_on references for children.
|
||||
// validateChildDependsOn recursively validates depends_on and needs references for children.
|
||||
func validateChildDependsOn(children []*Step, idLocations map[string]string, errs *[]string, prefix string) {
|
||||
for i, child := range children {
|
||||
childPrefix := fmt.Sprintf("%s.children[%d]", prefix, i)
|
||||
@@ -357,6 +379,19 @@ func validateChildDependsOn(children []*Step, idLocations map[string]string, err
|
||||
*errs = append(*errs, fmt.Sprintf("%s (%s): depends_on references unknown step %q", childPrefix, child.ID, dep))
|
||||
}
|
||||
}
|
||||
// Validate needs field (bd-hr39)
|
||||
for _, need := range child.Needs {
|
||||
if _, exists := idLocations[need]; !exists {
|
||||
*errs = append(*errs, fmt.Sprintf("%s (%s): needs references unknown step %q", childPrefix, child.ID, need))
|
||||
}
|
||||
}
|
||||
// Validate waits_for field (bd-j4cr)
|
||||
if child.WaitsFor != "" {
|
||||
validGates := map[string]bool{"all-children": true, "any-children": true}
|
||||
if !validGates[child.WaitsFor] {
|
||||
*errs = append(*errs, fmt.Sprintf("%s (%s): waits_for has invalid value %q (must be all-children or any-children)", childPrefix, child.ID, child.WaitsFor))
|
||||
}
|
||||
}
|
||||
validateChildDependsOn(child.Children, idLocations, errs, childPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user