Files
beads/cmd/bd/daemon_status.go
emma 1039a69186 fix(daemon): silence deprecation warnings in --json mode
Follow-up fixes to PR #1006:
- Silence deprecation warnings when --json flag is set (agent ergonomics)
- Remove unused outputStatusJSON() function
- Remove unused encoding/json import from daemon_status.go

Agents parsing JSON output dont need deprecation warnings cluttering stderr.
The subcommand syntax (bd daemon start) remains the documented path forward.

See bd-ntl3b for deduplication work, bd-kpa7c for type consolidation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 16:31:16 -08:00

445 lines
12 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/daemon"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/ui"
)
// DaemonStatusReport is a single daemon status entry for JSON output
type DaemonStatusReport struct {
Workspace string `json:"workspace"`
PID int `json:"pid,omitempty"`
Version string `json:"version,omitempty"`
Status string `json:"status"`
Issue string `json:"issue,omitempty"`
Started string `json:"started,omitempty"`
UptimeSeconds float64 `json:"uptime_seconds,omitempty"`
AutoCommit bool `json:"auto_commit,omitempty"`
AutoPush bool `json:"auto_push,omitempty"`
AutoPull bool `json:"auto_pull,omitempty"`
LocalMode bool `json:"local_mode,omitempty"`
SyncInterval string `json:"sync_interval,omitempty"`
DaemonMode string `json:"daemon_mode,omitempty"`
LogPath string `json:"log_path,omitempty"`
VersionMismatch bool `json:"version_mismatch,omitempty"`
IsCurrent bool `json:"is_current,omitempty"`
}
// DaemonStatusAllResponse is returned for --all mode
type DaemonStatusAllResponse struct {
Total int `json:"total"`
Healthy int `json:"healthy"`
Outdated int `json:"outdated"`
Stale int `json:"stale"`
Unresponsive int `json:"unresponsive"`
Daemons []DaemonStatusReport `json:"daemons"`
}
var daemonStatusCmd = &cobra.Command{
Use: "status",
Short: "Show daemon status",
Long: `Show status of the current workspace's daemon, or all daemons with --all.
Examples:
bd daemon status # Current workspace daemon
bd daemon status --all # All running daemons`,
Run: func(cmd *cobra.Command, args []string) {
showAll, _ := cmd.Flags().GetBool("all")
if showAll {
showAllDaemonsStatus(cmd)
} else {
showCurrentDaemonStatus()
}
},
}
func init() {
daemonStatusCmd.Flags().Bool("all", false, "Show status of all daemons")
daemonStatusCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (with --all)")
}
// shortenPath replaces home directory with ~ for display
func shortenPath(p string) string {
home, err := os.UserHomeDir()
if err != nil {
return p
}
if strings.HasPrefix(p, home) {
return "~" + p[len(home):]
}
return p
}
// formatRelativeTime formats a time as relative (e.g., "2h ago")
func formatRelativeTime(t time.Time) string {
d := time.Since(t)
if d < time.Minute {
return "just now"
} else if d < time.Hour {
mins := int(d.Minutes())
if mins == 1 {
return "1m ago"
}
return fmt.Sprintf("%dm ago", mins)
} else if d < 24*time.Hour {
hours := int(d.Hours())
if hours == 1 {
return "1h ago"
}
return fmt.Sprintf("%dh ago", hours)
}
days := int(d.Hours() / 24)
if days == 1 {
return "1d ago"
}
return fmt.Sprintf("%dd ago", days)
}
// formatBoolIcon returns a styled checkmark or dash for boolean values
func formatBoolIcon(enabled bool) string {
if enabled {
return ui.RenderPass(ui.IconPass)
}
return ui.RenderMuted("-")
}
// renderDaemonStatusIcon renders status with semantic styling
func renderDaemonStatusIcon(status string) string {
switch status {
case "healthy", "running":
return ui.RenderPass(ui.IconPass + " " + status)
case "outdated", "version_mismatch":
return ui.RenderWarn(ui.IconWarn + " outdated")
case "stale":
return ui.RenderWarn(ui.IconWarn + " stale")
case "unresponsive":
return ui.RenderFail(ui.IconFail + " unresponsive")
case "not_running":
return ui.RenderMuted("○ not running")
default:
return status
}
}
// showCurrentDaemonStatus shows detailed status for current workspace daemon
func showCurrentDaemonStatus() {
pidFile, err := getPIDFilePath()
if err != nil {
if jsonOutput {
outputJSON(map[string]string{"error": err.Error()})
} else {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
os.Exit(1)
}
beadsDir := filepath.Dir(pidFile)
socketPath := filepath.Join(beadsDir, "bd.sock")
workspacePath := filepath.Dir(beadsDir)
// Check if daemon is running
isRunning, pid := isDaemonRunning(pidFile)
if !isRunning {
if jsonOutput {
outputJSON(DaemonStatusReport{
Workspace: workspacePath,
Status: "not_running",
})
} else {
fmt.Printf("%s\n\n", renderDaemonStatusIcon("not_running"))
fmt.Printf(" Workspace: %s\n", shortenPath(workspacePath))
fmt.Printf("\n To start: bd daemon start\n")
}
return
}
// Get detailed status via RPC
var rpcStatus *rpc.StatusResponse
if client, err := rpc.TryConnectWithTimeout(socketPath, 1*time.Second); err == nil && client != nil {
if status, err := client.Status(); err == nil {
rpcStatus = status
}
_ = client.Close()
}
// Get started time from PID file
var startedTime time.Time
if info, err := os.Stat(pidFile); err == nil {
startedTime = info.ModTime()
}
// Determine daemon version and check for mismatch
daemonVersion := ""
versionMismatch := false
if rpcStatus != nil {
daemonVersion = rpcStatus.Version
if daemonVersion != "" && daemonVersion != Version {
versionMismatch = true
}
}
// Determine status
status := "running"
issue := ""
if versionMismatch {
status = "outdated"
issue = fmt.Sprintf("daemon %s != cli %s", daemonVersion, Version)
}
// Get log path
logPath := filepath.Join(beadsDir, "daemon.log")
if _, err := os.Stat(logPath); err != nil {
logPath = ""
}
if jsonOutput {
report := DaemonStatusReport{
Workspace: workspacePath,
PID: pid,
Version: daemonVersion,
Status: status,
Issue: issue,
LogPath: logPath,
VersionMismatch: versionMismatch,
IsCurrent: true,
}
if !startedTime.IsZero() {
report.Started = startedTime.Format(time.RFC3339)
}
if rpcStatus != nil {
report.UptimeSeconds = rpcStatus.UptimeSeconds
report.AutoCommit = rpcStatus.AutoCommit
report.AutoPush = rpcStatus.AutoPush
report.AutoPull = rpcStatus.AutoPull
report.LocalMode = rpcStatus.LocalMode
report.SyncInterval = rpcStatus.SyncInterval
report.DaemonMode = rpcStatus.DaemonMode
}
outputJSON(report)
return
}
// Human-readable output with semantic styling
// Status line
versionStr := ""
if daemonVersion != "" {
versionStr = fmt.Sprintf(", v%s", daemonVersion)
}
if versionMismatch {
fmt.Printf("%s (PID %d%s)\n", renderDaemonStatusIcon("outdated"), pid, versionStr)
fmt.Printf(" %s\n\n", ui.RenderWarn(fmt.Sprintf("CLI version: %s", Version)))
} else {
fmt.Printf("%s (PID %d%s)\n\n", renderDaemonStatusIcon("running"), pid, versionStr)
}
// Details
fmt.Printf(" Workspace: %s\n", shortenPath(workspacePath))
if !startedTime.IsZero() {
fmt.Printf(" Started: %s (%s)\n", startedTime.Format("2006-01-02 15:04:05"), formatRelativeTime(startedTime))
}
if rpcStatus != nil {
fmt.Printf(" Mode: %s\n", rpcStatus.DaemonMode)
fmt.Printf(" Interval: %s\n", rpcStatus.SyncInterval)
// Compact sync flags display
syncFlags := []string{}
if rpcStatus.AutoCommit {
syncFlags = append(syncFlags, ui.RenderPass(ui.IconPass)+" commit")
}
if rpcStatus.AutoPush {
syncFlags = append(syncFlags, ui.RenderPass(ui.IconPass)+" push")
}
if rpcStatus.AutoPull {
syncFlags = append(syncFlags, ui.RenderPass(ui.IconPass)+" pull")
}
if len(syncFlags) > 0 {
fmt.Printf(" Sync: %s\n", strings.Join(syncFlags, " "))
} else {
fmt.Printf(" Sync: %s\n", ui.RenderMuted("none"))
}
if rpcStatus.LocalMode {
fmt.Printf(" Local: %s\n", ui.RenderWarn("yes (no git sync)"))
}
}
if logPath != "" {
// Show relative path for log
relLog := ".beads/daemon.log"
fmt.Printf(" Log: %s\n", relLog)
}
// Show hint about other daemons
daemons, err := daemon.DiscoverDaemons(nil)
if err == nil {
aliveCount := 0
for _, d := range daemons {
if d.Alive {
aliveCount++
}
}
if aliveCount > 1 {
fmt.Printf("\n %s\n", ui.RenderMuted(fmt.Sprintf("%d other daemon(s) running (bd daemon status --all)", aliveCount-1)))
}
}
}
// showAllDaemonsStatus shows status of all daemons
func showAllDaemonsStatus(cmd *cobra.Command) {
searchRoots, _ := cmd.Flags().GetStringSlice("search")
// Discover daemons
daemons, err := daemon.DiscoverDaemons(searchRoots)
if err != nil {
if jsonOutput {
outputJSON(map[string]string{"error": err.Error()})
} else {
fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err)
}
os.Exit(1)
}
// Auto-cleanup stale sockets
if cleaned, err := daemon.CleanupStaleSockets(daemons); err == nil && cleaned > 0 && !jsonOutput {
fmt.Fprintf(os.Stderr, "Cleaned up %d stale socket(s)\n", cleaned)
}
// Get current workspace to mark it
currentWorkspace := ""
if pidFile, err := getPIDFilePath(); err == nil {
beadsDir := filepath.Dir(pidFile)
currentWorkspace = filepath.Dir(beadsDir)
}
currentVersion := Version
var reports []DaemonStatusReport
healthyCount := 0
outdatedCount := 0
staleCount := 0
unresponsiveCount := 0
for _, d := range daemons {
report := DaemonStatusReport{
Workspace: d.WorkspacePath,
PID: d.PID,
Version: d.Version,
IsCurrent: d.WorkspacePath == currentWorkspace,
}
if !d.Alive {
report.Status = "stale"
report.Issue = d.Error
staleCount++
} else if d.Version != "" && d.Version != currentVersion {
report.Status = "outdated"
report.Issue = fmt.Sprintf("daemon %s != cli %s", d.Version, currentVersion)
report.VersionMismatch = true
outdatedCount++
} else {
report.Status = "healthy"
healthyCount++
}
reports = append(reports, report)
}
if jsonOutput {
outputJSON(DaemonStatusAllResponse{
Total: len(reports),
Healthy: healthyCount,
Outdated: outdatedCount,
Stale: staleCount,
Unresponsive: unresponsiveCount,
Daemons: reports,
})
return
}
// Human-readable output
if len(reports) == 0 {
fmt.Println("No daemons found")
return
}
// Summary line
fmt.Printf("Daemons: %d total", len(reports))
if healthyCount > 0 {
fmt.Printf(", %s", ui.RenderPass(fmt.Sprintf("%d healthy", healthyCount)))
}
if outdatedCount > 0 {
fmt.Printf(", %s", ui.RenderWarn(fmt.Sprintf("%d outdated", outdatedCount)))
}
if staleCount > 0 {
fmt.Printf(", %s", ui.RenderWarn(fmt.Sprintf("%d stale", staleCount)))
}
if unresponsiveCount > 0 {
fmt.Printf(", %s", ui.RenderFail(fmt.Sprintf("%d unresponsive", unresponsiveCount)))
}
fmt.Println()
fmt.Println()
// Table
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(w, " WORKSPACE\tPID\tVERSION\tSTATUS")
for _, r := range reports {
workspace := shortenPath(r.Workspace)
if workspace == "" {
workspace = "(unknown)"
}
// Add arrow for current workspace
prefix := " "
if r.IsCurrent {
prefix = ui.RenderAccent("→ ")
}
pidStr := "-"
if r.PID != 0 {
pidStr = fmt.Sprintf("%d", r.PID)
}
version := r.Version
if version == "" {
version = "-"
}
// Render status with icon and color
var statusDisplay string
switch r.Status {
case "healthy":
statusDisplay = ui.RenderPass(ui.IconPass + " healthy")
case "outdated":
statusDisplay = ui.RenderWarn(ui.IconWarn + " outdated")
// Add version hint
statusDisplay += ui.RenderMuted(fmt.Sprintf(" (cli: %s)", currentVersion))
case "stale":
statusDisplay = ui.RenderWarn(ui.IconWarn + " stale")
case "unresponsive":
statusDisplay = ui.RenderFail(ui.IconFail + " unresponsive")
default:
statusDisplay = r.Status
}
_, _ = fmt.Fprintf(w, "%s%s\t%s\t%s\t%s\n",
prefix, workspace, pidStr, version, statusDisplay)
}
_ = w.Flush()
// Exit with error if there are issues
if outdatedCount > 0 || staleCount > 0 || unresponsiveCount > 0 {
os.Exit(1)
}
}