Files
gastown/internal/cmd/convoy.go
furiosa aef99753df feat(convoy): add specific convoy ID check and dry-run flag
Add support for checking a specific convoy by ID instead of all convoys:
- `gt convoy check <convoy-id>` - check specific convoy
- `gt convoy check` - check all (existing behavior)
- `gt convoy check --dry-run` - preview mode

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

1685 lines
47 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cmd
import (
"bytes"
"crypto/rand"
"encoding/base32"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tui/convoy"
"github.com/steveyegge/gastown/internal/workspace"
)
// generateShortID generates a short random ID (5 lowercase chars).
func generateShortID() string {
b := make([]byte, 3)
_, _ = rand.Read(b)
return strings.ToLower(base32.StdEncoding.EncodeToString(b)[:5])
}
// looksLikeIssueID checks if a string looks like a beads issue ID.
// Issue IDs have the format: prefix-id (e.g., gt-abc, bd-xyz, hq-123).
func looksLikeIssueID(s string) bool {
// Common beads prefixes
prefixes := []string{"gt-", "bd-", "hq-"}
for _, prefix := range prefixes {
if strings.HasPrefix(s, prefix) {
return true
}
}
// Also check for pattern: 2-3 lowercase letters followed by hyphen
// This catches custom prefixes defined in routes.jsonl
if len(s) >= 4 && s[2] == '-' || (len(s) >= 5 && s[3] == '-') {
hyphenIdx := strings.Index(s, "-")
if hyphenIdx >= 2 && hyphenIdx <= 3 {
prefix := s[:hyphenIdx]
// Check if prefix is all lowercase letters
allLower := true
for _, c := range prefix {
if c < 'a' || c > 'z' {
allLower = false
break
}
}
return allLower
}
}
return false
}
// Convoy command flags
var (
convoyMolecule string
convoyNotify string
convoyOwner string
convoyStatusJSON bool
convoyListJSON bool
convoyListStatus string
convoyListAll bool
convoyListTree bool
convoyInteractive bool
convoyStrandedJSON bool
convoyCloseReason string
convoyCloseNotify string
convoyCheckDryRun bool
)
var convoyCmd = &cobra.Command{
Use: "convoy",
GroupID: GroupWork,
Short: "Track batches of work across rigs",
RunE: func(cmd *cobra.Command, args []string) error {
if convoyInteractive {
return runConvoyTUI()
}
return requireSubcommand(cmd, args)
},
Long: `Manage convoys - the primary unit for tracking batched work.
A convoy is a persistent tracking unit that monitors related issues across
rigs. When you kick off work (even a single issue), a convoy tracks it so
you can see when it lands and what was included.
WHAT IS A CONVOY:
- Persistent tracking unit with an ID (hq-*)
- Tracks issues across rigs (frontend+backend, beads+gastown, etc.)
- Auto-closes when all tracked issues complete → notifies subscribers
- Can be reopened by adding more issues
WHAT IS A SWARM:
- Ephemeral: "the workers currently assigned to a convoy's issues"
- No separate ID - uses the convoy ID
- Dissolves when work completes
TRACKING SEMANTICS:
- 'tracks' relation is non-blocking (tracked issues don't block convoy)
- Cross-prefix capable (convoy in hq-* tracks issues in gt-*, bd-*)
- Landed: all tracked issues closed → notification sent to subscribers
COMMANDS:
create Create a convoy tracking specified issues
add Add issues to an existing convoy (reopens if closed)
close Close a convoy (manually, regardless of tracked issue status)
status Show convoy progress, tracked issues, and active workers
list List convoys (the dashboard view)`,
}
var convoyCreateCmd = &cobra.Command{
Use: "create <name> [issues...]",
Short: "Create a new convoy",
Long: `Create a new convoy that tracks the specified issues.
The convoy is created in town-level beads (hq-* prefix) and can track
issues across any rig.
The --owner flag specifies who requested the convoy (receives completion
notification by default). If not specified, defaults to created_by.
The --notify flag adds additional subscribers beyond the owner.
Examples:
gt convoy create "Deploy v2.0" gt-abc bd-xyz
gt convoy create "Release prep" gt-abc --notify # defaults to mayor/
gt convoy create "Release prep" gt-abc --notify ops/ # notify ops/
gt convoy create "Feature rollout" gt-a gt-b --owner mayor/ --notify ops/
gt convoy create "Feature rollout" gt-a gt-b gt-c --molecule mol-release`,
Args: cobra.MinimumNArgs(1),
RunE: runConvoyCreate,
}
var convoyStatusCmd = &cobra.Command{
Use: "status [convoy-id]",
Short: "Show convoy status",
Long: `Show detailed status for a convoy.
Displays convoy metadata, tracked issues, and completion progress.
Without an ID, shows status of all active convoys.`,
Args: cobra.MaximumNArgs(1),
RunE: runConvoyStatus,
}
var convoyListCmd = &cobra.Command{
Use: "list",
Short: "List convoys",
Long: `List convoys, showing open convoys by default.
Examples:
gt convoy list # Open convoys only (default)
gt convoy list --all # All convoys (open + closed)
gt convoy list --status=closed # Recently landed
gt convoy list --tree # Show convoy + child status tree
gt convoy list --json`,
RunE: runConvoyList,
}
var convoyAddCmd = &cobra.Command{
Use: "add <convoy-id> <issue-id> [issue-id...]",
Short: "Add issues to an existing convoy",
Long: `Add issues to an existing convoy.
If the convoy is closed, it will be automatically reopened.
Examples:
gt convoy add hq-cv-abc gt-new-issue
gt convoy add hq-cv-abc gt-issue1 gt-issue2 gt-issue3`,
Args: cobra.MinimumNArgs(2),
RunE: runConvoyAdd,
}
var convoyCheckCmd = &cobra.Command{
Use: "check [convoy-id]",
Short: "Check and auto-close completed convoys",
Long: `Check convoys and auto-close any where all tracked issues are complete.
Without arguments, checks all open convoys. With a convoy ID, checks only that convoy.
This handles cross-rig convoy completion: convoys in town beads tracking issues
in rig beads won't auto-close via bd close alone. This command bridges that gap.
Can be run manually or by deacon patrol to ensure convoys close promptly.
Examples:
gt convoy check # Check all open convoys
gt convoy check hq-cv-abc # Check specific convoy
gt convoy check --dry-run # Preview what would close without acting`,
Args: cobra.MaximumNArgs(1),
RunE: runConvoyCheck,
}
var convoyStrandedCmd = &cobra.Command{
Use: "stranded",
Short: "Find stranded convoys with ready work but no workers",
Long: `Find convoys that have ready issues but no workers processing them.
A convoy is "stranded" when:
- Convoy is open
- Has tracked issues where:
- status = open (not in_progress, not closed)
- not blocked (all dependencies met)
- no assignee OR assignee session is dead
Use this to detect convoys that need feeding. The Deacon patrol runs this
periodically and dispatches dogs to feed stranded convoys.
Examples:
gt convoy stranded # Show stranded convoys
gt convoy stranded --json # Machine-readable output for automation`,
RunE: runConvoyStranded,
}
var convoyCloseCmd = &cobra.Command{
Use: "close <convoy-id>",
Short: "Close a convoy",
Long: `Close a convoy, optionally with a reason.
Closes the convoy regardless of tracked issue status. Use this to:
- Force-close abandoned convoys no longer relevant
- Close convoys where work completed outside the tracked path
- Manually close stuck convoys
The close is idempotent - closing an already-closed convoy is a no-op.
Examples:
gt convoy close hq-cv-abc
gt convoy close hq-cv-abc --reason="work done differently"
gt convoy close hq-cv-xyz --notify mayor/`,
Args: cobra.ExactArgs(1),
RunE: runConvoyClose,
}
func init() {
// Create flags
convoyCreateCmd.Flags().StringVar(&convoyMolecule, "molecule", "", "Associated molecule ID")
convoyCreateCmd.Flags().StringVar(&convoyOwner, "owner", "", "Owner who requested convoy (gets completion notification)")
convoyCreateCmd.Flags().StringVar(&convoyNotify, "notify", "", "Additional address to notify on completion (default: mayor/ if flag used without value)")
convoyCreateCmd.Flags().Lookup("notify").NoOptDefVal = "mayor/"
// Status flags
convoyStatusCmd.Flags().BoolVar(&convoyStatusJSON, "json", false, "Output as JSON")
// List flags
convoyListCmd.Flags().BoolVar(&convoyListJSON, "json", false, "Output as JSON")
convoyListCmd.Flags().StringVar(&convoyListStatus, "status", "", "Filter by status (open, closed)")
convoyListCmd.Flags().BoolVar(&convoyListAll, "all", false, "Show all convoys (open and closed)")
convoyListCmd.Flags().BoolVar(&convoyListTree, "tree", false, "Show convoy + child status tree")
// Interactive TUI flag (on parent command)
convoyCmd.Flags().BoolVarP(&convoyInteractive, "interactive", "i", false, "Interactive tree view")
// Check flags
convoyCheckCmd.Flags().BoolVar(&convoyCheckDryRun, "dry-run", false, "Preview what would close without acting")
// Stranded flags
convoyStrandedCmd.Flags().BoolVar(&convoyStrandedJSON, "json", false, "Output as JSON")
// Close flags
convoyCloseCmd.Flags().StringVar(&convoyCloseReason, "reason", "", "Reason for closing the convoy")
convoyCloseCmd.Flags().StringVar(&convoyCloseNotify, "notify", "", "Agent to notify on close (e.g., mayor/)")
// Add subcommands
convoyCmd.AddCommand(convoyCreateCmd)
convoyCmd.AddCommand(convoyStatusCmd)
convoyCmd.AddCommand(convoyListCmd)
convoyCmd.AddCommand(convoyAddCmd)
convoyCmd.AddCommand(convoyCheckCmd)
convoyCmd.AddCommand(convoyStrandedCmd)
convoyCmd.AddCommand(convoyCloseCmd)
rootCmd.AddCommand(convoyCmd)
}
// getTownBeadsDir returns the path to town-level beads directory.
func getTownBeadsDir() (string, error) {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return "", fmt.Errorf("not in a Gas Town workspace: %w", err)
}
return filepath.Join(townRoot, ".beads"), nil
}
func runConvoyCreate(cmd *cobra.Command, args []string) error {
name := args[0]
trackedIssues := args[1:]
// If first arg looks like an issue ID (has beads prefix), treat all args as issues
// and auto-generate a name from the first issue's title
if looksLikeIssueID(name) {
trackedIssues = args // All args are issue IDs
// Get the first issue's title to use as convoy name
if details := getIssueDetails(args[0]); details != nil && details.Title != "" {
name = details.Title
} else {
name = fmt.Sprintf("Tracking %s", args[0])
}
}
townBeads, err := getTownBeadsDir()
if err != nil {
return err
}
// Create convoy issue in town beads
description := fmt.Sprintf("Convoy tracking %d issues", len(trackedIssues))
// Default owner to creator identity if not specified
owner := convoyOwner
if owner == "" {
owner = detectSender()
}
if owner != "" {
description += fmt.Sprintf("\nOwner: %s", owner)
}
if convoyNotify != "" {
description += fmt.Sprintf("\nNotify: %s", convoyNotify)
}
if convoyMolecule != "" {
description += fmt.Sprintf("\nMolecule: %s", convoyMolecule)
}
// Generate convoy ID with cv- prefix
convoyID := fmt.Sprintf("hq-cv-%s", generateShortID())
createArgs := []string{
"create",
"--type=convoy",
"--id=" + convoyID,
"--title=" + name,
"--description=" + description,
"--json",
}
if beads.NeedsForceForID(convoyID) {
createArgs = append(createArgs, "--force")
}
createCmd := exec.Command("bd", createArgs...)
createCmd.Dir = townBeads
var stdout bytes.Buffer
var stderr bytes.Buffer
createCmd.Stdout = &stdout
createCmd.Stderr = &stderr
if err := createCmd.Run(); err != nil {
return fmt.Errorf("creating convoy: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
// Notify address is stored in description (line 166-168) and read from there
// Add 'tracks' relations for each tracked issue
trackedCount := 0
for _, issueID := range trackedIssues {
// Use --type=tracks for non-blocking tracking relation
depArgs := []string{"dep", "add", convoyID, issueID, "--type=tracks"}
depCmd := exec.Command("bd", depArgs...)
depCmd.Dir = townBeads
var depStderr bytes.Buffer
depCmd.Stderr = &depStderr
if err := depCmd.Run(); err != nil {
errMsg := strings.TrimSpace(depStderr.String())
if errMsg == "" {
errMsg = err.Error()
}
style.PrintWarning("couldn't track %s: %s", issueID, errMsg)
} else {
trackedCount++
}
}
// Output
fmt.Printf("%s Created convoy 🚚 %s\n\n", style.Bold.Render("✓"), convoyID)
fmt.Printf(" Name: %s\n", name)
fmt.Printf(" Tracking: %d issues\n", trackedCount)
if len(trackedIssues) > 0 {
fmt.Printf(" Issues: %s\n", strings.Join(trackedIssues, ", "))
}
if owner != "" {
fmt.Printf(" Owner: %s\n", owner)
}
if convoyNotify != "" {
fmt.Printf(" Notify: %s\n", convoyNotify)
}
if convoyMolecule != "" {
fmt.Printf(" Molecule: %s\n", convoyMolecule)
}
fmt.Printf("\n %s\n", style.Dim.Render("Convoy auto-closes when all tracked issues complete"))
return nil
}
func runConvoyAdd(cmd *cobra.Command, args []string) error {
convoyID := args[0]
issuesToAdd := args[1:]
townBeads, err := getTownBeadsDir()
if err != nil {
return err
}
// Validate convoy exists and get its status
showArgs := []string{"show", convoyID, "--json"}
showCmd := exec.Command("bd", showArgs...)
showCmd.Dir = townBeads
var stdout bytes.Buffer
showCmd.Stdout = &stdout
if err := showCmd.Run(); err != nil {
return fmt.Errorf("convoy '%s' not found", convoyID)
}
var convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Type string `json:"issue_type"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return fmt.Errorf("parsing convoy data: %w", err)
}
if len(convoys) == 0 {
return fmt.Errorf("convoy '%s' not found", convoyID)
}
convoy := convoys[0]
// Verify it's actually a convoy type
if convoy.Type != "convoy" {
return fmt.Errorf("'%s' is not a convoy (type: %s)", convoyID, convoy.Type)
}
// If convoy is closed, reopen it
reopened := false
if convoy.Status == "closed" {
reopenArgs := []string{"update", convoyID, "--status=open"}
reopenCmd := exec.Command("bd", reopenArgs...)
reopenCmd.Dir = townBeads
if err := reopenCmd.Run(); err != nil {
return fmt.Errorf("couldn't reopen convoy: %w", err)
}
reopened = true
fmt.Printf("%s Reopened convoy %s\n", style.Bold.Render("↺"), convoyID)
}
// Add 'tracks' relations for each issue
addedCount := 0
for _, issueID := range issuesToAdd {
depArgs := []string{"dep", "add", convoyID, issueID, "--type=tracks"}
depCmd := exec.Command("bd", depArgs...)
depCmd.Dir = townBeads
var depStderr bytes.Buffer
depCmd.Stderr = &depStderr
if err := depCmd.Run(); err != nil {
errMsg := strings.TrimSpace(depStderr.String())
if errMsg == "" {
errMsg = err.Error()
}
style.PrintWarning("couldn't add %s: %s", issueID, errMsg)
} else {
addedCount++
}
}
// Output
if reopened {
fmt.Println()
}
fmt.Printf("%s Added %d issue(s) to convoy 🚚 %s\n", style.Bold.Render("✓"), addedCount, convoyID)
if addedCount > 0 {
fmt.Printf(" Issues: %s\n", strings.Join(issuesToAdd[:addedCount], ", "))
}
return nil
}
func runConvoyCheck(cmd *cobra.Command, args []string) error {
townBeads, err := getTownBeadsDir()
if err != nil {
return err
}
// If a specific convoy ID is provided, check only that convoy
if len(args) == 1 {
convoyID := args[0]
return checkSingleConvoy(townBeads, convoyID, convoyCheckDryRun)
}
// Check all open convoys
closed, err := checkAndCloseCompletedConvoys(townBeads, convoyCheckDryRun)
if err != nil {
return err
}
if len(closed) == 0 {
fmt.Println("No convoys ready to close.")
} else {
if convoyCheckDryRun {
fmt.Printf("%s Would auto-close %d convoy(s):\n", style.Warning.Render("⚠"), len(closed))
} else {
fmt.Printf("%s Auto-closed %d convoy(s):\n", style.Bold.Render("✓"), len(closed))
}
for _, c := range closed {
fmt.Printf(" 🚚 %s: %s\n", c.ID, c.Title)
}
}
return nil
}
// checkSingleConvoy checks a specific convoy and closes it if all tracked issues are complete.
func checkSingleConvoy(townBeads, convoyID string, dryRun bool) error {
// Get convoy details
showArgs := []string{"show", convoyID, "--json"}
showCmd := exec.Command("bd", showArgs...)
showCmd.Dir = townBeads
var stdout bytes.Buffer
showCmd.Stdout = &stdout
if err := showCmd.Run(); err != nil {
return fmt.Errorf("convoy '%s' not found", convoyID)
}
var convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Type string `json:"issue_type"`
Description string `json:"description"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return fmt.Errorf("parsing convoy data: %w", err)
}
if len(convoys) == 0 {
return fmt.Errorf("convoy '%s' not found", convoyID)
}
convoy := convoys[0]
// Verify it's actually a convoy type
if convoy.Type != "convoy" {
return fmt.Errorf("'%s' is not a convoy (type: %s)", convoyID, convoy.Type)
}
// Check if convoy is already closed
if convoy.Status == "closed" {
fmt.Printf("%s Convoy %s is already closed\n", style.Dim.Render("○"), convoyID)
return nil
}
// Get tracked issues
tracked := getTrackedIssues(townBeads, convoyID)
if len(tracked) == 0 {
fmt.Printf("%s Convoy %s has no tracked issues\n", style.Dim.Render("○"), convoyID)
return nil
}
// Check if all tracked issues are closed
allClosed := true
openCount := 0
for _, t := range tracked {
if t.Status != "closed" && t.Status != "tombstone" {
allClosed = false
openCount++
}
}
if !allClosed {
fmt.Printf("%s Convoy %s has %d open issue(s) remaining\n", style.Dim.Render("○"), convoyID, openCount)
return nil
}
// All tracked issues are complete - close the convoy
if dryRun {
fmt.Printf("%s Would auto-close convoy 🚚 %s: %s\n", style.Warning.Render("⚠"), convoyID, convoy.Title)
return nil
}
// Actually close the convoy
closeArgs := []string{"close", convoyID, "-r", "All tracked issues completed"}
closeCmd := exec.Command("bd", closeArgs...)
closeCmd.Dir = townBeads
if err := closeCmd.Run(); err != nil {
return fmt.Errorf("closing convoy: %w", err)
}
fmt.Printf("%s Auto-closed convoy 🚚 %s: %s\n", style.Bold.Render("✓"), convoyID, convoy.Title)
// Send completion notification
notifyConvoyCompletion(townBeads, convoyID, convoy.Title)
return nil
}
func runConvoyClose(cmd *cobra.Command, args []string) error {
convoyID := args[0]
townBeads, err := getTownBeadsDir()
if err != nil {
return err
}
// Get convoy details
showArgs := []string{"show", convoyID, "--json"}
showCmd := exec.Command("bd", showArgs...)
showCmd.Dir = townBeads
var stdout bytes.Buffer
showCmd.Stdout = &stdout
if err := showCmd.Run(); err != nil {
return fmt.Errorf("convoy '%s' not found", convoyID)
}
var convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Type string `json:"issue_type"`
Description string `json:"description"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return fmt.Errorf("parsing convoy data: %w", err)
}
if len(convoys) == 0 {
return fmt.Errorf("convoy '%s' not found", convoyID)
}
convoy := convoys[0]
// Verify it's actually a convoy type
if convoy.Type != "convoy" {
return fmt.Errorf("'%s' is not a convoy (type: %s)", convoyID, convoy.Type)
}
// Idempotent: if already closed, just report it
if convoy.Status == "closed" {
fmt.Printf("%s Convoy %s is already closed\n", style.Dim.Render("○"), convoyID)
return nil
}
// Build close reason
reason := convoyCloseReason
if reason == "" {
reason = "Manually closed"
}
// Close the convoy
closeArgs := []string{"close", convoyID, "-r", reason}
closeCmd := exec.Command("bd", closeArgs...)
closeCmd.Dir = townBeads
if err := closeCmd.Run(); err != nil {
return fmt.Errorf("closing convoy: %w", err)
}
fmt.Printf("%s Closed convoy 🚚 %s: %s\n", style.Bold.Render("✓"), convoyID, convoy.Title)
if convoyCloseReason != "" {
fmt.Printf(" Reason: %s\n", convoyCloseReason)
}
// Send notification if --notify flag provided
if convoyCloseNotify != "" {
sendCloseNotification(convoyCloseNotify, convoyID, convoy.Title, reason)
} else {
// Check if convoy has a notify address in description
notifyConvoyCompletion(townBeads, convoyID, convoy.Title)
}
return nil
}
// sendCloseNotification sends a notification about convoy closure.
func sendCloseNotification(addr, convoyID, title, reason string) {
subject := fmt.Sprintf("🚚 Convoy closed: %s", title)
body := fmt.Sprintf("Convoy %s has been closed.\n\nReason: %s", convoyID, reason)
mailArgs := []string{"mail", "send", addr, "-s", subject, "-m", body}
mailCmd := exec.Command("gt", mailArgs...)
if err := mailCmd.Run(); err != nil {
style.PrintWarning("couldn't send notification: %v", err)
} else {
fmt.Printf(" Notified: %s\n", addr)
}
}
// strandedConvoyInfo holds info about a stranded convoy.
type strandedConvoyInfo struct {
ID string `json:"id"`
Title string `json:"title"`
ReadyCount int `json:"ready_count"`
ReadyIssues []string `json:"ready_issues"`
}
// readyIssueInfo holds info about a ready (stranded) issue.
type readyIssueInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Priority string `json:"priority"`
}
func runConvoyStranded(cmd *cobra.Command, args []string) error {
townBeads, err := getTownBeadsDir()
if err != nil {
return err
}
stranded, err := findStrandedConvoys(townBeads)
if err != nil {
return err
}
if convoyStrandedJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(stranded)
}
if len(stranded) == 0 {
fmt.Println("No stranded convoys found.")
return nil
}
fmt.Printf("%s Found %d stranded convoy(s):\n\n", style.Warning.Render("⚠"), len(stranded))
for _, s := range stranded {
fmt.Printf(" 🚚 %s: %s\n", s.ID, s.Title)
fmt.Printf(" Ready issues: %d\n", s.ReadyCount)
for _, issueID := range s.ReadyIssues {
fmt.Printf(" • %s\n", issueID)
}
fmt.Println()
}
fmt.Println("To feed stranded convoys, run:")
for _, s := range stranded {
fmt.Printf(" gt sling mol-convoy-feed deacon/dogs --var convoy=%s\n", s.ID)
}
return nil
}
// findStrandedConvoys finds convoys with ready work but no workers.
func findStrandedConvoys(townBeads string) ([]strandedConvoyInfo, error) {
var stranded []strandedConvoyInfo
// Get blocked issues (we need this to filter out blocked issues)
blockedIssues := getBlockedIssueIDs()
// List all open convoys
listArgs := []string{"list", "--type=convoy", "--status=open", "--json"}
listCmd := exec.Command("bd", listArgs...)
listCmd.Dir = townBeads
var stdout bytes.Buffer
listCmd.Stdout = &stdout
if err := listCmd.Run(); err != nil {
return nil, fmt.Errorf("listing convoys: %w", err)
}
var convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return nil, fmt.Errorf("parsing convoy list: %w", err)
}
// Check each convoy for stranded state
for _, convoy := range convoys {
tracked := getTrackedIssues(townBeads, convoy.ID)
if len(tracked) == 0 {
continue
}
// Find ready issues (open, not blocked, no live assignee)
var readyIssues []string
for _, t := range tracked {
if isReadyIssue(t, blockedIssues) {
readyIssues = append(readyIssues, t.ID)
}
}
if len(readyIssues) > 0 {
stranded = append(stranded, strandedConvoyInfo{
ID: convoy.ID,
Title: convoy.Title,
ReadyCount: len(readyIssues),
ReadyIssues: readyIssues,
})
}
}
return stranded, nil
}
// getBlockedIssueIDs returns a set of issue IDs that are currently blocked.
func getBlockedIssueIDs() map[string]bool {
blocked := make(map[string]bool)
// Run bd blocked --json
blockedCmd := exec.Command("bd", "blocked", "--json")
var stdout bytes.Buffer
blockedCmd.Stdout = &stdout
if err := blockedCmd.Run(); err != nil {
return blocked // Return empty set on error
}
var issues []struct {
ID string `json:"id"`
}
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
return blocked
}
for _, issue := range issues {
blocked[issue.ID] = true
}
return blocked
}
// isReadyIssue checks if an issue is ready for dispatch (stranded).
// An issue is ready if:
// - status = "open" (not in_progress, closed, hooked)
// - not in blocked set
// - no assignee OR assignee session is dead
func isReadyIssue(t trackedIssueInfo, blockedIssues map[string]bool) bool {
// Must be open status (not in_progress, closed, hooked)
if t.Status != "open" {
return false
}
// Must not be blocked
if blockedIssues[t.ID] {
return false
}
// Check assignee
if t.Assignee == "" {
return true // No assignee = ready
}
// Has assignee - check if session is alive
// Use the shared assigneeToSessionName from rig.go
sessionName, _ := assigneeToSessionName(t.Assignee)
if sessionName == "" {
return true // Can't determine session = treat as ready
}
// Check if tmux session exists
checkCmd := exec.Command("tmux", "has-session", "-t", sessionName)
if err := checkCmd.Run(); err != nil {
return true // Session doesn't exist = ready
}
return false // Session exists = not ready (worker is active)
}
// checkAndCloseCompletedConvoys finds open convoys where all tracked issues are closed
// and auto-closes them. Returns the list of convoys that were closed (or would be closed in dry-run mode).
// If dryRun is true, no changes are made and the function returns what would have been closed.
func checkAndCloseCompletedConvoys(townBeads string, dryRun bool) ([]struct{ ID, Title string }, error) {
var closed []struct{ ID, Title string }
// List all open convoys
listArgs := []string{"list", "--type=convoy", "--status=open", "--json"}
listCmd := exec.Command("bd", listArgs...)
listCmd.Dir = townBeads
var stdout bytes.Buffer
listCmd.Stdout = &stdout
if err := listCmd.Run(); err != nil {
return nil, fmt.Errorf("listing convoys: %w", err)
}
var convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return nil, fmt.Errorf("parsing convoy list: %w", err)
}
// Check each convoy
for _, convoy := range convoys {
tracked := getTrackedIssues(townBeads, convoy.ID)
if len(tracked) == 0 {
continue // No tracked issues, nothing to check
}
// Check if all tracked issues are closed
allClosed := true
for _, t := range tracked {
if t.Status != "closed" && t.Status != "tombstone" {
allClosed = false
break
}
}
if allClosed {
if dryRun {
// In dry-run mode, just record what would be closed
closed = append(closed, struct{ ID, Title string }{convoy.ID, convoy.Title})
continue
}
// Close the convoy
closeArgs := []string{"close", convoy.ID, "-r", "All tracked issues completed"}
closeCmd := exec.Command("bd", closeArgs...)
closeCmd.Dir = townBeads
if err := closeCmd.Run(); err != nil {
style.PrintWarning("couldn't close convoy %s: %v", convoy.ID, err)
continue
}
closed = append(closed, struct{ ID, Title string }{convoy.ID, convoy.Title})
// Check if convoy has notify address and send notification
notifyConvoyCompletion(townBeads, convoy.ID, convoy.Title)
}
}
return closed, nil
}
// notifyConvoyCompletion sends notifications to owner and any notify addresses.
func notifyConvoyCompletion(townBeads, convoyID, title string) {
// Get convoy description to find owner and notify addresses
showArgs := []string{"show", convoyID, "--json"}
showCmd := exec.Command("bd", showArgs...)
showCmd.Dir = townBeads
var stdout bytes.Buffer
showCmd.Stdout = &stdout
if err := showCmd.Run(); err != nil {
return
}
var convoys []struct {
Description string `json:"description"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil || len(convoys) == 0 {
return
}
// Parse owner and notify addresses from description
desc := convoys[0].Description
notified := make(map[string]bool) // Track who we've notified to avoid duplicates
for _, line := range strings.Split(desc, "\n") {
var addr string
if strings.HasPrefix(line, "Owner: ") {
addr = strings.TrimPrefix(line, "Owner: ")
} else if strings.HasPrefix(line, "Notify: ") {
addr = strings.TrimPrefix(line, "Notify: ")
}
if addr != "" && !notified[addr] {
// Send notification via gt mail
mailArgs := []string{"mail", "send", addr,
"-s", fmt.Sprintf("🚚 Convoy landed: %s", title),
"-m", fmt.Sprintf("Convoy %s has completed.\n\nAll tracked issues are now closed.", convoyID)}
mailCmd := exec.Command("gt", mailArgs...)
_ = mailCmd.Run() // Best effort, ignore errors
notified[addr] = true
}
}
}
func runConvoyStatus(cmd *cobra.Command, args []string) error {
townBeads, err := getTownBeadsDir()
if err != nil {
return err
}
// If no ID provided, show all active convoys
if len(args) == 0 {
return showAllConvoyStatus(townBeads)
}
convoyID := args[0]
// Check if it's a numeric shortcut (e.g., "1" instead of "hq-cv-xyz")
if n, err := strconv.Atoi(convoyID); err == nil && n > 0 {
resolved, err := resolveConvoyNumber(townBeads, n)
if err != nil {
return err
}
convoyID = resolved
}
// Get convoy details
showArgs := []string{"show", convoyID, "--json"}
showCmd := exec.Command("bd", showArgs...)
showCmd.Dir = townBeads
var stdout bytes.Buffer
showCmd.Stdout = &stdout
if err := showCmd.Run(); err != nil {
return fmt.Errorf("convoy '%s' not found", convoyID)
}
// Parse convoy data
var convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
ClosedAt string `json:"closed_at,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return fmt.Errorf("parsing convoy data: %w", err)
}
if len(convoys) == 0 {
return fmt.Errorf("convoy '%s' not found", convoyID)
}
convoy := convoys[0]
// Get tracked issues by querying SQLite directly
// (bd dep list doesn't properly show cross-rig external dependencies)
type trackedIssue struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Type string `json:"dependency_type"`
IssueType string `json:"issue_type"`
}
tracked := getTrackedIssues(townBeads, convoyID)
// Count completed
completed := 0
for _, t := range tracked {
if t.Status == "closed" {
completed++
}
}
if convoyStatusJSON {
type jsonStatus struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Tracked []trackedIssueInfo `json:"tracked"`
Completed int `json:"completed"`
Total int `json:"total"`
}
out := jsonStatus{
ID: convoy.ID,
Title: convoy.Title,
Status: convoy.Status,
Tracked: tracked,
Completed: completed,
Total: len(tracked),
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
// Human-readable output
fmt.Printf("🚚 %s %s\n\n", style.Bold.Render(convoy.ID+":"), convoy.Title)
fmt.Printf(" Status: %s\n", formatConvoyStatus(convoy.Status))
fmt.Printf(" Progress: %d/%d completed\n", completed, len(tracked))
fmt.Printf(" Created: %s\n", convoy.CreatedAt)
if convoy.ClosedAt != "" {
fmt.Printf(" Closed: %s\n", convoy.ClosedAt)
}
if len(tracked) > 0 {
fmt.Printf("\n %s\n", style.Bold.Render("Tracked Issues:"))
for _, t := range tracked {
// Status symbol: ✓ closed, ▶ in_progress/hooked, ○ other
status := "○"
switch t.Status {
case "closed":
status = "✓"
case "in_progress", "hooked":
status = "▶"
}
// Show assignee in brackets (extract short name from path like gastown/polecats/goose -> goose)
bracketContent := t.IssueType
if t.Assignee != "" {
parts := strings.Split(t.Assignee, "/")
bracketContent = parts[len(parts)-1] // Last part of path
} else if bracketContent == "" {
bracketContent = "unassigned"
}
line := fmt.Sprintf(" %s %s: %s [%s]", status, t.ID, t.Title, bracketContent)
if t.Worker != "" {
workerDisplay := "@" + t.Worker
if t.WorkerAge != "" {
workerDisplay += fmt.Sprintf(" (%s)", t.WorkerAge)
}
line += fmt.Sprintf(" %s", style.Dim.Render(workerDisplay))
}
fmt.Println(line)
}
}
return nil
}
func showAllConvoyStatus(townBeads string) error {
// List all convoy-type issues
listArgs := []string{"list", "--type=convoy", "--status=open", "--json"}
listCmd := exec.Command("bd", listArgs...)
listCmd.Dir = townBeads
var stdout bytes.Buffer
listCmd.Stdout = &stdout
if err := listCmd.Run(); err != nil {
return fmt.Errorf("listing convoys: %w", err)
}
var convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return fmt.Errorf("parsing convoy list: %w", err)
}
if len(convoys) == 0 {
fmt.Println("No active convoys.")
fmt.Println("Create a convoy with: gt convoy create <name> [issues...]")
return nil
}
if convoyStatusJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(convoys)
}
fmt.Printf("%s\n\n", style.Bold.Render("Active Convoys"))
for _, c := range convoys {
fmt.Printf(" 🚚 %s: %s\n", c.ID, c.Title)
}
fmt.Printf("\nUse 'gt convoy status <id>' for detailed status.\n")
return nil
}
func runConvoyList(cmd *cobra.Command, args []string) error {
townBeads, err := getTownBeadsDir()
if err != nil {
return err
}
// List convoy-type issues
listArgs := []string{"list", "--type=convoy", "--json"}
if convoyListStatus != "" {
listArgs = append(listArgs, "--status="+convoyListStatus)
} else if convoyListAll {
listArgs = append(listArgs, "--all")
}
// Default (no flags) = open only (bd's default behavior)
listCmd := exec.Command("bd", listArgs...)
listCmd.Dir = townBeads
var stdout bytes.Buffer
listCmd.Stdout = &stdout
if err := listCmd.Run(); err != nil {
return fmt.Errorf("listing convoys: %w", err)
}
var convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return fmt.Errorf("parsing convoy list: %w", err)
}
if convoyListJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(convoys)
}
if len(convoys) == 0 {
fmt.Println("No convoys found.")
fmt.Println("Create a convoy with: gt convoy create <name> [issues...]")
return nil
}
// Tree view: show convoys with their child issues
if convoyListTree {
return printConvoyTree(townBeads, convoys)
}
fmt.Printf("%s\n\n", style.Bold.Render("Convoys"))
for i, c := range convoys {
status := formatConvoyStatus(c.Status)
fmt.Printf(" %d. 🚚 %s: %s %s\n", i+1, c.ID, c.Title, status)
}
fmt.Printf("\nUse 'gt convoy status <id>' or 'gt convoy status <n>' for detailed view.\n")
return nil
}
// printConvoyTree displays convoys with their child issues in a tree format.
func printConvoyTree(townBeads string, convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}) error {
for _, c := range convoys {
// Get tracked issues for this convoy
tracked := getTrackedIssues(townBeads, c.ID)
// Count completed
completed := 0
for _, t := range tracked {
if t.Status == "closed" {
completed++
}
}
// Print convoy header with progress
total := len(tracked)
progress := ""
if total > 0 {
progress = fmt.Sprintf(" (%d/%d)", completed, total)
}
fmt.Printf("🚚 %s: %s%s\n", c.ID, c.Title, progress)
// Print tracked issues as tree children
for i, t := range tracked {
// Determine tree connector
isLast := i == len(tracked)-1
connector := "├──"
if isLast {
connector = "└──"
}
// Status symbol: ✓ closed, ▶ in_progress/hooked, ○ other
status := "○"
switch t.Status {
case "closed":
status = "✓"
case "in_progress", "hooked":
status = "▶"
}
fmt.Printf("%s %s %s: %s\n", connector, status, t.ID, t.Title)
}
// Add blank line between convoys
fmt.Println()
}
return nil
}
func formatConvoyStatus(status string) string {
switch status {
case "open":
return style.Warning.Render("●")
case "closed":
return style.Success.Render("✓")
case "in_progress":
return style.Info.Render("→")
default:
return status
}
}
// trackedIssueInfo holds info about an issue being tracked by a convoy.
type trackedIssueInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Type string `json:"dependency_type"`
IssueType string `json:"issue_type"`
Assignee string `json:"assignee,omitempty"` // Assigned agent (e.g., gastown/polecats/goose)
Worker string `json:"worker,omitempty"` // Worker currently assigned (e.g., gastown/nux)
WorkerAge string `json:"worker_age,omitempty"` // How long worker has been on this issue
}
// getTrackedIssues queries SQLite directly to get issues tracked by a convoy.
// This is needed because bd dep list doesn't properly show cross-rig external dependencies.
// Uses batched lookup to avoid N+1 subprocess calls.
func getTrackedIssues(townBeads, convoyID string) []trackedIssueInfo {
dbPath := filepath.Join(townBeads, "beads.db")
// Query tracked dependencies from SQLite
// Escape single quotes to prevent SQL injection
safeConvoyID := strings.ReplaceAll(convoyID, "'", "''")
queryCmd := exec.Command("sqlite3", "-json", dbPath,
fmt.Sprintf(`SELECT depends_on_id, type FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, safeConvoyID))
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
return nil
}
var deps []struct {
DependsOnID string `json:"depends_on_id"`
Type string `json:"type"`
}
if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil {
return nil
}
// First pass: collect all issue IDs (normalized from external refs)
issueIDs := make([]string, 0, len(deps))
idToDepType := make(map[string]string)
for _, dep := range deps {
issueID := dep.DependsOnID
// Handle external reference format: external:rig:issue-id
if strings.HasPrefix(issueID, "external:") {
parts := strings.SplitN(issueID, ":", 3)
if len(parts) == 3 {
issueID = parts[2] // Extract the actual issue ID
}
}
issueIDs = append(issueIDs, issueID)
idToDepType[issueID] = dep.Type
}
// Single batch call to get all issue details
detailsMap := getIssueDetailsBatch(issueIDs)
// Get workers for these issues (only for non-closed issues)
openIssueIDs := make([]string, 0, len(issueIDs))
for _, id := range issueIDs {
if details, ok := detailsMap[id]; ok && details.Status != "closed" {
openIssueIDs = append(openIssueIDs, id)
}
}
workersMap := getWorkersForIssues(openIssueIDs)
// Second pass: build result using the batch lookup
var tracked []trackedIssueInfo
for _, issueID := range issueIDs {
info := trackedIssueInfo{
ID: issueID,
Type: idToDepType[issueID],
}
if details, ok := detailsMap[issueID]; ok {
info.Title = details.Title
info.Status = details.Status
info.IssueType = details.IssueType
info.Assignee = details.Assignee
} else {
info.Title = "(external)"
info.Status = "unknown"
}
// Add worker info if available
if worker, ok := workersMap[issueID]; ok {
info.Worker = worker.Worker
info.WorkerAge = worker.Age
}
tracked = append(tracked, info)
}
return tracked
}
// issueDetails holds basic issue info.
type issueDetails struct {
ID string
Title string
Status string
IssueType string
Assignee string
}
// getIssueDetailsBatch fetches details for multiple issues in a single bd show call.
// Returns a map from issue ID to details. Missing/invalid issues are omitted from the map.
func getIssueDetailsBatch(issueIDs []string) map[string]*issueDetails {
result := make(map[string]*issueDetails)
if len(issueIDs) == 0 {
return result
}
// Build args: bd --no-daemon show id1 id2 id3 ... --json
// Use --no-daemon to ensure fresh data (avoid stale cache from daemon)
args := append([]string{"--no-daemon", "show"}, issueIDs...)
args = append(args, "--json")
showCmd := exec.Command("bd", args...)
var stdout bytes.Buffer
showCmd.Stdout = &stdout
if err := showCmd.Run(); err != nil {
// Batch failed - fall back to individual lookups for robustness
// This handles cases where some IDs are invalid/missing
for _, id := range issueIDs {
if details := getIssueDetails(id); details != nil {
result[id] = details
}
}
return result
}
var issues []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
IssueType string `json:"issue_type"`
Assignee string `json:"assignee"`
}
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
return result
}
for _, issue := range issues {
result[issue.ID] = &issueDetails{
ID: issue.ID,
Title: issue.Title,
Status: issue.Status,
IssueType: issue.IssueType,
Assignee: issue.Assignee,
}
}
return result
}
// getIssueDetails fetches issue details by trying to show it via bd.
// Prefer getIssueDetailsBatch for multiple issues to avoid N+1 subprocess calls.
func getIssueDetails(issueID string) *issueDetails {
// Use bd show with routing - it should find the issue in the right rig
// Use --no-daemon to ensure fresh data (avoid stale cache)
showCmd := exec.Command("bd", "--no-daemon", "show", issueID, "--json")
var stdout bytes.Buffer
showCmd.Stdout = &stdout
if err := showCmd.Run(); err != nil {
return nil
}
// Handle bd --no-daemon exit 0 bug: empty stdout means not found
if stdout.Len() == 0 {
return nil
}
var issues []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
IssueType string `json:"issue_type"`
Assignee string `json:"assignee"`
}
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil || len(issues) == 0 {
return nil
}
return &issueDetails{
ID: issues[0].ID,
Title: issues[0].Title,
Status: issues[0].Status,
IssueType: issues[0].IssueType,
Assignee: issues[0].Assignee,
}
}
// workerInfo holds info about a worker assigned to an issue.
type workerInfo struct {
Worker string // Agent identity (e.g., gastown/nux)
Age string // How long assigned (e.g., "12m")
}
// getWorkersForIssues finds workers currently assigned to the given issues.
// Returns a map from issue ID to worker info.
//
// Optimized to batch queries per rig (O(R) instead of O(N×R)) and
// parallelize across rigs.
func getWorkersForIssues(issueIDs []string) map[string]*workerInfo {
result := make(map[string]*workerInfo)
if len(issueIDs) == 0 {
return result
}
// Find town root
townRoot, err := workspace.FindFromCwd()
if err != nil || townRoot == "" {
return result
}
// Discover rigs with beads databases
rigDirs, _ := filepath.Glob(filepath.Join(townRoot, "*", "polecats"))
var beadsDBS []string
for _, polecatsDir := range rigDirs {
rigDir := filepath.Dir(polecatsDir)
beadsDB := filepath.Join(rigDir, "mayor", "rig", ".beads", "beads.db")
if _, err := os.Stat(beadsDB); err == nil {
beadsDBS = append(beadsDBS, beadsDB)
}
}
if len(beadsDBS) == 0 {
return result
}
// Build the IN clause with properly escaped issue IDs
var quotedIDs []string
for _, id := range issueIDs {
safeID := strings.ReplaceAll(id, "'", "''")
quotedIDs = append(quotedIDs, fmt.Sprintf("'%s'", safeID))
}
inClause := strings.Join(quotedIDs, ", ")
// Batch query: fetch all matching agents in one query per rig
query := fmt.Sprintf(
`SELECT id, hook_bead, last_activity FROM issues WHERE issue_type = 'agent' AND status = 'open' AND hook_bead IN (%s)`,
inClause)
// Query all rigs in parallel
type rigResult struct {
agents []struct {
ID string `json:"id"`
HookBead string `json:"hook_bead"`
LastActivity string `json:"last_activity"`
}
}
resultChan := make(chan rigResult, len(beadsDBS))
var wg sync.WaitGroup
for _, beadsDB := range beadsDBS {
wg.Add(1)
go func(db string) {
defer wg.Done()
queryCmd := exec.Command("sqlite3", "-json", db, query)
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
resultChan <- rigResult{}
return
}
var rr rigResult
if err := json.Unmarshal(stdout.Bytes(), &rr.agents); err != nil {
resultChan <- rigResult{}
return
}
resultChan <- rr
}(beadsDB)
}
// Wait for all queries to complete
go func() {
wg.Wait()
close(resultChan)
}()
// Collect results from all rigs
for rr := range resultChan {
for _, agent := range rr.agents {
// Skip if we already found a worker for this issue
if _, ok := result[agent.HookBead]; ok {
continue
}
// Parse agent ID to get worker identity
workerID := parseWorkerFromAgentBead(agent.ID)
if workerID == "" {
continue
}
// Calculate age from last_activity
age := ""
if agent.LastActivity != "" {
if t, err := time.Parse(time.RFC3339, agent.LastActivity); err == nil {
age = formatWorkerAge(time.Since(t))
}
}
result[agent.HookBead] = &workerInfo{
Worker: workerID,
Age: age,
}
}
}
return result
}
// parseWorkerFromAgentBead extracts worker identity from agent bead ID.
// Input: "gt-gastown-polecat-nux" -> Output: "gastown/nux"
// Input: "gt-beads-crew-amber" -> Output: "beads/crew/amber"
func parseWorkerFromAgentBead(agentID string) string {
// Remove prefix (gt-, bd-, etc.)
parts := strings.Split(agentID, "-")
if len(parts) < 3 {
return ""
}
// Skip prefix
parts = parts[1:]
// Reconstruct as path
return strings.Join(parts, "/")
}
// formatWorkerAge formats a duration as a short string (e.g., "5m", "2h", "1d")
func formatWorkerAge(d time.Duration) string {
if d < time.Minute {
return "<1m"
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh", int(d.Hours()))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
// runConvoyTUI launches the interactive convoy TUI.
func runConvoyTUI() error {
townBeads, err := getTownBeadsDir()
if err != nil {
return err
}
m := convoy.New(townBeads)
p := tea.NewProgram(m, tea.WithAltScreen())
_, err = p.Run()
return err
}
// resolveConvoyNumber converts a numeric shortcut (1, 2, 3...) to a convoy ID.
// Numbers correspond to the order shown in 'gt convoy list'.
func resolveConvoyNumber(townBeads string, n int) (string, error) {
// Get convoy list (same query as runConvoyList)
listArgs := []string{"list", "--type=convoy", "--json"}
listCmd := exec.Command("bd", listArgs...)
listCmd.Dir = townBeads
var stdout bytes.Buffer
listCmd.Stdout = &stdout
if err := listCmd.Run(); err != nil {
return "", fmt.Errorf("listing convoys: %w", err)
}
var convoys []struct {
ID string `json:"id"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return "", fmt.Errorf("parsing convoy list: %w", err)
}
if n < 1 || n > len(convoys) {
return "", fmt.Errorf("convoy %d not found (have %d convoys)", n, len(convoys))
}
return convoys[n-1].ID, nil
}