feat(formula): Implement gt formula run for convoy formulas
Add full convoy formula execution to gt formula run command: - Parse formula file to extract legs, synthesis, and prompts - Create convoy bead in town beads to track execution - Create leg beads for each parallel leg with tracking relations - Create synthesis bead with dependencies on all legs - Sling each leg to a polecat with leg-specific context - Support dry-run mode to preview execution The implementation supports convoy-type formulas with parallel legs and a synthesis step. Non-convoy formulas show manual execution steps. (gt-574qn) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@ package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -185,10 +187,24 @@ func runFormulaShow(cmd *cobra.Command, args []string) error {
|
||||
return bdCmd.Run()
|
||||
}
|
||||
|
||||
// runFormulaRun executes a formula
|
||||
// runFormulaRun executes a formula by spawning a convoy of polecats.
|
||||
// For convoy-type formulas, it creates a convoy bead, creates leg beads,
|
||||
// and slings each leg to a separate polecat with leg-specific prompts.
|
||||
func runFormulaRun(cmd *cobra.Command, args []string) error {
|
||||
formulaName := args[0]
|
||||
|
||||
// Find the formula file
|
||||
formulaPath, err := findFormulaFile(formulaName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding formula: %w", err)
|
||||
}
|
||||
|
||||
// Parse the formula
|
||||
f, err := parseFormulaFile(formulaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing formula: %w", err)
|
||||
}
|
||||
|
||||
// Determine target rig
|
||||
targetRig := formulaRunRig
|
||||
if targetRig == "" {
|
||||
@@ -205,35 +221,470 @@ func runFormulaRun(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dry-run mode
|
||||
if formulaRunDryRun {
|
||||
fmt.Printf("%s Would execute formula:\n", style.Dim.Render("[dry-run]"))
|
||||
fmt.Printf(" Formula: %s\n", style.Bold.Render(formulaName))
|
||||
fmt.Printf(" Rig: %s\n", targetRig)
|
||||
if formulaRunPR > 0 {
|
||||
fmt.Printf(" PR: #%d\n", formulaRunPR)
|
||||
}
|
||||
return dryRunFormula(f, formulaName, targetRig)
|
||||
}
|
||||
|
||||
// Currently only convoy formulas are supported for execution
|
||||
if f.Type != "convoy" {
|
||||
fmt.Printf("%s Formula type '%s' not yet supported for execution.\n",
|
||||
style.Dim.Render("Note:"), f.Type)
|
||||
fmt.Printf("Currently only 'convoy' formulas can be run.\n")
|
||||
fmt.Printf("\nTo run '%s' manually:\n", formulaName)
|
||||
fmt.Printf(" 1. View formula: gt formula show %s\n", formulaName)
|
||||
fmt.Printf(" 2. Cook to proto: bd cook %s\n", formulaName)
|
||||
fmt.Printf(" 3. Pour molecule: bd pour %s\n", formulaName)
|
||||
fmt.Printf(" 4. Sling to rig: gt sling <mol-id> %s\n", targetRig)
|
||||
return nil
|
||||
}
|
||||
|
||||
// For now, provide instructions on how to execute manually
|
||||
// TODO: Full implementation in gt-574qn (Formula execution: Spawn convoy from formula)
|
||||
fmt.Printf("Formula execution is being implemented.\n\n")
|
||||
fmt.Printf("To run '%s' manually:\n", formulaName)
|
||||
fmt.Printf(" 1. View formula: bd formula show %s\n", formulaName)
|
||||
fmt.Printf(" 2. Cook to proto: bd cook %s\n", formulaName)
|
||||
fmt.Printf(" 3. Pour molecule: bd pour %s\n", formulaName)
|
||||
fmt.Printf(" 4. Sling to rig: gt sling <mol-id> %s\n", targetRig)
|
||||
// Execute convoy formula
|
||||
return executeConvoyFormula(f, formulaName, targetRig)
|
||||
}
|
||||
|
||||
// dryRunFormula shows what would happen without executing
|
||||
func dryRunFormula(f *formulaData, formulaName, targetRig string) error {
|
||||
fmt.Printf("%s Would execute formula:\n", style.Dim.Render("[dry-run]"))
|
||||
fmt.Printf(" Formula: %s\n", style.Bold.Render(formulaName))
|
||||
fmt.Printf(" Type: %s\n", f.Type)
|
||||
fmt.Printf(" Rig: %s\n", targetRig)
|
||||
if formulaRunPR > 0 {
|
||||
fmt.Printf("\n For PR #%d, set variable: --var pr=%d\n", formulaRunPR, formulaRunPR)
|
||||
fmt.Printf(" PR: #%d\n", formulaRunPR)
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Full automation coming in gt-574qn\n",
|
||||
style.Dim.Render("Note:"))
|
||||
if f.Type == "convoy" && len(f.Legs) > 0 {
|
||||
fmt.Printf("\n Legs (%d parallel):\n", len(f.Legs))
|
||||
for _, leg := range f.Legs {
|
||||
fmt.Printf(" • %s: %s\n", leg.ID, leg.Title)
|
||||
}
|
||||
if f.Synthesis != nil {
|
||||
fmt.Printf("\n Synthesis:\n")
|
||||
fmt.Printf(" • %s\n", f.Synthesis.Title)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeConvoyFormula spawns a convoy of polecats to execute a convoy formula
|
||||
func executeConvoyFormula(f *formulaData, formulaName, targetRig string) error {
|
||||
fmt.Printf("%s Executing convoy formula: %s\n\n",
|
||||
style.Bold.Render("🚚"), formulaName)
|
||||
|
||||
// Get town beads directory for convoy creation
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding town root: %w", err)
|
||||
}
|
||||
townBeads := filepath.Join(townRoot, ".beads")
|
||||
|
||||
// Step 1: Create convoy bead
|
||||
convoyID := fmt.Sprintf("hq-cv-%s", generateFormulaShortID())
|
||||
convoyTitle := fmt.Sprintf("%s: %s", formulaName, f.Description)
|
||||
if len(convoyTitle) > 80 {
|
||||
convoyTitle = convoyTitle[:77] + "..."
|
||||
}
|
||||
|
||||
// Build description with formula context
|
||||
description := fmt.Sprintf("Formula convoy: %s\n\nLegs: %d\nRig: %s",
|
||||
formulaName, len(f.Legs), targetRig)
|
||||
if formulaRunPR > 0 {
|
||||
description += fmt.Sprintf("\nPR: #%d", formulaRunPR)
|
||||
}
|
||||
|
||||
createArgs := []string{
|
||||
"create",
|
||||
"--type=convoy",
|
||||
"--id=" + convoyID,
|
||||
"--title=" + convoyTitle,
|
||||
"--description=" + description,
|
||||
}
|
||||
|
||||
createCmd := exec.Command("bd", createArgs...)
|
||||
createCmd.Dir = townBeads
|
||||
createCmd.Stderr = os.Stderr
|
||||
if err := createCmd.Run(); err != nil {
|
||||
return fmt.Errorf("creating convoy bead: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Created convoy: %s\n", style.Bold.Render("✓"), convoyID)
|
||||
|
||||
// Step 2: Create leg beads and track them
|
||||
legBeads := make(map[string]string) // leg.ID -> bead ID
|
||||
for _, leg := range f.Legs {
|
||||
legBeadID := fmt.Sprintf("hq-leg-%s", generateFormulaShortID())
|
||||
|
||||
// Build leg description with prompt if available
|
||||
legDesc := leg.Description
|
||||
if f.Prompts != nil {
|
||||
if basePrompt, ok := f.Prompts["base"]; ok {
|
||||
legDesc = fmt.Sprintf("%s\n\n---\nBase Prompt:\n%s", leg.Description, basePrompt)
|
||||
}
|
||||
}
|
||||
|
||||
legArgs := []string{
|
||||
"create",
|
||||
"--type=task",
|
||||
"--id=" + legBeadID,
|
||||
"--title=" + leg.Title,
|
||||
"--description=" + legDesc,
|
||||
}
|
||||
|
||||
legCmd := exec.Command("bd", legArgs...)
|
||||
legCmd.Dir = townBeads
|
||||
legCmd.Stderr = os.Stderr
|
||||
if err := legCmd.Run(); err != nil {
|
||||
fmt.Printf("%s Failed to create leg bead for %s: %v\n",
|
||||
style.Dim.Render("Warning:"), leg.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Track the leg with the convoy
|
||||
trackArgs := []string{"dep", "add", convoyID, legBeadID, "--type=tracks"}
|
||||
trackCmd := exec.Command("bd", trackArgs...)
|
||||
trackCmd.Dir = townBeads
|
||||
if err := trackCmd.Run(); err != nil {
|
||||
fmt.Printf("%s Failed to track leg %s: %v\n",
|
||||
style.Dim.Render("Warning:"), leg.ID, err)
|
||||
}
|
||||
|
||||
legBeads[leg.ID] = legBeadID
|
||||
fmt.Printf(" %s Created leg: %s (%s)\n", style.Dim.Render("○"), leg.ID, legBeadID)
|
||||
}
|
||||
|
||||
// Step 3: Create synthesis bead if defined
|
||||
var synthesisBeadID string
|
||||
if f.Synthesis != nil {
|
||||
synthesisBeadID = fmt.Sprintf("hq-syn-%s", generateFormulaShortID())
|
||||
|
||||
synDesc := f.Synthesis.Description
|
||||
if synDesc == "" {
|
||||
synDesc = "Synthesize findings from all legs into unified output"
|
||||
}
|
||||
|
||||
synArgs := []string{
|
||||
"create",
|
||||
"--type=task",
|
||||
"--id=" + synthesisBeadID,
|
||||
"--title=" + f.Synthesis.Title,
|
||||
"--description=" + synDesc,
|
||||
}
|
||||
|
||||
synCmd := exec.Command("bd", synArgs...)
|
||||
synCmd.Dir = townBeads
|
||||
synCmd.Stderr = os.Stderr
|
||||
if err := synCmd.Run(); err != nil {
|
||||
fmt.Printf("%s Failed to create synthesis bead: %v\n",
|
||||
style.Dim.Render("Warning:"), err)
|
||||
} else {
|
||||
// Track synthesis with convoy
|
||||
trackArgs := []string{"dep", "add", convoyID, synthesisBeadID, "--type=tracks"}
|
||||
trackCmd := exec.Command("bd", trackArgs...)
|
||||
trackCmd.Dir = townBeads
|
||||
_ = trackCmd.Run()
|
||||
|
||||
// Add dependencies: synthesis depends on all legs
|
||||
for _, legBeadID := range legBeads {
|
||||
depArgs := []string{"dep", "add", synthesisBeadID, legBeadID}
|
||||
depCmd := exec.Command("bd", depArgs...)
|
||||
depCmd.Dir = townBeads
|
||||
_ = depCmd.Run()
|
||||
}
|
||||
|
||||
fmt.Printf(" %s Created synthesis: %s\n", style.Dim.Render("★"), synthesisBeadID)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Sling each leg to a polecat
|
||||
fmt.Printf("\n%s Dispatching legs to polecats...\n\n", style.Bold.Render("→"))
|
||||
|
||||
slingCount := 0
|
||||
for _, leg := range f.Legs {
|
||||
legBeadID, ok := legBeads[leg.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build context message for the polecat
|
||||
contextMsg := fmt.Sprintf("Convoy leg: %s\nFocus: %s", leg.Title, leg.Focus)
|
||||
|
||||
// Use gt sling with args for leg-specific context
|
||||
slingArgs := []string{
|
||||
"sling", legBeadID, targetRig,
|
||||
"-a", leg.Description,
|
||||
"-s", leg.Title,
|
||||
}
|
||||
|
||||
slingCmd := exec.Command("gt", slingArgs...)
|
||||
slingCmd.Stdout = os.Stdout
|
||||
slingCmd.Stderr = os.Stderr
|
||||
|
||||
if err := slingCmd.Run(); err != nil {
|
||||
fmt.Printf("%s Failed to sling leg %s: %v\n",
|
||||
style.Dim.Render("Warning:"), leg.ID, err)
|
||||
// Add comment to bead about failure
|
||||
commentArgs := []string{"comment", legBeadID, fmt.Sprintf("Failed to sling: %v", err)}
|
||||
commentCmd := exec.Command("bd", commentArgs...)
|
||||
commentCmd.Dir = townBeads
|
||||
_ = commentCmd.Run()
|
||||
continue
|
||||
}
|
||||
|
||||
slingCount++
|
||||
_ = contextMsg // Used in future for richer context
|
||||
}
|
||||
|
||||
// Summary
|
||||
fmt.Printf("\n%s Convoy dispatched!\n", style.Bold.Render("✓"))
|
||||
fmt.Printf(" Convoy: %s\n", convoyID)
|
||||
fmt.Printf(" Legs: %d dispatched\n", slingCount)
|
||||
if synthesisBeadID != "" {
|
||||
fmt.Printf(" Synthesis: %s (blocked until legs complete)\n", synthesisBeadID)
|
||||
}
|
||||
fmt.Printf("\n Track progress: gt convoy status %s\n", convoyID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formulaData holds parsed formula information
|
||||
type formulaData struct {
|
||||
Name string
|
||||
Description string
|
||||
Type string
|
||||
Legs []formulaLeg
|
||||
Synthesis *formulaSynthesis
|
||||
Prompts map[string]string
|
||||
}
|
||||
|
||||
type formulaLeg struct {
|
||||
ID string
|
||||
Title string
|
||||
Focus string
|
||||
Description string
|
||||
}
|
||||
|
||||
type formulaSynthesis struct {
|
||||
Title string
|
||||
Description string
|
||||
DependsOn []string
|
||||
}
|
||||
|
||||
// findFormulaFile searches for a formula file by name
|
||||
func findFormulaFile(name string) (string, error) {
|
||||
// Search paths in order
|
||||
searchPaths := []string{}
|
||||
|
||||
// 1. Project .beads/formulas/
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
searchPaths = append(searchPaths, filepath.Join(cwd, ".beads", "formulas"))
|
||||
}
|
||||
|
||||
// 2. Town .beads/formulas/
|
||||
if townRoot, err := workspace.FindFromCwd(); err == nil {
|
||||
searchPaths = append(searchPaths, filepath.Join(townRoot, ".beads", "formulas"))
|
||||
}
|
||||
|
||||
// 3. User ~/.beads/formulas/
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
searchPaths = append(searchPaths, filepath.Join(home, ".beads", "formulas"))
|
||||
}
|
||||
|
||||
// Try each path with common extensions
|
||||
extensions := []string{".formula.toml", ".formula.json"}
|
||||
for _, basePath := range searchPaths {
|
||||
for _, ext := range extensions {
|
||||
path := filepath.Join(basePath, name+ext)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("formula '%s' not found in search paths", name)
|
||||
}
|
||||
|
||||
// parseFormulaFile parses a formula file into formulaData
|
||||
func parseFormulaFile(path string) (*formulaData, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use simple TOML parsing for the fields we need
|
||||
// (avoids importing the full formula package which might cause cycles)
|
||||
f := &formulaData{
|
||||
Prompts: make(map[string]string),
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
// Parse formula name
|
||||
if match := extractTOMLValue(content, "formula"); match != "" {
|
||||
f.Name = match
|
||||
}
|
||||
|
||||
// Parse description
|
||||
if match := extractTOMLMultiline(content, "description"); match != "" {
|
||||
f.Description = match
|
||||
}
|
||||
|
||||
// Parse type
|
||||
if match := extractTOMLValue(content, "type"); match != "" {
|
||||
f.Type = match
|
||||
}
|
||||
|
||||
// Parse legs (convoy formulas)
|
||||
f.Legs = extractLegs(content)
|
||||
|
||||
// Parse synthesis
|
||||
f.Synthesis = extractSynthesis(content)
|
||||
|
||||
// Parse prompts
|
||||
f.Prompts = extractPrompts(content)
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// extractTOMLValue extracts a simple quoted value from TOML
|
||||
func extractTOMLValue(content, key string) string {
|
||||
// Match: key = "value" or key = 'value'
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, key+" =") || strings.HasPrefix(line, key+"=") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
val := strings.TrimSpace(parts[1])
|
||||
// Remove quotes
|
||||
if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') {
|
||||
return val[1 : len(val)-1]
|
||||
}
|
||||
return val
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractTOMLMultiline extracts a multiline string (""" ... """)
|
||||
func extractTOMLMultiline(content, key string) string {
|
||||
// Look for key = """
|
||||
keyPattern := key + ` = """`
|
||||
idx := strings.Index(content, keyPattern)
|
||||
if idx == -1 {
|
||||
// Try single-line
|
||||
return extractTOMLValue(content, key)
|
||||
}
|
||||
|
||||
start := idx + len(keyPattern)
|
||||
end := strings.Index(content[start:], `"""`)
|
||||
if end == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(content[start : start+end])
|
||||
}
|
||||
|
||||
// extractLegs parses [[legs]] sections from TOML
|
||||
func extractLegs(content string) []formulaLeg {
|
||||
var legs []formulaLeg
|
||||
|
||||
// Split by [[legs]]
|
||||
sections := strings.Split(content, "[[legs]]")
|
||||
for i, section := range sections {
|
||||
if i == 0 {
|
||||
continue // Skip content before first [[legs]]
|
||||
}
|
||||
|
||||
// Find where this section ends (next [[ or EOF)
|
||||
endIdx := strings.Index(section, "[[")
|
||||
if endIdx == -1 {
|
||||
endIdx = len(section)
|
||||
}
|
||||
section = section[:endIdx]
|
||||
|
||||
leg := formulaLeg{
|
||||
ID: extractTOMLValue(section, "id"),
|
||||
Title: extractTOMLValue(section, "title"),
|
||||
Focus: extractTOMLValue(section, "focus"),
|
||||
Description: extractTOMLMultiline(section, "description"),
|
||||
}
|
||||
|
||||
if leg.ID != "" {
|
||||
legs = append(legs, leg)
|
||||
}
|
||||
}
|
||||
|
||||
return legs
|
||||
}
|
||||
|
||||
// extractSynthesis parses [synthesis] section from TOML
|
||||
func extractSynthesis(content string) *formulaSynthesis {
|
||||
idx := strings.Index(content, "[synthesis]")
|
||||
if idx == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
section := content[idx:]
|
||||
// Find where section ends
|
||||
if endIdx := strings.Index(section[1:], "\n["); endIdx != -1 {
|
||||
section = section[:endIdx+1]
|
||||
}
|
||||
|
||||
syn := &formulaSynthesis{
|
||||
Title: extractTOMLValue(section, "title"),
|
||||
Description: extractTOMLMultiline(section, "description"),
|
||||
}
|
||||
|
||||
// Parse depends_on array
|
||||
if depsLine := extractTOMLValue(section, "depends_on"); depsLine != "" {
|
||||
// Simple array parsing: ["a", "b", "c"]
|
||||
depsLine = strings.Trim(depsLine, "[]")
|
||||
for _, dep := range strings.Split(depsLine, ",") {
|
||||
dep = strings.Trim(strings.TrimSpace(dep), `"'`)
|
||||
if dep != "" {
|
||||
syn.DependsOn = append(syn.DependsOn, dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if syn.Title == "" && syn.Description == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return syn
|
||||
}
|
||||
|
||||
// extractPrompts parses [prompts] section from TOML
|
||||
func extractPrompts(content string) map[string]string {
|
||||
prompts := make(map[string]string)
|
||||
|
||||
idx := strings.Index(content, "[prompts]")
|
||||
if idx == -1 {
|
||||
return prompts
|
||||
}
|
||||
|
||||
section := content[idx:]
|
||||
// Find where section ends
|
||||
if endIdx := strings.Index(section[1:], "\n["); endIdx != -1 {
|
||||
section = section[:endIdx+1]
|
||||
}
|
||||
|
||||
// Extract base prompt
|
||||
if base := extractTOMLMultiline(section, "base"); base != "" {
|
||||
prompts["base"] = base
|
||||
}
|
||||
|
||||
return prompts
|
||||
}
|
||||
|
||||
// generateFormulaShortID generates a short random ID (5 lowercase chars)
|
||||
func generateFormulaShortID() string {
|
||||
b := make([]byte, 3)
|
||||
rand.Read(b)
|
||||
return strings.ToLower(base32.StdEncoding.EncodeToString(b)[:5])
|
||||
}
|
||||
|
||||
// runFormulaCreate creates a new formula template
|
||||
func runFormulaCreate(cmd *cobra.Command, args []string) error {
|
||||
formulaName := args[0]
|
||||
|
||||
Reference in New Issue
Block a user