Files
beads/cmd/bd/cook.go
collins 7cf67153de refactor(types): remove Gas Town type constants from beads core (bd-w2zz4)
Remove Gas Town-specific type constants (TypeMolecule, TypeGate, TypeConvoy,
TypeMergeRequest, TypeSlot, TypeAgent, TypeRole, TypeRig, TypeEvent, TypeMessage)
from internal/types/types.go.

Beads now only has core work types built-in:
- bug, feature, task, epic, chore

All Gas Town types are now purely custom types with no special handling in beads.
Use string literals like "gate" or "molecule" when needed, and configure
types.custom in config.yaml for validation.

Changes:
- Remove Gas Town type constants from types.go
- Remove mr/mol aliases from Normalize()
- Update bd types command to only show core types
- Replace all constant usages with string literals throughout codebase
- Update tests to use string literals

This decouples beads from Gas Town, making it a generic issue tracker.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:36:59 -08:00

1063 lines
33 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/formula"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
)
// stepTypeToIssueType converts a formula step type string to a types.IssueType.
// Returns types.TypeTask for empty or unrecognized types.
func stepTypeToIssueType(stepType string) types.IssueType {
switch stepType {
case "task":
return types.TypeTask
case "bug":
return types.TypeBug
case "feature":
return types.TypeFeature
case "epic":
return types.TypeEpic
case "chore":
return types.TypeChore
default:
return types.TypeTask
}
}
// cookCmd compiles a formula JSON into a proto bead.
var cookCmd = &cobra.Command{
Use: "cook <formula-file>",
Short: "Compile a formula into a proto (ephemeral by default)",
Long: `Cook transforms a .formula.json file into a proto.
By default, cook outputs the resolved formula as JSON to stdout for
ephemeral use. The output can be inspected, piped, or saved to a file.
Two cooking modes are available:
COMPILE-TIME (default, --mode=compile):
Produces a proto with {{variable}} placeholders intact.
Use for: modeling, estimation, contractor handoff, planning.
Variables are NOT substituted - the output shows the template structure.
RUNTIME (--mode=runtime or when --var flags provided):
Produces a fully-resolved proto with variables substituted.
Use for: final validation before pour, seeing exact output.
Requires all variables to have values (via --var or defaults).
Formulas are high-level workflow templates that support:
- Variable definitions with defaults and validation
- Step definitions that become issue hierarchies
- Composition rules for bonding formulas together
- Inheritance via extends
The --persist flag enables the legacy behavior of writing the proto
to the database. This is useful when you want to reuse the same
proto multiple times without re-cooking.
For most workflows, prefer ephemeral protos: pour and wisp commands
accept formula names directly and cook inline.
Examples:
bd cook mol-feature.formula.json # Compile-time: keep {{vars}}
bd cook mol-feature --var name=auth # Runtime: substitute vars
bd cook mol-feature --mode=runtime --var name=auth # Explicit runtime mode
bd cook mol-feature --dry-run # Preview steps
bd cook mol-release.formula.json --persist # Write to database
bd cook mol-release.formula.json --persist --force # Replace existing
Output (default):
JSON representation of the resolved formula with all steps.
Output (--persist):
Creates a proto bead in the database with:
- ID matching the formula name (e.g., mol-feature)
- The "template" label for proto identification
- Child issues for each step
- Dependencies matching depends_on relationships`,
Args: cobra.ExactArgs(1),
Run: runCook,
}
// cookResult holds the result of cooking a formula
type cookResult struct {
ProtoID string `json:"proto_id"`
Formula string `json:"formula"`
Created int `json:"created"`
Variables []string `json:"variables"`
BondPoints []string `json:"bond_points,omitempty"`
}
// cookFlags holds parsed command-line flags for the cook command
type cookFlags struct {
dryRun bool
persist bool
force bool
searchPaths []string
prefix string
inputVars map[string]string
runtimeMode bool
formulaPath string
}
// parseCookFlags parses and validates cook command flags
func parseCookFlags(cmd *cobra.Command, args []string) (*cookFlags, error) {
dryRun, _ := cmd.Flags().GetBool("dry-run")
persist, _ := cmd.Flags().GetBool("persist")
force, _ := cmd.Flags().GetBool("force")
searchPaths, _ := cmd.Flags().GetStringSlice("search-path")
prefix, _ := cmd.Flags().GetString("prefix")
varFlags, _ := cmd.Flags().GetStringArray("var")
mode, _ := cmd.Flags().GetString("mode")
// Parse variables
inputVars := make(map[string]string)
for _, v := range varFlags {
parts := strings.SplitN(v, "=", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid variable format '%s', expected 'key=value'", v)
}
inputVars[parts[0]] = parts[1]
}
// Validate mode
if mode != "" && mode != "compile" && mode != "runtime" {
return nil, fmt.Errorf("invalid mode '%s', must be 'compile' or 'runtime'", mode)
}
// Runtime mode is triggered by: explicit --mode=runtime OR providing --var flags
runtimeMode := mode == "runtime" || len(inputVars) > 0
return &cookFlags{
dryRun: dryRun,
persist: persist,
force: force,
searchPaths: searchPaths,
prefix: prefix,
inputVars: inputVars,
runtimeMode: runtimeMode,
formulaPath: args[0],
}, nil
}
// loadAndResolveFormula parses a formula file and applies all transformations.
// It first tries to load by name from the formula registry (.beads/formulas/),
// and falls back to parsing as a file path if that fails.
func loadAndResolveFormula(formulaPath string, searchPaths []string) (*formula.Formula, error) {
parser := formula.NewParser(searchPaths...)
// Try to load by name first (from .beads/formulas/ registry)
f, err := parser.LoadByName(formulaPath)
if err != nil {
// Fall back to parsing as a file path
f, err = parser.ParseFile(formulaPath)
if err != nil {
return nil, fmt.Errorf("parsing formula: %w", err)
}
}
// Resolve inheritance
resolved, err := parser.Resolve(f)
if err != nil {
return nil, fmt.Errorf("resolving formula: %w", err)
}
// Apply control flow operators - loops, branches, gates
controlFlowSteps, err := formula.ApplyControlFlow(resolved.Steps, resolved.Compose)
if err != nil {
return nil, fmt.Errorf("applying control flow: %w", err)
}
resolved.Steps = controlFlowSteps
// Apply advice transformations
if len(resolved.Advice) > 0 {
resolved.Steps = formula.ApplyAdvice(resolved.Steps, resolved.Advice)
}
// Apply inline step expansions
inlineExpandedSteps, err := formula.ApplyInlineExpansions(resolved.Steps, parser)
if err != nil {
return nil, fmt.Errorf("applying inline expansions: %w", err)
}
resolved.Steps = inlineExpandedSteps
// Apply expansion operators
if resolved.Compose != nil && (len(resolved.Compose.Expand) > 0 || len(resolved.Compose.Map) > 0) {
expandedSteps, err := formula.ApplyExpansions(resolved.Steps, resolved.Compose, parser)
if err != nil {
return nil, fmt.Errorf("applying expansions: %w", err)
}
resolved.Steps = expandedSteps
}
// Apply aspects from compose.aspects
if resolved.Compose != nil && len(resolved.Compose.Aspects) > 0 {
for _, aspectName := range resolved.Compose.Aspects {
aspectFormula, err := parser.LoadByName(aspectName)
if err != nil {
return nil, fmt.Errorf("loading aspect %q: %w", aspectName, err)
}
if aspectFormula.Type != formula.TypeAspect {
return nil, fmt.Errorf("%q is not an aspect formula (type=%s)", aspectName, aspectFormula.Type)
}
if len(aspectFormula.Advice) > 0 {
resolved.Steps = formula.ApplyAdvice(resolved.Steps, aspectFormula.Advice)
}
}
}
return resolved, nil
}
// outputCookDryRun displays a dry-run preview of what would be cooked
func outputCookDryRun(resolved *formula.Formula, protoID string, runtimeMode bool, inputVars map[string]string, vars, bondPoints []string) {
modeLabel := "compile-time"
if runtimeMode {
modeLabel = "runtime"
// Apply defaults for runtime mode display
for name, def := range resolved.Vars {
if _, provided := inputVars[name]; !provided && def.Default != "" {
inputVars[name] = def.Default
}
}
}
fmt.Printf("\nDry run: would cook formula %s as proto %s (%s mode)\n\n", resolved.Formula, protoID, modeLabel)
// In runtime mode, show substituted steps
if runtimeMode {
substituteFormulaVars(resolved, inputVars)
fmt.Printf("Steps (%d) [variables substituted]:\n", len(resolved.Steps))
} else {
fmt.Printf("Steps (%d) [{{variables}} shown as placeholders]:\n", len(resolved.Steps))
}
printFormulaSteps(resolved.Steps, " ")
if len(vars) > 0 {
fmt.Printf("\nVariables used: %s\n", strings.Join(vars, ", "))
}
// Show variable values in runtime mode
if runtimeMode && len(inputVars) > 0 {
fmt.Printf("\nVariable values:\n")
for name, value := range inputVars {
fmt.Printf(" {{%s}} = %s\n", name, value)
}
}
if len(bondPoints) > 0 {
fmt.Printf("Bond points: %s\n", strings.Join(bondPoints, ", "))
}
// Show variable definitions (more useful in compile-time mode)
if !runtimeMode && len(resolved.Vars) > 0 {
fmt.Printf("\nVariable definitions:\n")
for name, def := range resolved.Vars {
attrs := []string{}
if def.Required {
attrs = append(attrs, "required")
}
if def.Default != "" {
attrs = append(attrs, fmt.Sprintf("default=%s", def.Default))
}
if len(def.Enum) > 0 {
attrs = append(attrs, fmt.Sprintf("enum=[%s]", strings.Join(def.Enum, ",")))
}
attrStr := ""
if len(attrs) > 0 {
attrStr = fmt.Sprintf(" (%s)", strings.Join(attrs, ", "))
}
fmt.Printf(" {{%s}}: %s%s\n", name, def.Description, attrStr)
}
}
}
// outputCookEphemeral outputs the resolved formula as JSON (ephemeral mode)
func outputCookEphemeral(resolved *formula.Formula, runtimeMode bool, inputVars map[string]string, vars []string) error {
if runtimeMode {
// Apply defaults from formula variable definitions
for name, def := range resolved.Vars {
if _, provided := inputVars[name]; !provided && def.Default != "" {
inputVars[name] = def.Default
}
}
// Check for missing required variables
var missingVars []string
for _, v := range vars {
if _, ok := inputVars[v]; !ok {
missingVars = append(missingVars, v)
}
}
if len(missingVars) > 0 {
return fmt.Errorf("runtime mode requires all variables to have values\nMissing: %s\nProvide with: --var %s=<value>",
strings.Join(missingVars, ", "), missingVars[0])
}
// Substitute variables in the formula
substituteFormulaVars(resolved, inputVars)
}
outputJSON(resolved)
return nil
}
// persistCookFormula creates a proto bead in the database (persist mode)
func persistCookFormula(ctx context.Context, resolved *formula.Formula, protoID string, force bool, vars, bondPoints []string) error {
// Check if proto already exists
existingProto, err := store.GetIssue(ctx, protoID)
if err == nil && existingProto != nil {
if !force {
return fmt.Errorf("proto %s already exists (use --force to replace)", protoID)
}
// Delete existing proto and its children
if err := deleteProtoSubgraph(ctx, store, protoID); err != nil {
return fmt.Errorf("deleting existing proto: %w", err)
}
}
// Create the proto bead from the formula
result, err := cookFormula(ctx, store, resolved, protoID)
if err != nil {
return fmt.Errorf("cooking formula: %w", err)
}
// Schedule auto-flush
markDirtyAndScheduleFlush()
if jsonOutput {
outputJSON(cookResult{
ProtoID: result.ProtoID,
Formula: resolved.Formula,
Created: result.Created,
Variables: vars,
BondPoints: bondPoints,
})
return nil
}
fmt.Printf("%s Cooked proto: %s\n", ui.RenderPass("✓"), result.ProtoID)
fmt.Printf(" Created %d issues\n", result.Created)
if len(vars) > 0 {
fmt.Printf(" Variables: %s\n", strings.Join(vars, ", "))
}
if len(bondPoints) > 0 {
fmt.Printf(" Bond points: %s\n", strings.Join(bondPoints, ", "))
}
fmt.Printf("\nTo use: bd mol pour %s --var <name>=<value>\n", result.ProtoID)
return nil
}
func runCook(cmd *cobra.Command, args []string) {
// Parse and validate flags
flags, err := parseCookFlags(cmd, args)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Validate store access for persist mode
if flags.persist {
CheckReadonly("cook --persist")
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: cook --persist requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon cook %s --persist ...\n", flags.formulaPath)
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
}
// Load and resolve the formula
resolved, err := loadAndResolveFormula(flags.formulaPath, flags.searchPaths)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Apply prefix to proto ID if specified
protoID := resolved.Formula
if flags.prefix != "" {
protoID = flags.prefix + resolved.Formula
}
// Extract variables and bond points
vars := formula.ExtractVariables(resolved)
var bondPoints []string
if resolved.Compose != nil {
for _, bp := range resolved.Compose.BondPoints {
bondPoints = append(bondPoints, bp.ID)
}
}
// Handle dry-run mode
if flags.dryRun {
outputCookDryRun(resolved, protoID, flags.runtimeMode, flags.inputVars, vars, bondPoints)
return
}
// Handle ephemeral mode (default)
if !flags.persist {
if err := outputCookEphemeral(resolved, flags.runtimeMode, flags.inputVars, vars); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}
// Handle persist mode
if err := persistCookFormula(rootCtx, resolved, protoID, flags.force, vars, bondPoints); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// cookFormulaResult holds the result of cooking
type cookFormulaResult struct {
ProtoID string
Created int
}
// cookFormulaToSubgraph creates an in-memory TemplateSubgraph from a resolved formula.
// This is the ephemeral proto implementation - no database storage.
// The returned subgraph can be passed directly to cloneSubgraph for instantiation.
//
//nolint:unparam // error return kept for API consistency with future error handling
func cookFormulaToSubgraph(f *formula.Formula, protoID string) (*TemplateSubgraph, error) {
// Map step ID -> created issue
issueMap := make(map[string]*types.Issue)
// Collect all issues and dependencies
var issues []*types.Issue
var deps []*types.Dependency
// Determine root title: use {{title}} placeholder if the variable is defined,
// otherwise fall back to formula name (GH#852)
rootTitle := f.Formula
if _, hasTitle := f.Vars["title"]; hasTitle {
rootTitle = "{{title}}"
}
// Determine root description: use {{desc}} placeholder if the variable is defined,
// otherwise fall back to formula description (GH#852)
rootDesc := f.Description
if _, hasDesc := f.Vars["desc"]; hasDesc {
rootDesc = "{{desc}}"
}
// Create root proto epic
rootIssue := &types.Issue{
ID: protoID,
Title: rootTitle,
Description: rootDesc,
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeEpic,
IsTemplate: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
issues = append(issues, rootIssue)
issueMap[protoID] = rootIssue
// Collect issues for each step (use protoID as parent for step IDs)
// The unified collectSteps builds both issueMap and idMapping
idMapping := make(map[string]string)
collectSteps(f.Steps, protoID, idMapping, issueMap, &issues, &deps, nil) // nil = keep labels on issues
// Collect dependencies from depends_on using the idMapping built above
for _, step := range f.Steps {
collectDependencies(step, idMapping, &deps)
}
return &TemplateSubgraph{
Root: rootIssue,
Issues: issues,
Dependencies: deps,
IssueMap: issueMap,
}, nil
}
// createGateIssue creates a gate issue for a step with a Gate field.
// Gate issues have type=gate and block the step they guard.
// Returns the gate issue and its ID.
func createGateIssue(step *formula.Step, parentID string) *types.Issue {
if step.Gate == nil {
return nil
}
// Generate gate issue ID: {parentID}.gate-{step.ID}
gateID := fmt.Sprintf("%s.gate-%s", parentID, step.ID)
// Build title from gate type and ID
title := fmt.Sprintf("Gate: %s", step.Gate.Type)
if step.Gate.ID != "" {
title = fmt.Sprintf("Gate: %s %s", step.Gate.Type, step.Gate.ID)
}
// Parse timeout if specified
var timeout time.Duration
if step.Gate.Timeout != "" {
if parsed, err := time.ParseDuration(step.Gate.Timeout); err == nil {
timeout = parsed
}
}
return &types.Issue{
ID: gateID,
Title: title,
Description: fmt.Sprintf("Async gate for step %s", step.ID),
Status: types.StatusOpen,
Priority: 2,
IssueType: "gate",
AwaitType: step.Gate.Type,
AwaitID: step.Gate.ID,
Timeout: timeout,
IsTemplate: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
// processStepToIssue converts a formula.Step to a types.Issue.
// The issue includes all fields including Labels populated from step.Labels and waits_for.
// This is the shared core logic used by both DB-persisted and in-memory cooking.
func processStepToIssue(step *formula.Step, parentID string) *types.Issue {
// Generate issue ID (formula-name.step-id)
issueID := fmt.Sprintf("%s.%s", parentID, step.ID)
// Determine issue type (children override to epic)
issueType := stepTypeToIssueType(step.Type)
if len(step.Children) > 0 {
issueType = types.TypeEpic
}
// Determine priority
priority := 2
if step.Priority != nil {
priority = *step.Priority
}
issue := &types.Issue{
ID: issueID,
Title: step.Title, // Keep {{variables}} for substitution at pour time
Description: step.Description,
Status: types.StatusOpen,
Priority: priority,
IssueType: issueType,
Assignee: step.Assignee,
IsTemplate: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceFormula: step.SourceFormula, // Source tracing
SourceLocation: step.SourceLocation, // Source tracing
}
// Populate labels from step
issue.Labels = append(issue.Labels, step.Labels...)
// Add gate label for waits_for field
if step.WaitsFor != "" {
gateLabel := fmt.Sprintf("gate:%s", step.WaitsFor)
issue.Labels = append(issue.Labels, gateLabel)
}
return issue
}
// collectSteps collects issues and dependencies for steps and their children.
// This is the unified implementation used by both DB-persisted and in-memory cooking.
//
// Parameters:
// - idMapping: step.ID → issue.ID (always populated, used for dependency resolution)
// - issueMap: issue.ID → issue (optional, nil for DB path, populated for in-memory path)
// - labelHandler: callback for each label (if nil, labels stay on issue; if set, labels are
// extracted and issue.Labels is cleared - use for DB path)
func collectSteps(steps []*formula.Step, parentID string,
idMapping map[string]string,
issueMap map[string]*types.Issue,
issues *[]*types.Issue,
deps *[]*types.Dependency,
labelHandler func(issueID, label string)) {
for _, step := range steps {
issue := processStepToIssue(step, parentID)
*issues = append(*issues, issue)
// Build mappings
idMapping[step.ID] = issue.ID
if issueMap != nil {
issueMap[issue.ID] = issue
}
// Handle labels: extract via callback (DB path) or keep on issue (in-memory path)
if labelHandler != nil {
for _, label := range issue.Labels {
labelHandler(issue.ID, label)
}
issue.Labels = nil // DB stores labels separately
}
// Add parent-child dependency
*deps = append(*deps, &types.Dependency{
IssueID: issue.ID,
DependsOnID: parentID,
Type: types.DepParentChild,
})
// Create gate issue if step has a Gate (bd-7zka.2)
if step.Gate != nil {
gateIssue := createGateIssue(step, parentID)
*issues = append(*issues, gateIssue)
// Add gate to mapping (use gate-{step.ID} as key)
gateKey := fmt.Sprintf("gate-%s", step.ID)
idMapping[gateKey] = gateIssue.ID
if issueMap != nil {
issueMap[gateIssue.ID] = gateIssue
}
// Handle gate labels if needed
if labelHandler != nil && len(gateIssue.Labels) > 0 {
for _, label := range gateIssue.Labels {
labelHandler(gateIssue.ID, label)
}
gateIssue.Labels = nil
}
// Gate is a child of the parent (same level as the step)
*deps = append(*deps, &types.Dependency{
IssueID: gateIssue.ID,
DependsOnID: parentID,
Type: types.DepParentChild,
})
// Step depends on gate (gate blocks the step)
*deps = append(*deps, &types.Dependency{
IssueID: issue.ID,
DependsOnID: gateIssue.ID,
Type: types.DepBlocks,
})
}
// Recursively collect children
if len(step.Children) > 0 {
collectSteps(step.Children, issue.ID, idMapping, issueMap, issues, deps, labelHandler)
}
}
}
// resolveAndCookFormula loads a formula by name, resolves it, applies all transformations,
// and returns an in-memory TemplateSubgraph ready for instantiation.
// This is the main entry point for ephemeral proto cooking.
func resolveAndCookFormula(formulaName string, searchPaths []string) (*TemplateSubgraph, error) {
return resolveAndCookFormulaWithVars(formulaName, searchPaths, nil)
}
// resolveAndCookFormulaWithVars loads a formula and optionally filters steps by condition.
// If conditionVars is provided, steps with conditions that evaluate to false are excluded.
// Pass nil for conditionVars to include all steps (condition filtering skipped).
func resolveAndCookFormulaWithVars(formulaName string, searchPaths []string, conditionVars map[string]string) (*TemplateSubgraph, error) {
// Create parser with search paths
parser := formula.NewParser(searchPaths...)
// Load formula by name
f, err := parser.LoadByName(formulaName)
if err != nil {
return nil, fmt.Errorf("loading formula %q: %w", formulaName, err)
}
// Resolve inheritance
resolved, err := parser.Resolve(f)
if err != nil {
return nil, fmt.Errorf("resolving formula %q: %w", formulaName, err)
}
// Apply control flow operators - loops, branches, gates
controlFlowSteps, err := formula.ApplyControlFlow(resolved.Steps, resolved.Compose)
if err != nil {
return nil, fmt.Errorf("applying control flow to %q: %w", formulaName, err)
}
resolved.Steps = controlFlowSteps
// Apply advice transformations
if len(resolved.Advice) > 0 {
resolved.Steps = formula.ApplyAdvice(resolved.Steps, resolved.Advice)
}
// Apply inline step expansions
inlineExpandedSteps, err := formula.ApplyInlineExpansions(resolved.Steps, parser)
if err != nil {
return nil, fmt.Errorf("applying inline expansions to %q: %w", formulaName, err)
}
resolved.Steps = inlineExpandedSteps
// Apply expansion operators
if resolved.Compose != nil && (len(resolved.Compose.Expand) > 0 || len(resolved.Compose.Map) > 0) {
expandedSteps, err := formula.ApplyExpansions(resolved.Steps, resolved.Compose, parser)
if err != nil {
return nil, fmt.Errorf("applying expansions to %q: %w", formulaName, err)
}
resolved.Steps = expandedSteps
}
// Apply aspects from compose.aspects
if resolved.Compose != nil && len(resolved.Compose.Aspects) > 0 {
for _, aspectName := range resolved.Compose.Aspects {
aspectFormula, err := parser.LoadByName(aspectName)
if err != nil {
return nil, fmt.Errorf("loading aspect %q: %w", aspectName, err)
}
if aspectFormula.Type != formula.TypeAspect {
return nil, fmt.Errorf("%q is not an aspect formula (type=%s)", aspectName, aspectFormula.Type)
}
if len(aspectFormula.Advice) > 0 {
resolved.Steps = formula.ApplyAdvice(resolved.Steps, aspectFormula.Advice)
}
}
}
// Apply step condition filtering if vars provided (bd-7zka.1)
// This filters out steps whose conditions evaluate to false
if conditionVars != nil {
// Merge with formula defaults for complete evaluation
mergedVars := make(map[string]string)
for name, def := range resolved.Vars {
if def != nil && def.Default != "" {
mergedVars[name] = def.Default
}
}
for k, v := range conditionVars {
mergedVars[k] = v
}
filteredSteps, err := formula.FilterStepsByCondition(resolved.Steps, mergedVars)
if err != nil {
return nil, fmt.Errorf("filtering steps by condition: %w", err)
}
resolved.Steps = filteredSteps
}
// Cook to in-memory subgraph, including variable definitions for default handling
return cookFormulaToSubgraphWithVars(resolved, resolved.Formula, resolved.Vars)
}
// cookFormulaToSubgraphWithVars creates an in-memory subgraph with variable info attached
func cookFormulaToSubgraphWithVars(f *formula.Formula, protoID string, vars map[string]*formula.VarDef) (*TemplateSubgraph, error) {
subgraph, err := cookFormulaToSubgraph(f, protoID)
if err != nil {
return nil, err
}
// Attach variable definitions to the subgraph for default handling during pour
// Convert from *VarDef to VarDef for simpler handling
if vars != nil {
subgraph.VarDefs = make(map[string]formula.VarDef)
for k, v := range vars {
if v != nil {
subgraph.VarDefs[k] = *v
}
}
}
// Attach recommended phase from formula (warn on pour of vapor formulas)
subgraph.Phase = f.Phase
return subgraph, nil
}
// cookFormula creates a proto bead from a resolved formula.
// 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")
}
// Check for SQLite store (needed for batch create with skip prefix)
sqliteStore, ok := s.(*sqlite.SQLiteStorage)
if !ok {
return nil, fmt.Errorf("cook requires SQLite storage")
}
// Map step ID -> created issue ID
idMapping := make(map[string]string)
// Collect all issues and dependencies
var issues []*types.Issue
var deps []*types.Dependency
var labels []struct{ issueID, label string }
// Determine root title: use {{title}} placeholder if the variable is defined,
// otherwise fall back to formula name (GH#852)
rootTitle := f.Formula
if _, hasTitle := f.Vars["title"]; hasTitle {
rootTitle = "{{title}}"
}
// Determine root description: use {{desc}} placeholder if the variable is defined,
// otherwise fall back to formula description (GH#852)
rootDesc := f.Description
if _, hasDesc := f.Vars["desc"]; hasDesc {
rootDesc = "{{desc}}"
}
// Create root proto epic using provided protoID (may include prefix)
rootIssue := &types.Issue{
ID: protoID,
Title: rootTitle,
Description: rootDesc,
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeEpic,
IsTemplate: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
issues = append(issues, rootIssue)
labels = append(labels, struct{ issueID, label string }{protoID, MoleculeLabel})
// Collect issues for each step (use protoID as parent for step IDs)
// Use labelHandler to extract labels for separate DB storage
collectSteps(f.Steps, protoID, idMapping, nil, &issues, &deps, func(issueID, label string) {
labels = append(labels, struct{ issueID, label string }{issueID, label})
})
// Collect dependencies from depends_on
for _, step := range f.Steps {
collectDependencies(step, idMapping, &deps)
}
// Create all issues using batch with skip prefix validation
opts := sqlite.BatchCreateOptions{
SkipPrefixValidation: true, // Molecules use mol-* prefix
}
if err := sqliteStore.CreateIssuesWithFullOptions(ctx, issues, actor, opts); err != nil {
return nil, fmt.Errorf("failed to create issues: %w", err)
}
// Track if we need cleanup on failure
issuesCreated := true
// Add labels and dependencies in a transaction
err := s.RunInTransaction(ctx, func(tx storage.Transaction) error {
// Add labels
for _, l := range labels {
if err := tx.AddLabel(ctx, l.issueID, l.label, actor); err != nil {
return fmt.Errorf("failed to add label %s to %s: %w", l.label, l.issueID, err)
}
}
// Add dependencies
for _, dep := range deps {
if err := tx.AddDependency(ctx, dep, actor); err != nil {
return fmt.Errorf("failed to create dependency: %w", err)
}
}
return nil
})
if err != nil {
// Clean up: delete the issues we created since labels/deps failed
if issuesCreated {
cleanupErr := s.RunInTransaction(ctx, func(tx storage.Transaction) error {
for i := len(issues) - 1; i >= 0; i-- {
_ = tx.DeleteIssue(ctx, issues[i].ID) // Best effort cleanup
}
return nil
})
if cleanupErr != nil {
return nil, fmt.Errorf("%w (cleanup also failed: %v)", err, cleanupErr)
}
}
return nil, err
}
return &cookFormulaResult{
ProtoID: protoID,
Created: len(issues),
}, nil
}
// collectDependencies collects blocking dependencies from depends_on, needs, and waits_for fields.
// This is the shared implementation used by both DB-persisted and in-memory subgraph cooking.
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 {
continue // Will be caught during validation
}
*deps = append(*deps, &types.Dependency{
IssueID: issueID,
DependsOnID: depIssueID,
Type: types.DepBlocks,
})
}
// Process needs field - 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,
})
}
// Process waits_for field - fanout gate dependency
if step.WaitsFor != "" {
waitsForSpec := formula.ParseWaitsFor(step.WaitsFor)
if waitsForSpec != nil {
// Determine spawner ID
spawnerStepID := waitsForSpec.SpawnerID
if spawnerStepID == "" && len(step.Needs) > 0 {
// Infer spawner from first need
spawnerStepID = step.Needs[0]
}
if spawnerStepID != "" {
if spawnerIssueID, ok := idMapping[spawnerStepID]; ok {
// Create WaitsFor dependency with metadata
meta := types.WaitsForMeta{
Gate: waitsForSpec.Gate,
}
metaJSON, _ := json.Marshal(meta)
*deps = append(*deps, &types.Dependency{
IssueID: issueID,
DependsOnID: spawnerIssueID,
Type: types.DepWaitsFor,
Metadata: string(metaJSON),
})
}
}
}
}
// Recursively handle children
for _, child := range step.Children {
collectDependencies(child, idMapping, deps)
}
}
// deleteProtoSubgraph deletes a proto and all its children.
func deleteProtoSubgraph(ctx context.Context, s storage.Storage, protoID string) error {
// Load the subgraph
subgraph, err := loadTemplateSubgraph(ctx, s, protoID)
if err != nil {
return fmt.Errorf("load proto: %w", err)
}
// Delete in reverse order (children first)
return s.RunInTransaction(ctx, func(tx storage.Transaction) error {
for i := len(subgraph.Issues) - 1; i >= 0; i-- {
issue := subgraph.Issues[i]
if err := tx.DeleteIssue(ctx, issue.ID); err != nil {
return fmt.Errorf("delete %s: %w", issue.ID, err)
}
}
return nil
})
}
// printFormulaSteps prints steps in a tree format.
func printFormulaSteps(steps []*formula.Step, indent string) {
for i, step := range steps {
connector := "├──"
if i == len(steps)-1 {
connector = "└──"
}
// Collect dependency info
var depParts []string
if len(step.DependsOn) > 0 {
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 := ""
if step.Type != "" && step.Type != "task" {
typeStr = fmt.Sprintf(" (%s)", step.Type)
}
// Source tracing info
sourceStr := ""
if step.SourceFormula != "" || step.SourceLocation != "" {
sourceStr = fmt.Sprintf(" [from: %s@%s]", step.SourceFormula, step.SourceLocation)
}
fmt.Printf("%s%s %s: %s%s%s%s\n", indent, connector, step.ID, step.Title, typeStr, depStr, sourceStr)
if len(step.Children) > 0 {
childIndent := indent
if i == len(steps)-1 {
childIndent += " "
} else {
childIndent += "│ "
}
printFormulaSteps(step.Children, childIndent)
}
}
}
// substituteFormulaVars substitutes {{variable}} placeholders in a formula.
// This is used in runtime mode to fully resolve the formula before output.
func substituteFormulaVars(f *formula.Formula, vars map[string]string) {
// Substitute in top-level fields
f.Description = substituteVariables(f.Description, vars)
// Substitute in all steps recursively
substituteStepVars(f.Steps, vars)
}
// substituteStepVars recursively substitutes variables in step titles and descriptions.
func substituteStepVars(steps []*formula.Step, vars map[string]string) {
for _, step := range steps {
step.Title = substituteVariables(step.Title, vars)
step.Description = substituteVariables(step.Description, vars)
if len(step.Children) > 0 {
substituteStepVars(step.Children, vars)
}
}
}
func init() {
cookCmd.Flags().Bool("dry-run", false, "Preview what would be created")
cookCmd.Flags().Bool("persist", false, "Persist proto to database (legacy behavior)")
cookCmd.Flags().Bool("force", false, "Replace existing proto if it exists (requires --persist)")
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')")
cookCmd.Flags().StringArray("var", []string{}, "Variable substitution (key=value), enables runtime mode")
cookCmd.Flags().String("mode", "", "Cooking mode: compile (keep placeholders) or runtime (substitute vars)")
rootCmd.AddCommand(cookCmd)
}