diff --git a/cmd/bd/cook.go b/cmd/bd/cook.go index 25bd0342..83bd0705 100644 --- a/cmd/bd/cook.go +++ b/cmd/bd/cook.go @@ -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) } diff --git a/internal/formula/parser_test.go b/internal/formula/parser_test.go index 4a9d170c..c3bfdce0 100644 --- a/internal/formula/parser_test.go +++ b/internal/formula/parser_test.go @@ -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) + } +} diff --git a/internal/formula/types.go b/internal/formula/types.go index 8591f0cc..a69848a0 100644 --- a/internal/formula/types.go +++ b/internal/formula/types.go @@ -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:" 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) } }