package web import ( "bytes" "encoding/json" "fmt" "os/exec" "path/filepath" "strings" "time" "github.com/steveyegge/gastown/internal/activity" "github.com/steveyegge/gastown/internal/workspace" ) // LiveConvoyFetcher fetches convoy data from beads. type LiveConvoyFetcher struct { townBeads string } // NewLiveConvoyFetcher creates a fetcher for the current workspace. func NewLiveConvoyFetcher() (*LiveConvoyFetcher, error) { townRoot, err := workspace.FindFromCwdOrError() if err != nil { return nil, fmt.Errorf("not in a Gas Town workspace: %w", err) } return &LiveConvoyFetcher{ townBeads: filepath.Join(townRoot, ".beads"), }, nil } // FetchConvoys fetches all open convoys with their activity data. func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) { // List all open convoy-type issues listArgs := []string{"list", "--type=convoy", "--status=open", "--json"} listCmd := exec.Command("bd", listArgs...) listCmd.Dir = f.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"` Status string `json:"status"` CreatedAt string `json:"created_at"` } if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil { return nil, fmt.Errorf("parsing convoy list: %w", err) } // Build convoy rows with activity data rows := make([]ConvoyRow, 0, len(convoys)) for _, c := range convoys { row := ConvoyRow{ ID: c.ID, Title: c.Title, Status: c.Status, } // Get tracked issues for progress and activity calculation tracked := f.getTrackedIssues(c.ID) row.Total = len(tracked) var mostRecentActivity time.Time for _, t := range tracked { if t.Status == "closed" { row.Completed++ } // Track most recent activity from workers if t.LastActivity.After(mostRecentActivity) { mostRecentActivity = t.LastActivity } } row.Progress = fmt.Sprintf("%d/%d", row.Completed, row.Total) // Calculate activity info from most recent worker activity if !mostRecentActivity.IsZero() { row.LastActivity = activity.Calculate(mostRecentActivity) } else { row.LastActivity = activity.Info{ FormattedAge: "no activity", ColorClass: activity.ColorUnknown, } } // Get tracked issues for expandable view row.TrackedIssues = make([]TrackedIssue, len(tracked)) for i, t := range tracked { row.TrackedIssues[i] = TrackedIssue{ ID: t.ID, Title: t.Title, Status: t.Status, Assignee: t.Assignee, } } rows = append(rows, row) } return rows, nil } // trackedIssueInfo holds info about an issue being tracked by a convoy. type trackedIssueInfo struct { ID string Title string Status string Assignee string LastActivity time.Time } // getTrackedIssues fetches tracked issues for a convoy. func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo { dbPath := filepath.Join(f.townBeads, "beads.db") // Query tracked dependencies from SQLite 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 } // Collect issue IDs (normalize external refs) issueIDs := make([]string, 0, len(deps)) for _, dep := range deps { issueID := dep.DependsOnID if strings.HasPrefix(issueID, "external:") { parts := strings.SplitN(issueID, ":", 3) if len(parts) == 3 { issueID = parts[2] } } issueIDs = append(issueIDs, issueID) } // Batch fetch issue details details := f.getIssueDetailsBatch(issueIDs) // Get worker activity from tmux sessions based on assignees workers := f.getWorkersFromAssignees(details) // Build result result := make([]trackedIssueInfo, 0, len(issueIDs)) for _, id := range issueIDs { info := trackedIssueInfo{ID: id} if d, ok := details[id]; ok { info.Title = d.Title info.Status = d.Status info.Assignee = d.Assignee } else { info.Title = "(external)" info.Status = "unknown" } if w, ok := workers[id]; ok && w.LastActivity != nil { info.LastActivity = *w.LastActivity } result = append(result, info) } return result } // issueDetail holds basic issue info. type issueDetail struct { ID string Title string Status string Assignee string } // getIssueDetailsBatch fetches details for multiple issues. func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]*issueDetail { result := make(map[string]*issueDetail) if len(issueIDs) == 0 { return result } args := append([]string{"show"}, issueIDs...) args = append(args, "--json") showCmd := exec.Command("bd", args...) var stdout bytes.Buffer showCmd.Stdout = &stdout if err := showCmd.Run(); err != nil { return result } var issues []struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Assignee string `json:"assignee"` } if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil { return result } for _, issue := range issues { result[issue.ID] = &issueDetail{ ID: issue.ID, Title: issue.Title, Status: issue.Status, Assignee: issue.Assignee, } } return result } // workerDetail holds worker info including last activity. type workerDetail struct { Worker string LastActivity *time.Time } // getWorkersFromAssignees gets worker activity from tmux sessions based on issue assignees. // Assignees are in format "rigname/polecats/polecatname" which maps to tmux session "gt-rigname-polecatname". func (f *LiveConvoyFetcher) getWorkersFromAssignees(details map[string]*issueDetail) map[string]*workerDetail { result := make(map[string]*workerDetail) // Collect unique assignees and map them to issue IDs assigneeToIssues := make(map[string][]string) for issueID, detail := range details { if detail == nil || detail.Assignee == "" { continue } assigneeToIssues[detail.Assignee] = append(assigneeToIssues[detail.Assignee], issueID) } if len(assigneeToIssues) == 0 { return result } // For each unique assignee, look up tmux session activity for assignee, issueIDs := range assigneeToIssues { activity := f.getSessionActivityForAssignee(assignee) if activity == nil { continue } // Apply this activity to all issues assigned to this worker for _, issueID := range issueIDs { result[issueID] = &workerDetail{ Worker: assignee, LastActivity: activity, } } } return result } // getSessionActivityForAssignee looks up tmux session activity for an assignee. // Assignee format: "rigname/polecats/polecatname" -> session "gt-rigname-polecatname" func (f *LiveConvoyFetcher) getSessionActivityForAssignee(assignee string) *time.Time { // Parse assignee: "roxas/polecats/dag" -> rig="roxas", polecat="dag" parts := strings.Split(assignee, "/") if len(parts) != 3 || parts[1] != "polecats" { return nil } rig := parts[0] polecat := parts[2] // Construct session name sessionName := fmt.Sprintf("gt-%s-%s", rig, polecat) // Query tmux for session activity // Format: session_activity returns unix timestamp cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{session_activity}", "-f", fmt.Sprintf("#{==:#{session_name},%s}", sessionName)) var stdout bytes.Buffer cmd.Stdout = &stdout if err := cmd.Run(); err != nil { return nil } output := strings.TrimSpace(stdout.String()) if output == "" { return nil } // Parse output: "gt-roxas-dag|1704312345" outputParts := strings.Split(output, "|") if len(outputParts) < 2 { return nil } var activityUnix int64 if _, err := fmt.Sscanf(outputParts[1], "%d", &activityUnix); err != nil || activityUnix == 0 { return nil } activity := time.Unix(activityUnix, 0) return &activity }