package feed import ( "bytes" "context" "encoding/json" "fmt" "os/exec" "path/filepath" "regexp" "sort" "strings" "time" "github.com/charmbracelet/lipgloss" ) // convoyIDPattern validates convoy IDs to prevent SQL injection var convoyIDPattern = regexp.MustCompile(`^hq-[a-zA-Z0-9-]+$`) // convoySubprocessTimeout is the timeout for bd and sqlite3 calls in the convoy panel. // Prevents TUI freezing if these commands hang. const convoySubprocessTimeout = 5 * time.Second // Convoy represents a convoy's status for the dashboard type Convoy struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Completed int `json:"completed"` Total int `json:"total"` CreatedAt time.Time `json:"created_at"` ClosedAt time.Time `json:"closed_at,omitempty"` } // ConvoyState holds all convoy data for the panel type ConvoyState struct { InProgress []Convoy Landed []Convoy LastUpdate time.Time } // FetchConvoys retrieves convoy status from town-level beads func FetchConvoys(townRoot string) (*ConvoyState, error) { townBeads := filepath.Join(townRoot, ".beads") state := &ConvoyState{ InProgress: make([]Convoy, 0), Landed: make([]Convoy, 0), LastUpdate: time.Now(), } // Fetch open convoys openConvoys, err := listConvoys(townBeads, "open") if err != nil { // Not a fatal error - just return empty state return state, nil } for _, c := range openConvoys { // Get detailed status for each convoy convoy := enrichConvoy(townBeads, c) state.InProgress = append(state.InProgress, convoy) } // Fetch recently closed convoys (landed in last 24h) closedConvoys, err := listConvoys(townBeads, "closed") if err == nil { cutoff := time.Now().Add(-24 * time.Hour) for _, c := range closedConvoys { convoy := enrichConvoy(townBeads, c) if !convoy.ClosedAt.IsZero() && convoy.ClosedAt.After(cutoff) { state.Landed = append(state.Landed, convoy) } } } // Sort: in-progress by created (oldest first), landed by closed (newest first) sort.Slice(state.InProgress, func(i, j int) bool { return state.InProgress[i].CreatedAt.Before(state.InProgress[j].CreatedAt) }) sort.Slice(state.Landed, func(i, j int) bool { return state.Landed[i].ClosedAt.After(state.Landed[j].ClosedAt) }) return state, nil } // listConvoys returns convoys with the given status func listConvoys(beadsDir, status string) ([]convoyListItem, error) { listArgs := []string{"list", "--type=convoy", "--status=" + status, "--json"} ctx, cancel := context.WithTimeout(context.Background(), convoySubprocessTimeout) defer cancel() cmd := exec.CommandContext(ctx, "bd", listArgs...) //nolint:gosec // G204: args are constructed internally cmd.Dir = beadsDir var stdout bytes.Buffer cmd.Stdout = &stdout if err := cmd.Run(); err != nil { return nil, err } var items []convoyListItem if err := json.Unmarshal(stdout.Bytes(), &items); err != nil { return nil, err } return items, nil } type convoyListItem struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` CreatedAt string `json:"created_at"` ClosedAt string `json:"closed_at,omitempty"` } // enrichConvoy adds tracked issue counts to a convoy func enrichConvoy(beadsDir string, item convoyListItem) Convoy { convoy := Convoy{ ID: item.ID, Title: item.Title, Status: item.Status, } // Parse timestamps if t, err := time.Parse(time.RFC3339, item.CreatedAt); err == nil { convoy.CreatedAt = t } else if t, err := time.Parse("2006-01-02 15:04", item.CreatedAt); err == nil { convoy.CreatedAt = t } if t, err := time.Parse(time.RFC3339, item.ClosedAt); err == nil { convoy.ClosedAt = t } else if t, err := time.Parse("2006-01-02 15:04", item.ClosedAt); err == nil { convoy.ClosedAt = t } // Get tracked issues and their status tracked := getTrackedIssueStatus(beadsDir, item.ID) convoy.Total = len(tracked) for _, t := range tracked { if t.Status == "closed" { convoy.Completed++ } } return convoy } type trackedStatus struct { ID string Status string } // getTrackedIssueStatus queries tracked issues and their status func getTrackedIssueStatus(beadsDir, convoyID string) []trackedStatus { // Validate convoyID to prevent SQL injection if !convoyIDPattern.MatchString(convoyID) { return nil } dbPath := filepath.Join(beadsDir, "beads.db") ctx, cancel := context.WithTimeout(context.Background(), convoySubprocessTimeout) defer cancel() // Query tracked dependencies from SQLite // convoyID is validated above to match ^hq-[a-zA-Z0-9-]+$ cmd := exec.CommandContext(ctx, "sqlite3", "-json", dbPath, //nolint:gosec // G204: convoyID is validated against strict pattern fmt.Sprintf(`SELECT depends_on_id FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, convoyID)) var stdout bytes.Buffer cmd.Stdout = &stdout if err := cmd.Run(); err != nil { return nil } var deps []struct { DependsOnID string `json:"depends_on_id"` } if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil { return nil } var tracked []trackedStatus 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] } } // Get issue status status := getIssueStatus(issueID) tracked = append(tracked, trackedStatus{ID: issueID, Status: status}) } return tracked } // getIssueStatus fetches just the status of an issue func getIssueStatus(issueID string) string { ctx, cancel := context.WithTimeout(context.Background(), convoySubprocessTimeout) defer cancel() cmd := exec.CommandContext(ctx, "bd", "show", issueID, "--json") var stdout bytes.Buffer cmd.Stdout = &stdout if err := cmd.Run(); err != nil { return "unknown" } var issues []struct { Status string `json:"status"` } if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil || len(issues) == 0 { return "unknown" } return issues[0].Status } // Convoy panel styles var ( ConvoyPanelStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colorDim). Padding(0, 1) ConvoyTitleStyle = lipgloss.NewStyle(). Bold(true). Foreground(colorPrimary) ConvoySectionStyle = lipgloss.NewStyle(). Foreground(colorDim). Bold(true) ConvoyIDStyle = lipgloss.NewStyle(). Foreground(colorHighlight) ConvoyNameStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("15")) ConvoyProgressStyle = lipgloss.NewStyle(). Foreground(colorSuccess) ConvoyLandedStyle = lipgloss.NewStyle(). Foreground(colorSuccess). Bold(true) ConvoyAgeStyle = lipgloss.NewStyle(). Foreground(colorDim) ) // renderConvoyPanel renders the convoy status panel func (m *Model) renderConvoyPanel() string { style := ConvoyPanelStyle if m.focusedPanel == PanelConvoy { style = FocusedBorderStyle } // Add title before content title := ConvoyTitleStyle.Render("🚚 Convoys") content := title + "\n" + m.convoyViewport.View() return style.Width(m.width - 2).Render(content) } // renderConvoys renders the convoy panel content func (m *Model) renderConvoys() string { if m.convoyState == nil { return AgentIdleStyle.Render("Loading convoys...") } var lines []string // In Progress section lines = append(lines, ConvoySectionStyle.Render("IN PROGRESS")) if len(m.convoyState.InProgress) == 0 { lines = append(lines, " "+AgentIdleStyle.Render("No active convoys")) } else { for _, c := range m.convoyState.InProgress { lines = append(lines, renderConvoyLine(c, false)) } } lines = append(lines, "") // Recently Landed section lines = append(lines, ConvoySectionStyle.Render("RECENTLY LANDED (24h)")) if len(m.convoyState.Landed) == 0 { lines = append(lines, " "+AgentIdleStyle.Render("No recent landings")) } else { for _, c := range m.convoyState.Landed { lines = append(lines, renderConvoyLine(c, true)) } } return strings.Join(lines, "\n") } // renderConvoyLine renders a single convoy status line func renderConvoyLine(c Convoy, landed bool) string { // Format: " hq-xyz Title 2/4 ●●○○" or " hq-xyz Title ✓ 2h ago" id := ConvoyIDStyle.Render(c.ID) // Truncate title if too long title := c.Title if len(title) > 20 { title = title[:17] + "..." } title = ConvoyNameStyle.Render(title) if landed { // Show checkmark and time since landing age := formatAge(time.Since(c.ClosedAt)) status := ConvoyLandedStyle.Render("✓") + " " + ConvoyAgeStyle.Render(age+" ago") return fmt.Sprintf(" %s %-20s %s", id, title, status) } // Show progress bar progress := renderProgressBar(c.Completed, c.Total) count := ConvoyProgressStyle.Render(fmt.Sprintf("%d/%d", c.Completed, c.Total)) return fmt.Sprintf(" %s %-20s %s %s", id, title, count, progress) } // renderProgressBar creates a simple progress bar: ●●○○ func renderProgressBar(completed, total int) string { if total == 0 { return "" } // Cap at 5 dots for display displayTotal := total if displayTotal > 5 { displayTotal = 5 } filled := (completed * displayTotal) / total if filled > displayTotal { filled = displayTotal } bar := strings.Repeat("●", filled) + strings.Repeat("○", displayTotal-filled) return ConvoyProgressStyle.Render(bar) }