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>
1063 lines
33 KiB
Go
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)
|
|
}
|