Files
beads/cmd/bd/activity.go
Andrew B f8a4fcd036 feat(activity): add --details/-d flag for full issue information (#1317)
* feat(activity): add --details/-d flag for full issue information

Add a new --details (-d) flag to the `bd activity` command that includes
full issue information in the output, including comments.

* style(activity): simplify --details text output format

Remove ASCII tree characters and use cleaner indentation with
blank lines between events for better readability.
2026-01-25 17:59:50 -08:00

898 lines
25 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/routing"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
)
var (
activityFollow bool
activityMol string
activitySince string
activityType string
activityLimit int
activityInterval time.Duration
activityTown bool
activityDetails bool
)
// ActivityEvent represents a formatted activity event for output
type ActivityEvent struct {
Timestamp time.Time `json:"timestamp"`
Type string `json:"type"`
IssueID string `json:"issue_id"`
Symbol string `json:"symbol"`
Message string `json:"message"`
// Optional metadata from richer events
OldStatus string `json:"old_status,omitempty"`
NewStatus string `json:"new_status,omitempty"`
ParentID string `json:"parent_id,omitempty"`
StepCount int `json:"step_count,omitempty"`
Actor string `json:"actor,omitempty"`
// Full issue details (populated when --details is used)
Issue *types.IssueDetails `json:"issue,omitempty"`
}
var activityCmd = &cobra.Command{
Use: "activity",
GroupID: "views",
Short: "Show real-time molecule state feed",
Long: `Display a real-time feed of issue and molecule state changes.
This command shows mutations (create, update, delete) as they happen,
providing visibility into workflow progress.
Event symbols:
+ created/bonded - New issue or molecule created
→ in_progress - Work started on an issue
✓ completed - Issue closed or step completed
✗ failed - Step or issue failed
⊘ deleted - Issue removed
Examples:
bd activity # Show last 100 events
bd activity --follow # Real-time streaming
bd activity --mol bd-x7k # Filter by molecule prefix
bd activity --since 5m # Events from last 5 minutes
bd activity --since 1h # Events from last hour
bd activity --type update # Only show updates
bd activity --limit 50 # Show last 50 events
bd activity --town # Aggregated feed from all rigs
bd activity --follow --town # Stream all rig activity
bd activity --details --json # Full issue details in JSON output`,
Run: runActivity,
}
func init() {
activityCmd.Flags().BoolVarP(&activityFollow, "follow", "f", false, "Stream events in real-time")
activityCmd.Flags().StringVar(&activityMol, "mol", "", "Filter by molecule/issue ID prefix")
activityCmd.Flags().StringVar(&activitySince, "since", "", "Show events since duration (e.g., 5m, 1h, 30s)")
activityCmd.Flags().StringVar(&activityType, "type", "", "Filter by event type (create, update, delete, comment)")
activityCmd.Flags().IntVar(&activityLimit, "limit", 100, "Maximum number of events to show")
activityCmd.Flags().DurationVar(&activityInterval, "interval", 500*time.Millisecond, "Polling interval for --follow mode")
activityCmd.Flags().BoolVar(&activityTown, "town", false, "Aggregated feed from all rigs (uses routes.jsonl)")
activityCmd.Flags().BoolVarP(&activityDetails, "details", "d", false, "Include full issue details in output (works best with --json)")
rootCmd.AddCommand(activityCmd)
}
func runActivity(cmd *cobra.Command, args []string) {
// Parse --since duration
var sinceTime time.Time
if activitySince != "" {
duration, err := parseDurationString(activitySince)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid --since duration: %v\n", err)
os.Exit(1)
}
sinceTime = time.Now().Add(-duration)
}
// Town-wide aggregated feed
if activityTown {
if activityFollow {
runTownActivityFollow(sinceTime)
} else {
runTownActivityOnce(sinceTime)
}
return
}
// Single-rig activity requires daemon
if daemonClient == nil {
fmt.Fprintln(os.Stderr, "Error: activity command requires daemon (mutations not available in direct mode)")
fmt.Fprintln(os.Stderr, "Hint: Start daemon with 'bd daemons start .' or remove --no-daemon flag")
os.Exit(1)
}
if activityFollow {
runActivityFollow(sinceTime)
} else {
runActivityOnce(sinceTime)
}
}
// runActivityOnce fetches and displays events once
func runActivityOnce(sinceTime time.Time) {
events, err := fetchMutations(sinceTime)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Apply filters and limit
events = filterEvents(events)
if len(events) > activityLimit {
events = events[len(events)-activityLimit:]
}
if jsonOutput {
formatted := make([]ActivityEvent, 0, len(events))
for _, e := range events {
var details *types.IssueDetails
if activityDetails {
details = fetchIssueDetails(daemonClient, e.IssueID)
}
formatted = append(formatted, formatEventWithDetails(e, details))
}
outputJSON(formatted)
return
}
if len(events) == 0 {
fmt.Println("No recent activity")
return
}
// For text output with --details, show full issue info
for _, e := range events {
printEvent(e)
if activityDetails {
details := fetchIssueDetails(daemonClient, e.IssueID)
if details != nil {
printEventDetails(details)
}
}
}
}
// runActivityFollow streams events in real-time using filesystem watching.
// Falls back to polling if fsnotify is not available.
func runActivityFollow(sinceTime time.Time) {
// Start from now if no --since specified
lastPoll := time.Now().Add(-1 * time.Second)
if !sinceTime.IsZero() {
lastPoll = sinceTime
}
// First fetch any events since the start time
events, err := fetchMutations(sinceTime)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Apply filters and display initial events
events = filterEvents(events)
for _, e := range events {
if jsonOutput {
var details *types.IssueDetails
if activityDetails {
details = fetchIssueDetails(daemonClient, e.IssueID)
}
data, _ := json.Marshal(formatEventWithDetails(e, details))
fmt.Println(string(data))
} else {
printEvent(e)
if activityDetails {
details := fetchIssueDetails(daemonClient, e.IssueID)
if details != nil {
printEventDetails(details)
}
}
}
if e.Timestamp.After(lastPoll) {
lastPoll = e.Timestamp
}
}
// Create filesystem watcher for near-instant wake-up
// Falls back to polling internally if fsnotify fails
beadsDir := filepath.Dir(dbPath)
watcher := NewActivityWatcher(beadsDir, activityInterval)
defer func() { _ = watcher.Close() }()
// Start watching
watcher.Start(rootCtx)
// Track consecutive failures for error reporting
consecutiveFailures := 0
const failureWarningThreshold = 5
lastWarningTime := time.Time{}
for {
select {
case <-rootCtx.Done():
return
case _, ok := <-watcher.Events():
if !ok {
return // Watcher closed
}
newEvents, err := fetchMutations(lastPoll)
if err != nil {
consecutiveFailures++
// Show warning after threshold failures, but not more than once per 30 seconds
if consecutiveFailures >= failureWarningThreshold {
if time.Since(lastWarningTime) >= 30*time.Second {
if jsonOutput {
// Emit error event in JSON mode
errorEvent := map[string]interface{}{
"type": "error",
"message": fmt.Sprintf("daemon unreachable (%d failures)", consecutiveFailures),
"timestamp": time.Now().Format(time.RFC3339),
}
data, _ := json.Marshal(errorEvent)
fmt.Fprintln(os.Stderr, string(data))
} else {
timestamp := time.Now().Format("15:04:05")
fmt.Fprintf(os.Stderr, "[%s] %s daemon unreachable (%d consecutive failures)\n",
timestamp, ui.RenderWarn("!"), consecutiveFailures)
}
lastWarningTime = time.Now()
}
}
continue
}
// Reset failure counter on success
if consecutiveFailures > 0 {
if consecutiveFailures >= failureWarningThreshold && !jsonOutput {
timestamp := time.Now().Format("15:04:05")
fmt.Fprintf(os.Stderr, "[%s] %s daemon reconnected\n", timestamp, ui.RenderPass("✓"))
}
consecutiveFailures = 0
}
newEvents = filterEvents(newEvents)
for _, e := range newEvents {
if jsonOutput {
var details *types.IssueDetails
if activityDetails {
details = fetchIssueDetails(daemonClient, e.IssueID)
}
data, _ := json.Marshal(formatEventWithDetails(e, details))
fmt.Println(string(data))
} else {
printEvent(e)
if activityDetails {
details := fetchIssueDetails(daemonClient, e.IssueID)
if details != nil {
printEventDetails(details)
}
}
}
if e.Timestamp.After(lastPoll) {
lastPoll = e.Timestamp
}
}
}
}
}
// fetchMutations retrieves mutations from the daemon
func fetchMutations(since time.Time) ([]rpc.MutationEvent, error) {
var sinceMillis int64
if !since.IsZero() {
sinceMillis = since.UnixMilli()
}
resp, err := daemonClient.GetMutations(&rpc.GetMutationsArgs{Since: sinceMillis})
if err != nil {
return nil, fmt.Errorf("failed to get mutations: %w", err)
}
var mutations []rpc.MutationEvent
if err := json.Unmarshal(resp.Data, &mutations); err != nil {
return nil, fmt.Errorf("failed to parse mutations: %w", err)
}
return mutations, nil
}
// fetchIssueDetails retrieves full issue details from the daemon
func fetchIssueDetails(client *rpc.Client, issueID string) *types.IssueDetails {
if client == nil {
return nil
}
resp, err := client.Show(&rpc.ShowArgs{ID: issueID})
if err != nil || !resp.Success {
return nil
}
var details types.IssueDetails
if err := json.Unmarshal(resp.Data, &details); err != nil {
return nil
}
return &details
}
// filterEvents applies --mol and --type filters
func filterEvents(events []rpc.MutationEvent) []rpc.MutationEvent {
if activityMol == "" && activityType == "" {
return events
}
filtered := make([]rpc.MutationEvent, 0, len(events))
for _, e := range events {
// Filter by molecule/issue ID prefix
if activityMol != "" && !strings.HasPrefix(e.IssueID, activityMol) {
continue
}
// Filter by event type
if activityType != "" && e.Type != activityType {
continue
}
filtered = append(filtered, e)
}
return filtered
}
// formatEvent converts a mutation event to a formatted activity event
func formatEvent(e rpc.MutationEvent) ActivityEvent {
return formatEventWithDetails(e, nil)
}
// formatEventWithDetails converts a mutation event to a formatted activity event with optional details
func formatEventWithDetails(e rpc.MutationEvent, details *types.IssueDetails) ActivityEvent {
symbol, message := getEventDisplay(e)
return ActivityEvent{
Timestamp: e.Timestamp,
Type: e.Type,
IssueID: e.IssueID,
Symbol: symbol,
Message: message,
OldStatus: e.OldStatus,
NewStatus: e.NewStatus,
ParentID: e.ParentID,
StepCount: e.StepCount,
Actor: e.Actor,
Issue: details,
}
}
// getEventDisplay returns the symbol and message for an event type
func getEventDisplay(e rpc.MutationEvent) (symbol, message string) {
// Build context suffix: title and/or assignee
context := buildEventContext(e)
switch e.Type {
case rpc.MutationCreate:
return "+", fmt.Sprintf("%s created%s", e.IssueID, context)
case rpc.MutationUpdate:
return "→", fmt.Sprintf("%s updated%s", e.IssueID, context)
case rpc.MutationDelete:
return "⊘", fmt.Sprintf("%s deleted%s", e.IssueID, context)
case rpc.MutationComment:
return "💬", fmt.Sprintf("%s comment%s", e.IssueID, context)
case rpc.MutationBonded:
if e.StepCount > 0 {
return "+", fmt.Sprintf("%s bonded (%d steps)%s", e.IssueID, e.StepCount, context)
}
return "+", fmt.Sprintf("%s bonded%s", e.IssueID, context)
case rpc.MutationSquashed:
return "◉", fmt.Sprintf("%s SQUASHED%s", e.IssueID, context)
case rpc.MutationBurned:
return "🔥", fmt.Sprintf("%s burned%s", e.IssueID, context)
case rpc.MutationStatus:
// Status change with transition info
if e.NewStatus == "in_progress" {
return "→", fmt.Sprintf("%s started%s", e.IssueID, context)
} else if e.NewStatus == "closed" {
return "✓", fmt.Sprintf("%s completed%s", e.IssueID, context)
} else if e.NewStatus == "open" && e.OldStatus != "" {
return "↺", fmt.Sprintf("%s reopened%s", e.IssueID, context)
}
return "→", fmt.Sprintf("%s → %s%s", e.IssueID, e.NewStatus, context)
default:
return "•", fmt.Sprintf("%s %s%s", e.IssueID, e.Type, context)
}
}
// buildEventContext creates a context string from title and actor/assignee
func buildEventContext(e rpc.MutationEvent) string {
var parts []string
// Add truncated title if present
if e.Title != "" {
title := truncateString(e.Title, 40)
parts = append(parts, title)
}
// For status changes, prefer showing actor (who performed the action)
// For other events, show assignee
if e.Actor != "" {
parts = append(parts, "@"+e.Actor)
} else if e.Assignee != "" {
parts = append(parts, "@"+e.Assignee)
}
if len(parts) == 0 {
return ""
}
return " · " + strings.Join(parts, " ")
}
// truncateString truncates a string to maxLen, adding ellipsis if needed
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
// printEvent prints a formatted event to stdout
func printEvent(e rpc.MutationEvent) {
symbol, message := getEventDisplay(e)
timestamp := e.Timestamp.Format("15:04:05")
// Colorize output based on event type
var coloredSymbol string
switch e.Type {
case rpc.MutationCreate, rpc.MutationBonded:
coloredSymbol = ui.RenderPass(symbol)
case rpc.MutationUpdate:
coloredSymbol = ui.RenderWarn(symbol)
case rpc.MutationDelete, rpc.MutationBurned:
coloredSymbol = ui.RenderFail(symbol)
case rpc.MutationComment:
coloredSymbol = ui.RenderAccent(symbol)
case rpc.MutationSquashed:
coloredSymbol = ui.RenderAccent(symbol)
case rpc.MutationStatus:
// Color based on new status
if e.NewStatus == "closed" {
coloredSymbol = ui.RenderPass(symbol)
} else if e.NewStatus == "in_progress" {
coloredSymbol = ui.RenderWarn(symbol)
} else {
coloredSymbol = ui.RenderAccent(symbol)
}
default:
coloredSymbol = symbol
}
fmt.Printf("[%s] %s %s\n", timestamp, coloredSymbol, message)
}
// printEventDetails prints full issue details for text output with --details
func printEventDetails(details *types.IssueDetails) {
fmt.Printf("Status: %s Priority: P%d Type: %s\n",
details.Status, details.Priority, details.IssueType)
if details.Assignee != "" {
fmt.Printf("Assignee: %s\n", details.Assignee)
}
if len(details.Labels) > 0 {
fmt.Printf("Labels: %s\n", strings.Join(details.Labels, ", "))
}
if details.Description != "" {
desc := truncateString(details.Description, 80)
desc = strings.ReplaceAll(desc, "\n", " ")
fmt.Printf("Description: %s\n", desc)
}
if len(details.Dependencies) > 0 {
deps := make([]string, 0, len(details.Dependencies))
for _, d := range details.Dependencies {
deps = append(deps, d.ID)
}
fmt.Printf("Depends on: %s\n", strings.Join(deps, ", "))
}
if len(details.Dependents) > 0 {
dependents := make([]string, 0, len(details.Dependents))
for _, d := range details.Dependents {
dependents = append(dependents, d.ID)
}
fmt.Printf("Blocked by: %s\n", strings.Join(dependents, ", "))
}
if len(details.Comments) > 0 {
fmt.Printf("Comments: %d\n", len(details.Comments))
for _, c := range details.Comments {
text := truncateString(c.Text, 60)
text = strings.ReplaceAll(text, "\n", " ")
fmt.Printf(" @%s: %s\n", c.Author, text)
}
}
fmt.Printf("Created: %s Updated: %s\n",
details.CreatedAt.Format("2006-01-02 15:04"), details.UpdatedAt.Format("2006-01-02 15:04"))
fmt.Println() // Blank line between events
}
// parseDurationString parses duration strings like "5m", "1h", "30s", "2d"
func parseDurationString(s string) (time.Duration, error) {
// Try standard Go duration first
if d, err := time.ParseDuration(s); err == nil {
return d, nil
}
// Handle custom formats like "2d" for days
re := regexp.MustCompile(`^(\d+)([dhms])$`)
matches := re.FindStringSubmatch(strings.ToLower(s))
if len(matches) != 3 {
return 0, fmt.Errorf("invalid duration format: %s (use 5m, 1h, 30s, or 2d)", s)
}
value, _ := strconv.Atoi(matches[1])
unit := matches[2]
switch unit {
case "d":
return time.Duration(value) * 24 * time.Hour, nil
case "h":
return time.Duration(value) * time.Hour, nil
case "m":
return time.Duration(value) * time.Minute, nil
case "s":
return time.Duration(value) * time.Second, nil
default:
return 0, fmt.Errorf("unknown duration unit: %s", unit)
}
}
// rigDaemon holds a connection to a rig's daemon
type rigDaemon struct {
prefix string // e.g., "bd-"
rig string // e.g., "beads"
client *rpc.Client // nil if daemon not running
}
// discoverRigDaemons finds all rigs via routes.jsonl and connects to their daemons
func discoverRigDaemons() []rigDaemon {
var daemons []rigDaemon
// Find town beads directory (uses findTownBeadsDir from create.go)
townBeadsDir, err := findTownBeadsDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: not in an orchestrator environment (%v)\n", err)
os.Exit(1)
}
// Load routes
routes, err := routing.LoadRoutes(townBeadsDir)
if err != nil || len(routes) == 0 {
fmt.Fprintln(os.Stderr, "Error: no routes found in routes.jsonl")
os.Exit(1)
}
townRoot := filepath.Dir(townBeadsDir)
for _, route := range routes {
// Resolve beads directory for this route
var beadsDir string
if route.Path == "." {
beadsDir = townBeadsDir
} else {
beadsDir = filepath.Join(townRoot, route.Path, ".beads")
}
// Follow redirect if present
beadsDir = resolveBeadsRedirect(beadsDir)
// Check if daemon is running
socketPath := filepath.Join(beadsDir, "bd.sock")
client, _ := rpc.TryConnect(socketPath)
rigName := routing.ExtractProjectFromPath(route.Path)
if rigName == "" {
rigName = "town" // For path="."
}
daemons = append(daemons, rigDaemon{
prefix: route.Prefix,
rig: rigName,
client: client,
})
}
return daemons
}
// resolveBeadsRedirect follows a redirect file if present.
// Similar to routing.resolveRedirect but simplified for activity use.
func resolveBeadsRedirect(beadsDir string) string {
redirectFile := filepath.Join(beadsDir, "redirect")
data, err := os.ReadFile(redirectFile) // #nosec G304 - redirects are trusted within beads rig paths
if err != nil {
return beadsDir
}
redirectPath := strings.TrimSpace(string(data))
if redirectPath == "" {
return beadsDir
}
// Handle relative paths
if !filepath.IsAbs(redirectPath) {
redirectPath = filepath.Join(beadsDir, redirectPath)
}
redirectPath = filepath.Clean(redirectPath)
// Verify target exists before returning
if info, err := os.Stat(redirectPath); err == nil && info.IsDir() {
return redirectPath
}
return beadsDir // Fallback to original if redirect target invalid
}
// fetchTownMutations retrieves mutations from all rig daemons
func fetchTownMutations(daemons []rigDaemon, since time.Time) []rpc.MutationEvent {
events, _ := fetchTownMutationsWithStatus(daemons, since)
return events
}
// findDaemonForIssue finds the appropriate daemon client for an issue ID based on prefix
func findDaemonForIssue(daemons []rigDaemon, issueID string) *rpc.Client {
for _, d := range daemons {
if d.client != nil && strings.HasPrefix(issueID, d.prefix) {
return d.client
}
}
return nil
}
// fetchTownMutationsWithStatus retrieves mutations and returns count of responding daemons
func fetchTownMutationsWithStatus(daemons []rigDaemon, since time.Time) ([]rpc.MutationEvent, int) {
var allEvents []rpc.MutationEvent
activeCount := 0
var sinceMillis int64
if !since.IsZero() {
sinceMillis = since.UnixMilli()
}
for _, d := range daemons {
if d.client == nil {
continue
}
resp, err := d.client.GetMutations(&rpc.GetMutationsArgs{Since: sinceMillis})
if err != nil {
continue
}
activeCount++
var mutations []rpc.MutationEvent
if err := json.Unmarshal(resp.Data, &mutations); err != nil {
continue
}
allEvents = append(allEvents, mutations...)
}
// Sort by timestamp
sort.Slice(allEvents, func(i, j int) bool {
return allEvents[i].Timestamp.Before(allEvents[j].Timestamp)
})
return allEvents, activeCount
}
// runTownActivityOnce fetches and displays events from all rigs once
func runTownActivityOnce(sinceTime time.Time) {
daemons := discoverRigDaemons()
defer closeDaemons(daemons)
// Count active daemons
activeCount := 0
for _, d := range daemons {
if d.client != nil {
activeCount++
}
}
if activeCount == 0 {
fmt.Fprintln(os.Stderr, "Error: no rig daemons running")
fmt.Fprintln(os.Stderr, "Hint: Start daemons with 'bd daemons start' in each rig")
os.Exit(1)
}
events := fetchTownMutations(daemons, sinceTime)
// Apply filters and limit
events = filterEvents(events)
if len(events) > activityLimit {
events = events[len(events)-activityLimit:]
}
if jsonOutput {
formatted := make([]ActivityEvent, 0, len(events))
for _, e := range events {
var details *types.IssueDetails
if activityDetails {
client := findDaemonForIssue(daemons, e.IssueID)
details = fetchIssueDetails(client, e.IssueID)
}
formatted = append(formatted, formatEventWithDetails(e, details))
}
outputJSON(formatted)
return
}
if len(events) == 0 {
fmt.Printf("No recent activity across %d rigs\n", activeCount)
return
}
for _, e := range events {
printEvent(e)
if activityDetails {
client := findDaemonForIssue(daemons, e.IssueID)
details := fetchIssueDetails(client, e.IssueID)
if details != nil {
printEventDetails(details)
}
}
}
}
// runTownActivityFollow streams events from all rigs in real-time
func runTownActivityFollow(sinceTime time.Time) {
daemons := discoverRigDaemons()
defer closeDaemons(daemons)
// Count active daemons
activeCount := 0
var activeRigs []string
for _, d := range daemons {
if d.client != nil {
activeCount++
activeRigs = append(activeRigs, d.rig)
}
}
if activeCount == 0 {
fmt.Fprintln(os.Stderr, "Error: no rig daemons running")
fmt.Fprintln(os.Stderr, "Hint: Start daemons with 'bd daemons start' in each rig")
os.Exit(1)
}
// Show which rigs we're monitoring
if !jsonOutput {
fmt.Printf("Streaming activity from %d rigs: %s\n", activeCount, strings.Join(activeRigs, ", "))
}
// Start from now if no --since specified
lastPoll := time.Now().Add(-1 * time.Second)
if !sinceTime.IsZero() {
lastPoll = sinceTime
}
// First fetch any events since the start time
events := fetchTownMutations(daemons, sinceTime)
events = filterEvents(events)
for _, e := range events {
if jsonOutput {
var details *types.IssueDetails
if activityDetails {
client := findDaemonForIssue(daemons, e.IssueID)
details = fetchIssueDetails(client, e.IssueID)
}
data, _ := json.Marshal(formatEventWithDetails(e, details))
fmt.Println(string(data))
} else {
printEvent(e)
if activityDetails {
client := findDaemonForIssue(daemons, e.IssueID)
details := fetchIssueDetails(client, e.IssueID)
if details != nil {
printEventDetails(details)
}
}
}
if e.Timestamp.After(lastPoll) {
lastPoll = e.Timestamp
}
}
// Poll for new events
ticker := time.NewTicker(activityInterval)
defer ticker.Stop()
// Track failures for warning messages
consecutiveFailures := 0
const failureWarningThreshold = 5
lastWarningTime := time.Time{}
lastActiveCount := activeCount
for {
select {
case <-rootCtx.Done():
return
case <-ticker.C:
newEvents, currentActive := fetchTownMutationsWithStatus(daemons, lastPoll)
// Track daemon availability changes
if currentActive < lastActiveCount {
consecutiveFailures++
if consecutiveFailures >= failureWarningThreshold {
if time.Since(lastWarningTime) >= 30*time.Second {
if !jsonOutput {
timestamp := time.Now().Format("15:04:05")
fmt.Fprintf(os.Stderr, "[%s] %s some rigs unreachable (%d/%d active)\n",
timestamp, ui.RenderWarn("!"), currentActive, len(daemons))
}
lastWarningTime = time.Now()
}
}
} else if currentActive > lastActiveCount {
// Daemon came back
if !jsonOutput {
timestamp := time.Now().Format("15:04:05")
fmt.Fprintf(os.Stderr, "[%s] %s rig reconnected (%d/%d active)\n",
timestamp, ui.RenderPass("✓"), currentActive, len(daemons))
}
consecutiveFailures = 0
}
lastActiveCount = currentActive
newEvents = filterEvents(newEvents)
for _, e := range newEvents {
if jsonOutput {
var details *types.IssueDetails
if activityDetails {
client := findDaemonForIssue(daemons, e.IssueID)
details = fetchIssueDetails(client, e.IssueID)
}
data, _ := json.Marshal(formatEventWithDetails(e, details))
fmt.Println(string(data))
} else {
printEvent(e)
if activityDetails {
client := findDaemonForIssue(daemons, e.IssueID)
details := fetchIssueDetails(client, e.IssueID)
if details != nil {
printEventDetails(details)
}
}
}
if e.Timestamp.After(lastPoll) {
lastPoll = e.Timestamp
}
}
}
}
}
// closeDaemons closes all daemon connections
func closeDaemons(daemons []rigDaemon) {
for _, d := range daemons {
if d.client != nil {
if err := d.client.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to close daemon client: %v\n", err)
}
}
}
}