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>
445 lines
12 KiB
Go
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)
|
|
}
|
|
}
|