diff --git a/cmd/bd/daemon.go b/cmd/bd/daemon.go index 4a544a19..6cc466f2 100644 --- a/cmd/bd/daemon.go +++ b/cmd/bd/daemon.go @@ -34,14 +34,16 @@ The daemon will: - Auto-import when remote changes detected Common operations: - bd daemon --start Start the daemon (background) - bd daemon --start --foreground Start in foreground (for systemd/supervisord) - bd daemon --stop Stop a running daemon - bd daemon --stop-all Stop ALL running bd daemons - bd daemon --status Check if daemon is running - bd daemon --health Check daemon health and metrics + bd daemon start Start the daemon (background) + bd daemon start --foreground Start in foreground (for systemd/supervisord) + bd daemon stop Stop current workspace daemon + bd daemon status Show daemon status + bd daemon status --all Show all daemons with health check + bd daemon logs View daemon logs + bd daemon restart Restart daemon + bd daemon killall Stop all running daemons -Run 'bd daemon' with no flags to see available options.`, +Run 'bd daemon --help' to see all subcommands.`, Run: func(cmd *cobra.Command, args []string) { start, _ := cmd.Flags().GetBool("start") stop, _ := cmd.Flags().GetBool("stop") @@ -65,6 +67,23 @@ Run 'bd daemon' with no flags to see available options.`, return } + // Show deprecation warnings for flag-based actions + if start { + fmt.Fprintf(os.Stderr, "Warning: --start is deprecated, use 'bd daemon start' instead\n") + } + if stop { + fmt.Fprintf(os.Stderr, "Warning: --stop is deprecated, use 'bd daemon stop' instead\n") + } + if stopAll { + fmt.Fprintf(os.Stderr, "Warning: --stop-all is deprecated, use 'bd daemon killall' instead\n") + } + if status { + fmt.Fprintf(os.Stderr, "Warning: --status is deprecated, use 'bd daemon status' instead\n") + } + if health { + fmt.Fprintf(os.Stderr, "Warning: --health is deprecated, use 'bd daemon status --all' instead\n") + } + // If auto-commit/auto-push flags weren't explicitly provided, read from config // GH#871: Read from config.yaml first (team-shared), then fall back to SQLite (legacy) // (skip if --stop, --status, --health, --metrics) @@ -206,16 +225,22 @@ Run 'bd daemon' with no flags to see available options.`, } func init() { - daemonCmd.Flags().Bool("start", false, "Start the daemon") + // Register subcommands (preferred interface) + daemonCmd.AddCommand(daemonStartCmd) + daemonCmd.AddCommand(daemonStatusCmd) + // Note: stop, restart, logs, killall, list, health subcommands are registered in daemons.go + + // Legacy flags (deprecated - use subcommands instead) + daemonCmd.Flags().Bool("start", false, "Start the daemon (deprecated: use 'bd daemon start')") daemonCmd.Flags().Duration("interval", 5*time.Second, "Sync check interval") daemonCmd.Flags().Bool("auto-commit", false, "Automatically commit changes") daemonCmd.Flags().Bool("auto-push", false, "Automatically push commits") daemonCmd.Flags().Bool("auto-pull", false, "Automatically pull from remote (default: true when sync.branch configured)") daemonCmd.Flags().Bool("local", false, "Run in local-only mode (no git required, no sync)") - daemonCmd.Flags().Bool("stop", false, "Stop running daemon") - daemonCmd.Flags().Bool("stop-all", false, "Stop all running bd daemons") - daemonCmd.Flags().Bool("status", false, "Show daemon status") - daemonCmd.Flags().Bool("health", false, "Check daemon health and metrics") + daemonCmd.Flags().Bool("stop", false, "Stop running daemon (deprecated: use 'bd daemon stop')") + daemonCmd.Flags().Bool("stop-all", false, "Stop all running bd daemons (deprecated: use 'bd daemon killall')") + daemonCmd.Flags().Bool("status", false, "Show daemon status (deprecated: use 'bd daemon status')") + daemonCmd.Flags().Bool("health", false, "Check daemon health (deprecated: use 'bd daemon status --all')") daemonCmd.Flags().Bool("metrics", false, "Show detailed daemon metrics") daemonCmd.Flags().String("log", "", "Log file path (default: .beads/daemon.log)") daemonCmd.Flags().Bool("foreground", false, "Run in foreground (don't daemonize)") diff --git a/cmd/bd/daemon_start.go b/cmd/bd/daemon_start.go new file mode 100644 index 00000000..81694c47 --- /dev/null +++ b/cmd/bd/daemon_start.go @@ -0,0 +1,151 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/rpc" +) + +var daemonStartCmd = &cobra.Command{ + Use: "start", + Short: "Start the background daemon", + Long: `Start the background daemon that automatically syncs issues with git remote. + +The daemon will: +- Poll for changes at configurable intervals (default: 5 seconds) +- Export pending database changes to JSONL +- Auto-commit changes if --auto-commit flag set +- Auto-push commits if --auto-push flag set +- Pull remote changes periodically +- Auto-import when remote changes detected + +Examples: + bd daemon start # Start with defaults + bd daemon start --auto-commit # Enable auto-commit + bd daemon start --auto-push # Enable auto-push (implies --auto-commit) + bd daemon start --foreground # Run in foreground (for systemd/supervisord) + bd daemon start --local # Local-only mode (no git sync)`, + Run: func(cmd *cobra.Command, args []string) { + interval, _ := cmd.Flags().GetDuration("interval") + autoCommit, _ := cmd.Flags().GetBool("auto-commit") + autoPush, _ := cmd.Flags().GetBool("auto-push") + autoPull, _ := cmd.Flags().GetBool("auto-pull") + localMode, _ := cmd.Flags().GetBool("local") + logFile, _ := cmd.Flags().GetString("log") + foreground, _ := cmd.Flags().GetBool("foreground") + logLevel, _ := cmd.Flags().GetString("log-level") + logJSON, _ := cmd.Flags().GetBool("log-json") + + // Load auto-commit/push/pull defaults from env vars, config, or sync-branch + autoCommit, autoPush, autoPull = loadDaemonAutoSettings(cmd, autoCommit, autoPush, autoPull) + + if interval <= 0 { + fmt.Fprintf(os.Stderr, "Error: interval must be positive (got %v)\n", interval) + os.Exit(1) + } + + pidFile, err := getPIDFilePath() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Skip daemon-running check if we're the forked child (BD_DAEMON_FOREGROUND=1) + if os.Getenv("BD_DAEMON_FOREGROUND") != "1" { + // Check if daemon is already running + if isRunning, pid := isDaemonRunning(pidFile); isRunning { + // Check if running daemon has compatible version + socketPath := getSocketPathForPID(pidFile) + if client, err := rpc.TryConnectWithTimeout(socketPath, 1*time.Second); err == nil && client != nil { + health, healthErr := client.Health() + _ = client.Close() + + // If we can check version and it's compatible, exit + if healthErr == nil && health.Compatible { + fmt.Fprintf(os.Stderr, "Error: daemon already running (PID %d, version %s)\n", pid, health.Version) + fmt.Fprintf(os.Stderr, "Use 'bd daemon stop' to stop it first\n") + os.Exit(1) + } + + // Version mismatch - auto-stop old daemon + if healthErr == nil && !health.Compatible { + fmt.Fprintf(os.Stderr, "Warning: daemon version mismatch (daemon: %s, client: %s)\n", health.Version, Version) + fmt.Fprintf(os.Stderr, "Stopping old daemon and starting new one...\n") + stopDaemon(pidFile) + } + } else { + fmt.Fprintf(os.Stderr, "Error: daemon already running (PID %d)\n", pid) + fmt.Fprintf(os.Stderr, "Use 'bd daemon stop' to stop it first\n") + os.Exit(1) + } + } + } + + // Validate --local mode constraints + if localMode { + if autoCommit { + fmt.Fprintf(os.Stderr, "Error: --auto-commit cannot be used with --local mode\n") + fmt.Fprintf(os.Stderr, "Hint: --local mode runs without git, so commits are not possible\n") + os.Exit(1) + } + if autoPush { + fmt.Fprintf(os.Stderr, "Error: --auto-push cannot be used with --local mode\n") + fmt.Fprintf(os.Stderr, "Hint: --local mode runs without git, so pushes are not possible\n") + os.Exit(1) + } + } + + // Validate we're in a git repo (skip in local mode) + if !localMode && !isGitRepo() { + fmt.Fprintf(os.Stderr, "Error: not in a git repository\n") + fmt.Fprintf(os.Stderr, "Hint: run 'git init' to initialize a repository, or use --local for local-only mode\n") + os.Exit(1) + } + + // Check for upstream if auto-push enabled + if autoPush && !gitHasUpstream() { + fmt.Fprintf(os.Stderr, "Error: no upstream configured (required for --auto-push)\n") + fmt.Fprintf(os.Stderr, "Hint: git push -u origin \n") + os.Exit(1) + } + + // Warn if starting daemon in a git worktree + if dbPath == "" { + if foundDB := beads.FindDatabasePath(); foundDB != "" { + dbPath = foundDB + } + } + if dbPath != "" { + warnWorktreeDaemon(dbPath) + } + + // Start daemon + if localMode { + fmt.Printf("Starting bd daemon in LOCAL mode (interval: %v, no git sync)\n", interval) + } else { + fmt.Printf("Starting bd daemon (interval: %v, auto-commit: %v, auto-push: %v, auto-pull: %v)\n", + interval, autoCommit, autoPush, autoPull) + } + if logFile != "" { + fmt.Printf("Logging to: %s\n", logFile) + } + + startDaemon(interval, autoCommit, autoPush, autoPull, localMode, foreground, logFile, pidFile, logLevel, logJSON) + }, +} + +func init() { + daemonStartCmd.Flags().Duration("interval", 5*time.Second, "Sync check interval") + daemonStartCmd.Flags().Bool("auto-commit", false, "Automatically commit changes") + daemonStartCmd.Flags().Bool("auto-push", false, "Automatically push commits") + daemonStartCmd.Flags().Bool("auto-pull", false, "Automatically pull from remote") + daemonStartCmd.Flags().Bool("local", false, "Run in local-only mode (no git required, no sync)") + daemonStartCmd.Flags().String("log", "", "Log file path (default: .beads/daemon.log)") + daemonStartCmd.Flags().Bool("foreground", false, "Run in foreground (don't daemonize)") + daemonStartCmd.Flags().String("log-level", "info", "Log level (debug, info, warn, error)") + daemonStartCmd.Flags().Bool("log-json", false, "Output logs in JSON format") +} diff --git a/cmd/bd/daemon_status.go b/cmd/bd/daemon_status.go new file mode 100644 index 00000000..9cabcaf4 --- /dev/null +++ b/cmd/bd/daemon_status.go @@ -0,0 +1,451 @@ +package main + +import ( + "encoding/json" + "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) + } +} + +// outputStatusJSON outputs the status as JSON (helper to avoid duplicating json.Marshal) +func outputStatusJSON(v interface{}) { + data, _ := json.MarshalIndent(v, "", " ") + fmt.Println(string(data)) +}