Files
beads/cmd/bd/cook.go
Steve Yegge 0521910503 fix: --var flag now allows commas in values (#786)
Changed all --var flags from StringSlice to StringArray.
StringSlice splits on commas, breaking values like 'desc=A, B, C'.
StringArray only splits on separate --var flags.

Affected commands: pour, cook, wisp, mol distill, mol bond, template instantiate

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:17:51 -08:00

925 lines
29 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
func loadAndResolveFormula(formulaPath string, searchPaths []string) (*formula.Formula, error) {
parser := formula.NewParser(searchPaths...)
// Parse the formula file
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
// Create root proto epic
rootIssue := &types.Issue{
ID: protoID,
Title: f.Formula, // Title is the original formula name
Description: f.Description,
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
}
// 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,
})
// 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) {
// 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)
}
}
}
// 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 }
// Create root proto epic using provided protoID (may include prefix)
rootIssue := &types.Issue{
ID: protoID,
Title: f.Formula, // Title is the original formula name
Description: f.Description,
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)
}